mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
Isolate Codex hooks in Orca runtime home
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user