fix(codex-accounts): stop overwriting auth.json when no managed account is active (#847)

This commit is contained in:
Jinwoo Hong 2026-04-19 17:56:44 -04:00 committed by GitHub
parent 9d56108e89
commit c294a4d49e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 6 deletions

View file

@ -242,10 +242,7 @@ describe('CodexRuntimeHomeService', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, '{"account":"managed"}\n', 'utf-8')
service.syncForCurrentSelection()
new CodexRuntimeHomeService(store as never)
expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null })
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
@ -262,6 +259,92 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(join(testState.fakeHomeDir, '.codex'))).toBe(true)
})
it('does not overwrite auth.json when no managed account was ever active', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"original"}\n', 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
writeFileSync(runtimeAuthPath, '{"account":"external-switch"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-switch"}\n')
})
it('does not overwrite auth.json after deselection + external change', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Deselect managed account — should restore system default once
settings.activeCodexManagedAccountId = null
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
// External tool changes auth — subsequent syncs must not overwrite
writeFileSync(runtimeAuthPath, '{"account":"cc-switch"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"cc-switch"}\n')
})
it('restores system default on restart when persisted active account is invalid', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath: join(testState.userDataDir, 'codex-accounts', 'account-1', 'home'),
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
// Constructor initializes lastSyncedAccountId='account-1' from settings,
// then syncForCurrentSelection finds missing auth.json and restores snapshot
expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null })
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
expect(warnSpy).toHaveBeenCalled()
})
it('imports legacy managed-home history into the shared runtime history', async () => {
const runtimeHomePath = join(testState.fakeHomeDir, '.codex')
const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl')

View file

@ -19,11 +19,23 @@ import type { Store } from '../persistence'
import { writeFileAtomically } from './fs-utils'
export class CodexRuntimeHomeService {
// Why: tracks whether auth.json is currently managed by Orca. When null,
// Orca does NOT own auth.json and must not overwrite external changes
// (e.g. user running `codex login` or using cc-switch). The snapshot
// restore only fires on the managed→system-default transition.
private lastSyncedAccountId: string | null = null
constructor(private readonly store: Store) {
this.safeMigrateLegacyManagedState()
this.initializeLastSyncedState()
this.safeSyncForCurrentSelection()
}
private initializeLastSyncedState(): void {
const settings = this.store.getSettings()
this.lastSyncedAccountId = settings.activeCodexManagedAccountId
}
prepareForCodexLaunch(): string {
this.safeSyncForCurrentSelection()
return this.getRuntimeHomePath()
@ -43,7 +55,15 @@ export class CodexRuntimeHomeService {
settings.activeCodexManagedAccountId
)
if (!activeAccount) {
this.restoreSystemDefaultSnapshot()
// Why: only restore the snapshot when transitioning FROM a managed
// account back to system default. When no managed account was ever
// active, auth.json belongs to the user and Orca must not touch it.
// This prevents overwriting external auth changes (codex login,
// cc-switch, or other tools) on every PTY launch / rate-limit fetch.
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
@ -53,10 +73,14 @@ export class CodexRuntimeHomeService {
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
)
this.store.updateSettings({ activeCodexManagedAccountId: null })
this.restoreSystemDefaultSnapshot()
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}