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..49ed739e616 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -467,6 +467,121 @@ 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('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('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..705c667ec6e 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -741,11 +741,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.id)) { + // 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 +930,30 @@ 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 { + if (!existsSync(this.getRuntimeAuthPath())) { + return true + } + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if (status.kind === 'committed') { + return status.provenance.owner === 'managed' && status.provenance.accountId === accountId + } + return status.kind === 'missing' && this.lastSyncedAccountId === accountId + } + + // 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 { + rmSync(this.getRuntimeAuthPath(), { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + } + private safeSyncForCurrentSelection(): void { try { this.syncForCurrentSelection()