Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.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-home-paths.ts",
"../src/main/codex/codex-hook-identity.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-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
Expand Down
6 changes: 4 additions & 2 deletions src/main/codex-accounts/runtime-home-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,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 @@ -1319,7 +1321,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 Down
69 changes: 62 additions & 7 deletions src/main/codex-accounts/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ function decodeEncodedWslBashCommand(command: string): string {
return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command
}

function withFileAuthStore(config: string): string {
return `cli_auth_credentials_store = "file"\n${config}`
}

function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
Expand Down Expand Up @@ -273,12 +277,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(
withFileAuthStore(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('rewrites relative path config values when syncing into managed homes', async () => {
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
writeFileSync(
Expand Down Expand Up @@ -329,7 +376,7 @@ describe('CodexAccountService config sync', () => {
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
canonicalConfig,
withFileAuthStore(canonicalConfig),
'{"account":"managed"}\n'
)
const managedConfigPath = join(managedHomePath, 'config.toml')
Expand Down Expand Up @@ -454,7 +501,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(
withFileAuthStore(canonicalConfig)
)
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
})
Expand Down Expand Up @@ -517,7 +566,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(
withFileAuthStore(canonicalConfig)
)

const payload = Buffer.from(JSON.stringify({ email: 'user@example.com' })).toString(
'base64url'
Expand Down Expand Up @@ -576,7 +627,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(
withFileAuthStore(canonicalConfig)
)

const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
Expand Down Expand Up @@ -783,8 +836,10 @@ describe('CodexAccountService config sync', () => {
// Why: codex login runs inside WSL, so the rewritten path must be the
// Linux-side ~/.codex, not a Windows UNC path.
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
'sandbox_mode = "danger-full-access"\n' +
"model_instructions_file = '/home/alice/.codex/instructions.md'\n"
withFileAuthStore(
'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 @@ -15,6 +15,7 @@ import type {
} from '../../shared/types'
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 { resolveCodexCommand } from '../codex-cli/command'
import type { Store } from '../persistence'
Expand Down Expand Up @@ -517,16 +518,17 @@ 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: read errors should not make a stale config look current; the
// atomic write path owns Windows ACL repair and persistent 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}`
)
}
120 changes: 119 additions & 1 deletion src/main/codex/codex-config-mirror.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import { join } from 'node:path'
Expand Down Expand Up @@ -28,6 +36,7 @@ import {
resolveCodexConfigMirrorSourceDirectory,
syncSystemConfigIntoManagedCodexHome
} from './codex-config-mirror'
import { forceFileAuthCredentialsStore } from './codex-config-auth-store'

let fakeHomeDir: string
let userDataDir: string
Expand Down Expand Up @@ -171,12 +180,18 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
writeFileSync(
getSystemConfigPath(),
[
'js_repl_node_path = "bin/node"',
'',
'[profiles.fast]',
'model_catalog_json = "catalogs/fast.json"',
'js_repl_node_path = "bin/profile-node"',
'',
'[debug.config_lockfile]',
'load_path = "locks/config.lock.toml"',
'export_dir = "locks"',
'',
'[otel.exporter.tls]',
'ca-certificate = "certs/ca.pem"',
''
].join('\n'),
'utf-8'
Expand All @@ -185,13 +200,75 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
syncSystemConfigIntoManagedCodexHome()

const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain(
`js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'node')}'`
)
expect(runtimeConfig).toContain(
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'`
)
expect(runtimeConfig).toContain(
`js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'profile-node')}'`
)
expect(runtimeConfig).toContain(
`load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'`
)
expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`)
expect(runtimeConfig).toContain(
`ca-certificate = '${join(getSystemCodexHomePath(), 'certs', 'ca.pem')}'`
)
})

it('mirrors free-standing profile-v2 config overlays into the runtime home', () => {
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
writeFileSync(
join(getSystemCodexHomePath(), 'work.config.toml'),
'model = "work-profile"\n',
'utf-8'
)

syncSystemConfigIntoManagedCodexHome()

const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml')
expect(existsSync(runtimeOverlayPath)).toBe(true)
expect(lstatSync(runtimeOverlayPath).isSymbolicLink()).toBe(false)
const runtimeOverlay = readFileSync(runtimeOverlayPath, 'utf-8')
expect(runtimeOverlay).toMatch(/^# orca-managed-profile-overlay:v1 sha256=[a-f0-9]{64}\n/)
expect(runtimeOverlay).toContain('cli_auth_credentials_store = "file"')
expect(runtimeOverlay).toContain('model = "work-profile"')
})

it('rewrites relative overlay paths against the system Codex home', () => {
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
writeFileSync(
join(getSystemCodexHomePath(), 'work.config.toml'),
'log_dir = "logs"\nmodel = "work-profile"\n',
'utf-8'
)

syncSystemConfigIntoManagedCodexHome()

const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml')
const runtimeOverlay = readFileSync(runtimeOverlayPath, 'utf-8')
expect(runtimeOverlay).toContain('cli_auth_credentials_store = "file"')
expect(runtimeOverlay).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
})

it('forces file-backed auth in BOM-prefixed profile config overlays', () => {
writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8')
writeFileSync(
join(getSystemCodexHomePath(), 'work.config.toml'),
'\uFEFFcli_auth_credentials_store = "keyring"\nmodel = "work-profile"\n',
'utf-8'
)

syncSystemConfigIntoManagedCodexHome()

const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml')
const contents = readFileSync(runtimeOverlayPath, 'utf-8')
expect(contents[0]).toBe('\uFEFF')
expect(contents.slice(1)).toMatch(/^# orca-managed-profile-overlay:v1 sha256=/)
expect(contents).toContain('cli_auth_credentials_store = "file"')
expect(contents).toContain('model = "work-profile"')
})

it('does not treat lines inside multiline arrays as headers or path keys', () => {
Expand Down Expand Up @@ -674,3 +751,44 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => {
expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
})
})

describe('forceFileAuthCredentialsStore', () => {
it('inserts the file store setting when missing', () => {
expect(forceFileAuthCredentialsStore('model = "gpt"\n')).toBe(
'cli_auth_credentials_store = "file"\nmodel = "gpt"\n'
)
})

it('keeps a UTF-8 BOM at the start when inserting the setting', () => {
expect(forceFileAuthCredentialsStore('\uFEFFmodel = "gpt"\n')).toBe(
'\uFEFFcli_auth_credentials_store = "file"\nmodel = "gpt"\n'
)
})

it('overrides quoted root keys without touching nested tables', () => {
const input = [
'"cli_auth_credentials_store" = "keyring"',
'model = "gpt"',
'',
'[features]',
'cli_auth_credentials_store = "auto"',
''
].join('\n')

expect(forceFileAuthCredentialsStore(input)).toBe(
[
'cli_auth_credentials_store = "file"',
'model = "gpt"',
'',
'[features]',
'cli_auth_credentials_store = "auto"',
''
].join('\n')
)
})

it('is idempotent when the file store is already set', () => {
const input = 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n'
expect(forceFileAuthCredentialsStore(input)).toBe(input)
})
})
Loading