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)