diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 4b4575fd6c2..41b3962d4e5 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -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('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 }) } diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index f71ed9e4408..56220d41f4a 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -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 { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d820830af8c..154b3358f6e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -118,6 +118,22 @@ function createSettings(overrides: Partial = {}): 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( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3c099ce575b..3ec807f4143 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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 { diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d7251b684ea..5d85a30a67e 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -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', () => { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 130336204db..ef787a8ca4a 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -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 { diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts new file mode 100644 index 00000000000..b3cc67e0b97 --- /dev/null +++ b/src/main/codex/codex-home-paths.ts @@ -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 +} diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index a431c17fff9..be76887a3a8 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -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 } @@ -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"') + }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index c431b121ce2..6bf511f1b83 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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( 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[] = [] diff --git a/src/main/index.ts b/src/main/index.ts index 149008ea1e1..97b131af52c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 6679e5cff26..4f747e36f64 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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 () => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 38f15fe68e8..9017b6f2705 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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)', () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4a1454292d6..392964c3ffb 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 } diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 2552323e9eb..9ecd76cbddc 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -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') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 7a23599b3b6..6361d502fb3 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -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