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.
This commit is contained in:
OrcaWin
2026-08-04 00:18:23 -04:00
parent af3cfead4d
commit ae7904dcdd
3 changed files with 360 additions and 35 deletions
@@ -50,6 +50,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)
if (!identity) {
return true
}
const accountEmail = normalizeField(account.email)
if (accountEmail && identity.email && accountEmail !== identity.email) {
return false
}
return (
identityFieldMatches(normalizeField(account.providerAccountId), identity.providerAccountId) &&
identityFieldMatches(normalizeField(account.workspaceAccountId), identity.workspaceAccountId)
)
}
// 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(
@@ -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')
}
@@ -659,6 +678,217 @@ describe('CodexRuntimeHomeService', () => {
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('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => {
const legacyLaunchHomePath = join(
testState.userDataDir,
+109 -34
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,7 +742,7 @@ export class CodexRuntimeHomeService {
const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath)
if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') {
if (!authAbsence.durable) {
if (this.sharedRuntimeAuthBelongsToAccount(activeAccount.id)) {
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(
@@ -931,32 +932,106 @@ 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 {
// 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 status = this.resolveSharedRuntimeAuthProvenanceStatus()
if (status.kind === 'committed') {
return status.provenance.owner === 'managed' && status.provenance.accountId === accountId
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
}
}
return status.kind === 'missing' && this.lastSyncedAccountId === accountId
const status = this.resolveSharedRuntimeAuthProvenanceStatus()
return (
status.kind === 'committed' &&
status.provenance.owner === 'managed' &&
status.provenance.accountId === account.id
)
}
// 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 {
// Why: a refresh Codex wrote into the mirror belongs to whoever owns those
// bytes; persist it to that account's home before dropping them.
this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), {
updateLastWrittenAuthJson: false
})
rmSync(this.getRuntimeAuthPath(), { force: true })
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
const runtimeAuthPath = this.getRuntimeAuthPath()
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.
const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
updateLastWrittenAuthJson: false
})
if (readBackResult === 'rejected') {
this.readBackRefreshedSystemDefaultAuth()
}
// Why: fence before deleting so a delete the OS refuses (Windows AV lock,
// EBUSY) still leaves the surviving bytes marked untrusted.
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
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 {
@@ -1273,6 +1348,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
@@ -1285,9 +1361,16 @@ export class CodexRuntimeHomeService {
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
// Why: an unreadable home can never be the match, but letting the read
// 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.
// 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)) {
@@ -1295,6 +1378,9 @@ export class CodexRuntimeHomeService {
}
}
if (unreadableHomeCouldOwnRuntimeAuth) {
return { kind: 'ambiguous' }
}
if (matches.length === 1) {
return { kind: 'matched', ...matches[0] }
}
@@ -1699,21 +1785,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)