From bde30e38a213da7a036fcc151fcef682da2358e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:26:51 -0700 Subject: [PATCH 1/2] fix(codex): persist outgoing managed tokens before real-home lane takeover (PR-C) prepareForCodexLaunch returns null early for the real-home / system-default lane before syncForCurrentSelection runs. If a managed account is still recorded as synced when the selection has dropped to the system default (nulled without a sync pass, or auto-deselect on missing managed auth), a Codex-refreshed token stranded in the shared runtime home is never persisted to its canonical per-account home -> token loss. Read the outgoing managed account's refreshed token back before the real home takes over. The real-home lane implies host === null, so running the managed->system-default transition restores only Orca's runtime mirror from ~/.codex and never writes the real ~/.codex. It is a no-op once the selection has already been reconciled, so the normal select path does not double-write. --- .../runtime-home-service.test.ts | 170 ++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 20 +++ 2 files changed, 190 insertions(+) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 513385b5f24..4491e80e3b6 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1102,6 +1102,176 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) }) + it('persists an outgoing managed refresh at launch before the real-home lane takes over', 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) + + // Managed account is active, so the launch stays on the managed lane and + // mirrors account-1 into the shared runtime home. + expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) + + // Codex CLI refreshes the token in the shared runtime home. + writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8') + + // Selection drops to the system default WITHOUT an explicit select (no + // syncForCurrentSelection), then Codex launches on the real home. + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.prepareForCodexLaunch()).toBeNull() + + // The refreshed token was read back to account-1's own home before the real + // home took over. + 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 () => { + 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: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + 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', + 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) + + // 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() + + // account-1's refresh was persisted before the auto-deselect handed off, and + // the selection is now the system default. + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) + expect(warnSpy).toHaveBeenCalled() + + // The follow-up launch takes the real-home lane and stays a no-op read-back. + expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed) + }) + + 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') + 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) + + // Refresh, then deselect through the normal (explicit) select path. + writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth) + + // A subsequent real-home launch must not re-run the transition — the + // outgoing account is already reconciled to the system default. + const syncSpy = vi.spyOn(service, 'syncForCurrentSelection') + expect(service.prepareForCodexLaunch()).toBeNull() + expect(syncSpy).not.toHaveBeenCalled() + expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth) + }) + it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 355052b5568..2bdc0015258 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -174,6 +174,10 @@ export class CodexRuntimeHomeService { // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; // sessions, auth, and config all live in the native home. No system-> // managed session bridge runs, so the real home stays the single source. + // First guarantee any still-synced managed account's refreshed token is + // read back to its own home: this early return skips syncForCurrentSelection, + // so a launch-time managed->default transition would otherwise lose it. + this.readBackOutgoingManagedHostAccountBeforeSystemDefault() return null } this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) @@ -189,6 +193,22 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + // Why: the real-home / system-default lane leaves the shared runtime home + // untouched, so a managed account still recorded as synced (selection nulled + // without a syncForCurrentSelection pass, or auto-deselect on missing auth) + // can strand a Codex-refreshed token there. Run the managed->system-default + // transition to read that token back to its canonical per-account home before + // the real home takes over. Callers gate this on the system-default selection + // (host === null), so syncForCurrentSelection restores only Orca's runtime + // mirror from ~/.codex and never writes the real ~/.codex. No-op once the + // selection has already been reconciled to the system default. + private readBackOutgoingManagedHostAccountBeforeSystemDefault(): void { + if (this.lastSyncedAccountId === null) { + return + } + this.syncForCurrentSelection() + } + private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { const settings = this.store.getSettings() if (normalizeCodexRuntimeSelection(settings).host !== null) { 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 2/2] 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()