mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user