fix(codex): never launch the previously synced account during the credential grace window

The credential-absence grace window exists so a mid-rotation read of a managed
auth.json does not deselect the account over a race. In the shared runtime-home
lane it did that by returning early from syncForCurrentSelection, which leaves
the runtime home holding whatever it held before — the previously synced
account's credentials.

So when the selected account's auth.json was missing or unreadable (home on a
disconnected network drive, never provisioned, removed, or held under an
EPERM/EACCES lock by Windows AV), the next launch ran Codex as the *previous*
account while Settings showed the selected one: wrong quota, wrong identity,
work attributed to the wrong account, and no signal to the user.

Not being able to read the selected account's credentials proves nothing about
the identity already mirrored in the runtime home. The grace window now only
short-circuits when Orca can prove the runtime home already holds that same
account (shared-runtime-auth provenance, falling back to the in-memory sync
state on pre-provenance installs). Otherwise the runtime auth is cleared and
the provenance fenced: the selection survives in case the absence heals, but
the launch is logged out rather than logged in as someone else.

Tests cover ENOENT, EACCES/EPERM, and a genuinely still-settling rotation of
the selected account, which keeps its grace and its credentials.
This commit is contained in:
OrcaWin
2026-08-03 22:36:43 -04:00
parent a69ca6d5b7
commit 60970636e8
4 changed files with 181 additions and 5 deletions
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -57,6 +57,21 @@ describe('CodexCredentialAbsenceGrace', () => {
})
})
it('keeps a permission-denied read inside the grace window before it turns durable', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) {
return
}
const grace = new CodexCredentialAbsenceGrace()
writeFileSync(authPath, VALID_AUTH, 'utf-8')
chmodSync(authPath, 0o000)
expect(grace.assess(authPath, 1_000)).toEqual({ state: 'unreadable', durable: false })
expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({
state: 'unreadable',
durable: true
})
})
it('reports settled credential-free JSON as durable immediately', () => {
const grace = new CodexCredentialAbsenceGrace()
writeFileSync(authPath, '{}', 'utf-8')
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -208,6 +208,18 @@ describe('waitForManagedCodexAuthReady', () => {
)
})
it('separates an absent credential from one that cannot be read', () => {
const fixture = createFixture()
expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('missing')
if (process.platform === 'win32' || process.getuid?.() === 0) {
return
}
writeAuth(fixture.home, testChatGptAuth)
chmodSync(join(fixture.home, 'auth.json'), 0o000)
expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable')
})
it('does not gate system, WSL, or unmanaged custom homes', async () => {
const fixture = createFixture()
await waitForManagedCodexAuthReady({
@@ -467,6 +467,121 @@ describe('CodexRuntimeHomeService', () => {
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull()
})
it.each([
['missing', (authPath: string) => rmSync(authPath, { force: true })],
[
'unreadable',
(authPath: string) => {
chmodSync(authPath, 0o000)
}
]
])(
'never launches the previously synced account when the selected account auth is %s',
async (label, breakAuth) => {
if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) {
return
}
const runtimeAuthPath = getRuntimeCodexAuthPath()
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a')
const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b')
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB)
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-a',
email: 'a@example.com',
managedHomePath: homeA,
providerAccountId: 'acct-a',
workspaceLabel: null,
workspaceAccountId: 'acct-a',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-b',
email: 'b@example.com',
managedHomePath: homeB,
providerAccountId: 'acct-b',
workspaceLabel: null,
workspaceAccountId: 'acct-b',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-a',
activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} }
})
)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA)
store.updateSettings({
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
breakAuth(join(homeB, 'auth.json'))
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
// The selection survives the grace window, but the launch must never run
// as account A while the UI says account B.
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe(
authA
)
expect(warnSpy).toHaveBeenCalledWith(
'[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth'
)
}
)
it('keeps the runtime auth through the grace window while the selected account rotates', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed')
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
const store = createStore(
createSettings({
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: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
// Codex rewrites the selected account's auth.json in place: the runtime home
// already holds that same account, so its credentials must survive the race.
writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8')
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1')
})
it('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => {
const legacyLaunchHomePath = join(
testState.userDataDir,
@@ -741,11 +741,21 @@ export class CodexRuntimeHomeService {
const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath)
if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') {
if (!authAbsence.durable) {
// Why: mid-rotation reads look missing/unreadable for a moment; skip
// this sync without deselecting and let a settled read decide later.
if (this.sharedRuntimeAuthBelongsToAccount(activeAccount.id)) {
// Why: mid-rotation reads look missing/unreadable for a moment; skip
// this sync without deselecting and let a settled read decide later.
console.warn(
'[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window'
)
return
}
// Why: the runtime home still holds another account, so riding out the
// grace would launch that account under this selection. Not being able
// to read the selected account is no license to run a different one.
console.warn(
'[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window'
'[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth'
)
this.clearRuntimeAuthForUnprovenSelection()
return
}
console.warn(
@@ -920,6 +930,30 @@ export class CodexRuntimeHomeService {
}
}
// Why: an unreadable managed auth.json says nothing about who the shared
// runtime home currently holds, so only Orca's own provenance can prove those
// bytes belong to the selected account. Pre-provenance installs have no record
// to consult and fall back to the in-memory sync state.
private sharedRuntimeAuthBelongsToAccount(accountId: string): boolean {
if (!existsSync(this.getRuntimeAuthPath())) {
return true
}
const status = this.resolveSharedRuntimeAuthProvenanceStatus()
if (status.kind === 'committed') {
return status.provenance.owner === 'managed' && status.provenance.accountId === accountId
}
return status.kind === 'missing' && this.lastSyncedAccountId === accountId
}
// Why: the absence may still heal, so keep the selection — but leave no other
// identity's credentials behind for the launch. Logged out beats logged in as
// someone else, and fencing stops later syncs adopting the removed bytes.
private clearRuntimeAuthForUnprovenSelection(): void {
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
}
private safeSyncForCurrentSelection(): void {
try {
this.syncForCurrentSelection()