diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 513385b5f24..a6b8ff8b03c 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1102,6 +1102,251 @@ 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('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) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + 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 + } + ], + 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') + + // 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 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 }) + ) + 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('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') + 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..00b6c885069 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) { @@ -349,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() @@ -454,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: { @@ -462,7 +486,9 @@ export class CodexRuntimeHomeService { } }) if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.restoreSystemDefaultSnapshot({ + detectExternalLogin: outgoingReadBackResult !== 'rejected' + }) this.lastSyncedAccountId = null } return @@ -596,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()