From ae7904dcddcabbc5f7239b302cc27eaf79a4fc0a Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:18:23 -0400 Subject: [PATCH] fix(codex): prove mirror ownership from the credential, not just provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../codex-accounts/codex-auth-identity.ts | 20 ++ .../runtime-home-service.test.ts | 232 +++++++++++++++++- .../codex-accounts/runtime-home-service.ts | 143 ++++++++--- 3 files changed, 360 insertions(+), 35 deletions(-) diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index e8d094f03b6..6c30a30f10c 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -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( diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 0b18bd65cf4..5f821ccbe44 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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 { 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, diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2eab5a7ce6a..97950f9b889 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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)