diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 5abcc2d4b61..03c2c60fa48 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -1,16 +1,27 @@ -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { findTrustedCodexSessionResume, resolveTrustedCodexSessionResumeHome } from './codex-session-resume-home' +const tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + describe('resolveTrustedCodexSessionResumeHome', () => { it('returns the trusted home containing a persisted rollout', () => { expect( resolveTrustedCodexSessionResumeHome({ transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl', trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'], - fileExists: () => true + fileIsRegular: () => true }) ).toBe('/Users/example/.codex') }) @@ -20,25 +31,33 @@ describe('resolveTrustedCodexSessionResumeHome', () => { resolveTrustedCodexSessionResumeHome({ transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', trustedCodexHomes: ['c:\\users\\example\\.codex'], - fileExists: () => true + fileIsRegular: () => true }) ).toBe('c:\\users\\example\\.codex') }) it('rejects paths outside trusted homes or outside the rollout layout', () => { - const fileExists = (): boolean => true + const fileIsRegular = (): boolean => true expect( resolveTrustedCodexSessionResumeHome({ transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl', trustedCodexHomes: ['/Users/example/.codex'], - fileExists + fileIsRegular }) ).toBeNull() expect( resolveTrustedCodexSessionResumeHome({ transcriptPath: '/Users/example/.codex/sessions/index.jsonl', trustedCodexHomes: ['/Users/example/.codex'], - fileExists + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: + '/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular }) ).toBeNull() }) @@ -48,11 +67,34 @@ describe('resolveTrustedCodexSessionResumeHome', () => { resolveTrustedCodexSessionResumeHome({ transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl', trustedCodexHomes: ['/Users/example/.codex'], - fileExists: () => false + fileIsRegular: () => false }) ).toBeNull() }) + it('requires the transcript provenance to name a regular rollout file', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + mkdirSync(rolloutDirectory, { recursive: true }) + + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutDirectory, + trustedCodexHomes: [homePath] + }) + ).toBeNull() + + const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl') + writeFileSync(rolloutFile, '{}\n') + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutFile, + trustedCodexHomes: [homePath] + }) + ).toBe(homePath) + }) + it('finds older saved sessions by id when transcript provenance is absent', async () => { const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl` @@ -72,6 +114,25 @@ describe('resolveTrustedCodexSessionResumeHome', () => { ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) }) + it('does not scan session trees when exact transcript provenance is valid', async () => { + const transcriptPath = + '/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath, + trustedCodexHomes: ['/managed/account/home'], + fileIsRegular: () => true, + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath }) + expect(listSessionFiles).not.toHaveBeenCalled() + }) + it('does not scan homes for an untrusted legacy session id shape', async () => { const listSessionFiles = (): AsyncIterable => { throw new Error('must not scan') diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index c1a609e7628..d8aa8456d1c 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, lstatSync } from 'node:fs' import { join } from 'node:path' import { getRuntimePathBasename, @@ -7,15 +7,23 @@ import { } from '../../shared/cross-platform-path' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' -const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-.+\.jsonl$/ +const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/ + +function isRegularFile(filePath: string): boolean { + try { + return lstatSync(filePath).isFile() + } catch { + return false + } +} export function resolveTrustedCodexSessionResumeHome(args: { transcriptPath: string | undefined trustedCodexHomes: readonly string[] - fileExists?: (filePath: string) => boolean + fileIsRegular?: (filePath: string) => boolean }): string | null { const transcriptPath = args.transcriptPath?.trim() - if (!transcriptPath || !(args.fileExists ?? existsSync)(transcriptPath)) { + if (!transcriptPath || !(args.fileIsRegular ?? isRegularFile)(transcriptPath)) { return null } @@ -32,7 +40,7 @@ export async function findTrustedCodexSessionResume(args: { sessionId: string transcriptPath: string | undefined trustedCodexHomes: readonly string[] - fileExists?: (filePath: string) => boolean + fileIsRegular?: (filePath: string) => boolean listSessionFiles?: (sessionsRoot: string) => AsyncIterable }): Promise<{ homePath: string; transcriptPath: string } | null> { const directHome = resolveTrustedCodexSessionResumeHome(args) diff --git a/src/main/index.ts b/src/main/index.ts index 0916124c1e2..9c3eb26ee88 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -797,10 +797,10 @@ async function prepareCodexSessionResumeForLaunch(args: { if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { return null } - const configuredSourceHome = resolveHostCodexSessionSourceHome(store.getSettings()) const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. const trustedHomes = [ - configuredSourceHome ?? systemHomePath, + systemHomePath, ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() ] const sessionSource = await findTrustedCodexSessionResume({ @@ -821,12 +821,10 @@ async function prepareCodexSessionResumeForLaunch(args: { }, { isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), - systemCodexHomePath: configuredSourceHome + systemCodexHomePath: systemHomePath } ) - const resumeHome = migrated.useRealCodexHome - ? (configuredSourceHome ?? systemHomePath) - : sessionSource.homePath + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath if (args.workspacePath) { try {