fix(codex): do not silently run the previous account when managed auth is unreadable (#12407)

* 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.

* fix(codex): keep a mirrored token refresh when refusing an unproven selection

Clearing the shared runtime auth to avoid launching the wrong account can drop
bytes Codex refreshed in place — on a cold start whose mirror still holds the
previous account, no outgoing read-back has run yet, so those tokens exist
nowhere else and OAuth refresh-token rotation would force a re-login.

Read the mirror back to whichever managed account owns it (identity + freshness
proven, as on an account switch) before removing it.

* fix(codex): keep an unreadable managed home from dropping another account's mirrored refresh

An account whose auth.json cannot be read aborted the whole read-back scan,
so removing the runtime mirror for an unproven selection destroyed a token
refresh it held for a different account. Skip the unreadable home instead.

* fix(codex): prove mirror ownership from the credential, not just provenance

Review found the unproven-selection clear was both too eager and too
destructive:

- A `fenced` provenance record (torn provenance file, or a crashed pending
  write) turned the grace window into a logout of the account the mirror
  actually and correctly held.
- The pre-provenance fallback was tautological: initializeLastSyncedState
  seeds lastSyncedAccountId with the SELECTED account, so
  `missing && lastSyncedAccountId === id` is true on every cold start —
  the wrong-account launch still reproduced on installs with no
  provenance file.
- The pre-delete read-back only rescued managed-owned bytes, so a
  system-default refresh living only in the mirror was destroyed with no
  path back to ~/.codex.
- rmSync was unguarded on a branch whose entry condition is "the
  filesystem refused a read", so an EPERM/EBUSY lock could throw out of
  prepareForCodexLaunch, and the fence was written after the delete.
- Swallowing managed-home read errors let an ambiguous candidate set
  collapse to a single match and be written to the wrong home.

Ownership is now decided from the credential's own identity claims
(codexAuthMatchesManagedAccount against the account record, no read of the
unreadable home), then from the exact bytes Orca mirrored this run, and
only then from provenance. The clear rescues a mirror-only system-default
refresh to ~/.codex first (reusing the ownership rules
syncRuntimeAuthWithSystemDefault already applies, now shared via
resolveSystemDefaultMirrorClaim), fences before deleting, and never throws.
An unreadable managed home whose record cannot rule it out now makes the
read-back scan ambiguous instead of misattributing the bytes.

Five tests added; each fails on the previous commit and passes here.

* fix(codex): never let the fence cancel the delete, or unattributable bytes cause a logout

Round-2 review found the previous response commit walked the guard backwards
in three places:

- `fence before deleting` put persistSharedRuntimeAuthProvenance and rmSync in
  one try, so a fence write the OS refuses (Windows AV lock on the provenance
  file) cancelled the delete and the launch ran with the other account's
  credentials — the original defect, reproduced. The delete now runs from a
  finally: the fence is still attempted first, but neither it nor the read-back
  rescue can skip it.
- Credentials with no OAuth identity claims (apikey/agentIdentity/PAT/bedrock,
  all first-class in managed-codex-auth-readiness) can never satisfy a positive
  ownership proof, so a pre-provenance install deleted the mirror it correctly
  held during a transient absence. With no committed provenance record left,
  ownership is now refuted rather than proven: only claims that contradict the
  account rule the mirror out, and claims a credential does not carry
  contradict nothing.
- A stale record email hard-failed the proof even when the account id matched
  exactly. Orca only refreshes account.email on add/re-auth, so a renamed
  ChatGPT account kept a stale email indefinitely and both lost its mirror and
  had its refreshes dropped instead of read back. A positive account-id match
  now outranks the email, in one shared rule both identity predicates use.

Four tests added; each fails without its fix.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
(cherry picked from commit 401f66939d)
This commit is contained in:
OrcaWin
2026-08-04 02:28:06 -04:00
committed by OrcaWin
parent 45b877fb86
commit d2f3394340
5 changed files with 809 additions and 24 deletions
+61 -2
View File
@@ -29,8 +29,17 @@ export function codexAuthMatchesManagedAccount(
normalizeField(account.workspaceAccountId),
managedIdentity?.workspaceAccountId
)
const emailMatches = Boolean(selectedEmail && identity.email && selectedEmail === identity.email)
if (selectedEmail && identity.email && selectedEmail !== identity.email) {
const emailMatches = identityFieldsAgree(selectedEmail, identity.email)
if (
identityContradictsSelection(
{
email: selectedEmail,
providerAccountId: selectedProviderId,
workspaceAccountId: selectedWorkspaceId
},
identity
)
) {
return false
}
if (!identityFieldMatches(selectedProviderId, identity.providerAccountId)) {
@@ -50,6 +59,26 @@ export function codexAuthMatchesManagedAccount(
)
}
// Why: an unreadable managed home cannot be compared byte-for-byte, so only the
// account record itself can rule that home out as the credential's owner.
export function codexAuthCouldBelongToManagedAccount(
runtimeAuthContents: string,
account: CodexManagedAccount
): boolean {
const identity = readIdentityFromAuthContents(runtimeAuthContents)
return (
!identity ||
!identityContradictsSelection(
{
email: normalizeField(account.email),
providerAccountId: normalizeField(account.providerAccountId),
workspaceAccountId: normalizeField(account.workspaceAccountId)
},
identity
)
)
}
// Why: the shared mirror may still hold managed credentials; only the same
// positively identified system account may ever be read back to ~/.codex.
export function codexAuthMatchesSystemDefaultIdentity(
@@ -240,3 +269,33 @@ function firstNonNull(...values: (string | null | undefined)[]): string | null {
function identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean {
return !selectedField || Boolean(runtimeField && selectedField === runtimeField)
}
// Why: only a claim both sides carry can rule an account out — a credential with
// no identity claims (api key, PAT, bedrock) contradicts nothing. A ChatGPT
// rename leaves the record email stale, so a matching account id outranks it.
function identityContradictsSelection(
selected: CodexAuthIdentity,
identity: CodexAuthIdentity
): boolean {
if (
identityFieldsConflict(selected.providerAccountId, identity.providerAccountId) ||
identityFieldsConflict(selected.workspaceAccountId, identity.workspaceAccountId)
) {
return true
}
return (
identityFieldsConflict(selected.email, identity.email) &&
!identityFieldsAgree(selected.providerAccountId, identity.providerAccountId)
)
}
function identityFieldsAgree(selectedField: string | null, runtimeField: string | null): boolean {
return Boolean(selectedField && runtimeField && selectedField === runtimeField)
}
function identityFieldsConflict(
selectedField: string | null,
runtimeField: string | null
): boolean {
return Boolean(selectedField && runtimeField && selectedField !== runtimeField)
}
@@ -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({
@@ -16,7 +16,7 @@ import {
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { GlobalSettings } from '../../shared/types'
import type { CodexManagedAccount, GlobalSettings } from '../../shared/types'
import type * as ShellStartupEnv from '../pty/shell-startup-env'
const testState = {
@@ -259,6 +259,25 @@ function createManagedAuth(rootDir: string, accountId: string, auth: string): st
return managedHomePath
}
function createCodexAccountRecord(
id: string,
email: string,
providerAccountId: string,
managedHomePath: string
): CodexManagedAccount {
return {
id,
email,
managedHomePath,
providerAccountId,
workspaceLabel: null,
workspaceAccountId: providerAccountId,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
}
function encodeJwtPart(value: Record<string, unknown>): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
@@ -467,6 +486,557 @@ 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.each([
['missing', (authPath: string) => rmSync(authPath, { force: true })],
[
'unreadable',
(authPath: string) => {
chmodSync(authPath, 0o000)
}
]
])(
'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s',
async (label, breakAuth) => {
if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) {
return
}
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1)
const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2)
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// A crash left account A's refreshed tokens in the mirror while settings
// already select account B, whose own auth.json cannot be read.
writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`,
'utf-8'
)
breakAuth(join(homeB, 'auth.json'))
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-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(existsSync(runtimeAuthPath)).toBe(false)
expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
}
)
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('keeps runtime auth the selected account still owns when provenance is unreadable', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed')
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
// Cold start: the mirror holds the selected account's own credential, but a
// torn provenance file can no longer say so and its auth.json reads torn.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, managedAuth, 'utf-8')
writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8')
writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8')
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath)
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1')
})
it('never launches another account on a cold start with no provenance record', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a')
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// Pre-provenance install: the mirror still holds account A, settings already
// select account B, and nothing on disk records who wrote those bytes.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, authA, 'utf-8')
rmSync(getSharedRuntimeAuthProvenancePath(), { force: true })
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA),
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe(
authA
)
expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
})
it('persists a mirror-only system-default refresh before dropping unproven runtime auth', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system', 1)
const systemAuthRefreshed = createCodexAuthJson(
'system@example.com',
'acct-system',
'system-refreshed',
2
)
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// Codex rotated the system-default credential inside the mirror; ~/.codex is
// still the older copy Orca mirrored, so the refresh lives only here.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, systemAuthRefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n`,
'utf-8'
)
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuthRefreshed)
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('survives a runtime auth delete the filesystem refuses', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const homeA = createManagedAuth(
testState.userDataDir,
'account-a',
createCodexAuthJson('a@example.com', 'acct-a', 'a')
)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// Stands in for a Windows AV/EBUSY lock: the mirror auth.json cannot be
// removed, and the branch is only reached because reads are already failing.
mkdirSync(runtimeAuthPath, { recursive: true })
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`,
'utf-8'
)
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA),
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(() => service.prepareForCodexLaunch()).not.toThrow()
// The bytes survived the failed delete, so they must at least be fenced off.
expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({
owner: 'fenced'
})
rmSync(runtimeAuthPath, { recursive: true, force: true })
})
it('refuses to read runtime auth back into a duplicate account while a home is unreadable', async () => {
if (process.platform === 'win32' || process.getuid?.() === 0) {
return
}
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authX = createCodexAuthJson('x@example.com', 'acct-x', 'x', 1)
const authXRefreshed = createCodexAuthJson('x@example.com', 'acct-x', 'x-refreshed', 2)
// Two records for the same identity: one home unreadable, one readable.
const homeX1 = createManagedAuth(testState.userDataDir, 'account-x1', authX)
const homeX2 = createManagedAuth(testState.userDataDir, 'account-x2', authX)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, authXRefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-x1' })}\n`,
'utf-8'
)
chmodSync(join(homeX1, 'auth.json'), 0o000)
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-x1', 'x@example.com', 'acct-x', homeX1),
createCodexAccountRecord('account-x2', 'x@example.com', 'acct-x', homeX2),
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(join(homeX2, 'auth.json'), 'utf-8')).toBe(authX)
})
it('drops another account from the mirror even when the fence write fails', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a')
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// Stands in for a Windows AV lock on the provenance file: the fence cannot
// be written, while auth.json itself is perfectly deletable.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, authA, 'utf-8')
rmSync(getSharedRuntimeAuthProvenancePath(), { force: true })
mkdirSync(getSharedRuntimeAuthProvenancePath(), { recursive: true })
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA),
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe(
authA
)
})
it('keeps a mirrored api-key credential no identity claim can attribute', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const apiKeyAuth = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-managed' })}\n`
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', apiKeyAuth)
// Pre-provenance install, cold start: the mirror holds the selected account's
// own api-key credential — bytes carrying no id_token to identify — while its
// auth.json reads torn mid-rotation.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, apiKeyAuth, 'utf-8')
rmSync(getSharedRuntimeAuthProvenancePath(), { force: true })
writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', 'utf-8')
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath)
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(apiKeyAuth)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1')
})
it('reads a renamed account refresh back into its own home despite a stale record email', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const authA = createCodexAuthJson('new@example.com', 'acct-a', 'a', 1)
const authARefreshed = createCodexAuthJson('new@example.com', 'acct-a', 'a-refreshed', 2)
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
const homeB = createManagedAuth(
testState.userDataDir,
'account-b',
createCodexAuthJson('b@example.com', 'acct-b', 'b')
)
// Account A was renamed on the ChatGPT side after it was added, so its record
// email is stale while the mirrored refresh carries the new one.
writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8')
writeFileSync(
getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`,
'utf-8'
)
rmSync(join(homeB, 'auth.json'), { force: true })
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-a', 'old@example.com', 'acct-a', homeA),
createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB)
],
activeCodexManagedAccountId: 'account-b',
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed)
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('keeps the mirror of a renamed account whose record email is stale', async () => {
const runtimeAuthPath = getRuntimeCodexAuthPath()
writeFileSync(
getSystemCodexAuthPath(),
createCodexAuthJson('system@example.com', 'acct-system', 'system'),
'utf-8'
)
const renamedAuth = createCodexAuthJson('new@example.com', 'acct-user', 'managed')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', renamedAuth)
// Torn provenance can no longer vouch for the mirror, and the only remaining
// evidence is a credential whose email no longer matches the record.
mkdirSync(getRuntimeCodexHomePath(), { recursive: true })
writeFileSync(runtimeAuthPath, renamedAuth, 'utf-8')
writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8')
writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8')
const store = createStore(
createSettings({
codexManagedAccounts: [
createCodexAccountRecord('account-1', 'old@example.com', 'acct-user', managedHomePath)
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(renamedAuth)
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,
+148 -19
View File
@@ -72,6 +72,7 @@ import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-pa
import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
import {
codexAuthCouldBelongToManagedAccount,
codexAuthIsFresher,
codexAuthMatchesManagedAccount,
codexAuthMatchesSystemDefaultIdentity
@@ -741,11 +742,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)) {
// 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 +931,116 @@ export class CodexRuntimeHomeService {
}
}
// Why: an unreadable managed auth.json says nothing about who the shared
// runtime home currently holds. The credential's own identity claims answer
// that without touching the unreadable home; Orca's write records and its
// provenance file are corroboration, not the only evidence — a torn or fenced
// provenance file is no proof the bytes belong to somebody else.
private sharedRuntimeAuthBelongsToAccount(account: CodexManagedAccount): boolean {
if (!existsSync(this.getRuntimeAuthPath())) {
return true
}
const runtimeAuth = this.readRuntimeAuthForProvenance()
if (runtimeAuth !== null) {
if (codexAuthMatchesManagedAccount(runtimeAuth, account, null)) {
return true
}
// Why: Orca itself mirrored these exact bytes for this account this run.
if (this.lastSyncedAccountId === account.id && this.lastWrittenAuthJson === runtimeAuth) {
return true
}
}
const status = this.resolveSharedRuntimeAuthProvenanceStatus()
if (status.kind === 'committed') {
return status.provenance.owner === 'managed' && status.provenance.accountId === account.id
}
// Why: with no committed record left, only claims that contradict this
// account prove the mirror is somebody else's. Deleting bytes nothing can
// attribute logs the user out over the very absence the grace window covers.
return runtimeAuth !== null && codexAuthCouldBelongToManagedAccount(runtimeAuth, account)
}
// 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 {
const runtimeAuthPath = this.getRuntimeAuthPath()
try {
try {
// Why: a refresh Codex wrote into the mirror belongs to whoever owns
// those bytes; persist it to that owner's home — managed or ~/.codex —
// first, then fence so a delete the OS refuses (Windows AV lock, EBUSY)
// still leaves the surviving bytes marked untrusted.
const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
updateLastWrittenAuthJson: false
})
if (readBackResult === 'rejected') {
this.readBackRefreshedSystemDefaultAuth()
}
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
} finally {
// Why: neither the rescue nor the fence may cancel the delete — leaving
// another identity's credentials for the launch is the defect itself.
this.lastWrittenAuthJson = null
rmSync(runtimeAuthPath, { force: true })
}
} catch (error) {
// Why: this branch only runs because the filesystem already refused a
// read; a second failure must not throw out of launch preparation.
console.warn('[codex-runtime-home] Failed to clear unproven runtime auth:', error)
}
}
// Why: a token Codex refreshed inside the mirror has no managed home to fall
// back to when the mirror holds the user's own ~/.codex credential, so persist
// it there before the unproven mirror is dropped.
private readBackRefreshedSystemDefaultAuth(): void {
const runtimeAuth = this.readRuntimeAuthForProvenance()
const systemDefaultAuth = this.readSystemDefaultAuth()
if (runtimeAuth === null || systemDefaultAuth === null || runtimeAuth === systemDefaultAuth) {
return
}
const claim = this.resolveSystemDefaultMirrorClaim(
runtimeAuth,
this.resolveSharedRuntimeAuthProvenanceStatus()
)
if (
!claim.ownershipProven ||
claim.mirroredAuthJson === null ||
systemDefaultAuth !== claim.mirroredAuthJson ||
!this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemDefaultAuth)
) {
return
}
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
}
// Why: which ~/.codex bytes the mirror was seeded from, and whether the system
// default can be proven to own the mirror at all.
private resolveSystemDefaultMirrorClaim(
runtimeAuth: string,
provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus
): { ownershipProven: boolean; mirroredAuthJson: string | null } {
const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null
const snapshotAuth =
this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null
const preProvenanceRuntimeRefreshProven =
provenanceStatus.kind === 'missing' &&
snapshotAuth !== null &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) &&
codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth)
return {
ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven,
mirroredAuthJson:
provenance?.owner === 'system-default'
? provenance.authJson
: provenanceStatus.kind === 'missing'
? (this.lastWrittenAuthJson ?? snapshotAuth)
: null
}
}
private safeSyncForCurrentSelection(): void {
try {
this.syncForCurrentSelection()
@@ -1234,6 +1355,7 @@ export class CodexRuntimeHomeService {
managedAuthPath: string
managedAuthContents: string
}[] = []
let unreadableHomeCouldOwnRuntimeAuth = false
for (const account of this.store.getSettings().codexManagedAccounts) {
if (expectedAccountId && account.id !== expectedAccountId) {
continue
@@ -1242,12 +1364,30 @@ export class CodexRuntimeHomeService {
if (!existsSync(managedAuthPath)) {
continue
}
const managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
let managedAuthContents: string
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
// Why: an unreadable home can never be compared, but letting the read
// throw abandons the scan for every other account — dropping a refresh
// the runtime home holds for one of them. Only its record can rule it
// out as the owner; when it cannot, the scan is no longer unambiguous.
if (
!expectedAccountId &&
codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)
) {
unreadableHomeCouldOwnRuntimeAuth = true
}
continue
}
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
matches.push({ account, managedAuthPath, managedAuthContents })
}
}
if (unreadableHomeCouldOwnRuntimeAuth) {
return { kind: 'ambiguous' }
}
if (matches.length === 1) {
return { kind: 'matched', ...matches[0] }
}
@@ -1652,21 +1792,10 @@ export class CodexRuntimeHomeService {
})
return
}
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const snapshotAuth = snapshot?.authJson ?? null
const preProvenanceRuntimeRefreshProven =
provenanceStatus.kind === 'missing' &&
snapshotAuth !== null &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) &&
codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth)
const systemDefaultOwnershipProven =
provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven
const mirroredSystemDefaultAuth =
provenance?.owner === 'system-default'
? provenance.authJson
: provenanceStatus.kind === 'missing'
? (this.lastWrittenAuthJson ?? snapshotAuth)
: null
const {
ownershipProven: systemDefaultOwnershipProven,
mirroredAuthJson: mirroredSystemDefaultAuth
} = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus)
if (!existsSync(systemDefaultAuthPath)) {
if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) {
this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath)