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.
This commit is contained in:
Brennan Benson
2026-08-20 16:02:35 -07:00
parent 012e9f410c
commit ce10b304a4
3 changed files with 307 additions and 13 deletions
@@ -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<string, number>(),
reads: new Map<string, number>(),
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<typeof NodeFs>()
const original = actual.lstatSync as (...args: unknown[]) => unknown
const patched: Record<string, unknown> = {
...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<typeof import('node:os')>('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<typeof createStore>
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()
})
})
@@ -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<string>()
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
}
}
+12 -13
View File
@@ -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({