From 0f125eb93c2c84c93b190ff99744654152dde161 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:36:38 -0700 Subject: [PATCH] fix(codex): preserve refreshes across all default transitions --- .../runtime-home-service.test.ts | 117 ++++++++++++++---- .../codex-accounts/runtime-home-service.ts | 34 ++++- 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 4491e80e3b6..a6b8ff8b03c 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1157,15 +1157,61 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth) }) - it('persists the previous managed refresh when auto-deselect hands off to the real home', async () => { + it('reads back only once when polling first observes a managed-to-real-home transition', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1) + const refreshedManagedAuth = createCodexAuthJson( + 'user@example.com', + 'acct-user', + 'refreshed', + 2 + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-user', + workspaceLabel: null, + workspaceAccountId: 'acct-user', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + const syncSpy = vi.spyOn(service, 'syncForCurrentSelection') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(syncSpy).toHaveBeenCalledTimes(1) + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForCodexLaunch()).toBeNull() + expect(syncSpy).toHaveBeenCalledTimes(1) + }) + + it('persists the active managed refresh when missing auth auto-deselects to the real home', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed', 2) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) const settings = createSettings({ codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ @@ -1179,17 +1225,6 @@ describe('CodexRuntimeHomeService', () => { createdAt: 1, updatedAt: 1, lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 } ], activeCodexManagedAccountId: 'account-1', @@ -1203,15 +1238,15 @@ describe('CodexRuntimeHomeService', () => { // account-1 is synced; Codex refreshes its token in the shared runtime home. writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') - // Switch to account-2 whose managed auth.json is missing -> auto-deselect. - rmSync(join(managedHomePath2, 'auth.json'), { force: true }) - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - service.syncForCurrentSelection() + // The active account's canonical auth disappears before launch. This is the + // same-account auto-deselect path, where no ordinary outgoing switch runs. + rmSync(join(managedHomePath1, 'auth.json'), { force: true }) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - // account-1's refresh was persisted before the auto-deselect handed off, and - // the selection is now the system default. + // account-1's runtime refresh was recovered before auto-deselect, and the + // selection is now the system default without changing the real auth. expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(store.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ activeCodexManagedAccountId: null }) ) @@ -1223,6 +1258,46 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed) }) + it('refuses mismatched runtime auth when the active managed auth is missing', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1) + const mismatchedAuth = createCodexAuthJson('other@example.com', 'acct-other', 'other', 2) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: 'acct-user', + workspaceLabel: null, + workspaceAccountId: 'acct-user', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + 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) + + writeFileSync(runtimeAuthPath, mismatchedAuth, 'utf-8') + rmSync(join(managedHomePath, 'auth.json'), { force: true }) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + + expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(false) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(warnSpy).toHaveBeenCalled() + }) + it('does not re-run the outgoing read-back on a real-home launch after a normal deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2bdc0015258..00b6c885069 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -369,6 +369,7 @@ export class CodexRuntimeHomeService { // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the // managed home, restarting the background OAuth conflict (#5370), so // pin this non-interactive lane to the native home explicitly. + this.readBackOutgoingManagedHostAccountBeforeSystemDefault() return getSystemCodexHomePath() } this.syncForCurrentSelection() @@ -474,6 +475,9 @@ export class CodexRuntimeHomeService { console.warn( '[codex-runtime-home] Active managed account is missing auth.json, restoring system default' ) + if (this.lastSyncedAccountId === activeAccount.id) { + outgoingReadBackResult = this.recoverRefreshForMissingActiveAccount(activeAccount) + } this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -482,7 +486,9 @@ export class CodexRuntimeHomeService { } }) if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) this.lastSyncedAccountId = null } return @@ -616,6 +622,32 @@ export class CodexRuntimeHomeService { }) } + private recoverRefreshForMissingActiveAccount(account: CodexManagedAccount): CodexReadBackResult { + try { + const runtimeAuthPath = this.getRuntimeAuthPath() + if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) { + return 'rejected' + } + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + if (runtimeContents === this.lastWrittenAuthJson) { + return 'unchanged' + } + // Why: the canonical file is gone, so the exact in-memory bytes Orca + // previously mirrored are the only safe identity baseline for recovery. + if (!this.runtimeAuthMatchesAccount(runtimeContents, account, this.lastWrittenAuthJson)) { + return 'rejected' + } + writeFileAtomically(join(account.managedHomePath, 'auth.json'), runtimeContents, { + mode: 0o600 + }) + this.lastWrittenAuthJson = runtimeContents + return 'persisted' + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover missing managed auth:', error) + return 'rejected' + } + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection()