Isolate Codex hooks in Orca runtime home

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-19 14:15:03 -07:00
co-authored by Orca
parent 4b86fe8292
commit 72b413d920
15 changed files with 688 additions and 185 deletions
+45 -1
View File
@@ -13,9 +13,21 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const testState = {
fakeHomeDir: ''
fakeHomeDir: '',
userDataDir: ''
}
vi.mock('electron', () => ({
app: {
getPath: (name: string) => {
if (name === 'userData') {
return testState.userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
}
}
}))
vi.mock('node:os', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const actual = await vi.importActual<typeof import('node:os')>('node:os')
@@ -30,11 +42,14 @@ const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTr
beforeEach(() => {
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-'))
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-user-data-'))
})
afterEach(() => {
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
rmSync(testState.userDataDir, { recursive: true, force: true })
testState.fakeHomeDir = ''
testState.userDataDir = ''
})
describe('markCursorWorkspaceTrusted', () => {
@@ -119,10 +134,20 @@ describe('markCodexProjectTrusted', () => {
const realpath = realpathSync(workspace)
markCodexProjectTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const runtimeConfigPath = join(
testState.userDataDir,
'codex-runtime-home',
'home',
'config.toml'
)
expect(existsSync(configPath)).toBe(true)
expect(existsSync(runtimeConfigPath)).toBe(true)
const written = readFileSync(configPath, 'utf-8')
const runtimeWritten = readFileSync(runtimeConfigPath, 'utf-8')
expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(written).toContain('trust_level = "trusted"')
expect(runtimeWritten).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(runtimeWritten).toContain('trust_level = "trusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
@@ -133,7 +158,9 @@ describe('markCodexProjectTrusted', () => {
const realpath = realpathSync(workspace)
try {
const codexDir = join(testState.fakeHomeDir, '.codex')
const runtimeCodexDir = join(testState.userDataDir, 'codex-runtime-home', 'home')
mkdirSync(codexDir, { recursive: true })
mkdirSync(runtimeCodexDir, { recursive: true })
writeFileSync(
join(codexDir, 'config.toml'),
[
@@ -146,14 +173,31 @@ describe('markCodexProjectTrusted', () => {
].join('\n'),
'utf-8'
)
writeFileSync(
join(runtimeCodexDir, 'config.toml'),
[
'sandbox_mode = "workspace-write"',
'',
`[projects."${escapeTomlBasicString(realpath)}"]`,
'notes = "keep-runtime"',
'trust_level = "untrusted"',
''
].join('\n'),
'utf-8'
)
markCodexProjectTrusted(workspace)
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
const runtimeWritten = readFileSync(join(runtimeCodexDir, 'config.toml'), 'utf-8')
expect(written).toContain('model = "gpt-5.5"')
expect(written).toContain('notes = "keep"')
expect(written).toContain('trust_level = "trusted"')
expect(written).not.toContain('trust_level = "untrusted"')
expect(runtimeWritten).toContain('sandbox_mode = "workspace-write"')
expect(runtimeWritten).toContain('notes = "keep-runtime"')
expect(runtimeWritten).toContain('trust_level = "trusted"')
expect(runtimeWritten).not.toContain('trust_level = "untrusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
+4
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
/**
@@ -109,6 +110,9 @@ export function markCodexProjectTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configPath = join(homedir(), '.codex', 'config.toml')
upsertProjectTrustLevel(configPath, absPath, 'trusted')
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
// preset must also update the runtime config Codex will actually read.
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
}
function canonicalize(p: string): string {
@@ -118,6 +118,22 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
}
}
function getSystemCodexHomePath(): string {
return join(testState.fakeHomeDir, '.codex')
}
function getSystemCodexAuthPath(): string {
return join(getSystemCodexHomePath(), 'auth.json')
}
function getRuntimeCodexHomePath(): string {
return join(testState.userDataDir, 'codex-runtime-home', 'home')
}
function getRuntimeCodexAuthPath(): string {
return join(getRuntimeCodexHomePath(), 'auth.json')
}
function createStore(settings: GlobalSettings) {
return {
getSettings: vi.fn(() => settings),
@@ -182,7 +198,8 @@ describe('CodexRuntimeHomeService', () => {
vi.clearAllMocks()
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-'))
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-'))
mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true })
mkdirSync(getSystemCodexHomePath(), { recursive: true })
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
})
afterEach(() => {
@@ -191,8 +208,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('captures the existing ~/.codex auth as the system-default snapshot', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
@@ -209,9 +225,9 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('materializes the active managed account auth into ~/.codex on startup', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
it('materializes the active managed account auth into the runtime home on startup', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -246,8 +262,8 @@ describe('CodexRuntimeHomeService', () => {
})
it('restores the system-default snapshot when no managed account is selected', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -284,7 +300,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('removes runtime auth when restoring a no-login system default', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -320,7 +336,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('removes runtime auth when deselecting with a missing system-default snapshot', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed')
writeFileSync(runtimeAuthPath, managedAuth, 'utf-8')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
@@ -351,9 +367,9 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('removes runtime auth when deselecting with a corrupt system-default snapshot', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
it('repairs a corrupt system-default snapshot from the live ~/.codex auth on deselect', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
const settings = createSettings({
@@ -373,7 +389,6 @@ describe('CodexRuntimeHomeService', () => {
activeCodexManagedAccountId: null
})
const store = createStore(settings)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
@@ -389,15 +404,15 @@ describe('CodexRuntimeHomeService', () => {
settings.activeCodexManagedAccountId = null
service.syncForCurrentSelection()
expect(existsSync(runtimeAuthPath)).toBe(false)
expect(existsSync(snapshotPath)).toBe(false)
expect(warnSpy).toHaveBeenCalledWith(
'[codex-runtime-home] Ignoring invalid system-default auth snapshot'
)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
expect(existsSync(snapshotPath)).toBe(true)
expect(JSON.parse(readFileSync(snapshotPath, 'utf-8'))).toEqual({
authJson: '{"account":"system"}\n'
})
})
it('clears an invalid active account selection and removes untrusted runtime auth', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const missingManagedHomePath = join(
testState.userDataDir,
@@ -433,7 +448,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('clears an unknown active account id and removes untrusted runtime auth', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"stale-managed"}\n', 'utf-8')
const settings = createSettings({
activeCodexManagedAccountId: 'missing-account'
@@ -447,18 +462,18 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('returns ~/.codex for Codex launch and rate-limit preparation', async () => {
it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => {
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch()).toBe(join(testState.fakeHomeDir, '.codex'))
expect(service.prepareForRateLimitFetch()).toBe(join(testState.fakeHomeDir, '.codex'))
expect(existsSync(join(testState.fakeHomeDir, '.codex'))).toBe(true)
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
})
it('does not overwrite auth.json when no managed account was ever active', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8')
const store = createStore(createSettings())
@@ -471,9 +486,151 @@ describe('CodexRuntimeHomeService', () => {
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-switch"}\n')
})
it('does not overwrite auth.json after deselection + external change', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
it('refreshes the runtime auth when the system-default auth changes later', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-1"}\n')
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n')
})
it('reads back system-default token refreshes from runtime auth', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, '{"account":"system-refreshed"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system-refreshed"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-refreshed"}\n')
expect(
JSON.parse(
readFileSync(
join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'),
'utf-8'
)
)
).toEqual({ authJson: '{"account":"system-refreshed"}\n' })
})
it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, '{"account":"system-refreshed"}\n', 'utf-8')
const restartedService = new CodexRuntimeHomeService(store as never)
restartedService.syncForCurrentSelection()
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system-refreshed"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-refreshed"}\n')
})
it('keeps a local runtime logout when the system-default auth still exists', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
rmSync(runtimeAuthPath, { force: true })
service.syncForCurrentSelection()
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('keeps a local runtime logout after restart when the system-default auth still exists', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const settings = createSettings()
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
rmSync(runtimeAuthPath, { force: true })
service.syncForCurrentSelection()
new CodexRuntimeHomeService(store as never)
expect(existsSync(runtimeAuthPath)).toBe(false)
expect(
existsSync(
join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json')
)
).toBe(true)
})
it('mirrors a fresh external system-default login after a persisted local runtime logout', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8')
const settings = createSettings()
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
rmSync(runtimeAuthPath, { force: true })
service.syncForCurrentSelection()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-new"}\n', 'utf-8')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-new"}\n')
expect(
existsSync(
join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json')
)
).toBe(false)
})
it('clears the mirrored runtime auth after an external system-default logout', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
rmSync(getSystemCodexAuthPath(), { force: true })
service.syncForCurrentSelection()
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('clears mirrored runtime auth after restart when the system-default auth was deleted', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const settings = createSettings()
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
rmSync(getSystemCodexAuthPath(), { force: true })
const restartedService = new CodexRuntimeHomeService(store as never)
restartedService.syncForCurrentSelection()
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('persists runtime auth refreshes after returning to system default', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -507,14 +664,16 @@ describe('CodexRuntimeHomeService', () => {
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
// External tool changes auth — subsequent syncs must not overwrite
// Codex used to refresh tokens directly in ~/.codex. With an Orca-owned
// runtime home, the same refresh must be read back to the system default.
writeFileSync(runtimeAuthPath, '{"account":"external-tool"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n')
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"external-tool"}\n')
})
it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const settings = createSettings({
codexManagedAccounts: [
@@ -544,7 +703,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('imports legacy managed-home history into the shared runtime history', async () => {
const runtimeHomePath = join(testState.fakeHomeDir, '.codex')
const runtimeHomePath = getRuntimeCodexHomePath()
const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl')
writeFileSync(runtimeHistoryPath, '{"id":"shared-1"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
@@ -575,7 +734,7 @@ describe('CodexRuntimeHomeService', () => {
return
}
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
@@ -613,7 +772,7 @@ describe('CodexRuntimeHomeService', () => {
return
}
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
@@ -684,7 +843,7 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
const runtimeHistoryPath = join(testState.fakeHomeDir, '.codex', 'history.jsonl')
const runtimeHistoryPath = join(getRuntimeCodexHomePath(), 'history.jsonl')
expect(readFileSync(runtimeHistoryPath, 'utf-8')).toContain('legacy-1')
writeFileSync(
@@ -701,7 +860,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('clears system-default snapshot via clearSystemDefaultSnapshot', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const store = createStore(createSettings())
@@ -720,7 +879,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original')
const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed')
@@ -747,7 +906,7 @@ describe('CodexRuntimeHomeService', () => {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Simulate CLI refreshing the token in ~/.codex/auth.json
// Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json.
writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8')
// Next sync should read back the refreshed token to managed storage
@@ -758,7 +917,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects runtime read-back from a different Codex identity', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected')
const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale')
@@ -795,7 +954,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('routes runtime read-back from a different Codex identity to its matching account', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one')
const account1RefreshedAuth = createCodexAuthJson(
@@ -842,7 +1001,7 @@ describe('CodexRuntimeHomeService', () => {
// An older account-1 Codex process refreshed the shared runtime file after
// Orca selected account-2. Persist the refresh to account-1, then restore
// the selected account in ~/.codex.
// the selected account in runtime CODEX_HOME.
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
service.syncForCurrentSelection()
@@ -852,7 +1011,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original')
const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed')
@@ -914,7 +1073,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects runtime read-back without a positive selected-account identity match', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected')
const accountOnlyAuth = `${JSON.stringify({
@@ -954,7 +1113,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected')
const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale')
@@ -989,7 +1148,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original')
const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed')
@@ -1024,7 +1183,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('reads back strong account-id refreshes when the runtime auth has no email', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original')
const refreshedAuth = `${JSON.stringify({
@@ -1064,7 +1223,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects unverifiable Codex read-back on first sync after restart', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
@@ -1099,7 +1258,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('reads back verified same-account refreshes on first sync after restart', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000)
const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed', 2_000)
writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8')
@@ -1132,7 +1291,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('rejects older same-account Codex auth on first sync after restart', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000)
const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000)
writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8')
@@ -1165,7 +1324,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('does not contaminate the incoming Codex account during account switch', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath1 = createManagedAuth(
testState.userDataDir,
@@ -1218,7 +1377,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('does not carry the reauth read-back skip across Codex account switches', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one')
const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two')
@@ -1274,7 +1433,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('does not apply inactive-account Codex reauth skip to the active account', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one')
const account1RefreshedAuth = createCodexAuthJson(
@@ -1327,8 +1486,8 @@ describe('CodexRuntimeHomeService', () => {
})
it('restores system default when unverified runtime auth appears before deselect', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -1372,8 +1531,8 @@ describe('CodexRuntimeHomeService', () => {
})
it('restores system default after same-identity managed Codex refresh on deselect', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8')
const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed')
const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
@@ -1410,8 +1569,8 @@ describe('CodexRuntimeHomeService', () => {
})
it('restores system default when stale Codex credentials are rejected on deselect', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8')
const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected')
const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth)
@@ -1448,7 +1607,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('keeps external Codex logout when deselecting managed account', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system-old"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
@@ -1484,8 +1643,8 @@ describe('CodexRuntimeHomeService', () => {
})
it('captures a fresh system-default snapshot when re-entering managed mode', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system-1"}\n', 'utf-8')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
@@ -1514,7 +1673,7 @@ describe('CodexRuntimeHomeService', () => {
settings.activeCodexManagedAccountId = null
service.syncForCurrentSelection()
writeFileSync(runtimeAuthPath, '{"account":"system-2"}\n', 'utf-8')
writeFileSync(getSystemCodexAuthPath(), '{"account":"system-2"}\n', 'utf-8')
settings.activeCodexManagedAccountId = 'account-1'
service.syncForCurrentSelection()
@@ -1525,7 +1684,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('reads back refreshed tokens for the outgoing Codex account before switching', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original')
const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed')
@@ -1574,7 +1733,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original')
const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed')
@@ -1614,7 +1773,7 @@ describe('CodexRuntimeHomeService', () => {
})
it('preserves conflicting legacy session files under deterministic names', async () => {
const runtimeSessionsDir = join(testState.fakeHomeDir, '.codex', 'sessions')
const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions')
mkdirSync(runtimeSessionsDir, { recursive: true })
writeFileSync(join(runtimeSessionsDir, 'session.json'), '{"turns":[1]}', 'utf-8')
const managedHomePath = createManagedAuth(
+182 -60
View File
@@ -12,12 +12,12 @@ import {
rmSync,
statSync
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, extname, join, parse, relative } from 'node:path'
import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
import { writeFileAtomically } from './fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
type CodexAuthIdentity = {
email: string | null
@@ -29,6 +29,11 @@ type CodexSystemDefaultSnapshot = {
authJson: string | null
}
type CodexRuntimeLogoutMarker = {
systemDefaultAuthJson: string | null
loggedOutAt: number
}
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
type CodexReadBackMatch =
| {
@@ -40,12 +45,11 @@ type CodexReadBackMatch =
| { kind: 'none' | 'ambiguous' }
export class CodexRuntimeHomeService {
// Why: tracks whether auth.json is currently managed by Orca. When null,
// Orca does NOT own auth.json and must not overwrite external changes
// (e.g. user running `codex login` or another auth tool). The snapshot
// restore only fires on the managed→system-default transition.
// Why: tracks whether the runtime auth.json currently mirrors a managed
// account. When null, runtime auth follows the user's system-default
// ~/.codex/auth.json instead of being written back to a managed account.
private lastSyncedAccountId: string | null = null
// Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json.
// Why: tracks the auth.json content Orca last wrote to the runtime CODEX_HOME.
// Between syncs, if the file differs, Codex CLI refreshed the token — so
// Orca writes back the refreshed token to managed storage before overwriting.
// On managed→system-default transition, if the file differs, an external
@@ -77,6 +81,7 @@ export class CodexRuntimeHomeService {
syncForCurrentSelection(): void {
const settings = this.store.getSettings()
const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath())
if (this.lastSyncedAccountId === null) {
this.captureSystemDefaultSnapshot({ force: false })
}
@@ -98,16 +103,30 @@ export class CodexRuntimeHomeService {
if (settings.activeCodexManagedAccountId) {
this.store.updateSettings({ activeCodexManagedAccountId: null })
}
// Why: only restore the snapshot when transitioning FROM a managed
// account back to system default. When no managed account was ever
// active, auth.json belongs to the user and Orca must not touch it.
// This prevents overwriting external auth changes (codex login or other
// tools) on every PTY launch / rate-limit fetch.
// Why: only restore the system-default mirror when transitioning FROM a
// managed account. When no managed account was ever active, later syncs
// should mirror the user's current ~/.codex/auth.json instead of
// replaying an old snapshot on every PTY launch / rate-limit fetch.
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot({
detectExternalLogin: outgoingReadBackResult !== 'rejected'
})
this.lastSyncedAccountId = null
} else if (!runtimeAuthExistedBeforeSync) {
if (this.runtimeLogoutMarkerStillApplies()) {
this.lastWrittenAuthJson = null
} else if (this.lastWrittenAuthJson === null) {
// Why: Orca-launched Codex sessions now use an Orca-owned CODEX_HOME
// even when no managed account is selected. Seed that runtime home
// from the user's current system-default auth once so dev/prod Orca
// terminals stay logged in without mutating ~/.codex on startup.
this.restoreSystemDefaultSnapshot({ detectExternalLogin: false })
} else {
this.persistRuntimeLogoutMarker()
}
} else {
this.clearRuntimeLogoutMarker()
this.syncRuntimeAuthWithSystemDefault()
}
return
}
@@ -129,10 +148,10 @@ export class CodexRuntimeHomeService {
this.captureSystemDefaultSnapshot({ force: true })
}
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
// Why: Codex CLI refreshes expired OAuth tokens in CODEX_HOME/auth.json.
// If we detect the runtime file differs from what Orca last wrote, the CLI
// must have refreshed — so we preserve those tokens back to managed
// storage before overwriting runtime with managed state.
if (this.lastSyncedAccountId === activeAccount.id) {
if (this.skipNextReadBackForAccountId === activeAccount.id) {
this.skipNextReadBackForAccountId = null
@@ -441,9 +460,7 @@ export class CodexRuntimeHomeService {
}
private getRuntimeHomePath(): string {
const runtimeHomePath = join(homedir(), '.codex')
mkdirSync(runtimeHomePath, { recursive: true })
return runtimeHomePath
return getOrcaManagedCodexHomePath()
}
private getRuntimeAuthPath(): string {
@@ -454,6 +471,10 @@ export class CodexRuntimeHomeService {
return join(this.getRuntimeMetadataDir(), 'system-default-auth.json')
}
private getRuntimeLogoutMarkerPath(): string {
return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json')
}
private getRuntimeMetadataDir(): string {
const metadataDir = join(app.getPath('userData'), 'codex-runtime-home')
mkdirSync(metadataDir, { recursive: true })
@@ -490,8 +511,8 @@ export class CodexRuntimeHomeService {
}
// Why: migration is intentionally one-shot. Re-importing every startup
// would keep replaying stale managed-home state back into ~/.codex and
// make the shared runtime feel nondeterministic.
// would keep replaying stale managed-home state back into the shared
// runtime and make it feel nondeterministic.
writeFileAtomically(
this.getMigrationMarkerPath(),
`${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n`
@@ -618,79 +639,128 @@ export class CodexRuntimeHomeService {
return
}
const runtimeAuthPath = this.getRuntimeAuthPath()
const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json')
const snapshot: CodexSystemDefaultSnapshot = {
authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null
}
writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 })
}
private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void {
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
// auth.json while a managed account was active. If so, that external login
// becomes the new system default — skip the stale snapshot restore.
if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) {
private syncRuntimeAuthWithSystemDefault(): void {
const runtimeAuthPath = this.getRuntimeAuthPath()
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
if (!existsSync(runtimeAuthPath)) {
return
}
try {
const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8')
if (!existsSync(systemDefaultAuthPath)) {
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const mirroredSystemDefaultAuth =
this.lastWrittenAuthJson ?? (snapshot?.authJson !== null ? snapshot?.authJson : null)
if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) {
// Why: when the mirrored ~/.codex auth disappears, Orca should treat
// that as an external logout for unmanaged sessions instead of
// leaving the stale mirrored runtime auth behind after restart.
rmSync(runtimeAuthPath, { force: true })
this.captureSystemDefaultSnapshot({ force: true })
this.lastWrittenAuthJson = null
}
return
}
const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8')
if (runtimeAuth !== systemDefaultAuth) {
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const mirroredSystemDefaultAuth =
this.lastWrittenAuthJson ?? (snapshot?.authJson !== null ? snapshot?.authJson : null)
if (mirroredSystemDefaultAuth !== null && systemDefaultAuth === mirroredSystemDefaultAuth) {
// Why: system-default Codex now refreshes tokens inside Orca's
// runtime CODEX_HOME. Read that refresh back to ~/.codex so the next
// sync does not overwrite fresh runtime credentials with stale ones.
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
this.lastWrittenAuthJson = runtimeAuth
return
}
// Why: the unmanaged path used to read ~/.codex directly. Mirror later
// external logins/logouts into Orca's runtime home so ordinary Orca
// Codex sessions keep matching the user's current system-default state.
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth)
}
} catch (error) {
console.warn('[codex-runtime-home] Failed to sync system-default auth:', error)
}
}
private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void {
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
rmSync(this.getRuntimeAuthPath(), { force: true })
const runtimeAuthPath = this.getRuntimeAuthPath()
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) {
// Why: once Orca owns the runtime CODEX_HOME, deleting auth.json there is
// a local logout signal for Orca-launched Codex sessions, not a reason to
// rewrite the user's real ~/.codex snapshot back into place.
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
return
}
if (existsSync(systemDefaultAuthPath)) {
const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8')
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth)
return
}
if (!existsSync(snapshotPath)) {
this.captureSystemDefaultSnapshot({ force: true })
}
const snapshot = this.readSystemDefaultSnapshot(snapshotPath)
if (!snapshot) {
console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot')
rmSync(snapshotPath, { force: true })
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
this.captureSystemDefaultSnapshot({ force: true })
const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath)
if (!refreshedSnapshot) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
return
}
if (refreshedSnapshot.authJson === null) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
return
}
this.writeRuntimeAuth(refreshedSnapshot.authJson)
return
}
if (snapshot.authJson === null) {
rmSync(this.getRuntimeAuthPath(), { force: true })
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
return
}
this.writeRuntimeAuth(snapshot.authJson)
}
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
// If the runtime auth.json differs from what Orca last wrote, something
// external changed it. That external state should become the new system
// default rather than being overwritten by a potentially stale snapshot.
private detectExternalLoginAndUpdateSnapshot(): boolean {
if (this.lastWrittenAuthJson === null) {
return false
}
private writeSystemDefaultAuth(contents: string): void {
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
mkdirSync(dirname(systemDefaultAuthPath), { recursive: true })
writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 })
this.ensureOwnerOnlyMode(systemDefaultAuthPath)
}
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
}
try {
const currentAuth = readFileSync(runtimeAuthPath, 'utf-8')
if (currentAuth === this.lastWrittenAuthJson) {
return false
}
} catch {
return false
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
private readSystemDefaultAuth(): string | null {
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null
}
private writeRuntimeAuth(contents: string): void {
// Why: auth.json contains sensitive credentials. Restrict to owner-only
// so other users on a shared Linux/macOS machine cannot read it.
this.clearRuntimeLogoutMarker()
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
this.lastWrittenAuthJson = contents
@@ -719,6 +789,58 @@ export class CodexRuntimeHomeService {
}
}
private runtimeLogoutMarkerStillApplies(): boolean {
const marker = this.readRuntimeLogoutMarker()
if (!marker) {
return false
}
if (this.readSystemDefaultAuth() === marker.systemDefaultAuthJson) {
return true
}
this.clearRuntimeLogoutMarker()
return false
}
private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void {
const marker: CodexRuntimeLogoutMarker = {
systemDefaultAuthJson,
loggedOutAt: Date.now()
}
writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, {
mode: 0o600
})
}
private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null {
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown
} catch {
return null
}
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
!('systemDefaultAuthJson' in parsed) ||
!('loggedOutAt' in parsed)
) {
return null
}
const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown }
if (
(marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') ||
typeof marker.loggedOutAt !== 'number'
) {
return null
}
return marker as CodexRuntimeLogoutMarker
}
private clearRuntimeLogoutMarker(): void {
rmSync(this.getRuntimeLogoutMarkerPath(), { force: true })
}
private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null {
let rawContents: string
try {
+53 -2
View File
@@ -1,5 +1,56 @@
import { describe, expect, it } from 'vitest'
import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'path'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
import {
attributeCodexUsageEvent,
getCodexSessionsDirectory,
parseCodexUsageRecord
} from './scanner'
const originalCodexHome = process.env.CODEX_HOME
beforeEach(() => {
delete process.env.CODEX_HOME
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return '/tmp/orca-user-data'
}
throw new Error(`unexpected app.getPath(${name})`)
})
})
afterEach(() => {
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
vi.clearAllMocks()
})
describe('getCodexSessionsDirectory', () => {
it('defaults to Orca-managed Codex runtime sessions', () => {
expect(getCodexSessionsDirectory()).toBe(
join('/tmp/orca-user-data', 'codex-runtime-home', 'home', 'sessions')
)
})
it('respects an explicit CODEX_HOME override', () => {
process.env.CODEX_HOME = '/tmp/explicit-codex-home'
expect(getCodexSessionsDirectory()).toBe(join('/tmp/explicit-codex-home', 'sessions'))
})
})
describe('parseCodexUsageRecord', () => {
it('uses token totals only as a duplicate baseline', () => {
+5 -3
View File
@@ -1,11 +1,11 @@
/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */
import { homedir } from 'os'
import { basename, join, win32, posix } from 'path'
import { createReadStream } from 'fs'
import { realpath, readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
import type {
CodexUsageAttributedEvent,
CodexUsageDailyAggregate,
@@ -51,7 +51,6 @@ type CodexUsageDeltaResolution =
| { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null }
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
const DEFAULT_CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
const YIELD_EVERY_FILES = 10
function ensureNumber(value: unknown): number {
@@ -112,7 +111,10 @@ export function getCodexSessionsDirectory(): string {
if (codexHome) {
return join(codexHome, 'sessions')
}
return DEFAULT_CODEX_SESSIONS_DIR
// Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so
// the main-process usage scanner must follow that runtime home instead of
// the user's ambient ~/.codex directory.
return join(getOrcaManagedCodexHomePath(), 'sessions')
}
export async function listCodexSessionFiles(): Promise<string[]> {
+14
View File
@@ -0,0 +1,14 @@
import { mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { app } from 'electron'
export function getSystemCodexHomePath(): string {
return join(homedir(), '.codex')
}
export function getOrcaManagedCodexHomePath(): string {
const managedHomePath = join(app.getPath('userData'), 'codex-runtime-home', 'home')
mkdirSync(managedHomePath, { recursive: true })
return managedHomePath
}
+82 -5
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
@@ -48,13 +48,20 @@ afterEach(() => {
describe('CodexHookService', () => {
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
writeFileSync(
join(systemCodexHome, 'config.toml'),
'model = "gpt-5.2-codex"\napproval_policy = "on-request"\n',
'utf-8'
)
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const hooksConfig = JSON.parse(
readFileSync(join(tmpHome, '.codex', 'hooks.json'), 'utf-8')
) as {
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
@@ -71,7 +78,77 @@ describe('CodexHookService', () => {
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks')
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8')
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('model = "gpt-5.2-codex"')
expect(trustConfig).toContain('approval_policy = "on-request"')
expect(trustConfig).toContain(':permission_request:0:0')
})
it('keeps hooks isolated by Orca userData instead of mutating system ~/.codex', () => {
const systemCodexHome = join(tmpHome, '.codex')
const systemHooksPath = join(systemCodexHome, 'hooks.json')
const existingSystemHooks = '{"hooks":{"Stop":[{"hooks":[{"command":"user-hook"}]}]}}\n'
mkdirSync(systemCodexHome, { recursive: true })
writeFileSync(systemHooksPath, existingSystemHooks, 'utf-8')
const devUserDataDir = mkdtempSync(join(tmpdir(), 'orca-dev-codex-user-data-'))
const prodUserDataDir = mkdtempSync(join(tmpdir(), 'orca-prod-codex-user-data-'))
try {
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return devUserDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
expect(new CodexHookService().install().state).toBe('installed')
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return prodUserDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
expect(new CodexHookService().install().state).toBe('installed')
const devHooksPath = join(devUserDataDir, 'codex-runtime-home', 'home', 'hooks.json')
const prodHooksPath = join(prodUserDataDir, 'codex-runtime-home', 'home', 'hooks.json')
expect(existsSync(devHooksPath)).toBe(true)
expect(existsSync(prodHooksPath)).toBe(true)
expect(readFileSync(devHooksPath, 'utf-8')).toContain('codex-hook')
expect(readFileSync(prodHooksPath, 'utf-8')).toContain('codex-hook')
expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks)
} finally {
rmSync(devUserDataDir, { recursive: true, force: true })
rmSync(prodUserDataDir, { recursive: true, force: true })
}
})
it('does not overwrite runtime-only Codex config on hook install', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
mkdirSync(managedCodexHome, { recursive: true })
writeFileSync(
join(managedCodexHome, 'config.toml'),
[
'model = "runtime-model"',
'',
'[projects."/workspace/runtime-only"]',
'trust_level = "trusted"',
''
].join('\n'),
'utf-8'
)
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('model = "runtime-model"')
expect(trustConfig).toContain('[projects."/workspace/runtime-only"]')
expect(trustConfig).toContain(':permission_request:0:0')
expect(trustConfig).not.toContain('model = "system-model"')
})
})
+20 -6
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
import { homedir } from 'os'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
@@ -33,6 +33,8 @@ import {
type CodexHookTrustState,
type CodexTrustEntry
} from './config-toml-trust'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
@@ -49,11 +51,23 @@ const CODEX_EVENTS = [
] as const
function getConfigPath(): string {
return join(homedir(), '.codex', 'hooks.json')
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
}
function getCodexConfigTomlPath(): string {
return join(homedir(), '.codex', 'config.toml')
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
function syncSystemConfigIntoManagedCodexHome(): void {
const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml')
const runtimeConfigPath = getCodexConfigTomlPath()
if (!existsSync(systemConfigPath) || existsSync(runtimeConfigPath)) {
return
}
// Why: Orca-launched Codex now reads the Orca-owned runtime CODEX_HOME.
// Seed once from the user's real Codex config before appending Orca hook
// trust, but never overwrite runtime-only edits Codex made later.
writeFileAtomically(runtimeConfigPath, readFileSync(systemConfigPath, 'utf-8'))
}
// Why: Codex's hash key uses the snake_case event label (see
@@ -326,6 +340,7 @@ export class CodexHookService {
// pointing at a hook that doesn't exist. Surface failures — without this,
// getStatus would report green for a hook Codex won't actually fire.
try {
syncSystemConfigIntoManagedCodexHome()
upsertHookTrustEntries(getCodexConfigTomlPath(), trustEntries)
} catch (error) {
return {
@@ -477,9 +492,8 @@ export class CodexHookService {
const managedEventLabels = new Set<CodexEventLabel>(
CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event])
)
// Why: only drop entries WE wrote. configPath (~/.codex/hooks.json) is
// shared with Codex CLI, so user-approved trust entries for non-Orca
// commands live in the same `[hooks.state.*]` namespace. Match by hash
// Why: only drop entries WE wrote. The same config.toml can contain
// user-approved trust entries for non-Orca commands, so match by hash
// equivalence to our managed command — a sourcePath-only filter would
// wipe the user's manually-approved entries.
const ourKeys: string[] = []
+13 -16
View File
@@ -38,7 +38,8 @@ import {
installDevParentDisconnectQuit,
installDevParentWatchdog,
installUncaughtPipeErrorGuard,
patchPackagedProcessPath
patchPackagedProcessPath,
shouldInstallManagedHooks
} from './startup/configure-process'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
@@ -315,10 +316,7 @@ function openMainWindow(): BrowserWindow {
window.webContents.id,
automations,
{
prepareForCodexLaunch: () =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
prepareForCodexLaunch: () => codexRuntimeHome!.prepareForCodexLaunch(),
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
},
agentAwakeService ?? undefined,
@@ -330,10 +328,7 @@ function openMainWindow(): BrowserWindow {
window,
store,
runtime,
() =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
() => codexRuntimeHome!.prepareForCodexLaunch(),
() => claudeRuntimeAuth!.prepareForClaudeLaunch()
)
rateLimits.attach(window)
@@ -792,10 +787,10 @@ app.whenReady().then(async () => {
runtimeService.setAutomationService(automations)
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
prepareForCodexLaunch: () =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
// Why: local Codex hooks and auth now live in Orca's managed runtime home
// even for the system-default path, so every Orca-launched Codex process
// must resolve CODEX_HOME through the runtime-home service.
prepareForCodexLaunch: () => codexRuntimeHome!.prepareForCodexLaunch(),
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
})
disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({
@@ -811,8 +806,8 @@ app.whenReady().then(async () => {
})
)
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
// Why: managed hook installation mutates user-global agent config. Each
// installer runs inside its own try/catch so a malformed local config
// Why: most managed hook installers still mutate user-global agent config.
// Each installer runs inside its own try/catch so a malformed local config
// (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup.
// The agent label travels with each installer so the catch can attribute
// the failure in the `agent_hook_install_failed` telemetry event.
@@ -826,7 +821,9 @@ app.whenReady().then(async () => {
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()]
] as const
runManagedHookInstallers(managedHookInstallers)
if (shouldInstallManagedHooks(is.dev)) {
runManagedHookInstallers(managedHookInstallers)
}
app.on('child-process-gone', (_event, details) => {
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
+14 -19
View File
@@ -895,9 +895,7 @@ describe('registerFilesystemHandlers', () => {
)
})
it('preserves the inherited Codex environment when no managed account is selected', async () => {
const previousCodexHome = process.env.CODEX_HOME
process.env.CODEX_HOME = '/system/codex-home'
it('prepares the Orca-managed Codex home for the default system selection', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
@@ -911,26 +909,23 @@ describe('registerFilesystemHandlers', () => {
message: 'Update README'
})
try {
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch: () => null
})
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch: () => '/orca-managed/codex-home'
})
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, {
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'local',
cwd: WORKTREE_FEATURE_PATH
cwd: WORKTREE_FEATURE_PATH,
env: expect.objectContaining({ CODEX_HOME: '/orca-managed/codex-home' })
})
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
}
)
})
it('returns a sanitized error when local agent account preparation fails', async () => {
+3 -3
View File
@@ -666,13 +666,13 @@ describe('registerPtyHandlers', () => {
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('leaves ambient CODEX_HOME untouched when system default is selected', async () => {
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
const env = await spawnAndGetEnv(
undefined,
{ CODEX_HOME: '/tmp/system-codex-home' },
() => null
() => '/tmp/orca-codex-home'
)
expect(env.CODEX_HOME).toBe('/tmp/system-codex-home')
expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home')
})
describe('daemon-active provider (parity with LocalPtyProvider)', () => {
+4 -5
View File
@@ -338,11 +338,10 @@ export function buildPtyHostEnv(
}
}
// Why: Codex account switching now materializes auth into one shared
// runtime home (~/.codex), and Codex launched inside Orca terminals must
// use that same prepared home as quota fetches and other entry points.
// Keep the override PTY-scoped so Orca does not mutate the app process
// environment or the user's unrelated external shells.
// Why: Codex account switching now materializes auth into an Orca-scoped
// runtime home, and Codex launched inside Orca terminals must use that same
// prepared home as quota fetches and other entry points. Keep the override
// PTY-scoped so dev/prod Orcas do not share hooks through ~/.codex.
if (opts.selectedCodexHomePath) {
baseEnv.CODEX_HOME = opts.selectedCodexHomePath
}
@@ -151,6 +151,20 @@ describe('configureDevUserDataPath', () => {
})
})
describe('shouldInstallManagedHooks', () => {
it('keeps managed hook auto-install enabled for default dev runs', async () => {
const { shouldInstallManagedHooks } = await import('./configure-process')
expect(shouldInstallManagedHooks(true)).toBe(true)
})
it('allows managed hook auto-install for packaged runs', async () => {
const { shouldInstallManagedHooks } = await import('./configure-process')
expect(shouldInstallManagedHooks(false)).toBe(true)
})
})
describe('installDevParentDisconnectQuit', () => {
it('quits the dev app when the supervising IPC channel disconnects', async () => {
const { app } = await import('electron')
+11
View File
@@ -126,6 +126,17 @@ export function configureDevUserDataPath(isDev: boolean): void {
app.setPath('userData', join(app.getPath('appData'), 'orca-dev'))
}
export function shouldInstallManagedHooks(isDev: boolean): boolean {
void isDev
// Why: managed hook installation now targets Orca-owned, environment-scoped
// homes for Codex rather than the user's default ~/.codex state, so plain
// dev runs need the install path enabled to keep hook-backed agent statuses
// accurate without an opt-in flag. The remaining agents still rely on the
// shared startup installer loop, so keep the policy uniformly on until
// they are migrated to more granular ownership seams.
return true
}
export function installDevParentDisconnectQuit(isDev: boolean): void {
if (!isDev || typeof process.send !== 'function') {
return