Skip to content
Closed
3 changes: 3 additions & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-auth-store.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-preservation.ts",
Expand All @@ -41,6 +42,8 @@
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-profile-config-overlay-active-publish.ts",
"../src/main/codex/codex-profile-config-overlay-mirror.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
Expand Down
8 changes: 5 additions & 3 deletions src/main/codex-accounts/runtime-home-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1350,7 +1350,9 @@ describe('CodexRuntimeHomeService', () => {
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe(
'cli_auth_credentials_store = "file"\nmodel = "outside-edit"\n'
)
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')

// Codex now persists a /model change inside Orca's reconciled runtime.
Expand Down Expand Up @@ -3616,7 +3618,7 @@ describe('CodexRuntimeHomeService', () => {
service.prepareForCodexLaunch()

expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(
'model = "second"\n'
'cli_auth_credentials_store = "file"\nmodel = "second"\n'
)
})

Expand All @@ -3638,7 +3640,7 @@ describe('CodexRuntimeHomeService', () => {

expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(
canonicalConfig
`cli_auth_credentials_store = "file"\n${canonicalConfig}`
)
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(canonicalConfig)
Expand Down
77 changes: 68 additions & 9 deletions src/main/codex-accounts/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-cre
import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger'
import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command'
import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust'
import { forceFileAuthCredentialsStore } from '../codex/codex-config-auth-store'

const testState = {
userDataDir: '',
Expand Down Expand Up @@ -414,12 +415,55 @@ describe('CodexAccountService config sync', () => {
const { CodexAccountService } = await import('./service')
new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)

expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(
forceFileAuthCredentialsStore(canonicalConfig)
)
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(
'{"account":"managed"}\n'
)
})

it('overrides a canonical keyring preference in managed homes', async () => {
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
writeFileSync(
canonicalConfigPath,
'approval_policy = "never"\ncli_auth_credentials_store = "keyring"\n',
'utf-8'
)
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
'approval_policy = "on-request"\n',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
})

const { CodexAccountService } = await import('./service')
new CodexAccountService(
createStore(settings) as never,
createRateLimits() as never,
createRuntimeHome() as never
)

expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(
'approval_policy = "never"\ncli_auth_credentials_store = "file"\n'
)
})

it('does not seed source-home hook trust into a self-contained account home', async () => {
const fixture = await createCanonicalHookTrustFixture()
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
Expand Down Expand Up @@ -509,7 +553,11 @@ describe('CodexAccountService config sync', () => {
createRuntimeHome() as never
)

expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config)
// Why: managed homes force file-backed auth so account switch stays deterministic;
// the source ~/.codex stays untouched.
expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(
forceFileAuthCredentialsStore(fixture.config)
)
expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config)
})

Expand Down Expand Up @@ -568,10 +616,12 @@ describe('CodexAccountService config sync', () => {
''
].join('\n')
writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8')
// Pre-seed the forced file-auth form so the sync path's write is a true no-op.
const managedConfig = forceFileAuthCredentialsStore(canonicalConfig)
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
canonicalConfig,
managedConfig,
'{"account":"managed"}\n'
)
const managedConfigPath = join(managedHomePath, 'config.toml')
Expand Down Expand Up @@ -696,7 +746,9 @@ describe('CodexAccountService config sync', () => {

await service.selectAccount('account-1')

expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(
forceFileAuthCredentialsStore(canonicalConfig)
)
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
})
Expand Down Expand Up @@ -760,7 +812,9 @@ describe('CodexAccountService config sync', () => {

const loginHome = options.env.CODEX_HOME
expect(loginHome).toBeTruthy()
expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(
forceFileAuthCredentialsStore(canonicalConfig)
)

const payload = Buffer.from(JSON.stringify({ email: 'user@example.com' })).toString(
'base64url'
Expand Down Expand Up @@ -932,7 +986,9 @@ describe('CodexAccountService config sync', () => {
const loginHome = options.env.CODEX_HOME
expect(loginHome).toBeTruthy()
expect(readFileSync(join(loginHome!, '.orca-managed-home'), 'utf-8')).toBe('account-1\n')
expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(
forceFileAuthCredentialsStore(canonicalConfig)
)

const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
Expand Down Expand Up @@ -1310,10 +1366,13 @@ describe('CodexAccountService config sync', () => {
expect(command).toBe('wsl.exe')
expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath))
// Why: codex login runs inside WSL, so the rewritten path must be the
// Linux-side ~/.codex, not a Windows UNC path.
// Linux-side ~/.codex, not a Windows UNC path. Managed homes also force
// file-backed auth so credentials stay inside the selected CODEX_HOME.
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
'sandbox_mode = "danger-full-access"\n' +
"model_instructions_file = '/home/alice/.codex/instructions.md'\n"
forceFileAuthCredentialsStore(
'sandbox_mode = "danger-full-access"\n' +
"model_instructions_file = '/home/alice/.codex/instructions.md'\n"
)
)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
Expand Down
6 changes: 4 additions & 2 deletions src/main/codex-accounts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
} from '../../shared/codex-reset-credit-attempt-ledger'
import type { CodexRuntimeHomeService } from './runtime-home-service'
import { writeFileAtomically } from './fs-utils'
import { forceFileAuthCredentialsStore } from '../codex/codex-config-auth-store'
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation'
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
Expand Down Expand Up @@ -1354,15 +1355,16 @@ export class CodexAccountService {
}

private writeManagedConfig(managedHomePath: string, contents: string): void {
const managedContents = forceFileAuthCredentialsStore(contents)
const configPath = join(managedHomePath, 'config.toml')
try {
if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) {
if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === managedContents) {
return
}
} catch {
// Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing.
}
writeFileAtomically(configPath, contents)
writeFileAtomically(configPath, managedContents)
}

private getManagedAccountsRoot(): string {
Expand Down
47 changes: 47 additions & 0 deletions src/main/codex/codex-config-auth-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'

// Why: managed Codex homes keep account credentials in auth.json; keyring/auto
// would store them outside the selected home and break deterministic switching.
const FILE_AUTH_CREDENTIALS_STORE_LINE = 'cli_auth_credentials_store = "file"'
const AUTH_CREDENTIALS_STORE_KEY_RE =
/^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=/
const FILE_AUTH_CREDENTIALS_STORE_RE =
/^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=[ \t]*(?:"file"|'file')[ \t\r]*(?:#.*)?$/

export function forceFileAuthCredentialsStore(config: string): string {
const hasBom = config.charCodeAt(0) === 0xfeff
const content = hasBom ? config.slice(1) : config
const restoreBom = (value: string): string => (hasBom ? `\uFEFF${value}` : value)
const lines = content.split('\n')
let scanState = createTomlLineScanState()

for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(scanState)) {
if (getTomlTableHeader(line)) {
break
}
if (AUTH_CREDENTIALS_STORE_KEY_RE.test(line)) {
if (FILE_AUTH_CREDENTIALS_STORE_RE.test(line)) {
return config
}
const indent = /^[ \t]*/.exec(line)?.[0] ?? ''
const lineEnding = line.endsWith('\r') ? '\r' : ''
lines[index] = `${indent}${FILE_AUTH_CREDENTIALS_STORE_LINE}${lineEnding}`
return restoreBom(lines.join('\n'))
}
}
scanState = updateTomlLineScanState(scanState, line)
}

return restoreBom(
content.length === 0
? `${FILE_AUTH_CREDENTIALS_STORE_LINE}\n`
: `${FILE_AUTH_CREDENTIALS_STORE_LINE}\n${content}`
)
}
Loading