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) {