diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8f7dd751..b0c5966b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -242,10 +242,7 @@ describe('CodexRuntimeHomeService', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8') - - service.syncForCurrentSelection() + new CodexRuntimeHomeService(store as never) expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') @@ -262,6 +259,92 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(join(testState.fakeHomeDir, '.codex'))).toBe(true) }) + it('does not overwrite auth.json when no managed account was ever active', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, '{"account":"external-switch"}\n', 'utf-8') + service.syncForCurrentSelection() + + 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') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Deselect managed account — should restore system default once + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + + // External tool changes auth — subsequent syncs must not overwrite + writeFileSync(runtimeAuthPath, '{"account":"cc-switch"}\n', 'utf-8') + service.syncForCurrentSelection() + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"cc-switch"}\n') + }) + + it('restores system default on restart when persisted active account is invalid', async () => { + const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') + writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath: join(testState.userDataDir, 'codex-accounts', 'account-1', 'home'), + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + // Constructor initializes lastSyncedAccountId='account-1' from settings, + // then syncForCurrentSelection finds missing auth.json and restores snapshot + expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null }) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') + expect(warnSpy).toHaveBeenCalled() + }) + it('imports legacy managed-home history into the shared runtime history', async () => { const runtimeHomePath = join(testState.fakeHomeDir, '.codex') const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 84cfcf91..b298b100 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -19,11 +19,23 @@ import type { Store } from '../persistence' import { writeFileAtomically } from './fs-utils' 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 using cc-switch). The snapshot + // restore only fires on the managed→system-default transition. + private lastSyncedAccountId: string | null = null + constructor(private readonly store: Store) { this.safeMigrateLegacyManagedState() + this.initializeLastSyncedState() this.safeSyncForCurrentSelection() } + private initializeLastSyncedState(): void { + const settings = this.store.getSettings() + this.lastSyncedAccountId = settings.activeCodexManagedAccountId + } + prepareForCodexLaunch(): string { this.safeSyncForCurrentSelection() return this.getRuntimeHomePath() @@ -43,7 +55,15 @@ export class CodexRuntimeHomeService { settings.activeCodexManagedAccountId ) if (!activeAccount) { - this.restoreSystemDefaultSnapshot() + // 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, + // cc-switch, or other tools) on every PTY launch / rate-limit fetch. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot() + this.lastSyncedAccountId = null + } return } @@ -53,10 +73,14 @@ export class CodexRuntimeHomeService { '[codex-runtime-home] Active managed account is missing auth.json, restoring system default' ) this.store.updateSettings({ activeCodexManagedAccountId: null }) - this.restoreSystemDefaultSnapshot() + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot() + this.lastSyncedAccountId = null + } return } + this.lastSyncedAccountId = activeAccount.id this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) }