diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts index 6f0603c73f4..8dfebe8df1d 100644 --- a/src/main/codex-accounts/host-codex-managed-home-ownership.ts +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' +import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' type HostCodexManagedHomeOwnershipOptions = { @@ -8,6 +8,43 @@ type HostCodexManagedHomeOwnershipOptions = { expectedAccountId?: string } +export const MISSING_MANAGED_HOME_MESSAGE = 'Managed Codex home directory does not exist on disk.' + +/** + * Why: the gate answers two different questions and callers act on them very + * differently. `untrusted` is a *successful observation* that failed a trust + * check (or a definitive absence where absence is itself the verdict), and only + * it may clear the user's persisted account selection. `indeterminate` means we + * could not read the home at all — the home may be perfectly valid, so callers + * must refuse to *use* it without erasing durable state (#STA-4422: one EBUSY + * from an antivirus lock used to log the user out permanently). + */ +export type HostCodexManagedHomeVerdict = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted'; reason: string } + | { kind: 'indeterminate'; error: unknown } + +/** Thrown for a proven trust failure; safe to clear selection on. */ +export class UntrustedManagedCodexHomeError extends Error {} + +/** + * Thrown when the home could not be read. Callers must refuse the operation and + * leave persisted selection, credentials, and managed directories untouched. + */ +export class ManagedCodexHomeTemporarilyUnavailableError extends Error { + constructor( + message = 'Codex account files are temporarily locked. Retry in a moment.', + options?: { cause?: unknown } + ) { + super(message, options) + } +} + +function isDefinitiveAbsence(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + function pathsEqual(left: string, right: string): boolean { const resolvedLeft = resolve(left) const resolvedRight = resolve(right) @@ -24,73 +61,148 @@ function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean { ) } +/** + * Why: the system Codex home is optional — a user with no ~/.codex is normal, + * and its absence says nothing about the managed candidate. Only a definitive + * absence may fall back to the resolved spelling; any other read error is + * inconclusive and must reach the caller as `indeterminate`. + */ function canonicalizeIfPresent(candidatePath: string): string { const resolvedPath = resolve(candidatePath) - return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath + try { + return realpathSync(resolvedPath) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return resolvedPath + } + throw error + } } -/** - * Proves a host managed home is an Orca-owned account directory and cannot - * resolve into the user's real CODEX_HOME before callers write launch state. - */ -export function assertOwnedHostCodexManagedHomePath({ +function evaluate({ candidatePath, managedAccountsRoot, systemCodexHomePath, expectedAccountId -}: HostCodexManagedHomeOwnershipOptions): string { +}: HostCodexManagedHomeOwnershipOptions): HostCodexManagedHomeVerdict { const resolvedCandidate = resolve(candidatePath) const resolvedRoot = resolve(managedAccountsRoot) - if (!existsSync(resolvedCandidate)) { - throw new Error('Managed Codex home directory does not exist on disk.') + + // Why: absence of the candidate IS the structural verdict here, but only a + // definitive one. `existsSync` used to fold EPERM into "does not exist", which + // is what made an antivirus lock look like a deleted account. + let canonicalCandidate: string + try { + statSync(resolvedCandidate) + canonicalCandidate = realpathSync(resolvedCandidate) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: MISSING_MANAGED_HOME_MESSAGE } + } + return { kind: 'indeterminate', error } } - // Why: macOS can spell one path as /var and /private/var, while a replaced - // path component must still be caught as a canonical containment escape. - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(resolvedRoot) - const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + + let canonicalRoot: string + let canonicalSystemHome: string + try { + canonicalRoot = realpathSync(resolvedRoot) + canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + } catch (error) { + return { kind: 'indeterminate', error } + } + if (expectedAccountId !== undefined) { const candidateUsesManagedRootSpelling = pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) || pathIsInsideOrEqual(canonicalRoot, resolvedCandidate) - const canonicalExpectedHome = canonicalizeIfPresent( - join(canonicalRoot, expectedAccountId, 'home') - ) + let canonicalExpectedHome: string + try { + canonicalExpectedHome = canonicalizeIfPresent(join(canonicalRoot, expectedAccountId, 'home')) + } catch (error) { + return { kind: 'indeterminate', error } + } if ( !candidateUsesManagedRootSpelling || !pathsEqual(canonicalCandidate, canonicalExpectedHome) ) { - throw new Error('Managed Codex home does not match its persisted account ID.') + return { + kind: 'untrusted', + reason: 'Managed Codex home does not match its persisted account ID.' + } } } + // Why: a replaced codex-accounts directory could otherwise redirect config, // hook, or resource writes into the user's real ~/.codex tree. if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) { - throw new Error('Managed Codex home resolves inside the system Codex home.') + return { + kind: 'untrusted', + reason: 'Managed Codex home resolves inside the system Codex home.' + } } if ( !pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) || canonicalRoot === canonicalCandidate ) { - throw new Error( - `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` - ) + return { + kind: 'untrusted', + reason: `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` + } } const markerPath = join(canonicalCandidate, '.orca-managed-home') let markerIsRegularFile: boolean + let markerContents: string try { markerIsRegularFile = lstatSync(markerPath).isFile() + markerContents = markerIsRegularFile ? readFileSync(markerPath, 'utf-8') : '' } catch (error) { - throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error }) + // Why: the marker is required, so its definitive absence is structural — but + // an unreadable marker is not evidence of anything. + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: 'Managed Codex home is missing Orca ownership marker.' } + } + return { kind: 'indeterminate', error } } if (!markerIsRegularFile) { - throw new Error('Managed Codex home ownership marker is not a regular file.') + return { + kind: 'untrusted', + reason: 'Managed Codex home ownership marker is not a regular file.' + } } - const markerContents = readFileSync(markerPath, 'utf-8') if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { - throw new Error('Managed Codex home ownership marker does not match its account ID.') + return { + kind: 'untrusted', + reason: 'Managed Codex home ownership marker does not match its account ID.' + } } - return canonicalCandidate + return { kind: 'owned', homePath: canonicalCandidate } +} + +/** Non-throwing verdict; callers decide how to treat each kind. */ +export function resolveHostCodexManagedHomeVerdict( + options: HostCodexManagedHomeOwnershipOptions +): HostCodexManagedHomeVerdict { + return evaluate(options) +} + +/** + * Throwing wrapper for write paths (add, re-auth, migration) that must never + * proceed on an unproven home. Distinct error classes keep an unreadable home + * from being mistaken for a deleted one — `isMissingManagedHomeError` must not + * match the temporary case, or re-auth would recreate the home and rewrite its + * ownership marker after a transient stat failure. + */ +export function assertOwnedHostCodexManagedHomePath( + options: HostCodexManagedHomeOwnershipOptions +): string { + const verdict = evaluate(options) + if (verdict.kind === 'owned') { + return verdict.homePath + } + if (verdict.kind === 'untrusted') { + throw new UntrustedManagedCodexHomeError(verdict.reason) + } + throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: verdict.error }) } diff --git a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts index 6df03f63e77..a3a4355130d 100644 --- a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts +++ b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts @@ -101,7 +101,10 @@ describe('CodexRuntimeHomeService', () => { expect(warnSpy).not.toHaveBeenCalled() expect(service.isHostSystemDefaultRealHome()).toBe(false) - expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath1 + }) expect(service.prepareForCodexLaunch()).toBe(managedHomePath1) expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false) diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index d771ab9cbfd..58b48c099bd 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -76,7 +76,7 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(createStore([], null) as never) - expect(service.getMirroredHostHomePathForStatus()).toBeNull() + expect(service.getMirroredHostHomePathForStatus()).toEqual({ kind: 'ready', homePath: null }) }) it('returns the selected account own home, which is what its mirror targets', async () => { @@ -84,7 +84,10 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(createStore([account], account.id) as never) - expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) + expect(service.getMirroredHostHomePathForStatus()).toEqual({ + kind: 'ready', + homePath: account.managedHomePath + }) }) it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => { @@ -95,6 +98,9 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { // Why: compare against the real helper, not a repeated literal, so the // status cannot silently drift if the managed home layout ever moves. - expect(service.getMirroredHostHomePathForStatus()).toBe(getOrcaManagedCodexHomePath()) + expect(service.getMirroredHostHomePathForStatus()).toEqual({ + kind: 'ready', + homePath: getOrcaManagedCodexHomePath() + }) }) }) diff --git a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts index f8accc5ada5..fd4823acfbc 100644 --- a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts +++ b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts @@ -221,7 +221,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch()).toBe(home1) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: home1 }) }) it('preserves a managed selection whose auth.json is temporarily missing', async () => { @@ -253,7 +253,7 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(brokenHome) - expect(service.prepareForRateLimitFetch()).toBe(brokenHome) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: brokenHome }) expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) @@ -435,12 +435,18 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } const syncSpy = vi.spyOn(service, 'syncForCurrentSelection') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(syncSpy).not.toHaveBeenCalled() - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(service.prepareForCodexLaunch()).toBeNull() expect(syncSpy).not.toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 943261f66b2..9bd55bba6e4 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -109,7 +109,10 @@ describe('CodexRuntimeHomeService', () => { expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( true ) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) @@ -176,13 +179,19 @@ describe('CodexRuntimeHomeService', () => { try { // Background fetchers prefer ambient CODEX_HOME when passed null, so an // explicit path proves nested Orca launches cannot poll the managed home. - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) process.env.CODEX_HOME = getSystemCodexHomePath() delete process.env.ORCA_CODEX_HOME expect(service.isHostSystemDefaultRealHome()).toBe(true) process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home') expect(service.isHostSystemDefaultRealHome()).toBe(false) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) } finally { if (previousCodexHome === undefined) { delete process.env.CODEX_HOME @@ -242,7 +251,10 @@ describe('CodexRuntimeHomeService', () => { service.reconcileLegacySharedHomeForRetainedPanes() expect(service.prepareForCodexLaunch()).toBeNull() - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) diff --git a/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts new file mode 100644 index 00000000000..7e212fc5e25 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4422 P1g: automatic session resume picks the resumed pane's CODEX_HOME, +// i.e. its account. The read-only resolver collapses an unreadable home to +// `null`, which the ranking reads as "no account selected" — another account's +// readable alias then wins and the pane resumes under the wrong credentials. +// The resume boundary therefore resolves the selection eagerly through a +// refusing gate. + +const lstatFaults = vi.hoisted(() => { + const state = { + /** paths that fail on EVERY read until released (models a held AV lock) */ + held: new Set(), + reads: new Map(), + hold(path: string): void { + state.held.add(path) + }, + release(path: string): void { + state.held.delete(path) + }, + heldReads(path: string): number { + return state.reads.get(path) ?? 0 + }, + reset(): void { + state.held.clear() + state.reads.clear() + }, + consume(target: unknown): void { + if (typeof target !== 'string' || !state.held.has(target)) { + return + } + state.reads.set(target, (state.reads.get(target) ?? 0) + 1) + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, lstat '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = 'lstat' + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const original = actual.lstatSync as (...args: unknown[]) => unknown + const patched: Record = { + ...actual, + lstatSync: Object.assign((...args: unknown[]): unknown => { + lstatFaults.consume(args[0]) + return original(...args) + }, original) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +async function createServiceWithSelectedAccount(): Promise<{ + service: CodexRuntimeHomeService + store: ReturnType + managedHomePath: string +}> { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a') + ) + createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b') + ) + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', managedHomePath), + createCodexAccountRecord( + 'account-b', + 'b@example.com', + 'acct-b', + join(testState.userDataDir, 'codex-accounts', 'account-b', 'home') + ) + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + return { service: new CodexRuntimeHomeService(store as never), store, managedHomePath } +} + +describe('CodexRuntimeHomeService.resolveSelectedHostAccountCodexHomePathForResume', () => { + beforeEach(() => { + lstatFaults.reset() + setupRuntimeHomeTest() + }) + + afterEach(() => { + lstatFaults.reset() + teardownRuntimeHomeTest() + }) + + it('refuses the resume while the selected account marker is locked, instead of reporting no selection', async () => { + const { service, store, managedHomePath } = await createServiceWithSelectedAccount() + const { ManagedCodexHomeTemporarilyUnavailableError } = + await import('./host-codex-managed-home-ownership') + + // Anchor: a readable marker resolves the selected account's own home. + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath) + + const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home') + lstatFaults.hold(markerPath) + + expect(() => service.resolveSelectedHostAccountCodexHomePathForResume()).toThrow( + ManagedCodexHomeTemporarilyUnavailableError + ) + // The injected fault really was consumed by the gate under test. + expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0) + // Why this is the whole point: the read-only resolver still answers `null`, + // which the resume ranking cannot distinguish from "system default", so the + // resume boundary must not use it. + expect(service.getSelectedHostAccountCodexHomePath()).toBeNull() + // Refusing is not clearing. + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a') + expect(store.updateSettings).not.toHaveBeenCalled() + + // Recovery is automatic once the lock clears. + lstatFaults.release(markerPath) + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath) + }) + + it('reports no managed selection for a proven untrusted home rather than refusing', async () => { + const { service, store, managedHomePath } = await createServiceWithSelectedAccount() + + // A successful observation that fails a trust check: the marker names + // another account. The resume must proceed on the system/default ranking. + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBeNull() + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() + expect(store.updateSettings).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts index ba41cb68dd0..29205f4472c 100644 --- a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts +++ b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts @@ -178,7 +178,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -266,9 +269,15 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection() writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -287,7 +296,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -305,7 +317,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) }) @@ -331,7 +346,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) }) @@ -371,7 +389,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) }) @@ -390,7 +411,10 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) }) @@ -405,11 +429,17 @@ describe('CodexRuntimeHomeService', () => { setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) }) @@ -443,7 +473,10 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') - expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(restartedService.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) } ) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index be9901799ac..20b2ef75839 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -136,7 +136,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated) expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) writeFileSync(sharedAuthPath(), laterShared, 'utf-8') - expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: account.managedHomePath + }) expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated) expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n') @@ -157,7 +160,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } expect(service.prepareForCodexLaunch()).toBeNull() - expect(service.prepareForRateLimitFetch()).toBe(systemHome()) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: systemHome() + }) expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(fresh) expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(mismatch) expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n') @@ -180,7 +186,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { writeFileSync(sharedAuthPath(), laterShared, 'utf-8') expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath) - expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: account.managedHomePath + }) expect(existsSync(accountAuthPath)).toBe(false) expect(settings.activeCodexManagedAccountId).toBe(account.id) expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 195e2a719df..62ef099031b 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -72,7 +72,10 @@ import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' +import { + ManagedCodexHomeTemporarilyUnavailableError, + resolveHostCodexManagedHomeVerdict +} from './host-codex-managed-home-ownership' import { codexAuthCouldBelongToManagedAccount, codexAuthIsFresher, @@ -169,6 +172,20 @@ function codexAuthIsMonotonicallyFresher( return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) } +/** + * Why: a skipped Codex quota poll must be distinguishable from "use the + * system-default home". `null` inside `ready` still means the system lane; + * `skip` means do not fetch at all this cycle (#STA-4422). + */ +/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } + export class CodexRuntimeHomeService { // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. private lastSyncedAccountId: string | null = null @@ -345,11 +362,18 @@ export class CodexRuntimeHomeService { account: CodexManagedAccount, unavailableManagedHomePath?: string ): string | null { - const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) - if (!perAccountHome) { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { this.clearSelfContainedManagedSelection(account) return null } + const perAccountHome = resolved.homePath if ( unavailableManagedHomePath && normalizeRuntimePathForComparison(unavailableManagedHomePath) === @@ -407,7 +431,13 @@ export class CodexRuntimeHomeService { // hot-swap or token read-back to reconcile. A trusted home remains selected // while Codex atomically replaces auth.json. private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null if (perAccountHome) { this.lastSyncedAccountId = account.id this.lastHostAccountUsedSelfContainedHome = true @@ -421,21 +451,41 @@ export class CodexRuntimeHomeService { this.clearSelfContainedManagedSelection(account) } - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - try { - assertOwnedHostCodexManagedHomePath({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + private resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { // Preserve the persisted path spelling (notably /var vs /private/var on // macOS) so injected CODEX_HOME stays stable across the rollout. - return account.managedHomePath - } catch (error) { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error) - return null + return { kind: 'owned', homePath: account.managedHomePath } } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null } private clearSelfContainedManagedSelection( @@ -539,6 +589,42 @@ export class CodexRuntimeHomeService { : null } + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { if (this.getSelfContainedManagedHostAccount()) { return 'account-home' @@ -672,22 +758,32 @@ export class CodexRuntimeHomeService { syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) } - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) - return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + return { + kind: 'ready', + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() - const selfContainedHome = selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - if (selfContainedAccount && selfContainedHome) { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return selfContainedHome - } if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } this.clearSelfContainedManagedSelection(selfContainedAccount) } if (this.isHostSystemDefaultRealHome()) { @@ -698,12 +794,12 @@ export class CodexRuntimeHomeService { if (hasRecordedLegacySharedCodexPane()) { this.syncLegacySharedSystemDefaultAuthForRetainedPanes() } - return getSystemCodexHomePath() + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - return this.getRuntimeHomePath() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } } syncForCurrentSelection( @@ -1310,15 +1406,24 @@ export class CodexRuntimeHomeService { * real-home lane, which runs Codex directly against ~/.codex — there is no * mirror there, so there is nothing that can fall behind. */ - getMirroredHostHomePathForStatus(): string | null { + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { - return this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } } if (this.isHostSystemDefaultRealHome()) { - return null + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') } - return join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') } private getRuntimeAuthPath(): string { diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 978768916b4..efc7ea3f2b9 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -92,10 +92,14 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( '{"account":"wsl"}\n' ) - expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getRuntimeCodexHomePath() + }) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: wslRuntimeHomePath + }) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index 978daf0d04c..bfac7707f5a 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -64,8 +64,14 @@ describe('CodexRuntimeHomeService', () => { const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') - expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) - expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(service.prepareForRateLimitFetch(target)).toEqual({ + kind: 'ready', + codexHomePath: expectedHome + }) + expect(service.prepareForRateLimitFetch(target)).toEqual({ + kind: 'ready', + codexHomePath: expectedHome + }) expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { @@ -138,9 +144,10 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( - join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ + kind: 'ready', + codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + }) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { @@ -194,9 +201,10 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: systemCodexHomePath + }) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) const externallyRefreshedAuth = createCodexAuthJson( 'wsl@example.com', @@ -205,9 +213,10 @@ describe('CodexRuntimeHomeService', () => { 3_000 ) writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8') - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath - ) + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ + kind: 'ready', + codexHomePath: systemCodexHomePath + }) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( externallyRefreshedAuth ) diff --git a/src/main/codex-accounts/service-reauthenticate-activation.test.ts b/src/main/codex-accounts/service-reauthenticate-activation.test.ts new file mode 100644 index 00000000000..37cd2a5e741 --- /dev/null +++ b/src/main/codex-accounts/service-reauthenticate-activation.test.ts @@ -0,0 +1,206 @@ +/** + * STA-4422 symptom 2: the status bar's "Sign in" action re-authenticates an + * account while nothing is selected, and the pre-login capture restored that + * empty selection — the browser said "Signed in to Codex" and the account was + * still inactive. The activation intent is deliberately narrow: it may only fill + * an empty lane, and the decision is made from the value captured *before* the + * OAuth await because the runtime-home poll runs outside the mutation queue. + */ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { + createCodexAuthJson, + createManagedHome, + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +function createHostAccounts(): GlobalSettings['codexManagedAccounts'] { + return ['account-1', 'account-2'].map((id, index) => ({ + id, + email: `${id}@example.com`, + managedHomePath: createManagedHome( + testState.userDataDir, + id, + '', + createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`) + ), + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: `provider-${id}`, + createdAt: index + 1, + updatedAt: index + 1, + lastAuthenticatedAt: index + 1 + })) +} + +/** Real `codex login` stand-in: writes fresh credentials, optionally racing the store. */ +function createLoginSpawn(onLogin?: () => void) { + return vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: () => void + } + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn() + onLogin?.() + writeFileSync( + join(options.env.CODEX_HOME!, 'auth.json'), + createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'), + 'utf-8' + ) + queueMicrotask(() => child.emit('close', 0)) + return child + }) +} + +describe('CodexAccountService reauthenticate activation intent', () => { + registerCodexAccountsTestHomes() + + it('activates the re-authed account when the intent is set and nothing was selected', async () => { + vi.resetModules() + const spawnMock = createLoginSpawn() + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2', { + activateIfSelectionWasEmpty: true + }) + + // Healthy anchor: the login really ran and its identity landed. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({ + email: 'reauthenticated@example.com', + providerAccountId: 'provider-new' + }) + expect(result.activeAccountId).toBe('account-2') + expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-2', wsl: {} }) + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: 'account-2', + activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } + }) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) + }) + + it('keeps the previously selected account even when the selection is cleared during OAuth', async () => { + vi.resetModules() + // Why: the runtime-home poll runs outside the mutation queue, so it can null + // the lane while the login promise is pending. A post-login read of the + // selection would see that null and hand the lane to the wrong account. + const spawnMock = createLoginSpawn(() => { + const current = store.getSettings() + store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...current.activeCodexManagedAccountIdsByRuntime!, + host: null + } + }) + }) + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2', { + activateIfSelectionWasEmpty: true + }) + + // Healthy anchor: the concurrent clear really landed before the settings write. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ activeCodexManagedAccountId: null }) + ) + expect(result.activeAccountId).toBe('account-1') + expect(result.activeAccountIdsByRuntime).toEqual({ host: 'account-1', wsl: {} }) + expect(store.getSettings()).toMatchObject({ + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + }) + + it('leaves the selection empty without the intent, as the settings pane expects', async () => { + vi.resetModules() + const spawnMock = createLoginSpawn() + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock })) + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + + const settings = createSettings({ + codexManagedAccounts: createHostAccounts(), + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + const store = createStore(settings) + const runtimeHome = createRuntimeHome() + const rateLimits = createRateLimits() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.reauthenticateAccount('account-2') + + // Healthy anchor: the same login ran; only the selection outcome differs. + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(result.accounts.find((account) => account.id === 'account-2')).toMatchObject({ + email: 'reauthenticated@example.com' + }) + expect(result.activeAccountId).toBeNull() + expect(result.activeAccountIdsByRuntime).toEqual({ host: null, wsl: {} }) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + }) +}) diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts new file mode 100644 index 00000000000..94e5a1746c3 --- /dev/null +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { realpathSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import { + createManagedHome, + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' +import { + createResetCreditLimits, + createResetRateLimitState +} from './service-reset-credit-test-fixtures' + +const fsFaults = vi.hoisted(() => { + const held = new Set() + let heldReads = 0 + let mkdirCalls = 0 + return { + hold(path: string): void { + held.add(path) + }, + reset(): void { + held.clear() + heldReads = 0 + mkdirCalls = 0 + }, + heldReads(): number { + return heldReads + }, + resetMkdirCalls(): void { + mkdirCalls = 0 + }, + mkdirCalls(): number { + return mkdirCalls + }, + noteMkdir(): void { + mkdirCalls += 1 + }, + consumeLstat(path: unknown): void { + if (typeof path !== 'string' || !held.has(path)) { + return + } + heldReads += 1 + const error: NodeJS.ErrnoException = new Error(`EPERM: lstat '${path}'`) + error.code = 'EPERM' + throw error + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const patched = { + ...actual, + lstatSync: (...args: Parameters) => { + fsFaults.consumeLstat(args[0]) + return actual.lstatSync(...args) + }, + mkdirSync: (...args: Parameters) => { + fsFaults.noteMkdir() + return actual.mkdirSync(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('Codex reset-credit managed-home ownership', () => { + registerCodexAccountsTestHomes() + + it('refuses an indeterminate host home before ledger or provider mutation', async () => { + const fixture = await createFixture() + fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home')) + fsFaults.resetMkdirCalls() + + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow('temporarily locked') + + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before') + }) + + it('refuses a proven-untrusted host home before ledger or provider mutation', async () => { + const fixture = await createFixture() + writeFileSync(join(fixture.managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + fsFaults.resetMkdirCalls() + + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow('ownership marker does not match') + + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + }) + + it.each([ + { + homeState: 'indeterminate', + expectedError: 'temporarily locked', + makeHomeUnsafe: (managedHomePath: string) => + fsFaults.hold(join(realpathSync(managedHomePath), '.orca-managed-home')) + }, + { + homeState: 'proven untrusted', + expectedError: 'ownership marker does not match', + makeHomeUnsafe: (managedHomePath: string) => + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + } + ])( + 'rechecks ownership for a durable providerPending retry when the home is $homeState', + async ({ expectedError, makeHomeUnsafe }) => { + const fixture = await createFixture() + const pendingLedger = { + version: 1 as const, + attempts: [ + { + idempotencyKey: fixture.idempotencyKey, + expectedScope: fixture.expectedScope, + state: 'providerPending' as const + } + ] + } + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) + makeHomeUnsafe(fixture.managedHomePath) + + const settingsBefore = structuredClone(fixture.store.getSettings()) + const authPath = join(fixture.managedHomePath, 'auth.json') + const markerPath = join(fixture.managedHomePath, '.orca-managed-home') + const authBefore = readFileSync(authPath, 'utf-8') + const markerBefore = readFileSync(markerPath, 'utf-8') + const directoryEntriesBefore = readdirSync(fixture.managedHomePath).sort() + fixture.consume.mockClear() + fixture.store.getCodexResetCreditAttemptLedger.mockClear() + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear() + fixture.store.updateSettings.mockClear() + fsFaults.resetMkdirCalls() + + const restarted = fixture.createService() + await expect( + restarted.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).rejects.toThrow(expectedError) + + expect(fixture.store.getCodexResetCreditAttemptLedger).toHaveBeenCalledOnce() + expect(fixture.store.getCodexResetCreditAttemptLedger()).toEqual(pendingLedger) + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fixture.store.getSettings()).toEqual(settingsBefore) + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(authPath, 'utf-8')).toBe(authBefore) + expect(readFileSync(markerPath, 'utf-8')).toBe(markerBefore) + expect(readdirSync(fixture.managedHomePath).sort()).toEqual(directoryEntriesBefore) + } + ) + + it('replays a durable settled outcome without reading a locked managed home', async () => { + const fixture = await createFixture() + await expect( + fixture.service.consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).resolves.toMatchObject({ outcome: 'reset' }) + fixture.consume.mockClear() + fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush.mockClear() + fixture.store.updateSettings.mockClear() + fsFaults.hold(join(realpathSync(fixture.managedHomePath), '.orca-managed-home')) + fsFaults.resetMkdirCalls() + + await expect( + fixture + .createService() + .consumeRateLimitResetCredit(fixture.idempotencyKey, fixture.expectedScope) + ).resolves.toMatchObject({ outcome: 'reset', scope: fixture.expectedScope }) + + expect(fsFaults.heldReads()).toBe(0) + expect(fixture.consume).not.toHaveBeenCalled() + expect(fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(fsFaults.mkdirCalls()).toBe(0) + expect(readFileSync(join(fixture.managedHomePath, 'auth.json'), 'utf-8')).toBe('auth-before') + }) + + async function createFixture() { + fsFaults.reset() + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1', '', 'auth-before') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id, + activeCodexManagedAccountIdsByRuntime: { host: account.id, wsl: {} } + }) + const store = createStore(settings) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const createService = () => + new CodexAccountService(store as never, rateLimits as never, createRuntimeHome() as never) + return { + service: createService(), + createService, + store, + consume, + expectedScope, + managedHomePath, + idempotencyKey: '11111111-1111-4111-8111-111111111111' + } + } +}) diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index f5940dba0b9..ebfe4e3ac1c 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -168,7 +168,7 @@ describe('CodexAccountService config sync', () => { consumeCodexRateLimitResetCredit: consume } const runtimeHome = createRuntimeHome() - runtimeHome.prepareForRateLimitFetch.mockReturnValue(null) + runtimeHome.prepareForRateLimitFetch.mockReturnValue({ kind: 'ready', codexHomePath: null }) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( createStore(settings) as never, diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 14af58f8f21..adc1f4304dd 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexRateLimitHomeResolution } from './runtime-home-service' export const testState = { userDataDir: '', @@ -197,14 +198,16 @@ export function createRateLimits(): RateLimitsStub { export type RuntimeHomeStub = { syncForCurrentSelection: Mock<(...args: unknown[]) => void> clearLastWrittenAuthJson: Mock<(...args: unknown[]) => void> - prepareForRateLimitFetch: Mock<(...args: unknown[]) => string | null> + prepareForRateLimitFetch: Mock<(...args: unknown[]) => CodexRateLimitHomeResolution> } export function createRuntimeHome(): RuntimeHomeStub { return { syncForCurrentSelection: vi.fn(), clearLastWrittenAuthJson: vi.fn(), - prepareForRateLimitFetch: vi.fn(() => null) + prepareForRateLimitFetch: vi.fn( + (): CodexRateLimitHomeResolution => ({ kind: 'ready', codexHomePath: null }) + ) } } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 54eaacef7ef..4b5a91c03f0 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -56,7 +56,10 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' -import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' +import { + assertOwnedHostCodexManagedHomePath, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -92,6 +95,15 @@ export type CodexAccountAddTarget = { wslDistro?: string | null } +export type CodexAccountReauthenticateOptions = { + /** + * Local-only intent from the status bar's "Sign in to see usage" action: when + * this account's runtime lane had no selection before login, activate the + * account that just signed in instead of restoring the empty selection. + */ + activateIfSelectionWasEmpty?: boolean +} + export type CodexAccountServiceLifecycle = { onHostSystemDefaultSelected?: () => void } @@ -140,6 +152,10 @@ type CodexResetCreditAttempt = { settledOutcome: CodexRateLimitResetOutcome | null } +type CodexResetCreditScopeValidation = + | { kind: 'settledReplay' } + | { kind: 'providerMutation'; requireCurrentOffer: boolean } + class CodexResetCreditScopeRejection extends Error { constructor( readonly reason: CodexResetCreditRejectedBeforeProviderReason, @@ -297,8 +313,11 @@ export class CodexAccountService { return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target)) } - async reauthenticateAccount(accountId: string): Promise { - return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) + async reauthenticateAccount( + accountId: string, + options?: CodexAccountReauthenticateOptions + ): Promise { + return this.serializeMutation(() => this.doReauthenticateAccount(accountId, options)) } async removeAccount(accountId: string): Promise { @@ -332,7 +351,9 @@ export class CodexAccountService { } if (existing.state === 'settled' && existing.settledOutcome) { return this.serializeMutation(async () => { - const { rateLimits } = this.validateResetCreditScope(expectedScope, false) + const { rateLimits } = this.validateResetCreditScope(expectedScope, { + kind: 'settledReplay' + }) return { outcome: existing.settledOutcome!, scope: existing.expectedScope, @@ -424,7 +445,13 @@ export class CodexAccountService { if (this.hasPendingResetForTarget(target)) { throw new Error('A previous reset attempt for this target still has an unknown outcome.') } - const codexHomePath = this.runtimeHome.prepareForRateLimitFetch(target) + const homeResolution = this.runtimeHome.prepareForRateLimitFetch(target) + // Why: reject before the provider mutation — a skip must never be spent + // against the system-default home (#STA-4422). + if (homeResolution.kind === 'skip') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + const codexHomePath = homeResolution.codexHomePath return this.rateLimits.consumeCodexRateLimitResetCredit({ idempotencyKey: randomUUID(), target, @@ -471,7 +498,10 @@ export class CodexAccountService { const isFresh = attempt.state === 'fresh' let validation: { managedHomePath: string; rateLimits: RateLimitState } try { - validation = this.validateResetCreditScope(expectedScope, isFresh) + validation = this.validateResetCreditScope(expectedScope, { + kind: 'providerMutation', + requireCurrentOffer: isFresh + }) } catch (error) { if (isFresh && error instanceof CodexResetCreditScopeRejection) { this.releaseFreshResetAttempt(idempotencyKey, attempt) @@ -538,7 +568,7 @@ export class CodexAccountService { private validateResetCreditScope( expectedScope: CodexResetCreditExpectedScope, - requireCurrentOffer: boolean + validation: CodexResetCreditScopeValidation ): { managedHomePath: string; rateLimits: RateLimitState } { const rateLimitState = this.rateLimits.getState() if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) { @@ -580,30 +610,35 @@ export class CodexAccountService { ) } - const currentScope = buildCodexResetCreditExpectedScope({ - target: rateLimitState.codexTarget, - account: this.toSummary(account), - limits: rateLimitState.codex - }) - // Why: a same-key replay resolves an already-started provider mutation; - // its credit snapshot may have refreshed, but its account/runtime identity may not change. - if (requireCurrentOffer && !currentScope) { - throw new CodexResetCreditScopeRejection( - 'offerUnavailable', - rateLimitState, - 'The Codex reset-credit offer is no longer available.' - ) + if (validation.kind === 'providerMutation' && validation.requireCurrentOffer) { + const currentScope = buildCodexResetCreditExpectedScope({ + target: rateLimitState.codexTarget, + account: this.toSummary(account), + limits: rateLimitState.codex + }) + if (!currentScope) { + throw new CodexResetCreditScopeRejection( + 'offerUnavailable', + rateLimitState, + 'The Codex reset-credit offer is no longer available.' + ) + } + if (resetScopeKey(expectedScope) !== resetScopeKey(currentScope)) { + throw new CodexResetCreditScopeRejection( + 'offerChanged', + rateLimitState, + 'The Codex reset-credit offer changed before reset.' + ) + } } - if ( - requireCurrentOffer && - currentScope && - resetScopeKey(expectedScope) !== resetScopeKey(currentScope) - ) { - throw new CodexResetCreditScopeRejection( - 'offerChanged', - rateLimitState, - 'The Codex reset-credit offer changed before reset.' - ) + + if (validation.kind === 'providerMutation' && expectedScope.target.runtime === 'host') { + assertOwnedHostCodexManagedHomePath({ + candidatePath: account.managedHomePath, + managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) } return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState } @@ -847,7 +882,10 @@ export class CodexAccountService { return this.getSnapshot() } - private async doReauthenticateAccount(accountId: string): Promise { + private async doReauthenticateAccount( + accountId: string, + options?: CodexAccountReauthenticateOptions + ): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) const accountTarget = getCodexSelectionTargetForAccount(account) @@ -855,6 +893,11 @@ export class CodexAccountService { this.store.getSettings(), accountTarget ) + // Why: decided from the pre-login capture, never a post-login read — the + // runtime-home poll runs outside the mutation queue and can clear this lane + // while OAuth is open, which would look like an empty selection to activate. + const activateAfterLogin = + options?.activateIfSelectionWasEmpty === true && selectedAccountId === null this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id) await this.runCodexLogin(managedHomePath) @@ -880,7 +923,7 @@ export class CodexAccountService { ) const activeSelection = setSelectedCodexAccountIdForTarget( normalizeCodexRuntimeSelection(settings), - selectedAccountId, + activateAfterLogin ? accountId : selectedAccountId, accountTarget ) diff --git a/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts new file mode 100644 index 00000000000..c3d628b93eb --- /dev/null +++ b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// STA-4422 regression: a transient lstat failure on the ownership marker +// (Windows AV/indexer EPERM/EBUSY) must NOT deselect the managed account. The +// poll skips instead, and the selection survives — before the fix one fault +// cleared it permanently. + +const lstatFaults = vi.hoisted(() => { + const state = { + /** path -> remaining injected EPERM failures */ + pending: new Map(), + /** paths that fail on EVERY read until released (models a held AV lock) */ + held: new Set(), + failOnce(path: string): void { + state.pending.set(path, (state.pending.get(path) ?? 0) + 1) + }, + hold(path: string): void { + state.held.add(path) + }, + release(path: string): void { + state.held.delete(path) + }, + consumedHeld: new Map(), + heldReads(path: string): number { + return state.consumedHeld.get(path) ?? 0 + }, + remaining(path: string): number { + return state.pending.get(path) ?? 0 + }, + reset(): void { + state.pending.clear() + state.held.clear() + state.consumedHeld.clear() + }, + consume(target: unknown): void { + if (typeof target !== 'string') { + return + } + const remaining = state.pending.get(target) ?? 0 + const isHeld = state.held.has(target) + if (remaining <= 0 && !isHeld) { + return + } + if (isHeld) { + state.consumedHeld.set(target, (state.consumedHeld.get(target) ?? 0) + 1) + } else { + state.pending.set(target, remaining - 1) + } + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, lstat '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = 'lstat' + error.path = target + throw error + } + } + return state +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const original = actual.lstatSync as (...args: unknown[]) => unknown + const patched: Record = { + ...actual, + lstatSync: Object.assign((...args: unknown[]): unknown => { + lstatFaults.consume(args[0]) + return original(...args) + }, original) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('STA-4422 Codex sessions keep logging out', () => { + beforeEach(() => { + lstatFaults.reset() + setupRuntimeHomeTest() + }) + + afterEach(() => { + lstatFaults.reset() + teardownRuntimeHomeTest() + }) + + it('keeps the selection and SKIPS the poll while the ownership marker is locked', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Anchor: a healthy poll resolves the managed home and touches nothing. + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath + }) + expect(store.updateSettings).not.toHaveBeenCalled() + + // Hold the marker for the whole window, the way an AV scan does. A + // single-shot fault would be too weak: it could be masked by any future + // retry and would green-light an unimplemented fix. + const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home') + lstatFaults.hold(markerPath) + + const duringLock = service.prepareForRateLimitFetch() + + // The fault really was consumed by the code under test. + expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0) + // THE FIX: skip the poll rather than retargeting the user's real ~/.codex. + expect(duringLock).toEqual({ kind: 'skip' }) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'skip' }) + expect(duringLock).not.toEqual({ kind: 'ready', codexHomePath: getSystemCodexHomePath() }) + // THE FIX: the selection survives, and nothing was persisted. + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') + expect(store.updateSettings).not.toHaveBeenCalled() + + // Repeated evaluation must not accumulate into a destructive verdict. + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' }) + expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'skip' }) + expect(store.updateSettings).not.toHaveBeenCalled() + + // Recovery is automatic: the next readable poll resolves normally. + lstatFaults.release(markerPath) + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: managedHomePath + }) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'ready', homePath: managedHomePath }) + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(store.updateSettings).not.toHaveBeenCalled() + }) + + it('still clears the selection when the home is genuinely untrusted', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + // A proven trust failure: the marker names a different account. + writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8') + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + // Why this case matters: the fix must not make the gate toothless. A + // successful observation that fails a trust check still deselects. + expect(service.prepareForRateLimitFetch()).toEqual({ + kind: 'ready', + codexHomePath: getSystemCodexHomePath() + }) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(store.updateSettings).toHaveBeenCalledTimes(1) + expect( + service.resolveCodexManagedAccountHomeForInactiveFetch( + store.getSettings().codexManagedAccounts[0]! + ) + ).toEqual({ kind: 'skip' }) + }) +}) diff --git a/src/main/codex/codex-ai-vault-session-resume.test.ts b/src/main/codex/codex-ai-vault-session-resume.test.ts new file mode 100644 index 00000000000..172b4f34ec9 --- /dev/null +++ b/src/main/codex/codex-ai-vault-session-resume.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { linkSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume' + +describe('prepareCodexAiVaultSessionResume', () => { + let root: string + let peerHome: string + let selectedHome: string + let peerRolloutPath: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-ai-vault-resume-')) + peerHome = join(root, 'codex-accounts', 'account-a', 'home') + selectedHome = join(root, 'codex-accounts', 'account-b', 'home') + const relativeRolloutPath = join( + 'sessions', + '2026', + '08', + '17', + 'rollout-2026-08-17T10-00-00-session.jsonl' + ) + peerRolloutPath = join(peerHome, relativeRolloutPath) + const selectedRolloutPath = join(selectedHome, relativeRolloutPath) + mkdirSync(dirname(peerRolloutPath), { recursive: true }) + mkdirSync(dirname(selectedRolloutPath), { recursive: true }) + writeFileSync(peerRolloutPath, '{"type":"session_meta"}\n', 'utf-8') + linkSync(peerRolloutPath, selectedRolloutPath) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('refuses before falling through when selected-home trust is indeterminate', async () => { + const resolveSelectedHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + const isSystemDefaultRealHome = vi.fn(() => true) + + await expect(prepare(resolveSelectedHome, isSystemDefaultRealHome)).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + expect(resolveSelectedHome).toHaveBeenCalledOnce() + expect(isSystemDefaultRealHome).not.toHaveBeenCalled() + }) + + it('repins an aliased rollout when the selected home is owned', async () => { + await expect(prepare(() => selectedHome)).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: selectedHome + }) + }) + + it('preserves deliberate no-selection behavior for a proven-untrusted home', async () => { + await expect(prepare(() => null)).resolves.toEqual({ useRealCodexHome: false }) + }) + + function prepare( + resolveSelectedHome: () => string | null, + isSystemDefaultRealHome: () => boolean = () => false + ) { + return prepareCodexAiVaultSessionResume( + { + agent: 'codex', + filePath: peerRolloutPath, + codexHome: peerHome, + executionHostId: 'local' + }, + { + runtimeHome: { + isHostSystemDefaultRealHome: isSystemDefaultRealHome, + resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome + }, + systemCodexHomePath: join(root, 'system-codex-home') + } + ) + } +}) diff --git a/src/main/codex/codex-ai-vault-session-resume.ts b/src/main/codex/codex-ai-vault-session-resume.ts new file mode 100644 index 00000000000..da59287126b --- /dev/null +++ b/src/main/codex/codex-ai-vault-session-resume.ts @@ -0,0 +1,27 @@ +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' + +type CodexAiVaultRuntimeHome = Pick< + CodexRuntimeHomeService, + 'isHostSystemDefaultRealHome' | 'resolveSelectedHostAccountCodexHomePathForResume' +> + +/** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */ +export function prepareCodexAiVaultSessionResume( + args: AiVaultPrepareSessionResumeArgs, + options: { + runtimeHome: CodexAiVaultRuntimeHome | null + systemCodexHomePath: string | undefined + } +): Promise { + return prepareLegacySharedCodexSessionResume(args, { + isHostSystemDefaultRealHome: () => options.runtimeHome?.isHostSystemDefaultRealHome() === true, + getSelectedHostAccountCodexHomePath: () => + options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null, + systemCodexHomePath: options.systemCodexHomePath + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8769a055e5f..4ad93df0448 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -243,6 +243,7 @@ import { stopCodexStateDbBackfillRecoveries } from './codex/codex-state-db-backfill-recovery' import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' +import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' @@ -1073,6 +1074,9 @@ function prepareCodexRuntimeHomeForLaunch( return true } let realHomeHooksPrepared = ensureRealHomeHooksIfSelected() + // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — + // the fallbacks below all key off `null`, which means "system default", so + // swallowing the refusal would launch the wrong account (#STA-4422). let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { unavailableManagedHomePath: launchContext?.unavailableManagedHomePath }) @@ -1145,6 +1149,15 @@ async function prepareCodexSessionResumeForLaunch(args: { ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() ] const settingsStore = store + // Why: resolved eagerly, once, before any ranking or provenance match. The + // marker read used to be deferred into the ranking thunk so a + // provenance-present resume never paid for it, but that optimisation let an + // unreadable selected home reach the PTY as "no selection": the provenance + // branch simply omits the account from `trustedHomes` and another account's + // readable alias wins. A throw here refuses the whole resume instead + // (#STA-4422). + const selectedAccountCodexHome = + codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ @@ -1153,8 +1166,7 @@ async function prepareCodexSessionResumeForLaunch(args: { trustedCodexHomes: trustedHomes, // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; // rank it by the current selection so settings insertion order can never decide the account. - // Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker. - getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(), + getSelectedAccountCodexHome: () => selectedAccountCodexHome, systemCodexHomePath: systemHomePath, // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must // outrank the path-sorted account homes or a system-default selection resumes as an account. @@ -1466,11 +1478,8 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], prepareAiVaultSessionResume: (args) => - prepareLegacySharedCodexSessionResume(args, { - isHostSystemDefaultRealHome: () => - codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - getSelectedHostAccountCodexHomePath: () => - codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), onBeforeRelaunch: async () => { @@ -2528,7 +2537,15 @@ void app.whenReady().then(async () => { ) return settings.codexManagedAccounts .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) }) const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { resolve: (selector) => { @@ -2589,10 +2606,8 @@ void app.whenReady().then(async () => { getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], prepareAiVaultSessionResume: (args) => - prepareLegacySharedCodexSessionResume(args, { - isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - getSelectedHostAccountCodexHomePath: () => - codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), buildAgentHookPtyEnv: () => diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index f8c81fe0189..e89e8bca4be 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -38,8 +38,12 @@ export function registerCodexAccountHandlers( ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => codexAccounts.addAccount(args) ) - ipcMain.handle('codexAccounts:reauthenticate', (_event, args: { accountId: string }) => - codexAccounts.reauthenticateAccount(args.accountId) + ipcMain.handle( + 'codexAccounts:reauthenticate', + (_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + codexAccounts.reauthenticateAccount(args.accountId, { + activateIfSelectionWasEmpty: args.activateIfSelectionWasEmpty === true + }) ) ipcMain.handle('codexAccounts:remove', (_event, args: { accountId: string }) => codexAccounts.removeAccount(args.accountId) diff --git a/src/main/ipc/codex-config-sync.test.ts b/src/main/ipc/codex-config-sync.test.ts index e1219472c8d..aa64664a75b 100644 --- a/src/main/ipc/codex-config-sync.test.ts +++ b/src/main/ipc/codex-config-sync.test.ts @@ -25,9 +25,15 @@ import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types let root: string function invokeHandler(mirroredHome: string | null): CodexConfigSyncStatus { + return invokeHandlerWithStatus({ kind: 'ready', homePath: mirroredHome }) +} + +function invokeHandlerWithStatus( + mirrored: { kind: 'ready'; homePath: string | null } | { kind: 'unavailable' } +): CodexConfigSyncStatus { handleMock.mockClear() registerCodexConfigSyncHandlers({ - getMirroredHostHomePathForStatus: () => mirroredHome + getMirroredHostHomePathForStatus: () => mirrored }) const handler = handleMock.mock.calls.at(-1)?.[1] as () => CodexConfigSyncStatus return handler() @@ -78,3 +84,21 @@ describe('codexConfigSync:status handler', () => { expect(removeHandlerMock).toHaveBeenCalledWith('codexConfigSync:status') }) }) + +// STA-4422: an unreadable managed home must not be reported as healthy. Before +// the fix the resolver collapsed to `null`, which this channel reads as "no +// mirror exists" and reports as synced. +it('reports a managed-home-unavailable stall instead of synced when the home is unreadable', () => { + // Anchor: a genuine no-mirror lane still reports synced. + expect(invokeHandlerWithStatus({ kind: 'ready', homePath: null })).toEqual({ + state: 'synced', + reason: null, + systemConfigPath: join(root, '.codex', 'config.toml') + }) + + expect(invokeHandlerWithStatus({ kind: 'unavailable' })).toEqual({ + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: join(root, '.codex', 'config.toml') + }) +}) diff --git a/src/main/ipc/codex-config-sync.ts b/src/main/ipc/codex-config-sync.ts index c61ba571bae..53a718da3c5 100644 --- a/src/main/ipc/codex-config-sync.ts +++ b/src/main/ipc/codex-config-sync.ts @@ -3,10 +3,11 @@ import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { getCodexConfigSyncStatus } from '../codex/config-sync-stall' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { CodexMirroredHomeStatus } from '../codex-accounts/runtime-home-service' /** The read-only slice of the runtime home service this channel needs. */ type CodexMirroredHomeResolver = { - getMirroredHostHomePathForStatus: () => string | null + getMirroredHostHomePathForStatus: () => CodexMirroredHomeStatus } /** Registers the read-only IPC channel the settings pane reads once per mount for Codex config sync health. */ @@ -14,7 +15,17 @@ export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeRe ipcMain.removeHandler('codexConfigSync:status') ipcMain.handle('codexConfigSync:status', (): CodexConfigSyncStatus => { const systemHomePath = getSystemCodexHomePath() - const runtimeHomePath = runtimeHome.getMirroredHostHomePathForStatus() + const mirrored = runtimeHome.getMirroredHostHomePathForStatus() + if (mirrored.kind === 'unavailable') { + // Why: do not throw — the settings pane catches thrown status errors and + // would show nothing at all. Report the stall so the user sees why. + return { + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: join(systemHomePath, 'config.toml') + } + } + const runtimeHomePath = mirrored.homePath if (!runtimeHomePath) { // Why: the system default runs Codex directly against ~/.codex, so there // is no mirror that can fall behind. Reporting on the shared home here diff --git a/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts new file mode 100644 index 00000000000..e039f92ff87 --- /dev/null +++ b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSyncMock } from './pty-ipc-mock-registry' +import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +const UNAVAILABLE_MESSAGE = 'Codex account files are temporarily locked. Retry in a moment.' + +const MANAGED_ACCOUNT_SETTINGS = (() => + ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + }) as never) as () => never + +type RuntimeSpawnController = { + spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }> +} + +/** + * #STA-4422: launch prep refuses an unreadable managed home by throwing, because + * `null` already means "launch the system default". A refusal that reached + * either spawn path as `null` would start the pane on the user's real ~/.codex + * while the UI still shows the managed account, so every assertion here pairs + * the rejection with a zero-spawn check — existing suites prove a `null` + * re-resolution deliberately launches with no CODEX_HOME. + */ +describe('registerPtyHandlers Codex launch refusal on an unreadable managed home', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) + + function makeRuntime() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + } + + function register( + resolveHome: ( + target?: unknown, + env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ) => string | null, + runtime?: ReturnType, + prepareCodexSessionResume?: () => Promise + ): void { + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + resolveHome, + MANAGED_ACCOUNT_SETTINGS, + undefined, + undefined, + prepareCodexSessionResume ? { prepareCodexSessionResume } : undefined + ) + } + + const RESUME_SESSION = { + key: 'session_id' as const, + id: 'resume-session-1', + transcriptPath: '/tmp/orca-codex-rollout.jsonl' + } + + function makeAuthUnreadable(): void { + readFileSyncMock.mockImplementation((filePath: string) => { + if (filePath.endsWith('auth.json')) { + throw Object.assign(new Error('missing auth'), { code: 'ENOENT' }) + } + return '' + }) + } + + describe('local pty:spawn handler', () => { + it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register(() => TEST_CODEX_HOME) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME }) + }) + + it('refuses the spawn when the first home resolution is indeterminate', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + register(resolveHome) + + await expect( + handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, launchAgent: 'codex' }) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(resolveHome).toHaveBeenCalledOnce() + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => { + vi.useFakeTimers() + makeAuthUnreadable() + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn( + ( + _target?: unknown, + _env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ): string | null => { + if (context?.unavailableManagedHomePath) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + } + ) + register(resolveHome) + + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(2_000) + await rejection + + // Why: the first resolution must have succeeded, or the rejection would + // prove nothing about the re-resolution this test exists for. + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({ + unavailableManagedHomePath: TEST_CODEX_HOME + }) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the current-selection re-read is indeterminate after auth recovers', async () => { + vi.useFakeTimers() + let authReady = false + readFileSyncMock.mockImplementation((filePath: string) => { + if (!filePath.endsWith('auth.json')) { + return '' + } + if (!authReady) { + throw Object.assign(new Error('missing auth'), { code: 'ENOENT' }) + } + return TEST_CODEX_AUTH_JSON + }) + const daemonSpawn = setupDaemonAdapter() + const resolveHome = vi.fn((): string | null => { + if (resolveHome.mock.calls.length > 1) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + }) + register(resolveHome) + + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(0) + expect(daemonSpawn).not.toHaveBeenCalled() + authReady = true + await vi.advanceTimersByTimeAsync(25) + await rejection + + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + // Why (#STA-4422 P1g): the eager selected-home gate rejects the awaited + // resume preparation. That rejection has to reach the spawn, not be + // downgraded to "no resume" — a resume that proceeds picks its CODEX_HOME + // from whichever account's alias stayed readable. + it('refuses the spawn when the eager resume gate rejects', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register( + () => TEST_CODEX_HOME, + undefined, + () => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError()) + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + }) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('spawns a resume when the eager resume gate resolves', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + register(() => TEST_CODEX_HOME, undefined, (() => + Promise.resolve(null)) as unknown as () => Promise) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + }) + }) + + describe('runtime pty controller spawn', () => { + it('spawns with the managed CODEX_HOME when the home reads back cleanly', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + register(() => TEST_CODEX_HOME, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' }) + + expect(daemonSpawn).toHaveBeenCalledOnce() + expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({ CODEX_HOME: TEST_CODEX_HOME }) + }) + + it('refuses the spawn when the first home resolution is indeterminate', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + const resolveHome = vi.fn((): string | null => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + register(resolveHome, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await expect(controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })).rejects.toThrow( + UNAVAILABLE_MESSAGE + ) + + expect(resolveHome).toHaveBeenCalledOnce() + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the post-auth-wait re-resolution is indeterminate', async () => { + vi.useFakeTimers() + makeAuthUnreadable() + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + const resolveHome = vi.fn( + ( + _target?: unknown, + _env?: NodeJS.ProcessEnv, + context?: { unavailableManagedHomePath?: string } + ): string | null => { + if (context?.unavailableManagedHomePath) { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + return TEST_CODEX_HOME + } + ) + register(resolveHome, runtime) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' }) + const rejection = expect(spawnPromise).rejects.toThrow(UNAVAILABLE_MESSAGE) + await vi.advanceTimersByTimeAsync(2_000) + await rejection + + expect(resolveHome).toHaveBeenCalledTimes(2) + expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({ + unavailableManagedHomePath: TEST_CODEX_HOME + }) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + + it('refuses the spawn when the eager resume gate rejects', async () => { + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const daemonSpawn = setupDaemonAdapter() + const runtime = makeRuntime() + register( + () => TEST_CODEX_HOME, + runtime, + () => Promise.reject(new ManagedCodexHomeTemporarilyUnavailableError()) + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + resumeProviderSession: RESUME_SESSION + } as never) + ).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(daemonSpawn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 10ee17200db..3c3e148ffb2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -212,6 +212,7 @@ import { isCodexHomeAuthReadyForLaunch, waitForManagedCodexAuthReady } from '../codex-accounts/managed-codex-auth-readiness' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { forgetCodexPaneAccount, getCodexPaneAccount, @@ -1344,6 +1345,26 @@ const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' +const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE = + 'Codex account files are temporarily locked. Retry in a moment.' + +/** + * Why: launch prep refuses an unreadable managed home by throwing instead of + * returning `null`, because `null` already means "launch the system default". + * Turn that refusal into the same shape as the auth-unavailable refusal above — + * a user-facing spawn rejection — so the pane never starts on another account's + * credentials (#STA-4422). + */ +function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null { + try { + return resolve() + } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error }) + } + throw error + } +} type ManagedCodexAuthResolutionArgs = { selectedCodexHomePath: string | null @@ -4686,7 +4707,7 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - let selectedCodexHomePath = + let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => !preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, @@ -4706,6 +4727,7 @@ export function registerPtyHandlers( }) ?? null) ) : null + ) if ( !preAdoptedStablePane && args.launchAgent === 'codex' && @@ -4717,21 +4739,25 @@ export function registerPtyHandlers( requiredCodexHomePath: codexResumeHome?.codexHomePath, target: codexSelectionTarget, resolveCurrent: () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: 'codex' + }) ?? null + ) ), resolveAfterUnavailable: (unavailableManagedHomePath) => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) ) }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution @@ -6417,7 +6443,7 @@ export function registerPtyHandlers( // Why: declared after the strip so a local-provider spawn cannot capture the // pre-strip env — only the daemon branch below re-derives this from baseEnv. let env: Record | undefined = baseEnv - let selectedCodexHomePath = + let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => !preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, @@ -6437,6 +6463,7 @@ export function registerPtyHandlers( }) ?? null) ) : null + ) if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { const resolution = resolveCodexHomeAfterManagedAuthReadiness({ selectedCodexHomePath, @@ -6444,21 +6471,25 @@ export function registerPtyHandlers( requiredCodexHomePath: codexResumeHome?.codexHomePath, target: codexSelectionTarget, resolveCurrent: () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex' + }) ?? null + ) ), resolveAfterUnavailable: (unavailableManagedHomePath) => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null + resolveSelectedCodexHomeOrRefuseSpawn(() => + getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) ) }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index a7a32a24b7e..86008a4bd84 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -58,7 +58,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + const resolver = vi.fn((target) => ({ + kind: 'ready' as const, + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) service.setCodexHomePathResolver(resolver) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) @@ -71,10 +74,62 @@ describe('RateLimitService', () => { ) }) + it.each([ + [ + 'account-change refresh', + (service: RateLimitService) => service.refreshForCodexAccountChange(null, { runtime: 'host' }) + ], + [ + 'target refresh', + (service: RateLimitService) => service.refreshCodexForTarget({ runtime: 'host' }) + ] + ])('settles %s when managed-home resolution skips before the fetch', async (_label, refresh) => { + const service = new RateLimitService() + service.setCodexHomePathResolver(() => ({ kind: 'skip' })) + + await refresh(service) + + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(service.getState().codex).toBeNull() + }) + + it('settles without applying a result when the managed home becomes unavailable mid-fetch', async () => { + const service = new RateLimitService() + const resolver = vi + .fn() + .mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' }) + .mockReturnValue({ kind: 'skip' }) + service.setCodexHomePathResolver(resolver) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refreshForCodexAccountChange(null, { runtime: 'host' }) + + expect(resolver).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().codex).toBeNull() + }) + + it('settles the Codex slot when its home becomes unavailable during a full refresh', async () => { + const service = new RateLimitService() + service.setCodexHomePathResolver( + vi + .fn() + .mockReturnValueOnce({ kind: 'ready', codexHomePath: '/tmp/codex-home' }) + .mockReturnValue({ kind: 'skip' }) + ) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().codex).toBeNull() + }) + it('reuses a caller-provided idempotency key when consuming a Codex reset credit', async () => { const service = new RateLimitService() const idempotencyKey = '11111111-1111-4111-8111-111111111111' - service.setCodexHomePathResolver(() => '/tmp/codex-home') + service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/codex-home' })) vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) @@ -104,7 +159,7 @@ describe('RateLimitService', () => { ) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) - service.setCodexHomePathResolver(() => '/tmp/new-selection') + service.setCodexHomePathResolver(() => ({ kind: 'ready', codexHomePath: '/tmp/new-selection' })) const pending = service.consumeCodexRateLimitResetCredit({ idempotencyKey, target: { runtime: 'host', wslDistro: null }, @@ -139,9 +194,10 @@ describe('RateLimitService', () => { const service = new RateLimitService() const idempotencyKey = '33333333-3333-4333-8333-333333333333' const hostRefresh = deferred() - service.setCodexHomePathResolver((target) => - target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection' - ) + service.setCodexHomePathResolver((target) => ({ + kind: 'ready', + codexHomePath: target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection' + })) vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') vi.mocked(fetchCodexRateLimits) .mockReturnValueOnce(hostRefresh.promise) @@ -173,7 +229,10 @@ describe('RateLimitService', () => { it('does not let an older full refresh overwrite the post-reset Codex state', async () => { const service = new RateLimitService() const slowClaude = deferred() - service.setCodexHomePathResolver(() => '/tmp/approved-selection') + service.setCodexHomePathResolver(() => ({ + kind: 'ready', + codexHomePath: '/tmp/approved-selection' + })) vi.mocked(fetchClaudeRateLimits).mockReturnValueOnce(slowClaude.promise) vi.mocked(fetchCodexRateLimits) .mockResolvedValueOnce(okProvider('codex', 100, Date.now())) @@ -201,7 +260,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome)) + const resolver = vi.fn((target) => ({ + kind: 'ready' as const, + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) service.setCodexHomePathResolver(resolver) service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) @@ -218,7 +280,7 @@ describe('RateLimitService', () => { it('does not fetch host Codex usage when WSL home resolution fails', async () => { const service = new RateLimitService() - const resolver = vi.fn(() => null) + const resolver = vi.fn(() => ({ kind: 'ready' as const, codexHomePath: null })) service.setCodexHomePathResolver(resolver) service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) @@ -352,9 +414,10 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home' - service.setCodexHomePathResolver((target) => - target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome - ) + service.setCodexHomePathResolver((target) => ({ + kind: 'ready', + codexHomePath: target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome + })) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits) @@ -375,7 +438,7 @@ describe('RateLimitService', () => { it('caches an outgoing weekly-only Codex account so the switcher keeps its inline bars', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-weekly', managedHomePath: '/tmp/account-weekly/home' } + inactiveCodexAccount('account-weekly', '/tmp/account-weekly/home') ]) const weeklyOnly: ProviderRateLimits = { @@ -410,7 +473,7 @@ describe('RateLimitService', () => { it('does not cache an outgoing Codex account that has no usage windows', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-empty', managedHomePath: '/tmp/account-empty/home' } + inactiveCodexAccount('account-empty', '/tmp/account-empty/home') ]) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) @@ -464,3 +527,10 @@ describe('RateLimitService', () => { ) }) }) + +function inactiveCodexAccount(id: string, managedHomePath: string) { + return { + id, + resolveHome: () => ({ kind: 'ready' as const, managedHomePath }) + } +} diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 53338098074..e734b52172a 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -10,6 +10,13 @@ import { resetRateLimitProviderMocks } from './rate-limit-service-test-harness' +function inactiveCodexAccount(id: string, managedHomePath: string) { + return { + id, + resolveHome: () => ({ kind: 'ready' as const, managedHomePath }) + } +} + vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), fetchManagedAccountUsage: vi.fn() @@ -84,7 +91,7 @@ describe('RateLimitService', () => { it('aborts inactive Codex preview fetches on stop', async () => { const service = new RateLimitService() - const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + const account = inactiveCodexAccount('account-1', '/tmp/account-1/home') const capturedSignals: { codex?: AbortSignal } = {} service.setInactiveCodexAccountsResolver(() => [account]) vi.mocked(fetchCodexRateLimits).mockImplementation( @@ -116,7 +123,7 @@ describe('RateLimitService', () => { const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-1', managedHomePath: wslCodexHome } + inactiveCodexAccount('account-1', wslCodexHome) ]) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now())) @@ -142,6 +149,52 @@ describe('RateLimitService', () => { ]) }) + it('skips an unavailable inactive home without dropping its cache and recovers later', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-17T12:00:00Z')) + try { + const service = new RateLimitService() + let unavailable = false + service.setInactiveCodexAccountsResolver(() => [ + { + id: 'account-1', + resolveHome: () => + unavailable + ? { kind: 'skip' as const } + : { kind: 'ready' as const, managedHomePath: '/tmp/account-1/home' } + } + ]) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 33, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 67, Date.now())) + + await service.fetchInactiveCodexAccountsOnOpen() + unavailable = true + await vi.advanceTimersByTimeAsync(10 * 60_000) + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().inactiveCodexAccounts).toEqual([ + expect.objectContaining({ + accountId: 'account-1', + isFetching: false, + rateLimits: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 33 }) + }) + }) + ]) + + unavailable = false + await vi.advanceTimersByTimeAsync(10 * 60_000) + await service.fetchInactiveCodexAccountsOnOpen() + + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().inactiveCodexAccounts[0]?.rateLimits?.session?.usedPercent).toBe(67) + } finally { + vi.useRealTimers() + } + }) + it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { const service = new RateLimitService() const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } @@ -181,7 +234,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() const accountFetch = deferred() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + inactiveCodexAccount('account-1', '/tmp/account-1/home') ]) vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) @@ -199,8 +252,8 @@ describe('RateLimitService', () => { const service = new RateLimitService() const accountFetch = deferred() let inactiveAccounts = [ - { id: 'account-a', managedHomePath: '/tmp/account-a/home' }, - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') ] service.setInactiveCodexAccountsResolver(() => inactiveAccounts) vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise) @@ -208,11 +261,10 @@ describe('RateLimitService', () => { const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() await Promise.resolve() expect(service.getState().inactiveCodexAccounts).toEqual([ - { accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true }, - { accountId: 'account-b', rateLimits: null, updatedAt: 0, isFetching: true } + { accountId: 'account-a', rateLimits: null, updatedAt: 0, isFetching: true } ]) - inactiveAccounts = [{ id: 'account-a', managedHomePath: '/tmp/account-a/home' }] + inactiveAccounts = [inactiveCodexAccount('account-a', '/tmp/account-a/home')] service.evictInactiveCodexCache('account-b') accountFetch.resolve(okProvider('codex', 64, Date.now())) await fetchOnOpen @@ -234,9 +286,12 @@ describe('RateLimitService', () => { it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() - let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }] + let inactiveAccounts = [inactiveCodexAccount('account-b', '/tmp/account-b/home')] service.setInactiveCodexAccountsResolver(() => inactiveAccounts) - service.setCodexHomePathResolver(() => '/tmp/account-b/home') + service.setCodexHomePathResolver(() => ({ + kind: 'ready', + codexHomePath: '/tmp/account-b/home' + })) vi.mocked(fetchCodexRateLimits) .mockReturnValueOnce(accountFetch.promise) .mockResolvedValueOnce(okProvider('codex', 7, Date.now())) @@ -258,7 +313,7 @@ describe('RateLimitService', () => { it('keeps the inactive Codex debounce across an account switch instead of re-probing', async () => { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-b', '/tmp/account-b/home') ]) vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 10)) @@ -280,8 +335,8 @@ describe('RateLimitService', () => { try { const service = new RateLimitService() service.setInactiveCodexAccountsResolver(() => [ - { id: 'account-a', managedHomePath: '/tmp/account-a/home' }, - { id: 'account-b', managedHomePath: '/tmp/account-b/home' } + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') ]) vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5)) @@ -300,6 +355,34 @@ describe('RateLimitService', () => { } }) + it('does not start another inactive Codex batch during the inter-account stagger', async () => { + vi.useFakeTimers() + try { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + inactiveCodexAccount('account-a', '/tmp/account-a/home'), + inactiveCodexAccount('account-b', '/tmp/account-b/home') + ]) + vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5)) + + const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen() + await vi.advanceTimersByTimeAsync(0) + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + expect(service.getState().inactiveCodexAccounts).toEqual([ + expect.objectContaining({ accountId: 'account-a', isFetching: false }) + ]) + + await service.fetchInactiveCodexAccountsOnOpen() + expect(fetchCodexRateLimits).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(2_000) + await fetchOnOpen + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('does not recache an inactive Claude account removed during fetch-on-open', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index a8db8ec6db6..02bd1930bb6 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -32,13 +32,14 @@ import { type CodexAccountSelectionTarget, type NormalizedCodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' export type InactiveCodexAccountInfo = { id: string - managedHomePath: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } } -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution type KimiHomeResolver = () => Promise type ClaudeAuthPreparationResolver = ( target?: ClaudeAccountSelectionTarget @@ -241,6 +242,7 @@ export class RateLimitService { private inactiveCodexCache = new Map() private inactiveClaudeFetching = new Set() private inactiveCodexFetching = new Set() + private inactiveCodexFetchInFlight = false private lastInactiveClaudeFetchAt = 0 private inactiveClaudeAccountsGeneration = 0 private lastInactiveCodexFetchAt = 0 @@ -260,6 +262,21 @@ export class RateLimitService { this.codexHomePathResolver = resolver } + // Why: `skip` and a `ready` null are different answers — null still means the + // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). + private resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) } @@ -635,7 +652,7 @@ export class RateLimitService { return } this.pruneInactiveCodexState() - if (this.inactiveCodexFetching.size > 0) { + if (this.inactiveCodexFetchInFlight) { return } const accounts = this.inactiveCodexAccountsResolver?.() ?? [] @@ -646,11 +663,7 @@ export class RateLimitService { const fetchGeneration = this.inactiveCodexAccountsGeneration const controller = this.beginFetchCycle() const signal = controller.signal - - for (const account of accounts) { - this.inactiveCodexFetching.add(account.id) - } - this.pushToRenderer() + this.inactiveCodexFetchInFlight = true let staggerNextProbe = false try { @@ -683,12 +696,18 @@ export class RateLimitService { continue } } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() try { // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. const fresh = await fetchCodexRateLimits({ - codexHomePath: account.managedHomePath, + codexHomePath: home.managedHomePath, allowPtyFallback: false, signal }) @@ -724,6 +743,7 @@ export class RateLimitService { this.lastInactiveCodexFetchAt = Date.now() } } finally { + this.inactiveCodexFetchInFlight = false this.finishFetchCycle(controller) } } @@ -1338,10 +1358,13 @@ export class RateLimitService { } const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentHomePath = this.codexHomePathResolver?.(target) ?? null + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. const stillActive = + !currentCodexHome.skip && this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentHomePath) === + this.getCodexProvenance(target, currentCodexHome.homePath) === this.getCodexProvenance(target, codexHomePath) if (stillActive) { // Why: this post-redemption read is newer than every Codex fetch that @@ -1590,10 +1613,18 @@ export class RateLimitService { this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const codexTarget = this.codexFetchTarget - const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' @@ -1627,7 +1658,10 @@ export class RateLimitService { this.updateState({ ...previousState, claude: this.withFetchingStatus(previousState.claude, 'claude'), - codex: this.withFetchingStatus(previousState.codex, 'codex'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') @@ -1640,9 +1674,8 @@ export class RateLimitService { grok: this.withFetchingStatus(previousState.grok, 'grok') }) - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ signal, authReadResult: grokAuthReadResult @@ -1666,12 +1699,14 @@ export class RateLimitService { networkProxySettings: this.networkProxySettingsResolver?.(), signal }), - missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits( cookie, @@ -1780,15 +1815,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + const latestCodexHome = this.resolveCodexHome(codexTarget) const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). const shouldApplyCodex = - codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. const shouldApplyClaude = !claudeFetchGated && @@ -1822,7 +1863,9 @@ export class RateLimitService { : this.state.claude, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) - : this.state.codex, + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, gemini: this.applyStalePolicy(gemini, previousState.gemini), opencodeGo: shouldApplyOpencode ? opencodeConfigChanged @@ -1865,9 +1908,20 @@ export class RateLimitService { return } const codexTarget = this.codexFetchTarget - const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) const previousState = this.state this.updateState({ @@ -1901,10 +1955,18 @@ export class RateLimitService { return } - const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null - const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } const shouldApplyCodex = - codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) if (shouldApplyCodex) { this.trackActiveFailureStreak('codex', codex) diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index c34af95c3fc..33b66e1a4fe 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' const originalEnv = { ...process.env } const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -124,6 +125,26 @@ describe('prepareLocalCommitMessageAgentEnv', () => { }) }) + // Why (#STA-4422): launch prep throws when the managed home is unreadable. + // Falling through to an env would run the headless commit agent on the user's + // real ~/.codex while the UI shows the managed account selected. + it('fails commit generation instead of building a system-account env when the managed home is unreadable', async () => { + process.env.CODEX_HOME = '/home/me/.config/codex' + delete process.env.ORCA_CODEX_HOME + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + }) + + expect(result).toEqual({ + ok: false, + error: 'Failed to prepare the selected agent account for commit message generation.' + }) + expect(result).not.toHaveProperty('env') + }) + it('strips a nested-Orca CODEX_HOME override when the launch resolves to the real home', async () => { process.env.CODEX_HOME = '/managed/runtime/home' process.env.ORCA_CODEX_HOME = '/managed/runtime/home' diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index e3688b57f5b..ae75cbf1c35 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -11,7 +11,11 @@ export type CodexAccountsApi = { runtime?: 'host' | 'wsl' wslDistro?: string | null }) => Promise - reauthenticate: (args: { accountId: string }) => Promise + reauthenticate: (args: { + accountId: string + /** Local-only: activate the re-authed account when its runtime lane had no selection. */ + activateIfSelectionWasEmpty?: boolean + }) => Promise remove: (args: { accountId: string }) => Promise select: (args: { accountId: string | null diff --git a/src/preload/index.ts b/src/preload/index.ts index 2c7beed48a2..f1deda11071 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2184,8 +2184,10 @@ const api = { list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:reauthenticate', args), + reauthenticate: (args: { + accountId: string + activateIfSelectionWasEmpty?: boolean + }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => ipcRenderer.invoke('codexAccounts:remove', args), select: (args: { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 13b93c24596..c778a982aa0 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -96,6 +96,49 @@ import { watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' +// Why: bounded so a permanently unreadable home cannot poll forever; ~5 minutes +// total is long enough to outlast an antivirus scan or backup pass. +const CODEX_CONFIG_SYNC_RETRY_MS = 30_000 +const CODEX_CONFIG_SYNC_RETRY_LIMIT = 10 + +function watchCodexConfigSyncStatus( + onStatus: (status: CodexConfigSyncStatus | null) => void +): () => void { + let cancelled = false + let attempts = 0 + let retryTimer: ReturnType | null = null + const poll = (): void => { + void window.api.codexConfigSync + .status() + .then((status) => { + if (cancelled) { + return + } + onStatus(status) + if ( + status.state === 'stalled' && + status.reason === 'managed-home-unavailable' && + attempts < CODEX_CONFIG_SYNC_RETRY_LIMIT + ) { + attempts += 1 + retryTimer = setTimeout(poll, CODEX_CONFIG_SYNC_RETRY_MS) + } + }) + .catch(() => { + if (!cancelled) { + onStatus(null) + } + }) + } + poll() + return () => { + cancelled = true + if (retryTimer !== null) { + clearTimeout(retryTimer) + } + } +} + export { getAccountsPaneSearchEntries } const EMPTY_WSL_DISTROS: string[] = [] @@ -452,22 +495,12 @@ export function AccountsPane({ setCodexConfigSync(null) return } - let cancelled = false - void window.api.codexConfigSync - .status() - .then((status) => { - if (!cancelled) { - setCodexConfigSync(status) - } - }) - .catch(() => { - if (!cancelled) { - setCodexConfigSync(null) - } - }) - return () => { - cancelled = true - } + // Why: a temporarily locked managed home clears on its own, but this effect + // only reruns on scope/runtime/selection changes — none of which a lock + // release triggers. Without a retry the warning would stick until remount. + // Serialized (timeout, not interval) so a slow response can never be + // overwritten by an older one. + return watchCodexConfigSyncStatus(setCodexConfigSync) // Why: the status resolves whichever home the ACTIVE selection mirrors into // (per-account, shared, or none for the real-home lane), so switching // accounts must refetch or the banner describes the previous account. @@ -1180,23 +1213,28 @@ export function AccountsPane({
- {codexConfigSyncWarning === 'missing-source' + {codexConfigSyncWarning === 'managed-home-unavailable' ? translate( - 'auto.components.settings.AccountsPane.codexConfigSyncMissingSource', - 'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.', - { value0: codexConfigSync?.systemConfigPath ?? '' } + 'auto.components.settings.AccountsPane.codexConfigSyncManagedHomeUnavailable', + 'Orca could not read this account\u2019s Codex files just now, so settings may not be syncing. This usually clears on its own \u2014 antivirus or a backup tool briefly locks them.' ) - : codexConfigSyncWarning === 'blank-source' + : codexConfigSyncWarning === 'missing-source' ? translate( - 'auto.components.settings.AccountsPane.codexConfigSyncBlankSource', - 'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.', + 'auto.components.settings.AccountsPane.codexConfigSyncMissingSource', + 'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.', { value0: codexConfigSync?.systemConfigPath ?? '' } ) - : translate( - 'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource', - "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.", - { value0: codexConfigSync?.systemConfigPath ?? '' } - )} + : codexConfigSyncWarning === 'blank-source' + ? translate( + 'auto.components.settings.AccountsPane.codexConfigSyncBlankSource', + 'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.', + { value0: codexConfigSync?.systemConfigPath ?? '' } + ) + : translate( + 'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource', + "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.", + { value0: codexConfigSync?.systemConfigPath ?? '' } + )}
) : null} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 0243c5f8dee..436da40e59c 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -96,6 +96,7 @@ import { selectClaudeProviderAccount, selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' +import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getDisplayedUsagePercentage, @@ -1489,23 +1490,61 @@ export function CodexSwitcherMenu({ } } - const handleSignInAccount = async (accountId: string): Promise => { + const handleSignInAccount = async ( + accountId: string, + target: CodexStatusRuntimeTarget + ): Promise => { if (isSwitching || reauthenticatingAccountId !== null) { return } + const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setReauthenticatingAccountId(accountId) try { - const next = await window.api.codexAccounts.reauthenticate({ accountId }) + const next = await window.api.codexAccounts.reauthenticate({ + accountId, + // Why: signing in from a signed-out status bar should leave the account + // usable; the main process still refuses to steal an existing selection. + activateIfSelectionWasEmpty: true + }) recordFeatureInteraction('codex-account-switching') if (mountedRef.current) { setAccounts(next) } await fetchSettings() - if (mountedRef.current && accountsExpandedRef.current) { + const nextActiveAccountId = getCodexStatusActiveId(next, target) + if (previousActiveAccountId !== nextActiveAccountId) { + // Why: sign-in that lands on a new active account changes pane credentials + // exactly like an explicit switch, so it owes the same restart prompt. + await markLiveCodexSessionsForRestart({ + previousAccountLabel: resolveCodexRestartPromptAccountLabel( + accountState.accounts, + previousActiveAccountId + ), + nextAccountLabel: resolveCodexRestartPromptAccountLabel( + next.accounts, + nextActiveAccountId + ), + previousAccountId: previousActiveAccountId ?? null, + nextAccountId: nextActiveAccountId ?? null, + target + }) + if (mountedRef.current) { + setAccountsExpanded(false) + } + } else if (mountedRef.current && accountsExpandedRef.current) { await fetchInactiveCodexAccountUsage() } + toast.success( + translate('auto.components.status.bar.StatusBar.codexSignInSuccess', 'Signed in to Codex') + ) } catch (error) { console.error('Failed to re-authenticate Codex account from status bar:', error) + toast.error( + translate( + 'auto.components.status.bar.StatusBar.codexSignInError', + 'Codex sign-in failed. Please try again.' + ) + ) } finally { if (mountedRef.current) { setReauthenticatingAccountId(null) @@ -1802,7 +1841,7 @@ export function CodexSwitcherMenu({ onSignIn={() => { suppressNextAccountSelect() if (target.id !== null) { - void handleSignInAccount(target.id) + void handleSignInAccount(target.id, target.runtimeTarget) } }} /> diff --git a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx new file mode 100644 index 00000000000..446019b86be --- /dev/null +++ b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment happy-dom +/** + * STA-4422 symptom 2: the status bar's inline "Sign in" action re-authenticated + * an account and then reported nothing — no toast on success or failure — and + * never ran the session-restart workflow that an explicit account switch runs, + * so live panes kept the old credentials. These render the real switcher and + * click the real button so the wiring (intent flag, runtime target, restart + * workflow, toasts) cannot pass while the handler is mis-wired. + */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { + CodexManagedAccountSummary, + CodexRateLimitAccountsState +} from '../../../../shared/managed-account-types' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +const markLiveCodexSessionsForRestart = vi.fn(async () => {}) +const toastSuccess = vi.fn() +const toastError = vi.fn() +const fetchInactiveCodexAccountUsage = vi.fn(async () => {}) +const fetchSettings = vi.fn(async () => {}) +const reauthenticate = vi.fn(async (_args: unknown) => codexSnapshot(null)) + +let storeSettings: GlobalSettings + +function codexAccount(id: string, updatedAt: number): CodexManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + managedHomeRuntime: 'host', + wslDistro: null, + providerAccountId: `provider-${id}`, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: updatedAt, + updatedAt, + lastAuthenticatedAt: updatedAt + } +} + +function codexSnapshot(activeAccountId: string | null): CodexRateLimitAccountsState { + return { + accounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)], + activeAccountId, + activeAccountIdsByRuntime: { host: activeAccountId, wsl: {} } + } +} + +function settingsWithActive(activeAccountId: string | null): GlobalSettings { + return { + codexManagedAccounts: [codexAccount('account-1', 2), codexAccount('account-2', 1)], + activeCodexManagedAccountId: activeAccountId, + activeCodexManagedAccountIdsByRuntime: { host: activeAccountId, wsl: {} }, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + localAccountRuntime: 'host', + localAccountWslDistro: null, + skipCodexRateLimitResetConfirm: false + } as unknown as GlobalSettings +} + +/** An inactive account whose usage probe failed — the state that renders "Sign in". */ +const unavailableUsage: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: 'Not signed in', + updatedAt: 1 +} as unknown as ProviderRateLimits + +vi.mock('sonner', () => ({ + toast: { success: toastSuccess, error: toastError } +})) + +vi.mock('@/lib/codex-session-restart', () => ({ + markLiveCodexSessionsForRestart, + resolveCodexRestartPromptAccountLabel: ( + accounts: readonly { id: string; email: string }[], + accountId: string | null | undefined + ) => accounts.find((entry) => entry.id === accountId)?.email ?? 'System default' +})) + +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: vi.fn(async () => ({ + codex: codexSnapshot(null), + claude: { accounts: [], activeAccountId: null }, + failedProviders: [] + })), + selectCodexProviderAccount: vi.fn(async () => codexSnapshot(null)), + selectClaudeProviderAccount: vi.fn(async () => ({ accounts: [], activeAccountId: null })) +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => null +})) + +vi.mock('@/lib/windows-terminal-capabilities', () => ({ + useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }), + getWindowsTerminalCapabilityOwnerKey: () => 'local' +})) + +vi.mock('./tooltip', async (importOriginal) => ({ + ...(await importOriginal>()), + ProviderIcon: () => null, + ProviderPanel: () => null +})) + +// Why: Radix portals its menu behind pointer-capture the DOM shim cannot drive; +// passthrough shells render the same tree eagerly so the real handlers run. +vi.mock('@/components/ui/dropdown-menu', () => { + const passthrough = + (role?: string) => + ({ + children, + onSelect + }: { + children?: React.ReactNode + onSelect?: (event: { preventDefault: () => void }) => void + }): React.JSX.Element => + React.createElement( + 'div', + { + role, + onClick: onSelect ? () => onSelect({ preventDefault: () => {} }) : undefined + }, + children + ) + return { + DropdownMenu: passthrough(), + DropdownMenuCheckboxItem: passthrough(), + DropdownMenuContent: passthrough(), + DropdownMenuItem: passthrough('menuitem'), + DropdownMenuLabel: passthrough(), + DropdownMenuSeparator: passthrough(), + DropdownMenuSub: passthrough(), + DropdownMenuSubContent: passthrough(), + DropdownMenuSubTrigger: passthrough(), + DropdownMenuTrigger: passthrough() + } +}) + +vi.mock('../../store', () => { + const state = (): Record => ({ + settings: storeSettings, + runtimeEnvironments: [], + usagePercentageDisplay: 'used', + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + fetchSettings, + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + refreshCodexRateLimitsForTarget: vi.fn(), + consumeCodexRateLimitResetCredit: vi.fn(), + fetchInactiveCodexAccountUsage, + rateLimits: { + inactiveCodexAccounts: [ + { accountId: 'account-2', isFetching: false, rateLimits: unavailableUsage } + ], + codexTarget: { runtime: 'host', wslDistro: null } + } + }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +const codexProvider: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + status: 'error', + error: 'Not signed in', + updatedAt: 1 +} as unknown as ProviderRateLimits + +async function renderSwitcherAndOpenAccounts(summaryLabel: string): Promise { + const { CodexSwitcherMenu } = await import('./StatusBar') + render( + React.createElement(CodexSwitcherMenu, { + codex: codexProvider, + compact: false, + iconOnly: false + }) + ) + // The account list is collapsed behind the summary row, exactly as in the app. + fireEvent.click(screen.getByText(summaryLabel)) + await waitFor(() => expect(screen.getByRole('button', { name: /Sign in/ })).toBeTruthy()) +} + +describe('status bar Codex sign-in action', () => { + beforeEach(() => { + vi.clearAllMocks() + storeSettings = settingsWithActive(null) + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { codexAccounts: { reauthenticate } } + }) + }) + + afterEach(() => { + cleanup() + }) + + it('activates the signed-in account and runs the same restart workflow a switch runs', async () => { + reauthenticate.mockResolvedValue(codexSnapshot('account-2')) + + await renderSwitcherAndOpenAccounts('System default') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1)) + // Healthy anchor: the intent and the clicked row's lane both reach the main process. + expect(reauthenticate).toHaveBeenCalledWith({ + accountId: 'account-2', + activateIfSelectionWasEmpty: true + }) + expect(markLiveCodexSessionsForRestart).toHaveBeenCalledTimes(1) + expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith( + expect.objectContaining({ + previousAccountId: null, + nextAccountId: 'account-2', + target: { runtime: 'host', wslDistro: null } + }) + ) + expect(toastError).not.toHaveBeenCalled() + }) + + it('does not disturb live panes when the effective account did not change', async () => { + storeSettings = settingsWithActive('account-1') + reauthenticate.mockResolvedValue(codexSnapshot('account-1')) + + await renderSwitcherAndOpenAccounts('account-1@example.com') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1)) + // Healthy anchor: the same click reached the main process; only the follow-up differs. + expect(reauthenticate).toHaveBeenCalledWith({ + accountId: 'account-2', + activateIfSelectionWasEmpty: true + }) + expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + }) + + it('reports a failed sign-in instead of leaving the click silent', async () => { + reauthenticate.mockRejectedValue(new Error('Codex login exited with code 1.')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await renderSwitcherAndOpenAccounts('System default') + fireEvent.click(screen.getByRole('button', { name: /Sign in/ })) + + await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1)) + expect(toastSuccess).not.toHaveBeenCalled() + expect(markLiveCodexSessionsForRestart).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d62d5f1858..626231231ea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3489,7 +3489,9 @@ "antigravityUsageDetails": "Open Antigravity usage details", "grokUsageAria": "Open Grok usage details", "grokUsageMenu": "Grok Usage", - "floatingTerminalNewActivity": "{{label}}, new activity" + "floatingTerminalNewActivity": "{{label}}, new activity", + "codexSignInSuccess": "Signed in to Codex", + "codexSignInError": "Codex sign-in failed. Please try again." }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -6125,6 +6127,7 @@ "codexSystemDefaultNeedsSignIn": "No Codex sign-in was found for {{value0}}.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "Orca could not read this account’s Codex files just now, so settings may not be syncing. This usually clears on its own — antivirus or a backup tool briefly locks them.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c3c6768dbd9..24e57ee1074 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3276,7 +3276,9 @@ "antigravityUsageDetails": "Abrir detalles de uso de Antigravity", "grokUsageAria": "Abrir detalles de uso de Grok", "grokUsageMenu": "Uso de Grok", - "floatingTerminalNewActivity": "{{label}}, nueva actividad" + "floatingTerminalNewActivity": "{{label}}, nueva actividad", + "codexSignInSuccess": "Sesión iniciada en Codex", + "codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo." }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -5338,6 +5340,7 @@ "codexSystemDefaultNeedsSignIn": "No se encontró ningún inicio de sesión de Codex para {{value0}}.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "Orca no pudo leer los archivos de Codex de esta cuenta en este momento, por lo que es posible que la configuración no se esté sincronizando. Normalmente se resuelve solo: un antivirus o una herramienta de copia de seguridad puede bloquearlos brevemente.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6b3aa3ba9f8..f2d1b9c0b58 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3276,7 +3276,9 @@ "antigravityUsageDetails": "Antigravity の使用状況詳細を開く", "grokUsageAria": "Grok の使用状況詳細を開く", "grokUsageMenu": "Grok の使用状況", - "floatingTerminalNewActivity": "{{label}}、新規アクティビティ" + "floatingTerminalNewActivity": "{{label}}、新規アクティビティ", + "codexSignInSuccess": "Codex にサインインしました", + "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -5323,6 +5325,7 @@ "codexSystemDefaultNeedsSignIn": "{{value0}} の Codex サインインが見つかりません。", "codexConfigSyncMissingSource": "{{value0}} が見つからないため、Codex は最後に同期した設定を使い続けています。同期を再開するには、そのファイルを復元してください。", "codexConfigSyncBlankSource": "{{value0}} が空のため、Codex は最後に同期した設定を使い続けています。同期フォルダーのダウンロードが完了するまでは正常な状態です。", + "codexConfigSyncManagedHomeUnavailable": "Orca は現在このアカウントの Codex ファイルを読み取れないため、設定が同期されていない可能性があります。ウイルス対策ソフトやバックアップツールによる一時的なロックが原因で、通常は自動的に解消されます。", "codexConfigSyncUnreadableSource": "{{value0}} を読み取れなかったため、Codex は最後に同期した設定を使い続けています。そのファイルの権限を確認してください。" }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5e67a56c55e..e10a0456175 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3278,7 +3278,9 @@ "antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기", "grokUsageAria": "Grok 사용량 세부 정보 열기", "grokUsageMenu": "Grok 사용량", - "floatingTerminalNewActivity": "{{label}}, 새 활동" + "floatingTerminalNewActivity": "{{label}}, 새 활동", + "codexSignInSuccess": "Codex에 로그인했습니다", + "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요." }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -5325,6 +5327,7 @@ "codexSystemDefaultNeedsSignIn": "{{value0}}에 대한 Codex 로그인을 찾을 수 없습니다.", "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", + "codexConfigSyncManagedHomeUnavailable": "현재 Orca가 이 계정의 Codex 파일을 읽을 수 없어 설정이 동기화되지 않을 수 있습니다. 바이러스 백신이나 백업 도구가 파일을 잠시 잠근 경우가 많으며, 일반적으로 자동으로 해결됩니다.", "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." }, "AdvancedPane": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f060a9c7529..31238ca11ec 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3288,7 +3288,9 @@ "antigravityUsageDetails": "打开 Antigravity 使用详情", "grokUsageAria": "打开 Grok 使用详情", "grokUsageMenu": "Grok 使用情况", - "floatingTerminalNewActivity": "{{label}},有新活动" + "floatingTerminalNewActivity": "{{label}},有新活动", + "codexSignInSuccess": "已登录 Codex", + "codexSignInError": "Codex 登录失败,请重试。" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -5335,6 +5337,7 @@ "codexSystemDefaultNeedsSignIn": "未找到 {{value0}} 的 Codex 登录信息。", "codexConfigSyncMissingSource": "由于缺少 {{value0}},Codex 仍在使用上次同步的设置。请恢复该文件以恢复同步。", "codexConfigSyncBlankSource": "由于 {{value0}} 为空,Codex 仍在使用上次同步的设置。同步文件夹完成下载前出现这种情况是正常的。", + "codexConfigSyncManagedHomeUnavailable": "Orca 暂时无法读取此账户的 Codex 文件,因此设置可能尚未同步。这通常会自行恢复——防病毒软件或备份工具可能只是短暂锁定了这些文件。", "codexConfigSyncUnreadableSource": "由于无法读取 {{value0}},Codex 仍在使用上次同步的设置。请检查该文件的权限。" }, "AdvancedPane": { diff --git a/src/shared/codex-config-sync-types.ts b/src/shared/codex-config-sync-types.ts index 32f413dbaef..92284dcccbd 100644 --- a/src/shared/codex-config-sync-types.ts +++ b/src/shared/codex-config-sync-types.ts @@ -1,7 +1,15 @@ // Why: the managed runtime config keeps serving the last good settings while the // source is unusable, so a stall is "working but not picking up your edits" — // the reason is what makes it actionable, and the path is what the user fixes. -export type CodexConfigSyncStallReason = 'missing-source' | 'blank-source' | 'unreadable-source' +// Why: the first three describe the SYSTEM config source. 'managed-home-unavailable' +// describes the managed home itself being temporarily unreadable (e.g. an antivirus +// lock on Windows) — a different file and a different remedy, so it needs its own +// reason rather than borrowing 'unreadable-source' and blaming the wrong path. +export type CodexConfigSyncStallReason = + | 'missing-source' + | 'blank-source' + | 'unreadable-source' + | 'managed-home-unavailable' export type CodexConfigSyncStatus = | { state: 'synced'; reason: null; systemConfigPath: string }