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.
This commit is contained in:
Brennan Benson
2026-07-17 02:26:51 -07:00
parent 5880c6ad65
commit bde30e38a2
2 changed files with 190 additions and 0 deletions
@@ -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')
@@ -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) {