From ce10b304a47b4e0376d2b4ee12ed7ceeae0c1788 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:02:35 -0700 Subject: [PATCH] fix(codex): include verified selected home in resume discovery snapshot (STA-4919) Discovery omits a managed home whose ownership read is indeterminate, while selection microseconds later can still verify that same home. The legacy rescan then missed the selected account and a competing alias could win. Snapshot both reads together and union the verified selected home into the trusted list. Do not admit unverified homes. --- .../codex/codex-resume-home-snapshot.test.ts | 246 ++++++++++++++++++ src/main/codex/codex-resume-home-snapshot.ts | 49 ++++ src/main/index.ts | 25 +- 3 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 src/main/codex/codex-resume-home-snapshot.test.ts create mode 100644 src/main/codex/codex-resume-home-snapshot.ts diff --git a/src/main/codex/codex-resume-home-snapshot.test.ts b/src/main/codex/codex-resume-home-snapshot.test.ts new file mode 100644 index 00000000000..9a601cf2f5a --- /dev/null +++ b/src/main/codex/codex-resume-home-snapshot.test.ts @@ -0,0 +1,246 @@ +import { mkdirSync, realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { snapshotCodexResumeHomes } from './codex-resume-home-snapshot' +import { prepareCodexSessionResume } from './codex-session-resume-preparation' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' +import { createSettings } from '../codex-accounts/runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from '../codex-accounts/runtime-home-service-test-harness' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +const lstatFaults = vi.hoisted(() => { + const state = { + remaining: new Map(), + reads: new Map(), + failNext(path: string, count = 1): void { + state.remaining.set(path, count) + }, + reset(): void { + state.remaining.clear() + state.reads.clear() + }, + readsFor(path: string): number { + return state.reads.get(path) ?? 0 + }, + consume(target: unknown): void { + if (typeof target !== 'string') { + return + } + const left = state.remaining.get(target) ?? 0 + if (left <= 0) { + return + } + state.remaining.set(target, left - 1) + state.reads.set(target, (state.reads.get(target) ?? 0) + 1) + const error: NodeJS.ErrnoException = new Error( + `EBUSY: resource busy or locked, lstat '${target}'` + ) + error.code = 'EBUSY' + 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 + } +}) + +function writeCompetingAlias(homePath: string): void { + const datedDir = join(homePath, 'sessions', '2026', '07', '20') + mkdirSync(datedDir, { recursive: true }) + writeFileSync( + join(datedDir, `rollout-2026-07-20T15-50-19-${SESSION_ID}.jsonl`), + '{"session":"alias"}\n', + 'utf-8' + ) +} + +async function createTwoAccountService(): Promise<{ + service: { + getHostCodexHomePathsForSessionDiscovery: () => string[] + resolveSelectedHostAccountCodexHomePathForResume: () => string | null + } + store: ReturnType + homeA: string + homeB: string +}> { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const homeA = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a') + ) + const homeB = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b') + ) + writeCompetingAlias(homeA) + writeCompetingAlias(homeB) + const store = createStore( + createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('../codex-accounts/runtime-home-service') + return { + service: new CodexRuntimeHomeService(store as never), + store, + homeA, + homeB + } +} + +async function resumeFromSnapshot(snapshot: { + trustedCodexHomes: readonly string[] + selectedAccountCodexHome: string | null +}): Promise<{ outcome: string; codexHomePath?: string }> { + return prepareCodexSessionResume({ + sessionId: SESSION_ID, + transcriptPath: undefined, + trustedCodexHomes: snapshot.trustedCodexHomes, + getSelectedAccountCodexHome: () => snapshot.selectedAccountCodexHome, + systemCodexHomePath: getSystemCodexHomePath(), + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), + resolveVerifiedResumeHome: async (source) => source.homePath + }) +} + +describe('STA-4919 Codex resume home snapshot', () => { + beforeEach(() => { + lstatFaults.reset() + setupRuntimeHomeTest() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + lstatFaults.reset() + teardownRuntimeHomeTest() + }) + + it('omits a home from discovery while its ownership marker is locked', async () => { + const { service, homeA, homeB } = await createTwoAccountService() + const markerA = join(realpathSync(homeA), '.orca-managed-home') + lstatFaults.failNext(markerA, 1) + + const discovered = service.getHostCodexHomePathsForSessionDiscovery() + expect(discovered).not.toContain(homeA) + expect(discovered).toContain(homeB) + expect(lstatFaults.readsFor(markerA)).toBe(1) + // The lock has cleared; selection must still be able to verify A. + expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(homeA) + }) + + it('resumes under the selected account, not a competing alias, after a transient discovery miss', async () => { + const { service, homeA, homeB } = await createTwoAccountService() + const markerA = join(realpathSync(homeA), '.orca-managed-home') + lstatFaults.failNext(markerA, 1) + + const snapshot = snapshotCodexResumeHomes({ + systemHomePath: getSystemCodexHomePath(), + runtimeHome: service + }) + + expect(lstatFaults.readsFor(markerA)).toBe(1) + expect(snapshot.selectedAccountCodexHome).toBe(homeA) + expect(snapshot.trustedCodexHomes).toContain(homeA) + expect(snapshot.trustedCodexHomes).toContain(homeB) + + await expect(resumeFromSnapshot(snapshot)).resolves.toEqual({ + outcome: 'resume', + codexHomePath: homeA + }) + }) + + it('still excludes a genuinely untrusted home that holds a competing alias', async () => { + const { service, homeA, store } = await createTwoAccountService() + const untrustedHome = join(testState.userDataDir, 'outside', 'account-c', 'home') + mkdirSync(untrustedHome, { recursive: true }) + writeFileSync(join(untrustedHome, '.orca-managed-home'), 'account-c\n', 'utf-8') + writeFileSync( + join(untrustedHome, 'auth.json'), + createCodexAuthJson('c@example.com', 'acct-c', 'refresh-c'), + 'utf-8' + ) + writeCompetingAlias(untrustedHome) + const settings = store.getSettings() + settings.codexManagedAccounts = [ + ...settings.codexManagedAccounts, + createCodexAccountRecord('account-c', 'c@example.com', 'acct-c', untrustedHome) + ] + + const snapshot = snapshotCodexResumeHomes({ + systemHomePath: getSystemCodexHomePath(), + runtimeHome: service + }) + + expect(snapshot.selectedAccountCodexHome).toBe(homeA) + expect(snapshot.trustedCodexHomes).not.toContain(untrustedHome) + await expect(resumeFromSnapshot(snapshot)).resolves.toEqual({ + outcome: 'resume', + codexHomePath: homeA + }) + }) + + it('does not admit the selected path when that home is untrusted', async () => { + const { service, homeA, store } = await createTwoAccountService() + writeFileSync(join(homeA, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + const snapshot = snapshotCodexResumeHomes({ + systemHomePath: getSystemCodexHomePath(), + runtimeHome: service + }) + + expect(snapshot.selectedAccountCodexHome).toBeNull() + expect(snapshot.trustedCodexHomes).not.toContain(homeA) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + const preparation = await resumeFromSnapshot(snapshot) + expect(preparation).toMatchObject({ outcome: 'resume' }) + expect(preparation.codexHomePath).not.toBe(homeA) + expect(preparation.codexHomePath).toBeDefined() + }) +}) diff --git a/src/main/codex/codex-resume-home-snapshot.ts b/src/main/codex/codex-resume-home-snapshot.ts new file mode 100644 index 00000000000..01359c39c78 --- /dev/null +++ b/src/main/codex/codex-resume-home-snapshot.ts @@ -0,0 +1,49 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +export type CodexResumeHomeSnapshot = { + trustedCodexHomes: string[] + selectedAccountCodexHome: string | null +} + +export type CodexResumeHomeSnapshotSource = { + getHostCodexHomePathsForSessionDiscovery: () => readonly string[] + resolveSelectedHostAccountCodexHomePathForResume: () => string | null +} + +function uniqueTrustedHomes(homes: readonly string[]): string[] { + const seen = new Set() + const unique: string[] = [] + for (const home of homes) { + const comparison = normalizeRuntimePathForComparison(home) + if (seen.has(comparison)) { + continue + } + seen.add(comparison) + unique.push(home) + } + return unique +} + +/** + * Why: discovery silently omits a home whose ownership read is `indeterminate` + * (transient EBUSY/EPERM). Selection, microseconds later, may succeed for that + * same home. The legacy rescan iterates only the discovery list, so a competing + * alias in another account would win (STA-4919). Include the selected home only + * after this read verified it — never because verification was skipped. + */ +export function snapshotCodexResumeHomes(args: { + systemHomePath: string + runtimeHome: CodexResumeHomeSnapshotSource +}): CodexResumeHomeSnapshot { + const discoveredHomes = args.runtimeHome.getHostCodexHomePathsForSessionDiscovery() + const selectedAccountCodexHome = + args.runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + return { + trustedCodexHomes: uniqueTrustedHomes([ + args.systemHomePath, + ...discoveredHomes, + ...(selectedAccountCodexHome ? [selectedAccountCodexHome] : []) + ]), + selectedAccountCodexHome + } +} diff --git a/src/main/index.ts b/src/main/index.ts index ecd94696965..2b10d205772 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -252,6 +252,7 @@ import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/ho import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' +import { snapshotCodexResumeHomes } from './codex/codex-resume-home-snapshot' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' @@ -1163,20 +1164,18 @@ async function prepareCodexSessionResumeForLaunch(args: { } const systemHomePath = getSystemCodexHomePath() // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. - const trustedHomes = [ - systemHomePath, - ...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: discovery omits a home whose ownership read is `indeterminate` + // (antivirus EBUSY); selection microseconds later may verify that same home. + // Snapshot both from this one call and union the verified selected home into + // the scan list, or a competing alias in another account wins (STA-4919). + // An unreadable selected home still throws rather than collapsing to "no + // selection" (STA-4422). The selected home is included only because this + // read verified it — never because verification was skipped. + const { trustedCodexHomes: trustedHomes, selectedAccountCodexHome } = snapshotCodexResumeHomes({ + systemHomePath, + runtimeHome: codexRuntimeHome + }) // 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({