diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index e8d094f03b6..5c62dbfe272 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -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) +} diff --git a/src/main/codex-accounts/codex-credential-absence-grace.test.ts b/src/main/codex-accounts/codex-credential-absence-grace.test.ts index 6672bb7676a..37e360a3a6e 100644 --- a/src/main/codex-accounts/codex-credential-absence-grace.test.ts +++ b/src/main/codex-accounts/codex-credential-absence-grace.test.ts @@ -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') diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index 4de1da70396..abd6a4881ee 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -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({ diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d4ab194705d..3449dbd28f4 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') } @@ -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, diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fac946c51b2..7ab8f8a9766 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,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)