diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 4f55dec23..f2bf77107 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { + claimsCodexRolloutLayout, findTrustedCodexSessionResume, resolveTrustedCodexSessionResumeHome } from './codex-session-resume-home' @@ -224,3 +225,47 @@ describe('resolveTrustedCodexSessionResumeHome', () => { ).resolves.toBeNull() }) }) + +describe('claimsCodexRolloutLayout', () => { + it('is true for a rollout path even if the file is missing', () => { + expect( + claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl') + ).toBe(true) + }) + + it('is true for compressed rollouts and Windows-separated paths', () => { + expect( + claimsCodexRolloutLayout( + '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl.zst' + ) + ).toBe(true) + expect( + claimsCodexRolloutLayout( + 'C:\\Users\\example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl' + ) + ).toBe(true) + }) + + it('is true for a rollout under a home Orca no longer trusts, so resume cannot silently fall through to the selected account', () => { + expect( + claimsCodexRolloutLayout('/removed/account/home/sessions/2026/07/20/rollout-a.jsonl') + ).toBe(true) + }) + + it('is false for Claude (or other non-Codex) transcript paths', () => { + expect( + claimsCodexRolloutLayout( + '/Users/example/.claude/projects/-Users-example-repo/019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl' + ) + ).toBe(false) + }) + + it('is false for empty provenance and JSONL misplaced inside a sessions root', () => { + expect(claimsCodexRolloutLayout(undefined)).toBe(false) + expect(claimsCodexRolloutLayout(' ')).toBe(false) + expect(claimsCodexRolloutLayout('/Users/example/.codex/sessions/rollout-a.jsonl')).toBe(false) + expect( + claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/nested/rollout-a.jsonl') + ).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index aebaf6a15..639d2620e 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -8,7 +8,10 @@ import { import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' // Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. -const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/ +const DATED_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?` +const ROLLOUT_RELATIVE_PATH = new RegExp(`^${DATED_ROLLOUT_TAIL}$`) +// Why: case-insensitive because trusted-home matching folds Windows path case too. +const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${DATED_ROLLOUT_TAIL}$`, 'i') function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { const relativePath = relativePathInsideRoot(sessionsRoot, filePath) @@ -76,6 +79,20 @@ export function resolveTrustedCodexSessionResumeHome(args: { return resolveTrustedCodexSessionResume(args)?.homePath ?? null } +/** + * True when transcriptPath claims Codex's dated rollout layout, under any home and without + * checking existence — separating rejected Codex provenance from cross-agent/stale metadata. + * Not scoped to trusted homes: a rollout under a removed home is still rejected provenance, + * and admitting it would resume that session under whichever account is selected now. + */ +export function claimsCodexRolloutLayout(transcriptPath: string | undefined): boolean { + const persistedPath = transcriptPath?.trim() + if (!persistedPath) { + return false + } + return CODEX_ROLLOUT_LAYOUT_PATH.test(persistedPath.replace(/\\/g, '/')) +} + export async function findTrustedCodexSessionResume(args: { sessionId: string transcriptPath: string | undefined diff --git a/src/main/index.ts b/src/main/index.ts index 8d8c429f2..ec2dbdc1b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -172,7 +172,10 @@ import { startCodexSessionIndexHealInBackground } from './codex/codex-session-in import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' -import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home' +import { + claimsCodexRolloutLayout, + findTrustedCodexSessionResume +} from './codex/codex-session-resume-home' import { getSystemCodexHomePath } from './codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' @@ -852,7 +855,9 @@ async function prepareCodexSessionResumeForLaunch(args: { trustedCodexHomes: trustedHomes }) if (!sessionSource) { - if (args.providerSession.transcriptPath) { + // Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex + // provenance (e.g. ~/.claude/… on a pane mislabeled "codex") fall through to a normal launch. + if (claimsCodexRolloutLayout(args.providerSession.transcriptPath)) { throw new Error( 'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.' )