From 228ad45c8dce9624804fe45b3eaa0ceb7ab1abe9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:42:07 -0700 Subject: [PATCH 01/45] feat(codex): backfill managed-home sessions into the real Codex home once per host Orca-launched Codex sessions currently land only in the Orca-managed runtime home, so the user's own `codex resume` picker and app history never see them (#4444, #8612). Backfill the managed sessions tree into the real ~/.codex/sessions/YYYY/MM/DD layout once per host: - hardlink first (one physical rollout log), copy as the cross-volume fallback; existing target files are always skipped, nothing in either home is deleted or moved - idempotent; per-file failures leave the completion marker unset so the next startup retries cheaply - JSONL audit log of every link/copy/failure under /codex-session-backfill/ - honors the custom Codex session source home override, mirroring the existing system->managed bridge WSL managed homes are distro-local and need an in-distro variant; that is a follow-up. --- src/main/codex/codex-home-paths.ts | 4 + src/main/codex/codex-session-backfill.test.ts | 297 ++++++++++++++++++ src/main/codex/codex-session-backfill.ts | 264 ++++++++++++++++ src/main/index.ts | 12 + 4 files changed, 577 insertions(+) create mode 100644 src/main/codex/codex-session-backfill.test.ts create mode 100644 src/main/codex/codex-session-backfill.ts diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index a2c397dd132..7c267c5a9ed 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -38,6 +38,10 @@ export function getOrcaManagedCodexHomePath(): string { return managedHomePath } +export function getCodexSessionBackfillStateDirPath(): string { + return join(getOrcaUserDataPath(), 'codex-session-backfill') +} + function getOrcaUserDataPath(): string { if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts new file mode 100644 index 00000000000..ef400a1360d --- /dev/null +++ b/src/main/codex/codex-session-backfill.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { + failLink: false, + failCopy: false + } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + linkSync: (...args: Parameters) => { + if (fsMockState.failLink) { + const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException + error.code = 'EXDEV' + throw error + } + return actual.linkSync(...args) + }, + copyFileSync: (...args: Parameters) => { + if (fsMockState.failCopy) { + const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.copyFileSync(...args) + } + } +}) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: homedirMock + } +}) + +import { + backfillManagedCodexSessionsIntoSystemHome, + resolveCodexSessionBackfillPaths, + startCodexSessionBackfillInBackground +} from './codex-session-backfill' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getSystemSessionsRoot(): string { + return join(fakeHomeDir, '.codex', 'sessions') +} + +function getManagedSessionsRoot(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'sessions') +} + +function getMarkerPath(): string { + return join(userDataDir, 'codex-session-backfill', 'backfill-complete.json') +} + +function getAuditLogPath(): string { + return join(userDataDir, 'codex-session-backfill', 'audit.jsonl') +} + +function writeManagedSession(relativePath: string, contents: string): string { + const filePath = join(getManagedSessionsRoot(), relativePath) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, contents, 'utf-8') + return filePath +} + +function readAuditActions(): string[] { + return readFileSync(getAuditLogPath(), 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => (JSON.parse(line) as { action: string }).action) +} + +beforeEach(() => { + fsMockState.failLink = false + fsMockState.failCopy = false + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('backfillManagedCodexSessionsIntoSystemHome', () => { + it('hardlinks managed rollout files into the real home preserving layout', async () => { + const managedPath = writeManagedSession( + join('2026', '05', '26', 'rollout-a.jsonl'), + '{"type":"session_meta","id":"a"}\n' + ) + writeManagedSession(join('2026', '06', '01', 'rollout-b.jsonl'), '{"id":"b"}\n') + writeFileSync(join(getManagedSessionsRoot(), '2026', '05', '26', 'notes.txt'), 'skip me\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 2, linkedFiles: 2, failedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(lstatSync(targetPath).ino).toBe(lstatSync(managedPath).ino) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '06', '01', 'rollout-b.jsonl'))).toBe( + true + ) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'notes.txt'))).toBe(false) + expect(readAuditActions()).toEqual(['hardlink', 'hardlink', 'run-summary']) + }) + + it('never overwrites an existing target file, even with different contents', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(collidingPath), { recursive: true }) + writeFileSync(collidingPath, 'user contents\n', 'utf-8') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 0, skippedExistingFiles: 1 }) + expect(readFileSync(collidingPath, 'utf-8')).toBe('user contents\n') + expect(readAuditActions()).toEqual(['run-summary']) + }) + + it('treats a broken symlink at the target as taken', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(collidingPath), { recursive: true }) + try { + symlinkSync(join(fakeHomeDir, 'missing-target.jsonl'), collidingPath) + } catch { + // Windows without symlink privilege cannot set up this fixture. + return + } + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 1 }) + expect(lstatSync(collidingPath).isSymbolicLink()).toBe(true) + }) + + it('does not backfill symlinked managed session files', async () => { + const realSource = join(fakeHomeDir, 'outside.jsonl') + writeFileSync(realSource, 'outside contents\n', 'utf-8') + const managedLinkPath = join(getManagedSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(managedLinkPath), { recursive: true }) + try { + symlinkSync(realSource, managedLinkPath) + } catch { + return + } + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + // Why: the session walker skips symlink dirents, so bridge-created links + // (which point back into the user's own home) never reach the copier. + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, failedFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + false + ) + }) + + it('is idempotent: a second run links nothing new and changes nothing', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + const paths = resolveCodexSessionBackfillPaths() + + const first = await backfillManagedCodexSessionsIntoSystemHome(paths) + const second = await backfillManagedCodexSessionsIntoSystemHome(paths) + + expect(first).toMatchObject({ linkedFiles: 1 }) + expect(second).toMatchObject({ linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 1 }) + }) + + it('falls back to copy when hardlinking fails across volumes', async () => { + fsMockState.failLink = true + const managedPath = writeManagedSession( + join('2026', '05', '26', 'rollout-a.jsonl'), + '{"id":"a"}\n' + ) + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) + expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) + expect(readAuditActions()).toEqual(['copy', 'run-summary']) + }) + + it('records per-file failures without aborting the run', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ failedFiles: 1, linkedFiles: 0, copiedFiles: 0 }) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + + it('does not create the real sessions tree when there is nothing to backfill', async () => { + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ scannedFiles: 0 }) + expect(existsSync(getSystemSessionsRoot())).toBe(false) + }) +}) + +describe('startCodexSessionBackfillInBackground', () => { + it('writes a completion marker and skips the walk on later runs', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + + // A file appearing after the marker must not be backfilled again. + writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') + const second = await startCodexSessionBackfillInBackground() + expect(second).toBeNull() + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl')) + ).toBe(false) + }) + + it('leaves the marker unset when any file fails so the next startup retries', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ failedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + fsMockState.failLink = false + fsMockState.failCopy = false + const second = await startCodexSessionBackfillInBackground() + expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('honors a custom system Codex home override', async () => { + const customHome = join(fakeHomeDir, 'custom-codex-home') + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground({}, customHome) + + expect(summary).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(join(customHome, 'sessions', '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + true + ) + expect(existsSync(getSystemSessionsRoot())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts new file mode 100644 index 00000000000..9018a61928d --- /dev/null +++ b/src/main/codex/codex-session-backfill.ts @@ -0,0 +1,264 @@ +import { + appendFileSync, + constants, + copyFileSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + readFileSync +} from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + getCodexSessionBackfillStateDirPath, + getOrcaManagedCodexHomePath, + getSystemCodexHomePath +} from './codex-home-paths' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +// Why: bump to re-run the backfill for every host after a layout or semantics +// change; the run itself stays skip-existing so re-runs never overwrite. +const CODEX_SESSION_BACKFILL_MARKER_VERSION = 1 + +export type CodexSessionBackfillSummary = { + scannedFiles: number + linkedFiles: number + copiedFiles: number + skippedExistingFiles: number + skippedSymlinkFiles: number + failedFiles: number +} + +export type CodexSessionBackfillPaths = { + managedSessionsRoot: string + systemSessionsRoot: string + auditLogPath: string + markerPath: string +} + +let backgroundBackfillTask: Promise | null = null + +/** + * Resolves the production source/target/state paths for the session backfill. + * + * `systemCodexHomePathOverride` mirrors the session bridge: users who run + * Codex with a custom CODEX_HOME need their history placed where their own + * `codex resume` actually looks. + */ +export function resolveCodexSessionBackfillPaths( + systemCodexHomePathOverride?: string +): CodexSessionBackfillPaths { + const stateDir = getCodexSessionBackfillStateDirPath() + return { + managedSessionsRoot: join(getOrcaManagedCodexHomePath(), 'sessions'), + systemSessionsRoot: join(systemCodexHomePathOverride || getSystemCodexHomePath(), 'sessions'), + auditLogPath: join(stateDir, 'audit.jsonl'), + markerPath: join(stateDir, 'backfill-complete.json') + } +} + +/** + * Starts the once-per-host background backfill of managed-home session files + * into the user's real Codex home. + * + * Concurrent callers share one in-flight task; a completed-marker host resolves + * to null without walking the sessions tree. + */ +export function startCodexSessionBackfillInBackground( + options: CodexSessionBridgeIncrementalOptions = {}, + systemCodexHomePathOverride?: string +): Promise { + if (backgroundBackfillTask) { + return backgroundBackfillTask + } + const task = runCodexSessionBackfillOncePerHost(options, systemCodexHomePathOverride).catch( + (error: unknown) => { + console.warn('[codex-session-backfill] Background session backfill failed:', error) + return null + } + ) + backgroundBackfillTask = task + void task.finally(() => { + if (backgroundBackfillTask === task) { + backgroundBackfillTask = null + } + }) + return task +} + +async function runCodexSessionBackfillOncePerHost( + options: CodexSessionBridgeIncrementalOptions, + systemCodexHomePathOverride?: string +): Promise { + const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) + if (hasCompletedBackfillMarker(paths.markerPath)) { + return null + } + const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) + // Why: per-file failures (locked or unreadable files) leave the marker unset + // so the next startup retries; skip-existing keeps those retries cheap. + if (summary.failedFiles === 0) { + writeBackfillMarker(paths.markerPath, summary) + } + return summary +} + +/** + * Backfills managed-home session rollout files into the real Codex home. + * + * Non-destructive by contract: existing target files are always skipped, and + * nothing in either home is deleted or moved. Hardlink first so resume sees + * one physical JSONL log; copy is the cross-volume fallback. + */ +export async function backfillManagedCodexSessionsIntoSystemHome( + paths: CodexSessionBackfillPaths, + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + const summary: CodexSessionBackfillSummary = { + scannedFiles: 0, + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedSymlinkFiles: 0, + failedFiles: 0 + } + if (existsSync(paths.managedSessionsRoot)) { + for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( + paths.managedSessionsRoot, + options + )) { + summary.scannedFiles += 1 + backfillOneManagedSessionFile(paths, managedSessionFilePath, summary) + } + } + appendAuditRecord(paths.auditLogPath, { action: 'run-summary', ...summary }) + return summary +} + +function backfillOneManagedSessionFile( + paths: CodexSessionBackfillPaths, + managedSessionFilePath: string, + summary: CodexSessionBackfillSummary +): void { + if (isSymbolicLink(managedSessionFilePath)) { + // Why: bridge-created symlinks already point at a file in the user's own + // home; materializing them here could duplicate a foreign tree. + summary.skippedSymlinkFiles += 1 + return + } + const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) + const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) + if (pathEntryExists(systemSessionFilePath)) { + summary.skippedExistingFiles += 1 + return + } + + try { + mkdirSync(dirname(systemSessionFilePath), { recursive: true }) + linkSync(managedSessionFilePath, systemSessionFilePath) + summary.linkedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'hardlink', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (linkError) { + if (isExistsError(linkError)) { + summary.skippedExistingFiles += 1 + return + } + try { + // Why: hardlinks fail across volumes; COPYFILE_EXCL keeps the + // never-overwrite contract even if the target appeared mid-run. + copyFileSync(managedSessionFilePath, systemSessionFilePath, constants.COPYFILE_EXCL) + summary.copiedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'copy', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (copyError) { + if (isExistsError(copyError)) { + summary.skippedExistingFiles += 1 + return + } + summary.failedFiles += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'failed', + source: managedSessionFilePath, + target: systemSessionFilePath, + error: describeError(copyError), + linkError: describeError(linkError) + }) + } + } +} + +function isSymbolicLink(filePath: string): boolean { + try { + return lstatSync(filePath).isSymbolicLink() + } catch { + return false + } +} + +/** Existence via lstat so a broken symlink at the target still counts as taken. */ +function pathEntryExists(entryPath: string): boolean { + try { + lstatSync(entryPath) + return true + } catch { + return false + } +} + +function isExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function appendAuditRecord(auditLogPath: string, record: Record): void { + try { + mkdirSync(dirname(auditLogPath), { recursive: true }) + appendFileSync( + auditLogPath, + `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, + { + encoding: 'utf-8' + } + ) + } catch (error) { + // Why: the audit trail is diagnostics; losing a line must not fail the + // backfill or leave a half-linked tree unrecorded in the summary counts. + console.warn('[codex-session-backfill] Failed to append audit record:', error) + } +} + +function hasCompletedBackfillMarker(markerPath: string): boolean { + try { + const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false + } + return (parsed as { version?: unknown }).version === CODEX_SESSION_BACKFILL_MARKER_VERSION + } catch { + return false + } +} + +function writeBackfillMarker(markerPath: string, summary: CodexSessionBackfillSummary): void { + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileAtomically( + markerPath, + `${JSON.stringify( + { version: CODEX_SESSION_BACKFILL_MARKER_VERSION, completedAt: Date.now(), summary }, + null, + 2 + )}\n` + ) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..56770fcdc1e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,8 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' +import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -1749,6 +1751,16 @@ app.whenReady().then(async () => { rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) + // Why: one-time per-host backfill makes historical Orca-managed Codex + // sessions visible to the user's own resume picker and app history (#4444, + // #8612). Deferred so startup and first PTY spawns never compete with the + // sessions tree walk. + setTimeout(() => { + void startCodexSessionBackfillInBackground( + {}, + resolveHostCodexSessionSourceHome(store!.getSettings()) + ) + }, 15_000) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver((target) => From 07afc8fbb77d533bf2b1406e31919b77ed012211 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:23:24 -0700 Subject: [PATCH 02/45] feat(codex): flag-gated system-default real-home routing scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staged internal flag (default OFF, no settings UI): route the SYSTEM-DEFAULT Codex account at the user's real ~/.codex instead of Orca's managed runtime home. Flag OFF is byte-identical to today; managed (multi-account) selections are unchanged in either state. Routing (flag ON + host system default = no managed account): - CodexRuntimeHomeService.prepareForCodexLaunch / prepareForRateLimitFetch return null so the PTY/env layer injects no managed CODEX_HOME and the rate-limit fetcher + auth-presence gate fall back to ~/.codex (the background poller stops spawning Codex against the managed home — the #5370 auth war). - buildPtyHostEnv strips only a nested-Orca-inherited Orca-owned override (CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a user-set CODEX_HOME. Shell-ready re-exports already no-op without the marker. - The headless commit-message Codex path strips the same inherited override. Hook install for the real-home lane (append-last into ~/.codex/hooks.json, trust via the app-server client) lands with the trust plumbing; the managed hook install is skipped for this lane meanwhile. Credit @jellychoco (#8606) for the native-home routing direction. Depends on the codex trust-rpc-grant plumbing for the real-home hook installer. --- .../runtime-home-service.test.ts | 43 +++++++++++++ .../codex-accounts/runtime-home-service.ts | 26 ++++++++ src/main/codex/codex-real-home-flag.test.ts | 53 ++++++++++++++++ src/main/codex/codex-real-home-flag.ts | 40 +++++++++++++ src/main/index.ts | 7 +++ src/main/ipc/pty.test.ts | 34 +++++++++++ src/main/ipc/pty.ts | 60 ++++++++++++++++++- .../commit-message-agent-environment.ts | 18 +++++- src/shared/constants.ts | 1 + src/shared/types.ts | 5 ++ 10 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/main/codex/codex-real-home-flag.test.ts create mode 100644 src/main/codex/codex-real-home-flag.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 501229444dc..3f3b10970f8 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -991,6 +991,49 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) + it('routes host system default to the real home (null) when the flag is ON', async () => { + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.prepareForRateLimitFetch()).toBeNull() + }) + + it('keeps the managed home for a host MANAGED account even when the flag is ON', async () => { + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const store = createStore( + createSettings({ + codexSystemDefaultRealHomeEnabled: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + }) + it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 29e181bd775..52853883379 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -60,6 +60,7 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' type CodexAuthIdentity = { email: string | null @@ -147,6 +148,13 @@ export class CodexRuntimeHomeService { this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } + if (this.isHostSystemDefaultRealHome()) { + // Why (flag ON, system default): run Codex on the user's own ~/.codex. + // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; + // sessions, auth, and config all live in the native home. No system-> + // managed session bridge runs, so the real home stays the single source. + return null + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -192,6 +200,17 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + // Why: real-home routing applies only to the host system-default selection + // (no managed account chosen for host) with the staged flag ON. Managed host + // accounts keep the isolated runtime home for hot-swap and token persistence. + isHostSystemDefaultRealHome(): boolean { + const settings = this.store.getSettings() + return ( + isCodexSystemDefaultRealHomeEnabled(settings) && + normalizeCodexRuntimeSelection(settings).host === null + ) + } + syncActiveWslSelectionsBeforeRestart(): void { if (process.platform !== 'win32') { return @@ -255,6 +274,13 @@ export class CodexRuntimeHomeService { const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } + if (this.isHostSystemDefaultRealHome()) { + // Why (flag ON, system default): read usage/auth from the user's own + // ~/.codex. Returning null makes the fetcher fall back to ~/.codex and + // its auth-presence gate check the real auth.json, so the background + // poller never spawns Codex against the managed home (the #5370 auth war). + return null + } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() diff --git a/src/main/codex/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts new file mode 100644 index 00000000000..d5e9ab717a6 --- /dev/null +++ b/src/main/codex/codex-real-home-flag.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isCodexSystemDefaultRealHomeEnabled } from './codex-real-home-flag' + +const ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' + +afterEach(() => { + delete process.env[ENV_FLAG] +}) + +describe('isCodexSystemDefaultRealHomeEnabled', () => { + it('is OFF by default (undefined settings)', () => { + expect(isCodexSystemDefaultRealHomeEnabled(undefined)).toBe(false) + expect(isCodexSystemDefaultRealHomeEnabled(null)).toBe(false) + expect(isCodexSystemDefaultRealHomeEnabled({})).toBe(false) + }) + + it('honors the settings flag when set to true', () => { + expect(isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: true })).toBe( + true + ) + expect(isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: false })).toBe( + false + ) + }) + + it('lets the env override force ON regardless of settings', () => { + for (const raw of ['1', 'true', 'on', 'TRUE', ' On ']) { + process.env[ENV_FLAG] = raw + expect( + isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: false }) + ).toBe(true) + } + }) + + it('lets the env override force OFF regardless of settings', () => { + for (const raw of ['0', 'false', 'off']) { + process.env[ENV_FLAG] = raw + expect(isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: true })).toBe( + false + ) + } + }) + + it('ignores an unrecognized env value and falls back to settings', () => { + process.env[ENV_FLAG] = 'maybe' + expect(isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: true })).toBe( + true + ) + expect(isCodexSystemDefaultRealHomeEnabled({ codexSystemDefaultRealHomeEnabled: false })).toBe( + false + ) + }) +}) diff --git a/src/main/codex/codex-real-home-flag.ts b/src/main/codex/codex-real-home-flag.ts new file mode 100644 index 00000000000..c41f1b22dfa --- /dev/null +++ b/src/main/codex/codex-real-home-flag.ts @@ -0,0 +1,40 @@ +import type { GlobalSettings } from '../../shared/types' + +/** + * Staged internal flag: route the SYSTEM-DEFAULT Codex account at the user's + * real ~/.codex instead of Orca's managed runtime home. + * + * Why a flag: this moves where user Codex state lives (auth, config, sessions, + * hooks). It ships dark, default OFF, with NO settings UI, so flag-OFF stays + * byte-identical to today's managed-home behavior. Managed (multi-account) + * selections are unaffected either way. + * + * The env override exists only so isolated dev/CDP verification can exercise + * the ON path without a settings write; it never appears in the UI. + */ +const CODEX_REAL_HOME_ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' + +export function isCodexSystemDefaultRealHomeEnabled( + settings: Pick | null | undefined +): boolean { + const envOverride = readCodexRealHomeEnvOverride() + if (envOverride !== null) { + return envOverride + } + return settings?.codexSystemDefaultRealHomeEnabled === true +} + +function readCodexRealHomeEnvOverride(): boolean | null { + const raw = process.env[CODEX_REAL_HOME_ENV_FLAG] + if (raw === undefined) { + return null + } + const normalized = raw.trim().toLowerCase() + if (normalized === '1' || normalized === 'true' || normalized === 'on') { + return true + } + if (normalized === '0' || normalized === 'false' || normalized === 'off') { + return false + } + return null +} diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..525abb8c247 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -734,6 +734,13 @@ function startTerminalRuntimeStartupServices(): Promise { function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) + if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) { + // Why (flag ON, system default): Codex runs on the user's real ~/.codex, so + // the managed-home hook install below would target a home Codex never reads. + // The real-home hook installer (trust granted via the app-server client) + // owns hook install for this lane and lands with the trust plumbing. + return null + } const hookTarget = target?.runtime === 'wsl' ? { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6adb7e5cf69..8f69a265023 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1364,6 +1364,40 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => { + // Why: flag OFF must stay byte-identical to today. With no managed home + // selected (resolver null) and the real-home flag off, no CODEX_HOME + // injection or strip happens; an inherited value survives as before. + const env = await spawnAndGetEnv( + undefined, + { CODEX_HOME: '/tmp/system-codex-home' }, + () => null + ) + expect(env.CODEX_HOME).toBe('/tmp/system-codex-home') + }) + + it('strips a nested-Orca override for system default when the real-home flag is ON', async () => { + const env = await spawnAndGetEnv( + { CODEX_HOME: '/managed/home', ORCA_CODEX_HOME: '/managed/home' }, + undefined, + () => null, + () => ({ codexSystemDefaultRealHomeEnabled: true }) as never + ) + expect(env.CODEX_HOME).toBeUndefined() + expect(env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('preserves a user-owned CODEX_HOME for system default when the real-home flag is ON', async () => { + const env = await spawnAndGetEnv( + { CODEX_HOME: '/home/me/.config/codex' }, + { ORCA_CODEX_HOME: undefined }, + () => null, + () => ({ codexSystemDefaultRealHomeEnabled: true }) as never + ) + expect(env.CODEX_HOME).toBe('/home/me/.config/codex') + expect(env.ORCA_CODEX_HOME).toBeUndefined() + }) + it('injects explicit proxy settings into local PTY env', async () => { const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({ httpProxyUrl: 'http://proxy.example:8080', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ffaa3a3990d..a2cff121876 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -140,6 +140,7 @@ import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-st import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' @@ -546,6 +547,10 @@ export type BuildPtyHostEnvOptions = { userDataPath: string selectedCodexHomePath: string | null skipCodexHomeEnv?: boolean + /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, + * and strip only an inherited Orca-owned override so nested Orca panes do not + * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ + stripInheritedOrcaCodexHome?: boolean githubAttributionEnabled: boolean /** The launch command the renderer chose for this PTY (e.g. 'pi', 'omp', * 'claude'). Used to resolve the per-agent managed extension target for @@ -617,7 +622,39 @@ function shouldSkipCodexHomeEnvForWindowsShell( return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) } +// Why: with the real-home flag ON, a host system-default launch resolves to a +// null managed home. Signal the env builder to strip a nested-Orca-inherited +// override instead of injecting one, so Codex runs on the user's own ~/.codex. +function shouldStripInheritedOrcaCodexHome(args: { + target: CodexAccountSelectionTarget + selectedCodexHomePath: string | null + skipCodexHomeEnv: boolean + settings: GlobalSettings | undefined +}): boolean { + return ( + args.target.runtime === 'host' && + args.selectedCodexHomePath === null && + !args.skipCodexHomeEnv && + isCodexSystemDefaultRealHomeEnabled(args.settings) + ) +} + const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +// Why: system-default real-home routing runs Codex on the user's own ~/.codex. +// Nested Orca panes inherit the parent's Orca-owned override; strip only that +// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop +// the marker so a shell-ready wrapper cannot restore the managed home. A +// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). +function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { + const inheritedOrcaOverride = baseEnv.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME + const inheritedCodexHome = baseEnv.CODEX_HOME ?? process.env.CODEX_HOME + if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { + delete baseEnv.CODEX_HOME + } + delete baseEnv.ORCA_CODEX_HOME +} + type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget @@ -990,6 +1027,8 @@ export function buildPtyHostEnv( // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers // restore this runtime home before Codex can be launched from the prompt. baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath + } else if (opts.stripInheritedOrcaCodexHome) { + stripInheritedOrcaCodexHomeOverride(baseEnv) } // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. @@ -1556,11 +1595,18 @@ export function registerPtyHandlers( codexSelectionTarget, getSelectedCodexHomePath?.(codexSelectionTarget) ?? null ) + const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath, - skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }), githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, launchAgent: ctx?.launchAgent, @@ -3045,6 +3091,12 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }), githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, @@ -3927,6 +3979,12 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }), githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 30516945fd1..587007c9aed 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -25,6 +25,20 @@ function cloneProcessEnv(): Record { return env } +// Why: with system-default real-home routing, the headless Codex commit run +// must use the user's own ~/.codex. If Orca itself was launched from a nested +// Orca terminal it can inherit an Orca-owned CODEX_HOME override; strip only +// that (CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a +// user-set CODEX_HOME. +function cloneProcessEnvWithoutOrcaCodexHomeOverride(): Record { + const env = cloneProcessEnv() + if (env.ORCA_CODEX_HOME && env.CODEX_HOME === env.ORCA_CODEX_HOME) { + delete env.CODEX_HOME + } + delete env.ORCA_CODEX_HOME + return env +} + function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined { return ( (sourceName ? process.env[sourceName] : undefined) ?? @@ -100,7 +114,9 @@ export async function prepareLocalCommitMessageAgentEnv( } return { ok: true, - env: codexHomePath ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } : undefined + env: codexHomePath + ? { ...cloneProcessEnv(), CODEX_HOME: codexHomePath } + : cloneProcessEnvWithoutOrcaCodexHomeOverride() } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 950cc999477..dd9339bb6e8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -338,6 +338,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { minimaxGroupId: '', minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, + codexSystemDefaultRealHomeEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c1ebaf8cc8..6c5d15ab8ac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2841,6 +2841,11 @@ export type GlobalSettings = { /** Whether to extract OAuth credentials from the local Gemini CLI installation * for rate-limit fetching. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean + /** Staged internal flag (default OFF, no settings UI): route the system-default + * Codex account at the user's real ~/.codex instead of Orca's managed runtime + * home. OFF is byte-identical to today; managed accounts are unaffected. + * See src/main/codex/codex-real-home-flag.ts. */ + codexSystemDefaultRealHomeEnabled?: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Why: Orca bridges Codex session history from the user's real Codex home into From 6d980bb97cbc9ce49a1f72b92f07553b5262b38e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:33:22 -0700 Subject: [PATCH 03/45] fix(codex): strip the daemon-inherited Orca CODEX_HOME override for real-home routing The daemon spawns PTYs from its own inherited environment and honors only spawnOptions.envToDelete, so mutating the sparse env object was not enough to strip an Orca-owned CODEX_HOME the daemon already carries. Add the strip to envToDelete for both daemon host-spawn paths, preserving a user-set CODEX_HOME. Verified live via CDP against a sandboxed dev instance (flag ON): an Orca-spawned pane reports empty CODEX_HOME/ORCA_CODEX_HOME, so Codex resolves its own ~/.codex. Adds daemon-path unit coverage (strip Orca-owned, preserve user-owned, no-op when flag OFF). --- src/main/ipc/pty.test.ts | 33 ++++++++++++++++++++ src/main/ipc/pty.ts | 65 +++++++++++++++++++++++++++------------- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 8f69a265023..a7cb4b1c9e2 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1750,6 +1750,39 @@ describe('registerPtyHandlers', () => { } }) + it('strips the daemon-inherited Orca-owned CODEX_HOME for real-home routing', async () => { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => null, + () => ({ codexSystemDefaultRealHomeEnabled: true }) as never, + { CODEX_HOME: '/managed/home', ORCA_CODEX_HOME: '/managed/home' } + ) + expect(spawnOptions.env.CODEX_HOME).toBeUndefined() + expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) + ) + }) + + it('preserves a daemon-inherited user CODEX_HOME for real-home routing', async () => { + const spawnOptions = await daemonSpawnAndGetOptions( + {}, + () => null, + () => ({ codexSystemDefaultRealHomeEnabled: true }) as never, + { CODEX_HOME: '/home/me/.config/codex', ORCA_CODEX_HOME: undefined } + ) + expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME'])) + expect(spawnOptions.envToDelete).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) + }) + + it('does not strip the daemon-inherited CODEX_HOME when the flag is OFF', async () => { + const spawnOptions = await daemonSpawnAndGetOptions({}, () => null, undefined, { + CODEX_HOME: '/managed/home', + ORCA_CODEX_HOME: '/managed/home' + }) + expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) + }) + it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a2cff121876..72e499001c0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -647,12 +647,23 @@ const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const // the marker so a shell-ready wrapper cannot restore the managed home. A // user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { - const inheritedOrcaOverride = baseEnv.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME - const inheritedCodexHome = baseEnv.CODEX_HOME ?? process.env.CODEX_HOME + for (const key of getInheritedOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + delete baseEnv[key] + } +} + +// Why: the daemon spawns the PTY from its own inherited environment and honors +// only spawnOptions.envToDelete, so mutating the sparse env object is not enough +// to strip an Orca-owned CODEX_HOME the daemon already carries. Return the exact +// keys to delete, preserving a user-owned CODEX_HOME. +function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { + const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME + const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME + const keysToDelete = ['ORCA_CODEX_HOME'] if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { - delete baseEnv.CODEX_HOME + keysToDelete.push('CODEX_HOME') } - delete baseEnv.ORCA_CODEX_HOME + return keysToDelete } type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null @@ -3082,6 +3093,12 @@ export function registerPtyHandlers( isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && !selectedCodexHomePath + const stripInheritedOrcaCodexHome = shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }) if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -3091,12 +3108,7 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: getSettings?.() - }), + stripInheritedOrcaCodexHome, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, @@ -3129,6 +3141,13 @@ export function registerPtyHandlers( spawnOptions.envToDelete, CODEX_HOME_ENV_KEYS ) + } else if (stripInheritedOrcaCodexHome) { + // Why: the daemon inherits its own CODEX_HOME; strip the Orca-owned + // override there too, preserving a user-set CODEX_HOME. + spawnOptions.envToDelete = mergePtyEnvDeletions( + spawnOptions.envToDelete, + getInheritedOrcaCodexHomeEnvKeysToDelete(env ?? {}) + ) } deleteRequestedEnvKeys(env, spawnOptions.envToDelete) promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) @@ -3955,6 +3974,12 @@ export function registerPtyHandlers( isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && !selectedCodexHomePath + const stripInheritedOrcaCodexHome = shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }) if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when @@ -3979,12 +4004,7 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: getSettings?.() - }), + stripInheritedOrcaCodexHome, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, @@ -4023,12 +4043,17 @@ export function registerPtyHandlers( const combinedEnvToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( - mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), - agentTeamsEnvToDelete ?? [] + mergePtyEnvDeletions( + mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), + agentTeamsEnvToDelete ?? [] + ), + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] ), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] ), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] + // Why: real-home routing strips the Orca-owned override the daemon + // inherits, while preserving a user-set CODEX_HOME. + stripInheritedOrcaCodexHome ? getInheritedOrcaCodexHomeEnvKeysToDelete(spawnEnv ?? {}) : [] ) deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) From 64c76ae338ba8c843be08caaf67780a9477b4cba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:38:06 -0700 Subject: [PATCH 04/45] fix(codex): harden one-time session backfill --- src/main/codex/codex-session-backfill.test.ts | 97 +++++++++++++++- src/main/codex/codex-session-backfill.ts | 106 +++++++++++++++--- src/main/codex/codex-session-file-listing.ts | 4 +- 3 files changed, 189 insertions(+), 18 deletions(-) diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index ef400a1360d..8d725a56531 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -4,12 +4,14 @@ import { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import type * as NodeOs from 'node:os' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -21,7 +23,8 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, - failCopy: false + failCopy: false, + failDirectoryPath: null as string | null } })) @@ -39,6 +42,9 @@ vi.mock('node:fs', async () => { }, copyFileSync: (...args: Parameters) => { if (fsMockState.failCopy) { + // Simulate a copy that fails after opening its destination, which is + // the dangerous case for resumability rather than a preflight error. + actual.writeFileSync(args[1], 'partial copy\n', 'utf-8') const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException error.code = 'EACCES' throw error @@ -48,6 +54,21 @@ vi.mock('node:fs', async () => { } }) +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + opendir: (...args: Parameters) => { + if (args[0] === fsMockState.failDirectoryPath) { + const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.opendir(...args) + } + } +}) + vi.mock('node:os', async () => { const actual = await vi.importActual('node:os') return { @@ -99,6 +120,7 @@ function readAuditActions(): string[] { beforeEach(() => { fsMockState.failLink = false fsMockState.failCopy = false + fsMockState.failDirectoryPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -140,6 +162,32 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['hardlink', 'hardlink', 'run-summary']) }) + it('only backfills rollout files in the exact YYYY/MM/DD layout', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-valid ü.jsonl'), 'valid\n') + writeManagedSession(join('2026', '05', '26', 'session-index.jsonl'), 'not a rollout\n') + writeManagedSession(join('2026', '5', '26', 'rollout-wrong-month.jsonl'), 'wrong month\n') + writeManagedSession(join('scratch', 'rollout-too-shallow.jsonl'), 'too shallow\n') + writeManagedSession(join('2026', '05', '26', 'nested', 'rollout-too-deep.jsonl'), 'too deep\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ + scannedFiles: 5, + linkedFiles: 1, + skippedUnexpectedFiles: 4, + failedFiles: 0 + }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-valid ü.jsonl')) + ).toBe(true) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'session-index.jsonl')) + ).toBe(false) + expect(existsSync(join(getSystemSessionsRoot(), 'scratch'))).toBe(false) + }) + it('never overwrites an existing target file, even with different contents', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') @@ -192,9 +240,8 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { // Why: the session walker skips symlink dirents, so bridge-created links // (which point back into the user's own home) never reach the copier. expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, failedFiles: 0 }) - expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe( - false - ) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(existsSync(targetPath)).toBe(false) }) it('is idempotent: a second run links nothing new and changes nothing', async () => { @@ -236,6 +283,9 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { ) expect(summary).toMatchObject({ failedFiles: 1, linkedFiles: 0, copiedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(existsSync(targetPath)).toBe(false) + expect(readdirSync(dirname(targetPath))).toEqual([]) expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) @@ -274,11 +324,36 @@ describe('startCodexSessionBackfillInBackground', () => { const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ failedFiles: 1 }) expect(existsSync(getMarkerPath())).toBe(false) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(existsSync(targetPath)).toBe(false) fsMockState.failLink = false fsMockState.failCopy = false const second = await startCodexSessionBackfillInBackground() expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(readFileSync(targetPath, 'utf-8')).toBe('{"id":"a"}\n') + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('leaves the marker unset when a directory cannot be scanned', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-readable.jsonl'), 'readable\n') + const unreadableDirectory = dirname( + writeManagedSession(join('2026', '06', '01', 'rollout-unreadable.jsonl'), 'unreadable\n') + ) + fsMockState.failDirectoryPath = unreadableDirectory + + const first = await startCodexSessionBackfillInBackground({ yieldMs: 0 }) + + expect(first).toMatchObject({ failedDirectories: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + expect(readAuditActions()).toContain('scan-failed') + + fsMockState.failDirectoryPath = null + const second = await startCodexSessionBackfillInBackground({ yieldMs: 0 }) + expect(second).toMatchObject({ failedDirectories: 0, failedFiles: 0 }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '06', '01', 'rollout-unreadable.jsonl')) + ).toBe(true) expect(existsSync(getMarkerPath())).toBe(true) }) @@ -294,4 +369,18 @@ describe('startCodexSessionBackfillInBackground', () => { ) expect(existsSync(getSystemSessionsRoot())).toBe(false) }) + + it('re-runs when the configured real Codex home changes', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + const customHome = join(fakeHomeDir, 'custom Codex ü') + + const moved = await startCodexSessionBackfillInBackground({}, customHome) + + expect(moved).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(existsSync(join(customHome, 'sessions', '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + true + ) + expect(await startCodexSessionBackfillInBackground({}, customHome)).toBeNull() + }) }) diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 9018a61928d..0a2080c9dd8 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -6,9 +6,12 @@ import { linkSync, lstatSync, mkdirSync, - readFileSync + readFileSync, + rmSync, + writeFileSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' +import { randomUUID } from 'node:crypto' +import { dirname, join, relative, sep } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getCodexSessionBackfillStateDirPath, @@ -27,7 +30,9 @@ export type CodexSessionBackfillSummary = { linkedFiles: number copiedFiles: number skippedExistingFiles: number + skippedUnexpectedFiles: number skippedSymlinkFiles: number + failedDirectories: number failedFiles: number } @@ -93,14 +98,14 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedBackfillMarker(paths.markerPath)) { + if (hasCompletedBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) // Why: per-file failures (locked or unreadable files) leave the marker unset // so the next startup retries; skip-existing keeps those retries cheap. - if (summary.failedFiles === 0) { - writeBackfillMarker(paths.markerPath, summary) + if (summary.failedFiles === 0 && summary.failedDirectories === 0) { + writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) } return summary } @@ -121,15 +126,31 @@ export async function backfillManagedCodexSessionsIntoSystemHome( linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, skippedSymlinkFiles: 0, + failedDirectories: 0, failedFiles: 0 } if (existsSync(paths.managedSessionsRoot)) { for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( paths.managedSessionsRoot, - options + options, + (directoryPath, error) => { + // Why: a partial walk must remain retryable; otherwise an unreadable + // date directory would be silently omitted behind a completion marker. + summary.failedDirectories += 1 + appendAuditRecord(paths.auditLogPath, { + action: 'scan-failed', + source: directoryPath, + error: describeError(error) + }) + } )) { summary.scannedFiles += 1 + if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { + summary.skippedUnexpectedFiles += 1 + continue + } backfillOneManagedSessionFile(paths, managedSessionFilePath, summary) } } @@ -137,6 +158,20 @@ export async function backfillManagedCodexSessionsIntoSystemHome( return summary } +function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { + const pathParts = relative(sessionsRoot, filePath).split(sep) + if (pathParts.length !== 4) { + return false + } + const [year, month, day, fileName] = pathParts + return ( + /^\d{4}$/.test(year) && + /^\d{2}$/.test(month) && + /^\d{2}$/.test(day) && + /^rollout-.+\.jsonl$/.test(fileName) + ) +} + function backfillOneManagedSessionFile( paths: CodexSessionBackfillPaths, managedSessionFilePath: string, @@ -170,9 +205,9 @@ function backfillOneManagedSessionFile( return } try { - // Why: hardlinks fail across volumes; COPYFILE_EXCL keeps the - // never-overwrite contract even if the target appeared mid-run. - copyFileSync(managedSessionFilePath, systemSessionFilePath, constants.COPYFILE_EXCL) + // Why: cross-volume copies are staged so failures cannot strand a + // truncated rollout, then installed without overwriting collisions. + copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) summary.copiedFiles += 1 appendAuditRecord(paths.auditLogPath, { action: 'copy', @@ -196,6 +231,36 @@ function backfillOneManagedSessionFile( } } +function copySessionFileWithoutOverwrite(sourcePath: string, targetPath: string): void { + const temporaryPath = join(dirname(targetPath), `.orca-backfill-${randomUUID()}.tmp`) + // Why: stage cross-volume copies away from the rollout filename so a failed + // copy cannot strand a truncated session that a later retry would skip. + writeFileSync(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) + try { + copyFileSync(sourcePath, temporaryPath) + try { + // Why: this same-volume hardlink atomically installs the staged copy + // without risking a collision overwrite after an EXDEV fallback. + linkSync(temporaryPath, targetPath) + } catch (installLinkError) { + if (isExistsError(installLinkError)) { + throw installLinkError + } + // Some target filesystems do not support hardlinks at all. COPYFILE_EXCL + // preserves the collision contract while retaining the staged snapshot. + copyFileSync(temporaryPath, targetPath, constants.COPYFILE_EXCL) + } + } finally { + try { + rmSync(temporaryPath, { force: true }) + } catch (error) { + // Why: cleanup trouble must not misreport a successfully installed + // rollout as a copy failure; the .tmp file is ignored by Codex. + console.warn('[codex-session-backfill] Failed to remove staged copy:', temporaryPath, error) + } + } +} + function isSymbolicLink(filePath: string): boolean { try { return lstatSync(filePath).isSymbolicLink() @@ -239,24 +304,39 @@ function appendAuditRecord(auditLogPath: string, record: Record } } -function hasCompletedBackfillMarker(markerPath: string): boolean { +function hasCompletedBackfillMarker(markerPath: string, systemSessionsRoot: string): boolean { try { const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } - return (parsed as { version?: unknown }).version === CODEX_SESSION_BACKFILL_MARKER_VERSION + const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } + // Why: changing the configured real Codex home must backfill the new + // target instead of honoring a marker written for a different history. + return ( + marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && + marker.systemSessionsRoot === systemSessionsRoot + ) } catch { return false } } -function writeBackfillMarker(markerPath: string, summary: CodexSessionBackfillSummary): void { +function writeBackfillMarker( + markerPath: string, + systemSessionsRoot: string, + summary: CodexSessionBackfillSummary +): void { mkdirSync(dirname(markerPath), { recursive: true }) writeFileAtomically( markerPath, `${JSON.stringify( - { version: CODEX_SESSION_BACKFILL_MARKER_VERSION, completedAt: Date.now(), summary }, + { + version: CODEX_SESSION_BACKFILL_MARKER_VERSION, + systemSessionsRoot, + completedAt: Date.now(), + summary + }, null, 2 )}\n` diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index c6eeb9b1f77..92676b38052 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -56,7 +56,8 @@ function appendSessionFilePaths(target: string[], source: readonly string[]): vo */ export async function* listCodexSessionJsonlFilesIncrementally( rootPath: string, - options: CodexSessionBridgeIncrementalOptions + options: CodexSessionBridgeIncrementalOptions, + onDirectoryError?: (directoryPath: string, error: unknown) => void ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) @@ -84,6 +85,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( } } } catch (error) { + onDirectoryError?.(currentDirectory, error) console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } } From b0b63c60538234c960ed2e4164d9f66de956ccb1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:39:39 -0700 Subject: [PATCH 05/45] test(codex): cover staged cross-volume install --- src/main/codex/codex-session-backfill.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 8d725a56531..51513719582 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -33,7 +33,7 @@ vi.mock('node:fs', async () => { return { ...actual, linkSync: (...args: Parameters) => { - if (fsMockState.failLink) { + if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) { const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException error.code = 'EXDEV' throw error @@ -258,7 +258,7 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { it('falls back to copy when hardlinking fails across volumes', async () => { fsMockState.failLink = true const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a.jsonl'), + join('2026', '05', '26', 'rollout-a ü.jsonl'), '{"id":"a"}\n' ) @@ -267,7 +267,7 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { ) expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a ü.jsonl') expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) expect(readAuditActions()).toEqual(['copy', 'run-summary']) From 0c2a807c2080ca719a6e5ca8ad336e8d870cc68e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:09 -0700 Subject: [PATCH 06/45] feat(codex): app-server trust-grant client, capability cache, and grant ledger Short-lived codex app-server JSON-RPC client (hooks/list + config/batchWrite, the same pair the Codex TUI 'Trust all' flow calls), run in a bundled ELECTRON_RUN_AS_NODE entry so synchronous launch prep can block on it with a hard deadline and guaranteed child reap. Capability cache modeled on GitCapabilityCache, scoped per execution host (native vs each WSL distro), with a narrow unknown-method/missing-subcommand unsupported predicate. The grant ledger records verified grants so steady-state launches skip the RPC. --- build-plugins/plain-node-entry-guard.ts | 3 +- electron.vite.config.ts | 6 + src/main/codex-accounts/wsl-codex-command.ts | 13 + .../codex-app-server-capability-cache.ts | 84 ++++ src/main/codex/codex-app-server-client.ts | 362 ++++++++++++++++++ .../codex/codex-app-server-grant-bridge.ts | 150 ++++++++ .../codex/codex-app-server-grant-entry.ts | 68 ++++ src/main/codex/codex-hook-trust-grant.ts | 315 +++++++++++++++ src/main/codex/codex-trust-grant-ledger.ts | 143 +++++++ 9 files changed, 1143 insertions(+), 1 deletion(-) create mode 100644 src/main/codex/codex-app-server-capability-cache.ts create mode 100644 src/main/codex/codex-app-server-client.ts create mode 100644 src/main/codex/codex-app-server-grant-bridge.ts create mode 100644 src/main/codex/codex-app-server-grant-entry.ts create mode 100644 src/main/codex/codex-hook-trust-grant.ts create mode 100644 src/main/codex/codex-trust-grant-ledger.ts diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 1fddb5f6a12..81abb08cf79 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -18,7 +18,8 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', - 'agent-hooks/managed-agent-hook-controls' + 'agent-hooks/managed-agent-hook-controls', + 'codex/codex-app-server-grant-entry' ] as const const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7b8c9cb8318..57c6ffe792b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -185,6 +185,12 @@ export default defineConfig({ // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), + // Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on + // spawnSync — codex app-server trust grants need a live event loop + // but must finish before a Codex pane launch proceeds. + 'codex/codex-app-server-grant-entry': resolve( + 'src/main/codex/codex-app-server-grant-entry.ts' + ), // Why: electron-vite cleans out/main in dev. The dev CLI imports // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index 02527c8ed23..570129ea273 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -12,6 +12,19 @@ export function buildWslCodexAvailabilityArgs(distro: string): string[] { return buildWslCodexShellArgs(distro, command) } +export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" app-server' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { const command = [ buildCodexPathLookup(), diff --git a/src/main/codex/codex-app-server-capability-cache.ts b/src/main/codex/codex-app-server-capability-cache.ts new file mode 100644 index 00000000000..85816c9cf0c --- /dev/null +++ b/src/main/codex/codex-app-server-capability-cache.ts @@ -0,0 +1,84 @@ +// Why: suppress a known-missing RPC surface without pinning it forever — an +// in-place codex upgrade during a long Orca session self-heals after the +// interval, mirroring GitCapabilityCache's rationale. +export const CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 + +/** Execution host that runs the codex binary. WSL distros are isolated from + * the native host and from each other — each can carry a different codex. */ +export type CodexAppServerHostKey = 'native' | `wsl:${string}` + +export function getCodexAppServerHostKey( + host: { kind: 'native' } | { kind: 'wsl'; distro: string } +): CodexAppServerHostKey { + return host.kind === 'wsl' ? `wsl:${host.distro}` : 'native' +} + +/** + * Capability cache for the codex app-server trust-grant RPC pair, modeled on + * GitCapabilityCache but with a synchronous runner: the grant client blocks + * the main thread by design (launch prep), so probes cannot overlap — the + * unsupported mark alone is what keeps later installs off the dead probe. + */ +export class CodexAppServerCapabilityCache { + private readonly retryAfterByHost = new Map() + private readonly supportedHosts = new Set() + + shouldTry(hostKey: CodexAppServerHostKey, nowMs = Date.now()): boolean { + const retryAfterMs = this.retryAfterByHost.get(hostKey) + if (retryAfterMs === undefined) { + return true + } + if (nowMs < retryAfterMs) { + return false + } + this.retryAfterByHost.delete(hostKey) + return true + } + + isKnownSupported(hostKey: CodexAppServerHostKey): boolean { + return this.supportedHosts.has(hostKey) + } + + rememberUnsupported(hostKey: CodexAppServerHostKey, nowMs = Date.now()): void { + this.supportedHosts.delete(hostKey) + this.retryAfterByHost.set(hostKey, nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) + } + + rememberSupported(hostKey: CodexAppServerHostKey): void { + this.retryAfterByHost.delete(hostKey) + this.supportedHosts.add(hostKey) + } + + runWithFallbackSync( + hostKey: CodexAppServerHostKey, + runPreferred: () => T, + runFallback: () => T, + isUnsupportedError: (error: unknown) => boolean, + nowMs = Date.now() + ): T { + if (!this.supportedHosts.has(hostKey) && !this.shouldTry(hostKey, nowMs)) { + return runFallback() + } + try { + const result = runPreferred() + this.rememberSupported(hostKey) + return result + } catch (error) { + // Why: only a positive absence signal (unknown method / missing + // subcommand) marks unsupported. Transient spawn failures, timeouts, + // and RPC errors fall back once without poisoning the capability. + if (!isUnsupportedError(error)) { + throw error + } + this.rememberUnsupported(hostKey, nowMs) + return runFallback() + } + } + + clear(): void { + this.retryAfterByHost.clear() + this.supportedHosts.clear() + } +} + +export const codexAppServerCapabilityCache = new CodexAppServerCapabilityCache() diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts new file mode 100644 index 00000000000..bb1fccf48c7 --- /dev/null +++ b/src/main/codex/codex-app-server-client.ts @@ -0,0 +1,362 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { normalizeHookTrustKeyForLookup } from './config-toml-trust' + +// Why: Codex gates hooks on a `trusted_hash` it computes from a private +// canonical-JSON identity. Orca used to replicate that algorithm +// (computeTrustedHash), which drifted from the real one across Codex releases +// (#7896, #7110, #8699). `codex app-server` exposes the same sanctioned RPCs +// the Codex TUI "Trust all" button uses — hooks/list (returns Codex's own +// currentHash per hook) and config/batchWrite (upserts hooks.state through +// Codex's comment-preserving writer) — so this client grants trust with +// Codex as the only hash authority. See upstream codex-rs/tui/src/hooks_rpc.rs +// and codex-rs/tui/src/startup_hooks_review.rs. + +export type CodexAppServerInvocation = { + command: string + args: string[] + /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ + env?: Record + /** Whole-session deadline. The codex child is SIGKILLed when it lapses. */ + timeoutMs: number +} + +export type CodexHookTrustGrantRequest = { + invocation: CodexAppServerInvocation + /** cwd passed to hooks/list. Discovery of the managed CODEX_HOME's + * hooks.json is cwd-independent (user scope); this only scopes which + * project hooks appear, which the key filter below ignores anyway. */ + hooksListCwd: string + /** Lookup-normalized trust keys (normalizeHookTrustKeyForLookup shape) for + * the managed entries Orca just wrote. Grants are restricted to hooks whose + * reported key normalizes into this set — user hooks are never touched. */ + expectedTrustKeys: string[] + /** Exact command string written to the managed hooks.json entries. */ + managedCommand: string +} + +export type CodexGrantedHookTrust = { + /** Trust key exactly as Codex reported it. */ + key: string + normalizedKey: string + /** Codex-computed hash now stored as trusted_hash for this key. */ + trustedHash: string +} + +export type CodexHookTrustGrantSessionResult = + | { + outcome: 'granted' + entries: CodexGrantedHookTrust[] + /** False when every expected entry was already trusted (no write). */ + wroteTrust: boolean + } + | { outcome: 'verify-failed'; reason: string } + +/** Codex-side absence of the trust-grant RPC surface (old CLI without the + * app-server subcommand, or a server without hooks/list / config/batchWrite). + * This is the ONLY error class the capability cache marks unsupported. */ +export class CodexAppServerUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerUnsupportedError' + } +} + +export class CodexAppServerTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerTimeoutError' + } +} + +export function isCodexAppServerUnsupportedError(error: unknown): boolean { + return error instanceof Error && error.name === 'CodexAppServerUnsupportedError' +} + +type JsonRpcResponse = { + id?: number + result?: unknown + error?: { code?: number; message?: string } +} + +type CodexHookListing = { + key: string + command: string | null + currentHash: string + trustStatus: string +} + +const JSON_RPC_METHOD_NOT_FOUND = -32601 +const STDERR_TAIL_MAX_BYTES = 8192 + +function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { + return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') +} + +// Why: a CLI predating the app-server subcommand fails argv parsing before +// speaking any JSON-RPC; that shape is a capability signal, not a transient. +function stderrIndicatesMissingAppServer(stderrTail: string): boolean { + return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail) +} + +function collectHookListings(result: unknown): CodexHookListing[] { + const data = + result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data) + ? ((result as { data: unknown[] }).data as { hooks?: unknown }[]) + : [] + const listings: CodexHookListing[] = [] + const seenKeys = new Set() + for (const entry of data) { + const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [] + for (const hook of hooks as Record[]) { + if ( + typeof hook?.key !== 'string' || + typeof hook.currentHash !== 'string' || + typeof hook.trustStatus !== 'string' + ) { + continue + } + // Why: hooks/list repeats user-scope hooks per requested cwd; grants + // must consider each key once. + if (seenKeys.has(hook.key)) { + continue + } + seenKeys.add(hook.key) + listings.push({ + key: hook.key, + command: typeof hook.command === 'string' ? hook.command : null, + currentHash: hook.currentHash, + trustStatus: hook.trustStatus + }) + } + } + return listings +} + +/** + * Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL) + * and grants trust for exactly the expected managed entries: + * initialize → initialized → hooks/list → config/batchWrite → hooks/list. + * The child is reaped on every path; the session deadline SIGKILLs it. + */ +export async function runCodexHookTrustGrantSession( + request: CodexHookTrustGrantRequest, + spawnImpl: typeof spawn = spawn +): Promise { + const { invocation } = request + const child = spawnImpl(invocation.command, invocation.args, { + env: { ...process.env, ...invocation.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) as ChildProcessWithoutNullStreams + + let stderrTail = '' + let exited = false + let nextRequestId = 1 + let timedOut = false + const pending = new Map< + number, + { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void } + >() + + const exitPromise = new Promise((resolve) => { + child.on('exit', () => { + exited = true + resolve() + }) + }) + // Why: 'error' fires instead of 'exit' when the spawn itself fails + // (ENOENT); surface it to every in-flight request or they wait forever. + let spawnError: Error | null = null + child.on('error', (error) => { + spawnError = error + exited = true + failPending(error) + }) + // Why: 'close' (not 'exit') guarantees the stderr tail is complete, so an + // early death classifies correctly as missing-subcommand vs transient. + child.on('close', () => { + failPending(buildEarlyExitError()) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES) + }) + + let stdoutBuffer = '' + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') + let newlineIndex + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + if (!line) { + continue + } + let message: JsonRpcResponse + try { + message = JSON.parse(line) as JsonRpcResponse + } catch { + continue + } + if (typeof message.id === 'number' && pending.has(message.id)) { + const waiter = pending.get(message.id)! + pending.delete(message.id) + waiter.resolve(message) + } + } + }) + + function failPending(error: Error): void { + for (const waiter of pending.values()) { + waiter.reject(error) + } + pending.clear() + } + + const deadline = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + failPending( + new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + ) + }, invocation.timeoutMs) + + function sendLine(payload: Record): void { + child.stdin.write(`${JSON.stringify(payload)}\n`) + } + + async function requestRpc(method: string, params?: Record): Promise { + if (spawnError) { + throw spawnError + } + if (timedOut) { + throw new CodexAppServerTimeoutError('codex app-server session already timed out') + } + if (exited) { + throw buildEarlyExitError() + } + const id = nextRequestId++ + const response = await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + const payload: Record = { method, id } + if (params !== undefined) { + payload.params = params + } + try { + sendLine(payload) + } catch (error) { + pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + if (response.error) { + if (isMethodNotFoundError(response.error)) { + throw new CodexAppServerUnsupportedError( + `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` + ) + } + throw new Error( + `codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}` + ) + } + return response.result + } + + function buildEarlyExitError(): Error { + if (stderrIndicatesMissingAppServer(stderrTail)) { + return new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + return new Error( + `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` + ) + } + + try { + await requestRpc('initialize', { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } + }) + sendLine({ method: 'initialized' }) + + const expectedKeys = new Set(request.expectedTrustKeys) + const matchManaged = (listing: CodexHookListing): boolean => + listing.command === request.managedCommand && + expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key)) + + const listResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) + const managedListings = collectHookListings(listResult).filter(matchManaged) + if (managedListings.length !== expectedKeys.size) { + return { + outcome: 'verify-failed', + reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries` + } + } + + const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (needingTrust.length > 0) { + // Why: same wire shape as the Codex TUI "Trust all" flow — one upsert + // edit under hooks.state with each key's Codex-computed current hash. + const value: Record = {} + for (const listing of needingTrust) { + value[listing.key] = { trusted_hash: listing.currentHash } + } + await requestRpc('config/batchWrite', { + edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }], + reloadUserConfig: true + }) + } + + const verifyResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) + const verifiedListings = collectHookListings(verifyResult).filter(matchManaged) + const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) { + return { + outcome: 'verify-failed', + reason: + untrusted.length > 0 + ? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}` + : `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries` + } + } + return { + outcome: 'granted', + wroteTrust: needingTrust.length > 0, + entries: verifiedListings.map((listing) => ({ + key: listing.key, + normalizedKey: normalizeHookTrustKeyForLookup(listing.key), + trustedHash: listing.currentHash + })) + } + } catch (error) { + if ( + error instanceof Error && + !(error instanceof CodexAppServerUnsupportedError) && + !(error instanceof CodexAppServerTimeoutError) && + stderrIndicatesMissingAppServer(stderrTail) + ) { + throw new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + throw error + } finally { + clearTimeout(deadline) + try { + child.stdin.end() + } catch { + // stdin may already be destroyed after a kill; reaping below still runs. + } + if (!exited) { + // Why: the server exits promptly on stdin EOF; the grace period only + // bounds a wedged child before the guaranteed SIGKILL reap. + const grace = new Promise((resolve) => setTimeout(resolve, 1500)) + await Promise.race([exitPromise, grace]) + if (!exited) { + child.kill('SIGKILL') + await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) + } + } + } +} diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts new file mode 100644 index 00000000000..ef03183b162 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -0,0 +1,150 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest, + type CodexHookTrustGrantSessionResult +} from './codex-app-server-client' + +// Why: hook install/refresh is synchronous launch prep — a Codex pane must +// not start before its trust is settled — but a stdio JSON-RPC session needs +// a live event loop. This bridge blocks the caller on spawnSync of a bundled +// ELECTRON_RUN_AS_NODE entry (same pattern as the daemon and parcel-watcher +// entries) that runs the session and reports one JSON envelope on stdout. + +export type GrantEntryEnvelope = + | { ok: true; result: CodexHookTrustGrantSessionResult } + | { ok: false; errorName: string; message: string; unsupported?: boolean } + +export function buildGrantEntryEnvelope( + run: Promise +): Promise { + return run.then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ + ok: false as const, + errorName: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error), + ...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {}) + }) + ) +} + +const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js' +// Why: spawnSync must outlive the session deadline so the entry's own timeout +// (and its result envelope) win the race; the margin only reaps a hung entry. +const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 +const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 + +function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return ( + (require('electron') as { app?: { getAppPath(): string; isPackaged: boolean } }).app ?? null + ) + } catch { + return null + } +} + +export function resolveCodexGrantEntryPath( + pathExists: (candidate: string) => boolean = existsSync +): string | null { + const app = loadElectronApp() + let appPath: string | undefined + try { + appPath = app?.getAppPath() + } catch { + appPath = undefined + } + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must run from app.asar.unpacked (out/main/codex/** is in + // the asarUnpack list). + const unpackedAppPath = + app?.isPackaged && appPath ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const candidates = [ + // Dev/E2E: electron-vite's appPath is already out/main. + unpackedAppPath ? join(unpackedAppPath, 'codex', GRANT_ENTRY_FILE_NAME) : null, + unpackedAppPath ? join(unpackedAppPath, 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) : null, + // Plain-node CLI context (no electron): resolve relative to this chunk. + join(__dirname, 'codex', GRANT_ENTRY_FILE_NAME), + join(__dirname, '..', 'codex', GRANT_ENTRY_FILE_NAME) + ].filter((candidate): candidate is string => candidate !== null) + for (const candidate of candidates) { + if (pathExists(candidate)) { + return candidate + } + } + return null +} + +export type RunGrantSessionSyncOptions = { + entryPath?: string + nodeCommand?: string +} + +/** + * Blocking wrapper for the grant session. Hook install/refresh is synchronous + * launch prep (pane launch must not proceed until trust is settled), and a + * stdio JSON-RPC session needs a live event loop — so the session runs in a + * short-lived ELECTRON_RUN_AS_NODE child (same pattern as the daemon and + * parcel-watcher entries) while the caller blocks on spawnSync. spawnSync + * always reaps the entry; a killed entry closes the codex child's stdin, + * which makes codex app-server exit on EOF. + */ +export function runCodexHookTrustGrantSessionSync( + request: CodexHookTrustGrantRequest, + options: RunGrantSessionSyncOptions = {} +): CodexHookTrustGrantSessionResult { + const entryPath = options.entryPath ?? resolveCodexGrantEntryPath() + if (!entryPath) { + throw new Error('codex trust-grant entry bundle not found') + } + const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], { + input: JSON.stringify(request), + encoding: 'utf8', + timeout: request.invocation.timeoutMs + GRANT_ENTRY_TIMEOUT_MARGIN_MS, + killSignal: 'SIGKILL', + maxBuffer: GRANT_ENTRY_MAX_BUFFER_BYTES, + windowsHide: true, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + }) + if (spawned.error) { + throw spawned.error + } + if (spawned.signal) { + throw new CodexAppServerTimeoutError( + `codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline` + ) + } + const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0) + const lastLine = lines.at(-1) + let envelope: GrantEntryEnvelope | null = null + if (lastLine) { + try { + envelope = JSON.parse(lastLine) as GrantEntryEnvelope + } catch { + envelope = null + } + } + if (!envelope) { + throw new Error( + `codex trust-grant entry produced no result (exit ${spawned.status ?? 'unknown'})${ + spawned.stderr ? `: ${spawned.stderr.trim().slice(0, 400)}` : '' + }` + ) + } + if (!envelope.ok) { + if (envelope.unsupported) { + throw new CodexAppServerUnsupportedError(envelope.message) + } + if (envelope.errorName === 'CodexAppServerTimeoutError') { + throw new CodexAppServerTimeoutError(envelope.message) + } + throw new Error(envelope.message) + } + return envelope.result +} diff --git a/src/main/codex/codex-app-server-grant-entry.ts b/src/main/codex/codex-app-server-grant-entry.ts new file mode 100644 index 00000000000..b4613461a78 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-entry.ts @@ -0,0 +1,68 @@ +// Forked (ELECTRON_RUN_AS_NODE) child that runs one codex app-server +// trust-grant session. The parent blocks on spawnSync because hook +// install/refresh must finish before a Codex pane launch proceeds, while the +// JSONL RPC session itself needs a live event loop. Reads the request JSON +// from stdin, writes a single result-envelope JSON line to stdout, and never +// imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard). +import { buildGrantEntryEnvelope } from './codex-app-server-grant-bridge' +import { + runCodexHookTrustGrantSession, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' + +const HARD_EXIT_MARGIN_MS = 2_000 + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf8') +} + +async function main(): Promise { + const raw = await readStdin() + let request: CodexHookTrustGrantRequest + try { + request = JSON.parse(raw) as CodexHookTrustGrantRequest + } catch (error) { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: 'Error', + message: `invalid trust-grant request JSON: ${error instanceof Error ? error.message : String(error)}` + })}\n` + ) + return + } + // Why: backstop for a session whose own deadline failed to fire (clock + // suspend mid-session); exiting closes the codex child's stdio so it + // exits on EOF instead of orphaning. + const hardExit = setTimeout(() => { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: 'CodexAppServerTimeoutError', + message: `trust-grant entry hard deadline (${request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS}ms) elapsed` + })}\n` + ) + process.exit(3) + }, request.invocation.timeoutMs + HARD_EXIT_MARGIN_MS) + const envelope = await buildGrantEntryEnvelope(runCodexHookTrustGrantSession(request)) + clearTimeout(hardExit) + process.stdout.write(`${JSON.stringify(envelope)}\n`) +} + +void main().then( + () => process.exit(0), + (error: unknown) => { + process.stdout.write( + `${JSON.stringify({ + ok: false, + errorName: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error) + })}\n` + ) + process.exit(0) + } +) diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts new file mode 100644 index 00000000000..69cf30db302 --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -0,0 +1,315 @@ +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import { + isCodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest, + type CodexHookTrustGrantSessionResult +} from './codex-app-server-client' +import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' +import { + codexAppServerCapabilityCache, + getCodexAppServerHostKey +} from './codex-app-server-capability-cache' +import { + binaryStampsMatch, + buildNativeCodexBinaryStamp, + readCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome, + type CodexTrustGrantBinaryStamp, + type CodexTrustGrantLedgerEntry +} from './codex-trust-grant-ledger' +import { + computeTrustKey, + normalizeHookTrustKeyForLookup, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { getCodexHookTrustSignature } from './codex-hook-identity' + +// Why: grants must never make launch prep slower than the codex TUI's own +// startup on the same host. Native sessions complete in ~100ms; WSL pays +// wsl.exe + login-shell + possible cold-distro costs, so it gets more room. +const NATIVE_GRANT_TIMEOUT_MS = 10_000 +const WSL_GRANT_TIMEOUT_MS = 30_000 + +/** Ops escape hatch (not a setting): forces the unchanged fallback lane. */ +const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' + +export type CodexTrustGrantHost = + | { kind: 'native' } + | { kind: 'wsl'; distro: string; linuxRuntimeHome: string } + +export type CodexManagedTrustGrantPlan = { + /** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */ + runtimeHomePath: string + /** Host-visible config.toml path holding the trust entries. */ + tomlPath: string + /** Exact command string written to the managed hooks.json entries. */ + managedCommand: string + /** Managed trust identities Orca just wrote (no trustedHash). */ + managedEntries: readonly CodexTrustEntry[] + host: CodexTrustGrantHost +} + +export type CodexTrustGrantFallbackReason = + | 'disabled' + | 'no-managed-entries' + | 'unsupported' + | 'unsupported-cached' + | 'verify-failed' + | 'error' + +export type CodexManagedTrustGrantOutcome = + | { lane: 'rpc'; entries: CodexTrustEntry[] } + | { lane: 'fallback'; reason: CodexTrustGrantFallbackReason } + +export type CodexTrustGrantDiagnostics = { + granted: number + ledgerHits: number + fellBack: number + verifyFailed: number + lastFallbackReason: CodexTrustGrantFallbackReason | null +} + +const diagnostics: CodexTrustGrantDiagnostics = { + granted: 0, + ledgerHits: 0, + fellBack: 0, + verifyFailed: 0, + lastFallbackReason: null +} + +export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics { + return { ...diagnostics } +} + +type CodexTrustGrantTelemetry = (event: { + outcome: 'granted' | 'fallback' | 'verify_failed' + hostKind: 'native' | 'wsl' + reason?: CodexTrustGrantFallbackReason +}) => void + +// Why: hook-service is bundled into plain-node CLI entries where electron +// (and therefore the telemetry client) cannot load; the Electron main process +// injects the tracker at startup instead of a static import. +let telemetry: CodexTrustGrantTelemetry = () => {} + +export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void { + telemetry = tracker +} + +type GrantSessionRunnerSync = ( + request: CodexHookTrustGrantRequest +) => CodexHookTrustGrantSessionResult + +let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync + +function fallback( + plan: CodexManagedTrustGrantPlan, + reason: CodexTrustGrantFallbackReason, + detail?: unknown +): CodexManagedTrustGrantOutcome { + diagnostics.fellBack += 1 + diagnostics.lastFallbackReason = reason + if (reason === 'verify-failed') { + diagnostics.verifyFailed += 1 + } + console.warn( + `[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`, + detail ?? '' + ) + telemetry({ + outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback', + hostKind: plan.host.kind, + reason + }) + return { lane: 'fallback', reason } +} + +type ExpectedManagedEntry = { + entry: CodexTrustEntry + normalizedKey: string + signature: string +} + +function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManagedEntry[] { + return plan.managedEntries.map((entry) => ({ + entry, + normalizedKey: normalizeHookTrustKeyForLookup(computeTrustKey(entry)), + signature: getCodexHookTrustSignature(entry) + })) +} + +function resolveCurrentBinaryStamp(host: CodexTrustGrantHost): CodexTrustGrantBinaryStamp | null { + if (host.kind === 'wsl') { + return { kind: 'wsl', distro: host.distro } + } + const command = resolveCodexCommand() + // Why: an unresolved bare command cannot be stat'ed; a null stamp still + // allows ledger skips (config + signature checks gate them) and heals to a + // real stamp on the next grant once the binary is resolvable. + return command === 'codex' ? null : buildNativeCodexBinaryStamp(command) +} + +function findLedgerGrant( + plan: CodexManagedTrustGrantPlan, + expected: ExpectedManagedEntry[], + currentStamp: CodexTrustGrantBinaryStamp | null +): CodexTrustEntry[] | null { + const home = readCodexTrustGrantLedgerHome(plan.runtimeHomePath) + if (!home || !binaryStampsMatch(home.binary, currentStamp)) { + return null + } + let trustStates: ReturnType + try { + trustStates = readHookTrustEntries(plan.tomlPath) + } catch { + return null + } + const entries: CodexTrustEntry[] = [] + for (const { entry, normalizedKey, signature } of expected) { + const recorded = home.entries[normalizedKey] + if (!recorded || recorded.signature !== signature) { + return null + } + if (trustStates.get(normalizedKey)?.trustedHash !== recorded.trustedHash) { + return null + } + entries.push({ ...entry, trustedHash: recorded.trustedHash }) + } + return entries +} + +function buildGrantRequest( + plan: CodexManagedTrustGrantPlan, + expected: ExpectedManagedEntry[] +): CodexHookTrustGrantRequest { + if (plan.host.kind === 'wsl') { + return { + invocation: { + command: 'wsl.exe', + args: buildWslCodexAppServerArgs(plan.host.distro, plan.host.linuxRuntimeHome), + timeoutMs: WSL_GRANT_TIMEOUT_MS + }, + hooksListCwd: plan.host.linuxRuntimeHome, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), + managedCommand: plan.managedCommand + } + } + const codexCommand = resolveCodexCommand() + // Why: npm-installed codex on Windows is a .cmd shim that spawn cannot run + // without cmd.exe /c; args-array + shell:true would hit DEP0190 instead. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['app-server']) + return { + invocation: { + command: spawnCmd, + args: spawnArgs, + env: { CODEX_HOME: plan.runtimeHomePath }, + timeoutMs: NATIVE_GRANT_TIMEOUT_MS + }, + hooksListCwd: plan.runtimeHomePath, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), + managedCommand: plan.managedCommand + } +} + +/** + * Grants trust for Orca's managed Codex hooks through codex's own app-server + * RPCs, verified by re-list. Returns the granted entries carrying Codex's + * verbatim hashes, or a fallback marker — the caller then runs the previous + * computeTrustedHash lane, byte-identical to the pre-RPC behavior. Never + * throws: any unexpected failure is a fallback, because hook install is + * best-effort launch prep. + */ +export function grantManagedCodexHookTrust( + plan: CodexManagedTrustGrantPlan +): CodexManagedTrustGrantOutcome { + try { + if (process.env[DISABLE_ENV_FLAG] === '1') { + return fallback(plan, 'disabled') + } + if (plan.managedEntries.length === 0) { + return fallback(plan, 'no-managed-entries') + } + const expected = buildExpectedEntries(plan) + const currentStamp = resolveCurrentBinaryStamp(plan.host) + const ledgerEntries = findLedgerGrant(plan, expected, currentStamp) + if (ledgerEntries !== null) { + diagnostics.ledgerHits += 1 + return { lane: 'rpc', entries: ledgerEntries } + } + + const hostKey = getCodexAppServerHostKey(plan.host) + if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { + return fallback(plan, 'unsupported-cached') + } + + const startedAtMs = Date.now() + let result: CodexHookTrustGrantSessionResult + try { + result = runSessionSync(buildGrantRequest(plan, expected)) + } catch (error) { + if (isCodexAppServerUnsupportedError(error)) { + codexAppServerCapabilityCache.rememberUnsupported(hostKey) + return fallback(plan, 'unsupported', error) + } + return fallback(plan, 'error', error) + } + // Why: the RPC surface answered, even if our entries were not verifiable — + // remember support so a later drift event retries the preferred lane. + codexAppServerCapabilityCache.rememberSupported(hostKey) + if (result.outcome === 'verify-failed') { + return fallback(plan, 'verify-failed', result.reason) + } + + const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) + const grantedEntries: CodexTrustEntry[] = [] + const ledgerRecord: Record = {} + for (const granted of result.entries) { + const match = byNormalizedKey.get(granted.normalizedKey) + if (!match) { + return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`) + } + grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) + ledgerRecord[granted.normalizedKey] = { + signature: match.signature, + trustedHash: granted.trustedHash + } + } + if (grantedEntries.length !== expected.length) { + return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries') + } + try { + writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { + binary: currentStamp, + entries: ledgerRecord + }) + } catch (error) { + // Why: a ledger write failure only costs an extra session next launch. + console.warn('[codex-trust-grant] failed to persist grant ledger', error) + } + diagnostics.granted += 1 + console.log( + `[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` + + `(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)` + ) + telemetry({ outcome: 'granted', hostKind: plan.host.kind }) + return { lane: 'rpc', entries: grantedEntries } + } catch (error) { + return fallback(plan, 'error', error) + } +} + +export const _internals = { + setGrantSessionRunnerSync(runner: GrantSessionRunnerSync | null): void { + runSessionSync = runner ?? runCodexHookTrustGrantSessionSync + }, + resetDiagnostics(): void { + diagnostics.granted = 0 + diagnostics.ledgerHits = 0 + diagnostics.fellBack = 0 + diagnostics.verifyFailed = 0 + diagnostics.lastFallbackReason = null + } +} diff --git a/src/main/codex/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts new file mode 100644 index 00000000000..5b7786ffb50 --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -0,0 +1,143 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' +import { normalizeCodexProjectPathForLookup } from './config-toml-trust' + +// Why: a grant session blocks launch prep, so it must not run on every pane +// launch. This ledger records what a *verified* codex-side grant left behind +// (per runtime home): the hook identity that was granted, the Codex-computed +// hash, and the codex binary that computed it. Install skips the RPC while +// all three still hold; any drift (hook edit, config wipe, codex upgrade) +// re-triggers a grant before the pane launches. + +export type CodexTrustGrantBinaryStamp = + | { kind: 'native'; path: string; size: number; mtimeMs: number } + // Why: there is no cheap way to stat the codex binary inside a WSL distro + // from the host, so WSL grants revalidate only on hook/config drift. A WSL + // codex upgrade that changes the hash algorithm re-grants on verify-fail of + // the next launch's status rather than pre-emptively. + | { kind: 'wsl'; distro: string } + +export type CodexTrustGrantLedgerEntry = { + /** getCodexHookTrustSignature() of the granted hook identity. */ + signature: string + /** Codex-computed hash verified as trusted via hooks/list. */ + trustedHash: string +} + +export type CodexTrustGrantLedgerHome = { + binary: CodexTrustGrantBinaryStamp | null + /** Keyed by normalizeHookTrustKeyForLookup(trust key). */ + entries: Record +} + +type CodexTrustGrantLedgerFile = { + version: 1 + homes: Record +} + +export function getCodexTrustGrantLedgerPath(): string { + return join(dirname(getOrcaManagedCodexHomePath()), 'trust-grant-ledger.json') +} + +export function getCodexTrustGrantHomeKey(runtimeHomePath: string): string { + return normalizeCodexProjectPathForLookup(runtimeHomePath) +} + +function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { + const empty: CodexTrustGrantLedgerFile = { version: 1, homes: {} } + if (!existsSync(ledgerPath)) { + return empty + } + try { + const parsed: unknown = JSON.parse(readFileSync(ledgerPath, 'utf-8')) + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + (parsed as CodexTrustGrantLedgerFile).version !== 1 + ) { + return empty + } + const homes = (parsed as CodexTrustGrantLedgerFile).homes + if (!homes || typeof homes !== 'object' || Array.isArray(homes)) { + return empty + } + return { version: 1, homes } + } catch { + // Why: a corrupt ledger only costs one extra grant session; never let it + // block hook install. + return empty + } +} + +export function readCodexTrustGrantLedgerHome( + runtimeHomePath: string, + ledgerPath = getCodexTrustGrantLedgerPath() +): CodexTrustGrantLedgerHome | null { + const home = readLedgerFile(ledgerPath).homes[getCodexTrustGrantHomeKey(runtimeHomePath)] + if (!home || typeof home !== 'object' || Array.isArray(home)) { + return null + } + if (!home.entries || typeof home.entries !== 'object' || Array.isArray(home.entries)) { + return null + } + return home +} + +export function writeCodexTrustGrantLedgerHome( + runtimeHomePath: string, + home: CodexTrustGrantLedgerHome, + ledgerPath = getCodexTrustGrantLedgerPath() +): void { + const file = readLedgerFile(ledgerPath) + file.homes[getCodexTrustGrantHomeKey(runtimeHomePath)] = home + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +export function removeCodexTrustGrantLedgerHome( + runtimeHomePath: string, + ledgerPath = getCodexTrustGrantLedgerPath() +): void { + const file = readLedgerFile(ledgerPath) + const homeKey = getCodexTrustGrantHomeKey(runtimeHomePath) + if (!(homeKey in file.homes)) { + return + } + delete file.homes[homeKey] + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + +export function buildNativeCodexBinaryStamp(binaryPath: string): CodexTrustGrantBinaryStamp | null { + try { + const stat = statSync(binaryPath) + return { kind: 'native', path: binaryPath, size: stat.size, mtimeMs: stat.mtimeMs } + } catch { + return null + } +} + +export function binaryStampsMatch( + recorded: CodexTrustGrantBinaryStamp | null, + current: CodexTrustGrantBinaryStamp | null +): boolean { + if (recorded === null || current === null) { + // Why: an unresolvable binary stamp must not wedge installs into + // re-granting forever; the config/signature checks still gate the skip. + return recorded === null && current === null + } + if (recorded.kind === 'wsl' || current.kind === 'wsl') { + return recorded.kind === 'wsl' && current.kind === 'wsl' && recorded.distro === current.distro + } + return ( + recorded.path === current.path && + recorded.size === current.size && + recorded.mtimeMs === current.mtimeMs + ) +} From eb5a76c31a230ab9f73ca094bef3b2d105840a8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:22 -0700 Subject: [PATCH 07/45] fix(codex): grant managed hook trust via codex app-server RPCs in install/refresh Host and WSL installs now grant trust for Orca's managed status hooks through codex's own hooks/list -> config/batchWrite -> re-list verify, scoped to exactly the managed entries; the previous computeTrustedHash lane is the unchanged fallback for incapable/erroring CLIs. getStatus and the removal paths recognize ledger-recorded codex hashes so drift between codex's real algorithm and the replica no longer misreports or strands trust. SSH remote install is untouched by design. --- src/main/codex/codex-wsl-hook-install-plan.ts | 9 +- src/main/codex/hook-service.ts | 114 +++++++++++++++--- src/main/index.ts | 11 ++ src/shared/telemetry-events.ts | 23 ++++ 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 6edc4aed660..0f63d036443 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -13,6 +13,11 @@ export type CodexWslRuntimeHookInstallPlan = { scriptPath: string commandScriptPath: string trustConfigPath: string + /** Distro that executes Codex for this runtime home (RPC trust grants run + * codex inside it). */ + wslDistro: string + /** Canonical Linux-side runtime home — CODEX_HOME for in-distro codex runs. */ + linuxRuntimeHome: string } export type WslCanonicalPathSettlement = @@ -184,7 +189,9 @@ export function createCodexWslRuntimeHookInstallPlan( tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxRuntimeHome}/hooks.json` + trustConfigPath: `${linuxRuntimeHome}/hooks.json`, + wslDistro: distro, + linuxRuntimeHome } } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index b09a94f5ede..1011d595ac3 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -66,6 +66,12 @@ import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -630,12 +636,34 @@ function cleanupLegacyManagedHookRepresentations(): void { } } +function readLedgerHomeForCleanup(runtimeHomePath: string): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHome(runtimeHomePath) + } catch { + return null + } +} + +// Why: RPC-granted entries carry Codex's hash, which need not equal the +// self-computed one — the grant ledger is what proves those blocks are ours. +function addLedgerRecognizedHash( + recognizedHashes: Set, + ledgerHome: CodexTrustGrantLedgerHome | null, + key: string +): void { + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] + if (granted?.trustedHash) { + recognizedHashes.add(granted.trustedHash) + } +} + function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { const tomlPath = getCodexConfigTomlPath() const existingEntries = readHookTrustEntries(tomlPath) const scriptPath = getManagedScriptPath() const command = getManagedCommand(scriptPath) + const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) const managedEventLabels = new Set( CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) ) @@ -670,6 +698,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { continue } @@ -678,6 +707,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { if (ourKeys.length > 0) { removeHookTrustEntries(tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath()) } catch (error) { // Best effort — stale trust entries are harmless once hooks.json no // longer references the hook. Log so a programmer error doesn't disappear silently. @@ -689,6 +719,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal try { const existingEntries = readHookTrustEntries(plan.tomlPath) const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(plan.tomlPath)) const managedEventLabels = new Set( CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) ) @@ -717,6 +748,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -724,6 +756,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal if (ourKeys.length > 0) { removeHookTrustEntries(plan.tomlPath, ourKeys) } + removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath)) } catch (error) { // Why: removing disabled WSL status hooks should be best-effort like the // host cleanup path; stale trust is inert once hooks.json no longer points at us. @@ -739,6 +772,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) ) const existingEntries = readHookTrustEntries(tomlPath) + const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(tomlPath)) const ourKeys: string[] = [] for (const [key, state] of existingEntries) { if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { @@ -768,6 +802,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -923,10 +958,23 @@ function installManagedHooksIntoWslRuntime( writeManagedScript(plan.scriptPath, getManagedScript('posix')) writeCodexHooksJson(plan.configPath, nextHooks) try { - // Why: WSL runtime homes may carry user hook approvals we did not rebuild - // here; only upsert Orca's entries instead of sweeping the whole source. - upsertHookTrustEntries(plan.tomlPath, trustEntries) - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + // Why: same grant-then-fallback split as the host install — codex runs + // inside the distro so the hash authority matches the codex the pane runs. + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: pathWin32.dirname(plan.tomlPath), + tomlPath: plan.tomlPath, + managedCommand: command, + managedEntries: trustEntries, + host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } + }) + if (grant.lane === 'rpc') { + removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, grant.entries) + } else { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) + } } catch (error) { return { agent: 'codex', @@ -1131,6 +1179,10 @@ export class CodexHookService { trustEntries = new Map() trustReadError = error instanceof Error ? error.message : String(error) } + // Why: RPC-granted entries store Codex's own hash, which is authoritative + // even when it differs from computeTrustedHash — that difference is the + // drift bug class this lane exists to absorb, not a stale entry. + const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) const missing: string[] = [] const trustMissing: string[] = [] @@ -1175,9 +1227,14 @@ export class CodexHookService { command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS } - const expectedHash = computeTrustedHash(trustInput) - const actualState = trustEntries.get(computeTrustKey(trustInput)) - if (actualState?.trustedHash !== expectedHash) { + const trustKey = computeTrustKey(trustInput) + const validHashes = new Set([computeTrustedHash(trustInput)]) + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(trustKey)] + if (granted && granted.signature === getCodexHookTrustSignature(trustInput)) { + validHashes.add(granted.trustedHash) + } + const actualState = trustEntries.get(trustKey) + if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) { trustMissing.push(eventName) } else if (actualState?.enabled === false) { disabled.push(eventName) @@ -1277,7 +1334,10 @@ export class CodexHookService { const mirroredUserTrustEntries = moveMirroredRuntimeUserTrustAfterManagedStatusHook( hookPlan.trustEntries ) - const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) + const mirroredTrustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map( + ({ entry }) => entry + ) + const managedTrustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -1290,7 +1350,7 @@ export class CodexHookService { // state while Codex visibly reports that hooks are still running. // timeoutSec mirrors the hook's `timeout` so the trust hash matches the // entry actually written to hooks.json. - trustEntries.push({ + managedTrustEntries.push({ sourcePath: configPath, eventLabel: CODEX_EVENT_LABEL[eventName], groupIndex: 0, @@ -1299,6 +1359,7 @@ export class CodexHookService { timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS }) } + const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries] config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) @@ -1309,13 +1370,34 @@ export class CodexHookService { try { const tomlPath = getCodexConfigTomlPath() syncSystemConfigIntoManagedCodexHome() - // Why: system user hook approvals are mirrored into runtime CODEX_HOME. - // If the user later revokes approval in ~/.codex/config.toml, preserving - // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. - // Upsert first so duplicate repair can preserve a disabled managed copy - // before stale cleanup removes old managed hook keys. - upsertHookTrustEntries(tomlPath, trustEntries) - removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + // Why: Codex is the only authority on its trust-hash algorithm, so the + // managed entries are granted through codex app-server RPCs (verified by + // re-list) whenever the installed CLI supports them; the granted entries + // then carry Codex's verbatim hashes into stale cleanup so it cannot + // delete what Codex just wrote. Mirrored user trust keeps its existing + // verbatim-carry lane either way. + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: getOrcaManagedCodexHomePath(), + tomlPath, + managedCommand: command, + managedEntries: managedTrustEntries, + host: { kind: 'native' } + }) + if (grant.lane === 'rpc') { + upsertHookTrustEntries(tomlPath, mirroredTrustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [ + ...mirroredTrustEntries, + ...grant.entries + ]) + } else { + // Why: system user hook approvals are mirrored into runtime CODEX_HOME. + // If the user later revokes approval in ~/.codex/config.toml, preserving + // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + // Upsert first so duplicate repair can preserve a disabled managed copy + // before stale cleanup removes old managed hook keys. + upsertHookTrustEntries(tomlPath, trustEntries) + removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) + } applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..41e04cc7ace 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -1727,6 +1728,16 @@ app.whenReady().then(async () => { // the Store reference, seeds common props, and resets per-session burst // caps. Actual transport initialization is still gated by both flags. initTelemetry(store) + // Why: the trust-grant module is bundled into plain-node CLI entries where + // the telemetry client cannot load, so the tracker is injected here instead + // of imported there. + setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + ...(reason !== undefined ? { fallback_reason: reason } : {}) + }) + }) // Why: the error-tracking lane (telemetry-error-tracking.md) is its own // composition root — independent of product telemetry — and must // initialize before any IPC handler / runtime span is created so the diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index ee34b53ec3c..28b754102a2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -442,6 +442,27 @@ const agentErrorSchema = z // v1.4.129-rc.1, which was otherwise invisible until users filed bug reports. const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).strict() +// Rollout signal for granting Codex hook trust via codex app-server RPCs +// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` +// spikes mean the RPC lane is not taking; steady-state ledger skips are not +// reported (they would only measure launch volume). +const codexTrustGrantSchema = z + .object({ + outcome: z.enum(['granted', 'fallback', 'verify_failed']), + host_kind: z.enum(['native', 'wsl']), + fallback_reason: z + .enum([ + 'disabled', + 'no-managed-entries', + 'unsupported', + 'unsupported-cached', + 'verify-failed', + 'error' + ]) + .optional() + }) + .strict() + const settingsChangedSchema = z .object({ setting_key: settingsChangedKeySchema, @@ -1428,6 +1449,8 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, + codex_trust_grant: codexTrustGrantSchema, + settings_changed: settingsChangedSchema, native_chat_toggled: nativeChatToggledSchema, From be366ab828c93146d2c4ef7086b60718f6e1929c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:23 -0700 Subject: [PATCH 08/45] test(codex): cover app-server trust grant client, cache, ledger, and lanes --- .../codex-app-server-capability-cache.test.ts | 117 +++++++ .../codex/codex-app-server-client.test.ts | 328 ++++++++++++++++++ src/main/codex/codex-hook-trust-grant.test.ts | 236 +++++++++++++ .../codex/codex-trust-grant-ledger.test.ts | 93 +++++ .../codex/hook-service-wsl-runtime.test.ts | 12 +- src/main/codex/hook-service.test.ts | 198 ++++++++++- 6 files changed, 980 insertions(+), 4 deletions(-) create mode 100644 src/main/codex/codex-app-server-capability-cache.test.ts create mode 100644 src/main/codex/codex-app-server-client.test.ts create mode 100644 src/main/codex/codex-hook-trust-grant.test.ts create mode 100644 src/main/codex/codex-trust-grant-ledger.test.ts diff --git a/src/main/codex/codex-app-server-capability-cache.test.ts b/src/main/codex/codex-app-server-capability-cache.test.ts new file mode 100644 index 00000000000..c6dea6e0031 --- /dev/null +++ b/src/main/codex/codex-app-server-capability-cache.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS, + CodexAppServerCapabilityCache, + getCodexAppServerHostKey +} from './codex-app-server-capability-cache' + +const unsupportedError = new Error('unsupported') +const isUnsupported = (error: unknown): boolean => error === unsupportedError + +describe('CodexAppServerCapabilityCache', () => { + it('retries a host after the compatibility interval', () => { + const cache = new CodexAppServerCapabilityCache() + cache.rememberUnsupported('native', 1_000) + + expect( + cache.shouldTry('native', 1_000 + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS - 1) + ).toBe(false) + expect(cache.shouldTry('native', 1_000 + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS)).toBe( + true + ) + }) + + it('falls back on the first unsupported probe and skips the probe on later calls', () => { + const cache = new CodexAppServerCapabilityCache() + const firstPreferred = vi.fn(() => { + throw unsupportedError + }) + expect( + cache.runWithFallbackSync('native', firstPreferred, () => 'first-fallback', isUnsupported, 5) + ).toBe('first-fallback') + expect(firstPreferred).toHaveBeenCalledTimes(1) + + // Why: probes are synchronous on the main thread, so they can never + // overlap — back-to-back calls inside the retry window are the + // "concurrent probe" equivalent and must share the first probe's result. + const laterPreferred = vi.fn(() => 'unexpected-preferred') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 6) + ).toBe('cached-fallback') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 7) + ).toBe('cached-fallback') + expect(laterPreferred).not.toHaveBeenCalled() + }) + + it('isolates capability state per execution host', () => { + const cache = new CodexAppServerCapabilityCache() + cache.rememberUnsupported('wsl:Ubuntu', 1_000) + + expect(cache.shouldTry('wsl:Ubuntu', 1_001)).toBe(false) + expect(cache.shouldTry('native', 1_001)).toBe(true) + expect(cache.shouldTry('wsl:Debian', 1_001)).toBe(true) + + const nativePreferred = vi.fn(() => 'native-result') + expect( + cache.runWithFallbackSync('native', nativePreferred, () => 'unexpected', isUnsupported, 1_001) + ).toBe('native-result') + expect(nativePreferred).toHaveBeenCalledTimes(1) + }) + + it('drops known support when a later call reports the capability unsupported', () => { + const cache = new CodexAppServerCapabilityCache() + expect( + cache.runWithFallbackSync( + 'native', + () => 'supported', + () => 'unexpected', + isUnsupported, + 1 + ) + ).toBe('supported') + expect(cache.isKnownSupported('native')).toBe(true) + + expect( + cache.runWithFallbackSync( + 'native', + () => { + throw unsupportedError + }, + () => 'fallback', + isUnsupported, + 2 + ) + ).toBe('fallback') + expect(cache.isKnownSupported('native')).toBe(false) + + const laterPreferred = vi.fn(() => 'unexpected-preferred') + expect( + cache.runWithFallbackSync('native', laterPreferred, () => 'cached-fallback', isUnsupported, 3) + ).toBe('cached-fallback') + expect(laterPreferred).not.toHaveBeenCalled() + }) + + it('rethrows transient errors without marking the host unsupported', () => { + const cache = new CodexAppServerCapabilityCache() + const transient = new Error('spawn ETIMEDOUT') + expect(() => + cache.runWithFallbackSync( + 'native', + () => { + throw transient + }, + () => 'unexpected-fallback', + isUnsupported, + 1 + ) + ).toThrow(transient) + expect(cache.shouldTry('native', 2)).toBe(true) + }) + + it('builds host keys that keep WSL distros apart', () => { + expect(getCodexAppServerHostKey({ kind: 'native' })).toBe('native') + expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Ubuntu' })).toBe('wsl:Ubuntu') + expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Debian' })).toBe('wsl:Debian') + }) +}) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts new file mode 100644 index 00000000000..3db80904f54 --- /dev/null +++ b/src/main/codex/codex-app-server-client.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + runCodexHookTrustGrantSession, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' +import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' + +// Stub codex app-server speaking the same JSONL protocol: initialize → +// initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven +// via STUB_CONFIG so each test controls listings, errors, and hangs. +const STUB_SERVER_SOURCE = ` +const config = JSON.parse(process.env.STUB_CONFIG) +const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key)) +let buffer = '' +function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') } +function listing() { + return { + data: [{ + cwd: config.cwd, + hooks: config.hooks.map(h => ({ ...h, trustStatus: trusted.has(h.key) ? 'trusted' : h.trustStatus })), + warnings: [], + errors: [] + }] + } +} +if (config.scenario === 'no-subcommand') { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => { + buffer += chunk + let index + while ((index = buffer.indexOf('\\n')) !== -1) { + const line = buffer.slice(0, index).trim() + buffer = buffer.slice(index + 1) + if (!line) continue + const message = JSON.parse(line) + if (message.method === 'initialize') { + send({ id: message.id, result: { userAgent: 'stub/0.0.0' } }) + continue + } + if (message.method === 'initialized') continue + if (config.scenario === 'hang') continue + if (message.method === 'hooks/list') { + if (config.scenario === 'unknown-method') { + send({ id: message.id, error: { code: -32601, message: 'Method not found' } }) + continue + } + send({ id: message.id, result: listing() }) + continue + } + if (message.method === 'config/batchWrite') { + if (config.scenario === 'reject-write') { + process.exit(9) + } + writeFileSyncSafe(config.recordFile, JSON.stringify(message.params)) + for (const key of Object.keys(message.params.edits[0].value)) trusted.add(key) + send({ id: message.id, result: { status: 'ok', version: 'v1', filePath: config.cwd + '/config.toml' } }) + continue + } + } +}) +process.stdin.on('end', () => process.exit(0)) +function writeFileSyncSafe(file, contents) { require('node:fs').writeFileSync(file, contents) } +` + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +type StubHook = { + key: string + command: string | null + currentHash: string + trustStatus: string +} + +function createStubRequest(options: { + scenario: string + hooks: StubHook[] + expectedTrustKeys: string[] + managedCommand: string + timeoutMs?: number +}): { request: CodexHookTrustGrantRequest; recordFile: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-stub-')) + tempRoots.push(root) + const stubPath = join(root, 'stub-app-server.cjs') + writeFileSync(stubPath, STUB_SERVER_SOURCE) + const recordFile = join(root, 'batch-write-params.json') + return { + recordFile, + request: { + invocation: { + command: process.execPath, + args: [stubPath], + env: { + STUB_CONFIG: JSON.stringify({ + scenario: options.scenario, + hooks: options.hooks, + cwd: root, + recordFile + }) + }, + timeoutMs: options.timeoutMs ?? 10_000 + }, + hooksListCwd: root, + expectedTrustKeys: options.expectedTrustKeys, + managedCommand: options.managedCommand + } + } +} + +const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'" + +function managedHook(key: string, trustStatus = 'untrusted'): StubHook { + return { key, command: MANAGED_COMMAND, currentHash: `sha256:hash-of-${key}`, trustStatus } +} + +describe('runCodexHookTrustGrantSession', () => { + it('grants and verifies exactly the expected managed entries', async () => { + const keys = [ + '/home/a/.codex/hooks.json:session_start:0:0', + '/home/a/.codex/hooks.json:stop:0:0' + ] + const userHook: StubHook = { + key: '/home/a/.codex/hooks.json:stop:1:0', + command: 'echo user-hook', + currentHash: 'sha256:user-hash', + trustStatus: 'untrusted' + } + const { request, recordFile } = createStubRequest({ + scenario: 'happy', + hooks: [...keys.map((key) => managedHook(key)), userHook], + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result.outcome).toBe('granted') + if (result.outcome !== 'granted') { + return + } + expect(result.wroteTrust).toBe(true) + expect(result.entries.map((entry) => entry.key).sort()).toEqual([...keys].sort()) + expect(result.entries.map((entry) => entry.trustedHash).sort()).toEqual( + keys.map((key) => `sha256:hash-of-${key}`).sort() + ) + + // Why: the write must never include user hooks, even untrusted ones. + const written = JSON.parse(readFileSync(recordFile, 'utf-8')) as { + edits: { + keyPath: string + value: Record + mergeStrategy: string + }[] + reloadUserConfig: boolean + } + expect(written.edits).toHaveLength(1) + expect(written.edits[0].keyPath).toBe('hooks.state') + expect(written.edits[0].mergeStrategy).toBe('upsert') + expect(Object.keys(written.edits[0].value).sort()).toEqual([...keys].sort()) + expect(written.reloadUserConfig).toBe(true) + }) + + it('skips config/batchWrite when every expected entry is already trusted', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + // Why: the stub exits(9) on batchWrite in this scenario, so a write would + // fail the session instead of silently passing. + const { request, recordFile } = createStubRequest({ + scenario: 'reject-write', + hooks: keys.map((key) => managedHook(key, 'trusted')), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result).toMatchObject({ outcome: 'granted', wroteTrust: false }) + expect(existsSync(recordFile)).toBe(false) + }) + + it('reports verify-failed when expected entries are missing from the listing', async () => { + const { request } = createStubRequest({ + scenario: 'happy', + hooks: [managedHook('/home/a/.codex/hooks.json:session_start:0:0')], + expectedTrustKeys: [ + '/home/a/.codex/hooks.json:session_start:0:0', + '/home/a/.codex/hooks.json:stop:0:0' + ], + managedCommand: MANAGED_COMMAND + }) + + const result = await runCodexHookTrustGrantSession(request) + expect(result.outcome).toBe('verify-failed') + }) + + it('throws the unsupported error class for unknown JSON-RPC methods', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'unknown-method', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( + CodexAppServerUnsupportedError + ) + }) + + it('throws the unsupported error class when the CLI lacks the app-server subcommand', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'no-subcommand', + hooks: [], + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND + }) + + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(isCodexAppServerUnsupportedError(error)).toBe(true) + }) + + it('kills a hung server at the session deadline', async () => { + const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] + const { request } = createStubRequest({ + scenario: 'hang', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND, + timeoutMs: 500 + }) + + const startedAt = Date.now() + await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( + CodexAppServerTimeoutError + ) + // Why: the reap path must not stack the grace periods on top of the + // deadline — a wedged server may ignore everything but SIGKILL. + expect(Date.now() - startedAt).toBeLessThan(5_000) + }) + + it('surfaces spawn failures as regular errors, not capability signals', async () => { + const request: CodexHookTrustGrantRequest = { + invocation: { + command: join(tmpdir(), 'orca-codex-missing-binary-does-not-exist'), + args: [], + timeoutMs: 2_000 + }, + hooksListCwd: tmpdir(), + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + } + const error = await runCodexHookTrustGrantSession(request).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect(isCodexAppServerUnsupportedError(error)).toBe(false) + }) +}) + +describe('runCodexHookTrustGrantSessionSync', () => { + function writeEntryFixture(source: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-entry-')) + tempRoots.push(root) + const entryPath = join(root, 'grant-entry.cjs') + writeFileSync(entryPath, source) + return entryPath + } + + const baseRequest: CodexHookTrustGrantRequest = { + invocation: { command: 'codex', args: ['app-server'], timeoutMs: 1_000 }, + hooksListCwd: '/tmp', + expectedTrustKeys: ['k'], + managedCommand: MANAGED_COMMAND + } + + it('returns the entry envelope result and passes the request over stdin', () => { + const entryPath = writeEntryFixture(` + let input = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { input += chunk }) + process.stdin.on('end', () => { + const request = JSON.parse(input) + process.stdout.write(JSON.stringify({ + ok: true, + result: { + outcome: 'granted', + wroteTrust: true, + entries: [{ key: request.expectedTrustKeys[0], normalizedKey: request.expectedTrustKeys[0], trustedHash: 'sha256:x' }] + } + }) + '\\n') + }) + `) + const result = runCodexHookTrustGrantSessionSync(baseRequest, { entryPath }) + expect(result).toMatchObject({ outcome: 'granted', wroteTrust: true }) + }) + + it('rethrows unsupported envelopes as the unsupported error class', () => { + const entryPath = writeEntryFixture(` + process.stdin.resume() + process.stdin.on('end', () => { + process.stdout.write(JSON.stringify({ ok: false, errorName: 'CodexAppServerUnsupportedError', message: 'no app-server', unsupported: true }) + '\\n') + }) + `) + expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( + CodexAppServerUnsupportedError + ) + }) + + it('fails with a clear error when the entry produces no result', () => { + const entryPath = writeEntryFixture( + `process.stdin.resume(); process.stdin.on('end', () => process.exit(7))` + ) + expect(() => runCodexHookTrustGrantSessionSync(baseRequest, { entryPath })).toThrow( + /produced no result \(exit 7\)/ + ) + }) +}) diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts new file mode 100644 index 00000000000..37046f17d47 --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + CodexAppServerUnsupportedError, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { + _internals, + getCodexTrustGrantDiagnostics, + grantManagedCodexHookTrust, + type CodexManagedTrustGrantPlan +} from './codex-hook-trust-grant' +import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { + computeTrustKey, + normalizeHookTrustKeyForLookup, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' + +let userDataDir: string +let runtimeHomeDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-grant-userdata-')) + runtimeHomeDir = join(userDataDir, 'codex-runtime-home', 'home') + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + codexAppServerCapabilityCache.clear() + _internals.resetDiagnostics() +}) + +afterEach(() => { + _internals.setGrantSessionRunnerSync(null) + codexAppServerCapabilityCache.clear() + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(userDataDir, { recursive: true, force: true }) +}) + +const MANAGED_COMMAND = "/bin/sh '/tmp/orca/codex-hook.sh'" + +function managedEntry(eventLabel: CodexTrustEntry['eventLabel']): CodexTrustEntry { + return { + sourcePath: join(runtimeHomeDir, 'hooks.json'), + eventLabel, + groupIndex: 0, + handlerIndex: 0, + command: MANAGED_COMMAND, + timeoutSec: 10 + } +} + +function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan { + return { + runtimeHomePath: runtimeHomeDir, + tomlPath: join(runtimeHomeDir, 'config.toml'), + managedCommand: MANAGED_COMMAND, + managedEntries: entries, + host: { kind: 'native' } + } +} + +function grantedSessionResult(entries: CodexTrustEntry[], hashPrefix = 'sha256:codex-') { + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: entries.map((entry) => { + const key = computeTrustKey(entry) + return { + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `${hashPrefix}${entry.eventLabel}` + } + }) + } +} + +describe('grantManagedCodexHookTrust', () => { + it('returns granted entries with codex-verbatim hashes and records the ledger', () => { + const entries = [managedEntry('session_start'), managedEntry('stop')] + const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + const outcome = grantManagedCodexHookTrust(buildPlan(entries)) + expect(outcome.lane).toBe('rpc') + if (outcome.lane !== 'rpc') { + return + } + expect(outcome.entries.map((entry) => entry.trustedHash)).toEqual([ + 'sha256:codex-session_start', + 'sha256:codex-stop' + ]) + expect(runner).toHaveBeenCalledTimes(1) + const request = runner.mock.calls[0]![0]! + expect(request.managedCommand).toBe(MANAGED_COMMAND) + expect(request.expectedTrustKeys).toHaveLength(2) + expect(request.invocation.env?.CODEX_HOME).toBe(runtimeHomeDir) + + const ledgerHome = readCodexTrustGrantLedgerHome(runtimeHomeDir) + expect(ledgerHome).not.toBeNull() + expect(Object.keys(ledgerHome!.entries)).toHaveLength(2) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, fellBack: 0 }) + }) + + it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + const first = grantManagedCodexHookTrust(plan) + expect(first.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(1) + + // Why: the ledger skip only holds while config.toml still carries the + // granted hash at the granted key — write it the way codex left it. + upsertHookTrustEntries(plan.tomlPath, [ + { ...entries[0], trustedHash: 'sha256:codex-session_start' } + ]) + const second = grantManagedCodexHookTrust(plan) + expect(second.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(1) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ granted: 1, ledgerHits: 1 }) + + // Config drift (user wiped the trust entry) must re-run the session. + upsertHookTrustEntries(plan.tomlPath, [{ ...entries[0], trustedHash: 'sha256:wiped' }]) + const third = grantManagedCodexHookTrust(plan) + expect(third.lane).toBe('rpc') + expect(runner).toHaveBeenCalledTimes(2) + }) + + it('re-grants when the managed hook identity changes', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + grantManagedCodexHookTrust(plan) + upsertHookTrustEntries(plan.tomlPath, [ + { ...entries[0], trustedHash: 'sha256:codex-session_start' } + ]) + + const changedEntries = [{ ...entries[0], timeoutSec: 99 }] + const changedRunner = vi.fn(() => grantedSessionResult(changedEntries)) + _internals.setGrantSessionRunnerSync(changedRunner) + const outcome = grantManagedCodexHookTrust(buildPlan(changedEntries)) + expect(outcome.lane).toBe('rpc') + expect(changedRunner).toHaveBeenCalledTimes(1) + }) + + it('marks the host unsupported only for the unsupported error class', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => { + throw new CodexAppServerUnsupportedError('no such method') + }) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'unsupported' + }) + expect(runner).toHaveBeenCalledTimes(1) + + // Cached: the second install skips the probe entirely. + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'unsupported-cached' + }) + expect(runner).toHaveBeenCalledTimes(1) + }) + + it('falls back without poisoning the capability on transient errors', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => { + throw new Error('spawn ETIMEDOUT') + }) + _internals.setGrantSessionRunnerSync(runner) + const plan = buildPlan(entries) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(runner).toHaveBeenCalledTimes(2) + }) + + it('falls back on verify-failed without marking unsupported', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' })) + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'verify-failed' + }) + expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) + expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) + }) + + it('honors the ops kill switch env flag', () => { + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + const entries = [managedEntry('session_start')] + const runner = vi.fn(() => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'disabled' + }) + expect(runner).not.toHaveBeenCalled() + }) + + it('builds a WSL invocation that runs codex inside the distro', () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn((_request: CodexHookTrustGrantRequest) => grantedSessionResult(entries)) + _internals.setGrantSessionRunnerSync(runner) + + const outcome = grantManagedCodexHookTrust({ + ...buildPlan(entries), + host: { kind: 'wsl', distro: 'Ubuntu', linuxRuntimeHome: '/home/alice/.codex-runtime' } + }) + expect(outcome.lane).toBe('rpc') + const request = runner.mock.calls[0]![0]! + expect(request.invocation.command).toBe('wsl.exe') + expect(request.invocation.args.slice(0, 2)).toEqual(['-d', 'Ubuntu']) + expect(request.invocation.args.join(' ')).toContain('app-server') + expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime') + }) +}) diff --git a/src/main/codex/codex-trust-grant-ledger.test.ts b/src/main/codex/codex-trust-grant-ledger.test.ts new file mode 100644 index 00000000000..dfb8ecbd4a1 --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + binaryStampsMatch, + getCodexTrustGrantLedgerPath, + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-trust-ledger-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataDir, { recursive: true, force: true }) +}) + +describe('codex trust grant ledger', () => { + it('round-trips per-home grant records and isolates homes', () => { + const hostHome = join(userDataDir, 'codex-runtime-home', 'home') + const wslHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\runtime-home' + writeCodexTrustGrantLedgerHome(hostHome, { + binary: { kind: 'native', path: '/usr/local/bin/codex', size: 10, mtimeMs: 20 }, + entries: { 'k1:session_start:0:0': { signature: 'sig-1', trustedHash: 'sha256:a' } } + }) + writeCodexTrustGrantLedgerHome(wslHome, { + binary: { kind: 'wsl', distro: 'Ubuntu' }, + entries: { + '/home/alice/hooks.json:stop:0:0': { signature: 'sig-2', trustedHash: 'sha256:b' } + } + }) + + expect(readCodexTrustGrantLedgerHome(hostHome)?.entries['k1:session_start:0:0']).toEqual({ + signature: 'sig-1', + trustedHash: 'sha256:a' + }) + expect(readCodexTrustGrantLedgerHome(wslHome)?.binary).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + + removeCodexTrustGrantLedgerHome(hostHome) + expect(readCodexTrustGrantLedgerHome(hostHome)).toBeNull() + expect(readCodexTrustGrantLedgerHome(wslHome)).not.toBeNull() + }) + + it('treats Windows path-case variants as the same home', () => { + const home = 'C:\\Users\\Alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + writeCodexTrustGrantLedgerHome(home, { binary: null, entries: {} }) + expect( + readCodexTrustGrantLedgerHome('c:/users/alice/appdata/roaming/orca/codex-runtime-home/home') + ).not.toBeNull() + }) + + it('tolerates a corrupt ledger file', () => { + const home = join(userDataDir, 'codex-runtime-home', 'home') + writeFileSync(getCodexTrustGrantLedgerPath(), 'not-json{{{') + expect(readCodexTrustGrantLedgerHome(home)).toBeNull() + // Why: a corrupt file must not block recording the next verified grant. + writeCodexTrustGrantLedgerHome(home, { binary: null, entries: {} }) + expect(readCodexTrustGrantLedgerHome(home)).not.toBeNull() + }) + + it('matches binary stamps only on identical identity', () => { + const stamp = { kind: 'native' as const, path: '/bin/codex', size: 1, mtimeMs: 2 } + expect(binaryStampsMatch(stamp, { ...stamp })).toBe(true) + expect(binaryStampsMatch(stamp, { ...stamp, mtimeMs: 3 })).toBe(false) + expect(binaryStampsMatch(stamp, { ...stamp, size: 9 })).toBe(false) + expect(binaryStampsMatch(stamp, { ...stamp, path: '/other/codex' })).toBe(false) + expect(binaryStampsMatch(stamp, null)).toBe(false) + expect(binaryStampsMatch(null, null)).toBe(true) + expect( + binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Ubuntu' }) + ).toBe(true) + expect( + binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Debian' }) + ).toBe(false) + expect(binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, stamp)).toBe(false) + }) +}) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index d6b06f85189..73c8edd8b80 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -48,7 +48,9 @@ function createTestPlan(): CodexWslRuntimeHookInstallPlan { tomlPath: join(root, 'config.toml'), scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxHome}/hooks.json` + trustConfigPath: `${linuxHome}/hooks.json`, + wslDistro: 'Ubuntu', + linuxRuntimeHome: linuxHome } } @@ -83,7 +85,9 @@ describe('Codex WSL runtime hook install', () => { scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', - trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json', + wslDistro: 'Ubuntu', + linuxRuntimeHome: '/home/alice/.local/share/orca/codex-runtime-home/home' }) }) @@ -102,7 +106,9 @@ describe('Codex WSL runtime hook install', () => { scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), commandScriptPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', - trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json', + wslDistro: 'Ubuntu', + linuxRuntimeHome: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home' }) }) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 526a64d99ab..afd1b7ea4c3 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -17,7 +17,17 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils' -import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust' +import { + computeTrustedHash, + upsertHookTrustEntries, + upsertHookTrustEntriesInContent, + parseTrustKey, + type CodexTrustEntry +} from './config-toml-trust' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { _internals as trustGrantInternals } from './codex-hook-trust-grant' +import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -1454,3 +1464,189 @@ describe('CodexHookService', () => { expect(trustConfig).not.toContain('model = "runtime-model"') }) }) + +describe('CodexHookService app-server trust grant lane', () => { + afterEach(() => { + trustGrantInternals.setGrantSessionRunnerSync(null) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + }) + + // Simulates codex's side of a successful grant: write trusted_hash blocks + // through codex's shape (trusted_hash only, no enabled line) and report the + // entries trusted, exactly like the real app-server session result. + function installCodexLikeGrantRunner(): { + runner: ReturnType + codexHash: (key: string) => string + } { + const codexHash = (key: string): string => + `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + expect(codexHome).toBeTruthy() + const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { + const parsed = parseTrustKey(key)! + return { + sourcePath: parsed.sourcePath, + eventLabel: parsed.eventLabel, + groupIndex: parsed.groupIndex, + handlerIndex: parsed.handlerIndex, + command: request.managedCommand, + trustedHash: codexHash(key) + } + }) + upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: key, + trustedHash: codexHash(key) + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + return { runner, codexHash } + } + + it('grants managed trust through codex and treats the codex hash as authoritative', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + const { runner } = installCodexLikeGrantRunner() + + const status = new CodexHookService().install() + + // Why: the codex-written hash intentionally differs from + // computeTrustedHash — a drifted replica must no longer read as + // partial/stale (that misreport was the #7896/#7110/#8699 bug class). + expect(status.state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('sha256:codex-session_start') + expect(trustConfig).toContain('sha256:codex-stop') + + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') + const command = wrapPosixHookCommand(scriptPath) + const selfComputedHash = computeTrustedHash({ + sourcePath: join(managedCodexHome, 'hooks.json'), + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: 10 + }) + expect(trustConfig).not.toContain(selfComputedHash) + + const ledgerHome = readCodexTrustGrantLedgerHome(managedCodexHome) + expect(ledgerHome).not.toBeNull() + expect(Object.keys(ledgerHome!.entries)).toHaveLength(6) + }) + + it('keeps config byte-stable and skips the session on a repeat install (ledger hit)', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + const { runner } = installCodexLikeGrantRunner() + const service = new CodexHookService() + + expect(service.install().state).toBe('installed') + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const tomlAfterFirst = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + const hooksAfterFirst = readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') + + const secondStatus = service.install() + + expect(secondStatus.state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toBe(tomlAfterFirst) + expect(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')).toBe(hooksAfterFirst) + }) + + it('upgrades a home carrying self-computed trust entries in place without duplicates', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + const service = new CodexHookService() + + // Old-Orca state: fallback lane writes the self-computed hashes. + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const legacyToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(legacyToml).toContain('trusted_hash = "sha256:') + + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + installCodexLikeGrantRunner() + expect(service.install().state).toBe('installed') + + const upgradedToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(upgradedToml).toContain('sha256:codex-session_start') + for (const eventLabel of [ + 'session_start', + 'user_prompt_submit', + 'pre_tool_use', + 'permission_request', + 'post_tool_use', + 'stop' + ]) { + const headerCount = upgradedToml + .split('\n') + .filter( + (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) + ).length + expect(headerCount, `duplicate trust tables for ${eventLabel}`).toBe(1) + } + }) + + it('leaves user hook trust byte-untouched while granting managed entries', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + // Why: only the block itself is asserted — comments around runtime-owned + // sections are the config mirror's pre-existing concern, and codex-writer + // comment preservation is proven against the real binary instead. + const userBlock = [ + '[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]', + 'enabled = false', + 'trusted_hash = "sha256:user-owned-hash"' + ].join('\n') + writeFileSync(join(managedCodexHome, 'config.toml'), `${userBlock}\n`, 'utf-8') + + installCodexLikeGrantRunner() + expect(new CodexHookService().install().state).toBe('installed') + + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain(userBlock) + expect(trustConfig).toContain('sha256:codex-session_start') + }) + + it('routes the forced-fallback lane through the unchanged self-computed writes', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + const runner = vi.fn() + trustGrantInternals.setGrantSessionRunnerSync(runner) + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + expect(runner).not.toHaveBeenCalled() + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') + const command = wrapPosixHookCommand(scriptPath) + const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain( + computeTrustedHash({ + sourcePath: join(managedCodexHome, 'hooks.json'), + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: 10 + }) + ) + }) +}) From e800e947a5cb610142235d58273e9786b321e813 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:41:28 -0700 Subject: [PATCH 09/45] test(codex): cover commit-message real-home override strip/preserve Adds the two cases for the headless commit-message Codex env under real-home routing: a nested-Orca-inherited Orca-owned CODEX_HOME is stripped, and a user-owned CODEX_HOME is preserved. --- .../commit-message-agent-environment.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index b4b24059f1b..c34af95c3fc 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -124,6 +124,34 @@ describe('prepareLocalCommitMessageAgentEnv', () => { }) }) + it('strips a nested-Orca CODEX_HOME override when the launch resolves to the real home', async () => { + process.env.CODEX_HOME = '/managed/runtime/home' + process.env.ORCA_CODEX_HOME = '/managed/runtime/home' + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => null + }) + + expect(result.ok).toBe(true) + const env = (result as { ok: true; env?: NodeJS.ProcessEnv }).env + expect(env).toBeDefined() + expect(env?.CODEX_HOME).toBeUndefined() + expect(env?.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('preserves a user-owned CODEX_HOME when the launch resolves to the real home', async () => { + process.env.CODEX_HOME = '/home/me/.config/codex' + delete process.env.ORCA_CODEX_HOME + + const result = await prepareLocalCommitMessageAgentEnv('codex', { + prepareForCodexLaunch: () => null + }) + + expect(result.ok).toBe(true) + const env = (result as { ok: true; env?: NodeJS.ProcessEnv }).env + expect(env?.CODEX_HOME).toBe('/home/me/.config/codex') + }) + it('does not pass WSL managed Codex homes to host-local commit generation', async () => { process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex' From c9837fe002ecdc4ed0c79dc62b6a3064af5b2c66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:44:28 -0700 Subject: [PATCH 10/45] =?UTF-8?q?test(codex):=20WSL=20grant-lane=20coverag?= =?UTF-8?q?e=20=E2=80=94=20in-distro=20invocation=20and=20fallback=20parit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codex/hook-service-wsl-runtime.test.ts | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 73c8edd8b80..ce34c048f03 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' @@ -8,7 +8,9 @@ import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { computeTrustKey, computeTrustedHash, + normalizeHookTrustKeyForLookup, readHookTrustEntries, + upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' import { @@ -16,6 +18,9 @@ import { createCodexWslRuntimeHookInstallPlan, type CodexWslRuntimeHookInstallPlan } from './hook-service' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { _internals as trustGrantInternals } from './codex-hook-trust-grant' type HooksConfig = { hooks: Record @@ -414,3 +419,100 @@ describe('Codex WSL runtime hook install', () => { }) }) }) + +describe('Codex WSL runtime hook install app-server grant lane', () => { + let userDataDir: string + let previousUserDataPath: string | undefined + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-wsl-grant-userdata-')) + tempRoots.push(userDataDir) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + codexAppServerCapabilityCache.clear() + }) + + afterEach(() => { + trustGrantInternals.setGrantSessionRunnerSync(null) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + }) + + it('grants WSL managed trust through codex inside the distro instead of self-computed writes', () => { + const plan = createTestPlan() + writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') + writeFileSync(plan.tomlPath, '', 'utf-8') + + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + // Simulate codex's side: write trusted_hash blocks the way its config + // writer would, then report the entries trusted. + upsertHookTrustEntries( + plan.tomlPath, + request.expectedTrustKeys.map((key) => ({ + sourcePath: plan.trustConfigPath, + eventLabel: key.split(':').at(-3) as CodexTrustEntry['eventLabel'], + groupIndex: 0, + handlerIndex: 0, + command: request.managedCommand, + trustedHash: `sha256:codex-${key.split(':').at(-3)}` + })) + ) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `sha256:codex-${key.split(':').at(-3)}` + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + expect(runner).toHaveBeenCalledTimes(1) + const request = runner.mock.calls[0]![0]! + expect(request.invocation.command).toBe('wsl.exe') + expect(request.invocation.args.slice(0, 2)).toEqual(['-d', 'Ubuntu']) + expect(request.hooksListCwd).toBe(plan.linuxRuntimeHome) + + const command = expectedManagedCommand(plan.commandScriptPath) + const managedTrustEntry = getManagedTrustEntry(plan, command) + const trustEntries = readHookTrustEntries(plan.tomlPath) + // Why: the codex-granted hash must be authoritative — the self-computed + // hash must not overwrite it after a successful grant. + expect(trustEntries.get(computeTrustKey(managedTrustEntry))?.trustedHash).toBe( + 'sha256:codex-user_prompt_submit' + ) + expect(trustEntries.get(computeTrustKey(managedTrustEntry))?.trustedHash).not.toBe( + computeTrustedHash(managedTrustEntry) + ) + }) + + it('keeps the unchanged self-computed lane when the WSL grant falls back', () => { + const plan = createTestPlan() + writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8') + writeFileSync(plan.tomlPath, '', 'utf-8') + + const runner = vi.fn(() => { + throw new Error('wsl.exe not reachable') + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + expect(runner).toHaveBeenCalledTimes(1) + const command = expectedManagedCommand(plan.commandScriptPath) + const managedTrustEntry = getManagedTrustEntry(plan, command) + expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(managedTrustEntry))).toEqual({ + enabled: true, + trustedHash: computeTrustedHash(managedTrustEntry) + }) + }) +}) From c3f37e5500df7926449255fd2bffe8dbb38b94af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:54:37 -0700 Subject: [PATCH 11/45] feat(codex): real-home hook installer trusted via the codex app-server grant client With the real-home flag ON and the system-default selection, install Orca's status hook into the user's real ~/.codex before any pane spawns: - entry APPENDED LAST per managed event: codex hook trust keys are positional (source:event:group:handler), so appending keeps every user entry's position and trust record intact; user entries and unknown top-level hooks.json fields are preserved verbatim - trust is granted exclusively through the codex app-server client (hooks/list + config/batchWrite, verified by re-list); Orca never writes [hooks.state] into the user's real config.toml itself - if the grant lane is unavailable (old binary, unsupported RPC, verify failure), the appended entry is rolled back byte-exactly and the host keeps the managed-home lane end to end (PTY env, rate limits, commit messages) via a lane gate on the runtime-home service - one-time pristine backup of the user's hooks.json under Orca's userData; a rolling .bak sits next to the file (existing atomic writer) - hook opt-out sweeps Orca entries from the real home and drops Orca-owned trust records; flag-off downgrade re-arms the existing legacy system-home sweep, which removes the entry and its trust keys cleanly - the legacy system-home sweep is suppressed only while the real-home lane owns ~/.codex/hooks.json, so managed installs cannot delete the entry --- .../codex-accounts/runtime-home-service.ts | 15 +- .../codex-real-home-hook-install.test.ts | 208 +++++++++++++ .../codex/codex-real-home-hook-install.ts | 275 ++++++++++++++++++ src/main/codex/hook-service.ts | 35 +++ src/main/index.ts | 46 ++- 5 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 src/main/codex/codex-real-home-hook-install.test.ts create mode 100644 src/main/codex/codex-real-home-hook-install.ts diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 52853883379..c9990fcf9b0 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -200,10 +200,19 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + private realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + // Why: real-home routing applies only to the host system-default selection // (no managed account chosen for host) with the staged flag ON. Managed host // accounts keep the isolated runtime home for hot-swap and token persistence. - isHostSystemDefaultRealHome(): boolean { + isHostSystemDefaultRealHomeSelected(): boolean { const settings = this.store.getSettings() return ( isCodexSystemDefaultRealHomeEnabled(settings) && @@ -211,6 +220,10 @@ export class CodexRuntimeHomeService { ) } + isHostSystemDefaultRealHome(): boolean { + return this.isHostSystemDefaultRealHomeSelected() && this.realHomeLaneGate() + } + syncActiveWslSelectionsBeforeRestart(): void { if (process.platform !== 'win32') { return diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts new file mode 100644 index 00000000000..f6f1f3a054d --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' + +const { homedirMock, grantMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + grantMock: vi.fn() +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +vi.mock('./codex-hook-trust-grant', () => ({ + grantManagedCodexHookTrust: grantMock +})) + +import { + ensureRealHomeCodexHookState, + getRealHomeCodexHookLane, + _internals +} from './codex-real-home-hook-install' +import { getCodexManagedHookInstallMaterial } from './hook-service' + +let fakeHomeDir: string +let userDataDir: string +let previousUserDataPath: string | undefined + +function getRealHooksJsonPath(): string { + return join(fakeHomeDir, '.codex', 'hooks.json') +} + +function readRealHooksJson(): { + hooks?: Record + [key: string]: unknown +} { + return JSON.parse(readFileSync(getRealHooksJsonPath(), 'utf-8')) +} + +function grantSucceeds(): void { + grantMock.mockImplementation((plan: CodexManagedTrustGrantPlan) => ({ + lane: 'rpc', + entries: plan.managedEntries.map((entry) => ({ ...entry, trustedHash: 'codex-hash' })) + })) +} + +function grantUnavailable(): void { + grantMock.mockReturnValue({ lane: 'fallback', reason: 'unsupported' }) +} + +beforeEach(() => { + grantMock.mockReset() + fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-real-home-hooks-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-real-home-hooks-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) + _internals.setLaneForTesting('pending') +}) + +afterEach(() => { + rmSync(fakeHomeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('ensureRealHomeCodexHookState (install)', () => { + it('creates hooks.json with the Orca entry in every managed event for a fresh home', () => { + grantSucceeds() + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + const material = getCodexManagedHookInstallMaterial() + const config = readRealHooksJson() + for (const eventName of material.events) { + const definitions = config.hooks?.[eventName] + expect(definitions).toHaveLength(1) + expect(definitions?.[0]?.hooks?.[0]?.command).toBe(material.command) + } + // The grant plan targeted the real home with append-position trust keys. + const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan + expect(plan.runtimeHomePath).toBe(join(fakeHomeDir, '.codex')) + expect(plan.host).toEqual({ kind: 'native' }) + expect(plan.managedEntries.every((entry) => entry.groupIndex === 0)).toBe(true) + }) + + it('appends LAST and preserves user entries, unknown fields, and trust positions', () => { + grantSucceeds() + const userConfig = { + hooks: { + Stop: [{ matcher: 'deploy-*', hooks: [{ type: 'command', command: 'my-stop-hook.sh' }] }], + PreCompact: [{ hooks: [{ type: 'command', command: 'my-compact-hook.sh' }] }] + }, + _pluginManagerMetadata: { owner: 'someone-else' } + } + writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(userConfig, null, 2)}\n`, 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + const config = readRealHooksJson() + // User's Stop entry keeps position 0; Orca's entry is appended after it. + expect(config.hooks?.Stop).toHaveLength(2) + expect(config.hooks?.Stop?.[0]).toEqual(userConfig.hooks.Stop[0]) + // Non-managed events Orca does not subscribe to stay untouched. + expect(config.hooks?.PreCompact).toEqual(userConfig.hooks.PreCompact) + // Unknown top-level metadata survives, unlike the managed-home writer. + expect(config._pluginManagerMetadata).toEqual(userConfig._pluginManagerMetadata) + // The appended entry's trust key uses its appended position. + const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan + const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop') + expect(stopEntry?.groupIndex).toBe(1) + // Pristine pre-Orca backup lands under userData, not in ~/.codex. + expect( + readFileSync(join(userDataDir, 'codex-real-home-hooks', 'hooks.json.pre-orca'), 'utf-8') + ).toBe(`${JSON.stringify(userConfig, null, 2)}\n`) + }) + + it('rolls the file back byte-exactly when the grant lane is unavailable', () => { + grantUnavailable() + const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n` + writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(getRealHomeCodexHookLane()).toBe('unavailable') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw) + }) + + it('removes a freshly created hooks.json when the grant lane is unavailable', () => { + grantUnavailable() + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(existsSync(getRealHooksJsonPath())).toBe(false) + }) + + it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => { + writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('unavailable') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{not json') + expect(grantMock).not.toHaveBeenCalled() + }) + + it('is idempotent: a second ensure keeps a single appended entry per event', () => { + grantSucceeds() + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8') + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + + expect(lane).toBe('installed') + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw) + }) +}) + +describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { + it('removes only Orca entries and reports the removed lane', () => { + grantSucceeds() + const userStop = { + matcher: 'deploy-*', + hooks: [{ type: 'command', command: 'my-stop-hook.sh' }] + } + writeFileSync( + getRealHooksJsonPath(), + `${JSON.stringify({ hooks: { Stop: [userStop] } }, null, 2)}\n`, + 'utf-8' + ) + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + expect(readRealHooksJson().hooks?.Stop).toHaveLength(2) + + const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + + expect(lane).toBe('removed') + const config = readRealHooksJson() + expect(config.hooks?.Stop).toEqual([userStop]) + const material = getCodexManagedHookInstallMaterial() + for (const eventName of material.events) { + if (eventName === 'Stop') { + continue + } + expect(config.hooks?.[eventName]).toBeUndefined() + } + }) + + it('no-ops the sweep when the real home has no hooks.json', () => { + const lane = ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + + expect(lane).toBe('removed') + expect(existsSync(getRealHooksJsonPath())).toBe(false) + }) +}) diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts new file mode 100644 index 00000000000..9dbec577037 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -0,0 +1,275 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + hookDefinitionHasManagedCommand, + MANAGED_HOOK_TIMEOUT_SECONDS, + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' +import { getSystemCodexHomePath } from './codex-home-paths' +import { getCodexManagedScriptFileName } from './codex-hook-identity' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { removeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { getCodexManagedHookInstallMaterial } from './hook-service' +import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' + +/** + * Real-home Codex hook lane for the system-default selection (flag ON). + * + * - 'pending': no attempt yet this process; routing may optimistically use the + * real home (reads are hook-free and the install runs before pane spawns). + * - 'installed': entry appended LAST in ~/.codex/hooks.json and trusted by + * codex itself through the app-server grant client. + * - 'unavailable': the grant lane could not trust the entry (old binary, + * unsupported RPC, verify failure). The entry is rolled back and the host + * stays on the managed-home lane. + * - 'removed': hooks are opted out; Orca entries are swept from the real home. + */ +export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'removed' + +let currentLane: RealHomeCodexHookLane = 'pending' + +export function getRealHomeCodexHookLane(): RealHomeCodexHookLane { + return currentLane +} + +/** + * Routing gate consumed by CodexRuntimeHomeService: the real home is usable + * whenever hooks are opted out (nothing to install) or the grant lane has not + * proven incapable on this host. + */ +export function isRealHomeCodexHookLaneUsable(hooksEnabled: boolean): boolean { + return !hooksEnabled || currentLane !== 'unavailable' +} + +function getRealHomeHooksJsonPath(): string { + return join(getSystemCodexHomePath(), 'hooks.json') +} + +function getRealHomeConfigTomlPath(): string { + return join(getSystemCodexHomePath(), 'config.toml') +} + +/** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */ +function getRealHomeHookStateDir(userDataPath: string): string { + return join(userDataPath, 'codex-real-home-hooks') +} + +/** + * Ensures the real-home hook state matches the settings: installs and trusts + * the Orca status hook when enabled, sweeps it when opted out. Idempotent and + * synchronous (launch prep); repeat calls are cheap — an unchanged hooks.json + * write no-ops and a valid grant ledger skips the RPC session entirely. + * Never throws: any failure logs and leaves the host on the managed lane. + */ +export function ensureRealHomeCodexHookState(args: { + hooksEnabled: boolean + userDataPath: string +}): RealHomeCodexHookLane { + try { + currentLane = args.hooksEnabled + ? installRealHomeCodexHook(args.userDataPath) + : sweepRealHomeCodexHook() + } catch (error) { + console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error) + currentLane = args.hooksEnabled ? 'unavailable' : currentLane + } + return currentLane +} + +function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { + const material = getCodexManagedHookInstallMaterial() + const hooksJsonPath = getRealHomeHooksJsonPath() + const config = readHooksJson(hooksJsonPath) + if (!config) { + // Why: an unparseable user file must never be clobbered; without a hook + // entry the managed lane keeps status working for this host. + console.warn('[codex-real-home-hooks] could not parse', hooksJsonPath, '- managed lane kept') + return 'unavailable' + } + + // Why: the same script the managed lane maintains; deploying here too keeps + // host-connect ordering independent of the managed installer loop. + writeManagedScript(material.scriptPath, material.script) + + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + const nextHooks: Record = { ...config.hooks } + const managedEntries: CodexTrustEntry[] = [] + for (const eventName of material.events) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + // Why: append LAST. Codex trust keys are positional + // (source:event:group:handler); prepending would shift every user entry + // and invalidate the user's own hook trust records. + nextHooks[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(material.command)] }] + managedEntries.push({ + sourcePath: hooksJsonPath, + eventLabel: material.eventLabel[eventName], + groupIndex: cleaned.length, + handlerIndex: 0, + command: material.command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + // Why: sweep stale Orca entries out of events the managed lane no longer + // subscribes to, mirroring the managed installer's upgrade behavior. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if ((material.events as readonly string[]).includes(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const previousRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null + backupRealHomeHooksJsonOnce(userDataPath, hooksJsonPath, previousRaw) + // Why: unknown top-level fields belong to the user (other managers' + // metadata); unlike the managed-home writer, preserve them verbatim. + writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + + const grant = grantManagedCodexHookTrust({ + runtimeHomePath: getSystemCodexHomePath(), + tomlPath: getRealHomeConfigTomlPath(), + managedCommand: material.command, + managedEntries, + host: { kind: 'native' } + }) + if (grant.lane === 'rpc') { + return 'installed' + } + + // Why: never leave an untrusted Orca entry in the user's real home — it + // would surface as "Hooks need review". Roll the file back to its prior + // bytes and keep this host on the managed-home lane; the grant client + // already logged the fallback reason. + restoreRealHomeHooksJson(hooksJsonPath, previousRaw) + console.warn( + `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` + ) + return 'unavailable' +} + +function sweepRealHomeCodexHook(): RealHomeCodexHookLane { + const hooksJsonPath = getRealHomeHooksJsonPath() + const config = readHooksJson(hooksJsonPath) + if (!config?.hooks) { + return 'removed' + } + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + const material = getCodexManagedHookInstallMaterial() + const nextHooks: Record = { ...config.hooks } + const removedTrustKeys: string[] = [] + let removedAny = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + if (!hookDefinitionHasManagedCommand(definition, isManagedCommand)) { + return + } + const eventLabel = material.eventLabel[eventName as (typeof material.events)[number]] + if (!eventLabel) { + return + } + removedTrustKeys.push( + computeTrustKey({ + sourcePath: hooksJsonPath, + eventLabel, + groupIndex, + handlerIndex: 0, + command: material.command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + ) + }) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length !== definitions.length) { + removedAny = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedAny) { + writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; + // dropping them keeps the user's config.toml from accumulating orphans. + // Their removal never shifts user trust keys because Orca appends last. + try { + removeHookTrustEntries(getRealHomeConfigTomlPath(), removedTrustKeys) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error) + } + try { + removeCodexTrustGrantLedgerHome(getSystemCodexHomePath()) + } catch { + // Ledger cleanup is bookkeeping only; the next grant rebuilds it. + } + } + return 'removed' +} + +/** One-time pristine copy of the user's file, kept under Orca's userData. */ +function backupRealHomeHooksJsonOnce( + userDataPath: string, + hooksJsonPath: string, + previousRaw: string | null +): void { + if (previousRaw === null) { + return + } + try { + const backupDir = getRealHomeHookStateDir(userDataPath) + const backupPath = join(backupDir, 'hooks.json.pre-orca') + if (existsSync(backupPath)) { + return + } + mkdirSync(backupDir, { recursive: true }) + copyFileSync(hooksJsonPath, backupPath) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to write pristine backup:', error) + } +} + +function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | null): void { + try { + if (previousRaw === null) { + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath) + } + return + } + const tmpPath = `${hooksJsonPath}.${process.pid}.rollback.tmp` + writeFileSync(tmpPath, previousRaw, 'utf-8') + renameSync(tmpPath, hooksJsonPath) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error) + } +} + +export const _internals = { + setLaneForTesting(lane: RealHomeCodexHookLane): void { + currentLane = lane + } +} diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1011d595ac3..0e183c8d653 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -142,6 +142,38 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } +export type CodexManagedHookInstallMaterial = { + events: readonly (typeof CODEX_EVENTS)[number][] + eventLabel: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> + scriptPath: string + command: string + script: string +} + +// Why: the real-home installer must byte-match the managed lane's events, +// command, and script, or trust signatures diverge between the two homes. +export function getCodexManagedHookInstallMaterial(): CodexManagedHookInstallMaterial { + const scriptPath = getManagedScriptPath() + return { + events: CODEX_EVENTS, + eventLabel: CODEX_EVENT_LABEL, + scriptPath, + command: getManagedCommand(scriptPath), + script: getManagedScript() + } +} + +// Why: when the real-home lane owns ~/.codex/hooks.json (system-default flag ON +// with hooks enabled), the legacy system-home sweep must stand down or every +// managed install would delete the entry the real-home installer just wrote. +// Injected as a gate because this module is bundled into plain-node CLI entries +// that have no settings store; the CLI default keeps the sweep active. +let systemCodexHomeHookSweepSuppressed: () => boolean = () => false + +export function setSystemCodexHomeHookSweepSuppressed(gate: () => boolean): void { + systemCodexHomeHookSweepSuppressed = gate +} + export { createCodexWslRuntimeHookInstallPlan } export type { CodexWslRuntimeHookInstallPlan } @@ -539,6 +571,9 @@ function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefi } function cleanupLegacySystemManagedHooks(): void { + if (systemCodexHomeHookSweepSuppressed()) { + return + } const legacyConfigPath = getSystemConfigPath() const runtimeConfigPath = getConfigPath() if (legacyConfigPath === runtimeConfigPath) { diff --git a/src/main/index.ts b/src/main/index.ts index 50a6b48aed8..2e4317ce8ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -128,7 +128,11 @@ import { type CodexAccountSelectionTarget } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService } from './codex/hook-service' +import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' +import { + ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable +} from './codex/codex-real-home-hook-install' import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' @@ -734,12 +738,20 @@ function startTerminalRuntimeStartupServices(): Promise { } function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { + if (target?.runtime !== 'wsl' && codexRuntimeHome!.isHostSystemDefaultRealHomeSelected()) { + // Why (flag ON, system default): the hook entry must exist — appended last + // and trusted by codex's own app-server grant — in the real ~/.codex before + // the pane spawns. An incapable grant flips the lane gate so the launch + // below falls back to the managed home instead of a status-blind pane. + ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), + userDataPath: app.getPath('userData') + }) + } const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) { - // Why (flag ON, system default): Codex runs on the user's real ~/.codex, so - // the managed-home hook install below would target a home Codex never reads. - // The real-home hook installer (trust granted via the app-server client) - // owns hook install for this lane and lands with the trust plumbing. + // Why: Codex runs on the user's real ~/.codex; the managed-home hook + // install below would target a home Codex never reads on this lane. return null } const hookTarget = @@ -1766,6 +1778,21 @@ app.whenReady().then(async () => { openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) + // Why: an incapable trust-grant host must fall back to the managed home for + // every consumer (PTY env, rate limits, commit messages) in one place. + codexRuntimeHome.setRealHomeLaneGate(() => + isRealHomeCodexHookLaneUsable(isAgentStatusHooksEnabled(store?.getSettings())) + ) + // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy + // system-home sweep inside managed installs would delete the entry the + // real-home installer just appended. Flag OFF or hooks off re-arms the sweep, + // which is also what removes the entry again on downgrade or opt-out. + setSystemCodexHomeHookSweepSuppressed( + () => + codexRuntimeHome !== null && + codexRuntimeHome.isHostSystemDefaultRealHomeSelected() && + isAgentStatusHooksEnabled(store?.getSettings()) + ) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) @@ -1992,6 +2019,15 @@ app.whenReady().then(async () => { removeManagedAgentHooks() } } + if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { + // Why: host-connect seam — install (or sweep, when opted out) the trusted + // real-home status hook before the first pane can spawn, so the very first + // launch already knows whether this host's grant lane is usable. + ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()), + userDataPath: app.getPath('userData') + }) + } app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { From c668ab05bcffbc4768648bd62c2858e067220f37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:26:46 -0700 Subject: [PATCH 12/45] fix(codex): resolve the trust-grant entry without requiring electron The grant bridge is reachable from plain-Node CLI entries, where the plain-node entry guard rejects any chunk containing require("electron"). Resolve the bundled session entry from __dirname (root chunk and chunks/ layouts) with an app.asar -> app.asar.unpacked rewrite for packaged runs, instead of electron's app path APIs. --- .../codex/codex-app-server-grant-bridge.ts | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index ef03183b162..db0aef74235 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -39,40 +39,25 @@ const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js' const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - return ( - (require('electron') as { app?: { getAppPath(): string; isPackaged: boolean } }).app ?? null - ) - } catch { - return null - } -} - export function resolveCodexGrantEntryPath( pathExists: (candidate: string) => boolean = existsSync ): string | null { - const app = loadElectronApp() - let appPath: string | undefined - try { - appPath = app?.getAppPath() - } catch { - appPath = undefined - } - // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // Why: this module is reachable from plain-Node CLI entries, so it must not + // require electron (plain-node-entry-guard). __dirname of the built chunk is + // out/main (root chunk) or out/main/chunks; the entry sits at + // out/main/codex/. ELECTRON_RUN_AS_NODE bypasses asar integration, so the // packaged entry must run from app.asar.unpacked (out/main/codex/** is in - // the asarUnpack list). - const unpackedAppPath = - app?.isPackaged && appPath ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const candidates = [ - // Dev/E2E: electron-vite's appPath is already out/main. - unpackedAppPath ? join(unpackedAppPath, 'codex', GRANT_ENTRY_FILE_NAME) : null, - unpackedAppPath ? join(unpackedAppPath, 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) : null, - // Plain-node CLI context (no electron): resolve relative to this chunk. - join(__dirname, 'codex', GRANT_ENTRY_FILE_NAME), - join(__dirname, '..', 'codex', GRANT_ENTRY_FILE_NAME) - ].filter((candidate): candidate is string => candidate !== null) + // the asarUnpack list) — cover that with a path rewrite instead of app APIs. + const chunkDirs = [__dirname, join(__dirname, '..')] + const candidates = chunkDirs.flatMap((dir) => { + const unpackedDir = dir.includes('app.asar') + ? dir.replace('app.asar', 'app.asar.unpacked') + : null + return [ + ...(unpackedDir ? [join(unpackedDir, 'codex', GRANT_ENTRY_FILE_NAME)] : []), + join(dir, 'codex', GRANT_ENTRY_FILE_NAME) + ] + }) for (const candidate of candidates) { if (pathExists(candidate)) { return candidate From 6e4214a8646d513bcac262911168a7ec4be8be87 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:53:46 -0700 Subject: [PATCH 13/45] fix(codex): keep session backfill off main thread Use asynchronous, sequential filesystem operations for the one-time rollout backfill, and avoid repeated target-directory probes. Treat inaccessible managed session roots as retryable failures instead of writing a false completion marker. --- .../codex/codex-session-backfill-audit.ts | 25 +++ src/main/codex/codex-session-backfill-copy.ts | 41 +++++ src/main/codex/codex-session-backfill.test.ts | 60 +++++-- src/main/codex/codex-session-backfill.ts | 161 +++++++++--------- src/main/codex/codex-session-file-listing.ts | 4 +- 5 files changed, 195 insertions(+), 96 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-audit.ts create mode 100644 src/main/codex/codex-session-backfill-copy.ts diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts new file mode 100644 index 00000000000..0b9f06bf685 --- /dev/null +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -0,0 +1,25 @@ +import { appendFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' + +export type CodexSessionBackfillAuditWriter = (record: Record) => Promise + +export function createCodexSessionBackfillAuditWriter( + auditLogPath: string +): CodexSessionBackfillAuditWriter { + let auditDirectoryReady: Promise | undefined + return async (record): Promise => { + try { + auditDirectoryReady ??= mkdir(dirname(auditLogPath), { recursive: true }) + await auditDirectoryReady + await appendFile( + auditLogPath, + `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, + { encoding: 'utf-8' } + ) + } catch (error) { + // Why: the audit trail is diagnostics; losing a line must not fail the + // backfill or leave a half-linked tree unrecorded in the summary counts. + console.warn('[codex-session-backfill] Failed to append audit record:', error) + } + } +} diff --git a/src/main/codex/codex-session-backfill-copy.ts b/src/main/codex/codex-session-backfill-copy.ts new file mode 100644 index 00000000000..0455eb4b0f0 --- /dev/null +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { copyFile, link, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +export async function copySessionFileWithoutOverwrite( + sourcePath: string, + targetPath: string +): Promise { + const temporaryPath = join(dirname(targetPath), `.orca-backfill-${randomUUID()}.tmp`) + // Why: stage cross-volume copies away from the rollout filename so a failed + // copy cannot strand a truncated session that a later retry would skip. + await writeFile(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) + try { + await copyFile(sourcePath, temporaryPath) + try { + // Why: this same-volume hardlink atomically installs the staged copy + // without risking a collision overwrite after an EXDEV fallback. + await link(temporaryPath, targetPath) + } catch (installLinkError) { + if (isExistsError(installLinkError)) { + throw installLinkError + } + // Some target filesystems do not support hardlinks at all. COPYFILE_EXCL + // preserves the collision contract while retaining the staged snapshot. + await copyFile(temporaryPath, targetPath, constants.COPYFILE_EXCL) + } + } finally { + try { + await rm(temporaryPath, { force: true }) + } catch (error) { + // Why: cleanup trouble must not misreport a successfully installed + // rollout as a copy failure; the .tmp file is ignored by Codex. + console.warn('[codex-session-backfill] Failed to remove staged copy:', temporaryPath, error) + } + } +} + +function isExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 51513719582..f62296f678a 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -24,7 +24,8 @@ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, failCopy: false, - failDirectoryPath: null as string | null + failDirectoryPath: null as string | null, + failLstatPath: null as string | null } })) @@ -32,32 +33,46 @@ vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, - linkSync: (...args: Parameters) => { + existsSync: (...args: Parameters) => { + if (args[0] === fsMockState.failLstatPath) { + return false + } + return actual.existsSync(...args) + } + } +}) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + lstat: (...args: Parameters) => { + if (args[0] === fsMockState.failLstatPath) { + const error = new Error('EACCES: path inaccessible') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.lstat(...args) + }, + link: (...args: Parameters) => { if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) { const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException error.code = 'EXDEV' throw error } - return actual.linkSync(...args) + return actual.link(...args) }, - copyFileSync: (...args: Parameters) => { + copyFile: async (...args: Parameters) => { if (fsMockState.failCopy) { // Simulate a copy that fails after opening its destination, which is // the dangerous case for resumability rather than a preflight error. - actual.writeFileSync(args[1], 'partial copy\n', 'utf-8') + await actual.writeFile(args[1], 'partial copy\n', 'utf-8') const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException error.code = 'EACCES' throw error } - return actual.copyFileSync(...args) - } - } -}) - -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, + return actual.copyFile(...args) + }, opendir: (...args: Parameters) => { if (args[0] === fsMockState.failDirectoryPath) { const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException @@ -121,6 +136,7 @@ beforeEach(() => { fsMockState.failLink = false fsMockState.failCopy = false fsMockState.failDirectoryPath = null + fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -357,6 +373,22 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) }) + it('leaves the marker unset when the managed sessions root is inaccessible', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + fsMockState.failLstatPath = getManagedSessionsRoot() + + const first = await startCodexSessionBackfillInBackground() + + expect(first).toMatchObject({ scannedFiles: 0, failedDirectories: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + expect(readAuditActions()).toContain('scan-failed') + + fsMockState.failLstatPath = null + const second = await startCodexSessionBackfillInBackground() + expect(second).toMatchObject({ linkedFiles: 1, failedDirectories: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + it('honors a custom system Codex home override', async () => { const customHome = join(fakeHomeDir, 'custom-codex-home') writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 0a2080c9dd8..d7489126dd1 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -1,16 +1,5 @@ -import { - appendFileSync, - constants, - copyFileSync, - existsSync, - linkSync, - lstatSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { randomUUID } from 'node:crypto' +import { mkdirSync, readFileSync } from 'node:fs' +import { link, lstat, mkdir } from 'node:fs/promises' import { dirname, join, relative, sep } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -18,6 +7,11 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + createCodexSessionBackfillAuditWriter, + type CodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import { copySessionFileWithoutOverwrite } from './codex-session-backfill-copy' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' @@ -131,15 +125,22 @@ export async function backfillManagedCodexSessionsIntoSystemHome( failedDirectories: 0, failedFiles: 0 } - if (existsSync(paths.managedSessionsRoot)) { + const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath) + const ensuredTargetDirectories = new Set() + const managedSessionsRootExists = await checkManagedSessionsRoot( + paths, + summary, + appendAuditRecord + ) + if (managedSessionsRootExists) { for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( paths.managedSessionsRoot, options, - (directoryPath, error) => { + async (directoryPath, error) => { // Why: a partial walk must remain retryable; otherwise an unreadable // date directory would be silently omitted behind a completion marker. summary.failedDirectories += 1 - appendAuditRecord(paths.auditLogPath, { + await appendAuditRecord({ action: 'scan-failed', source: directoryPath, error: describeError(error) @@ -151,13 +152,45 @@ export async function backfillManagedCodexSessionsIntoSystemHome( summary.skippedUnexpectedFiles += 1 continue } - backfillOneManagedSessionFile(paths, managedSessionFilePath, summary) + // Why: sequential async mutations bound disk pressure while keeping the + // Electron main thread available for UI and PTY work. + await backfillOneManagedSessionFile( + paths, + managedSessionFilePath, + summary, + appendAuditRecord, + ensuredTargetDirectories + ) } } - appendAuditRecord(paths.auditLogPath, { action: 'run-summary', ...summary }) + await appendAuditRecord({ action: 'run-summary', ...summary }) return summary } +async function checkManagedSessionsRoot( + paths: CodexSessionBackfillPaths, + summary: CodexSessionBackfillSummary, + appendAuditRecord: CodexSessionBackfillAuditWriter +): Promise { + try { + await lstat(paths.managedSessionsRoot) + return true + } catch (error) { + if (isNotFoundError(error)) { + return false + } + // Why: existsSync collapses access failures into "missing," which could + // permanently hide sessions behind an incorrect completion marker. + summary.failedDirectories += 1 + await appendAuditRecord({ + action: 'scan-failed', + source: paths.managedSessionsRoot, + error: describeError(error) + }) + return false + } +} + function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { const pathParts = relative(sessionsRoot, filePath).split(sep) if (pathParts.length !== 4) { @@ -172,12 +205,14 @@ function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { ) } -function backfillOneManagedSessionFile( +async function backfillOneManagedSessionFile( paths: CodexSessionBackfillPaths, managedSessionFilePath: string, - summary: CodexSessionBackfillSummary -): void { - if (isSymbolicLink(managedSessionFilePath)) { + summary: CodexSessionBackfillSummary, + appendAuditRecord: CodexSessionBackfillAuditWriter, + ensuredTargetDirectories: Set +): Promise { + if (await isSymbolicLink(managedSessionFilePath)) { // Why: bridge-created symlinks already point at a file in the user's own // home; materializing them here could duplicate a foreign tree. summary.skippedSymlinkFiles += 1 @@ -185,16 +220,22 @@ function backfillOneManagedSessionFile( } const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) - if (pathEntryExists(systemSessionFilePath)) { + if (await pathEntryExists(systemSessionFilePath)) { summary.skippedExistingFiles += 1 return } try { - mkdirSync(dirname(systemSessionFilePath), { recursive: true }) - linkSync(managedSessionFilePath, systemSessionFilePath) + const targetDirectory = dirname(systemSessionFilePath) + if (!ensuredTargetDirectories.has(targetDirectory)) { + // Why: one date directory can contain thousands of rollouts; avoid a + // redundant filesystem round trip before every hardlink. + await mkdir(targetDirectory, { recursive: true }) + ensuredTargetDirectories.add(targetDirectory) + } + await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - appendAuditRecord(paths.auditLogPath, { + await appendAuditRecord({ action: 'hardlink', source: managedSessionFilePath, target: systemSessionFilePath @@ -204,12 +245,15 @@ function backfillOneManagedSessionFile( summary.skippedExistingFiles += 1 return } + if (isNotFoundError(linkError)) { + ensuredTargetDirectories.delete(dirname(systemSessionFilePath)) + } try { // Why: cross-volume copies are staged so failures cannot strand a // truncated rollout, then installed without overwriting collisions. - copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) + await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) summary.copiedFiles += 1 - appendAuditRecord(paths.auditLogPath, { + await appendAuditRecord({ action: 'copy', source: managedSessionFilePath, target: systemSessionFilePath @@ -220,7 +264,7 @@ function backfillOneManagedSessionFile( return } summary.failedFiles += 1 - appendAuditRecord(paths.auditLogPath, { + await appendAuditRecord({ action: 'failed', source: managedSessionFilePath, target: systemSessionFilePath, @@ -231,48 +275,18 @@ function backfillOneManagedSessionFile( } } -function copySessionFileWithoutOverwrite(sourcePath: string, targetPath: string): void { - const temporaryPath = join(dirname(targetPath), `.orca-backfill-${randomUUID()}.tmp`) - // Why: stage cross-volume copies away from the rollout filename so a failed - // copy cannot strand a truncated session that a later retry would skip. - writeFileSync(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) +async function isSymbolicLink(filePath: string): Promise { try { - copyFileSync(sourcePath, temporaryPath) - try { - // Why: this same-volume hardlink atomically installs the staged copy - // without risking a collision overwrite after an EXDEV fallback. - linkSync(temporaryPath, targetPath) - } catch (installLinkError) { - if (isExistsError(installLinkError)) { - throw installLinkError - } - // Some target filesystems do not support hardlinks at all. COPYFILE_EXCL - // preserves the collision contract while retaining the staged snapshot. - copyFileSync(temporaryPath, targetPath, constants.COPYFILE_EXCL) - } - } finally { - try { - rmSync(temporaryPath, { force: true }) - } catch (error) { - // Why: cleanup trouble must not misreport a successfully installed - // rollout as a copy failure; the .tmp file is ignored by Codex. - console.warn('[codex-session-backfill] Failed to remove staged copy:', temporaryPath, error) - } - } -} - -function isSymbolicLink(filePath: string): boolean { - try { - return lstatSync(filePath).isSymbolicLink() + return (await lstat(filePath)).isSymbolicLink() } catch { return false } } /** Existence via lstat so a broken symlink at the target still counts as taken. */ -function pathEntryExists(entryPath: string): boolean { +async function pathEntryExists(entryPath: string): Promise { try { - lstatSync(entryPath) + await lstat(entryPath) return true } catch { return false @@ -283,25 +297,12 @@ function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error) +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -function appendAuditRecord(auditLogPath: string, record: Record): void { - try { - mkdirSync(dirname(auditLogPath), { recursive: true }) - appendFileSync( - auditLogPath, - `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, - { - encoding: 'utf-8' - } - ) - } catch (error) { - // Why: the audit trail is diagnostics; losing a line must not fail the - // backfill or leave a half-linked tree unrecorded in the summary counts. - console.warn('[codex-session-backfill] Failed to append audit record:', error) - } +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) } function hasCompletedBackfillMarker(markerPath: string, systemSessionsRoot: string): boolean { diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index 92676b38052..6532f6ee5c9 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -57,7 +57,7 @@ function appendSessionFilePaths(target: string[], source: readonly string[]): vo export async function* listCodexSessionJsonlFilesIncrementally( rootPath: string, options: CodexSessionBridgeIncrementalOptions, - onDirectoryError?: (directoryPath: string, error: unknown) => void + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) @@ -85,7 +85,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( } } } catch (error) { - onDirectoryError?.(currentDirectory, error) + await onDirectoryError?.(currentDirectory, error) console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } } From 5c68a50385d7cca6f9ab6b0024d671fa3a5f73b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:02:05 -0700 Subject: [PATCH 14/45] fix(codex): harden app-server trust grant fallback --- config/tsconfig.cli.json | 9 +++ .../codex/codex-app-server-client.test.ts | 12 +++- src/main/codex/codex-app-server-client.ts | 13 +++- .../codex/codex-app-server-grant-bridge.ts | 59 ++++--------------- .../codex/codex-app-server-grant-entry.ts | 14 +++-- .../codex/codex-app-server-grant-envelope.ts | 22 +++++++ src/main/codex/codex-hook-trust-grant.test.ts | 57 ++++++++++++++++-- src/main/codex/codex-hook-trust-grant.ts | 40 +++++++++++++ src/main/codex/codex-trust-config-rollback.ts | 32 ++++++++++ .../codex/hook-service-wsl-runtime.test.ts | 1 + src/main/codex/hook-service.test.ts | 34 +++++++++++ src/main/codex/hook-service.ts | 11 ++-- src/shared/telemetry-events.ts | 1 + 13 files changed, 238 insertions(+), 67 deletions(-) create mode 100644 src/main/codex/codex-app-server-grant-envelope.ts create mode 100644 src/main/codex/codex-trust-config-rollback.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a8e5dcad27b..67b0c33fda8 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -15,6 +15,13 @@ "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-app-server-capability-cache.ts", + "../src/main/codex/codex-app-server-client.ts", + "../src/main/codex/codex-app-server-grant-bridge.ts", + "../src/main/codex/codex-app-server-grant-envelope.ts", + "../src/main/codex/codex-hook-trust-grant.ts", + "../src/main/codex/codex-trust-config-rollback.ts", + "../src/main/codex/codex-trust-grant-ledger.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", @@ -22,6 +29,8 @@ "../src/main/codex/hook-service.ts", "../src/main/codex/hook-trust-promotion.ts", "../src/main/codex-accounts/fs-utils.ts", + "../src/main/codex-accounts/wsl-codex-command.ts", + "../src/main/codex-cli/command.ts", "../src/main/command-code/command-code-managed-script.ts", "../src/main/command-code/hook-service.ts", "../src/main/copilot/hook-service.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 3db80904f54..f59f888568a 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -16,6 +16,7 @@ import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-brid // via STUB_CONFIG so each test controls listings, errors, and hangs. const STUB_SERVER_SOURCE = ` const config = JSON.parse(process.env.STUB_CONFIG) +require('node:fs').writeFileSync(config.pidFile, String(process.pid)) const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key)) let buffer = '' function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') } @@ -93,14 +94,16 @@ function createStubRequest(options: { expectedTrustKeys: string[] managedCommand: string timeoutMs?: number -}): { request: CodexHookTrustGrantRequest; recordFile: string } { +}): { request: CodexHookTrustGrantRequest; recordFile: string; pidFile: string } { const root = mkdtempSync(join(tmpdir(), 'orca-codex-stub-')) tempRoots.push(root) const stubPath = join(root, 'stub-app-server.cjs') writeFileSync(stubPath, STUB_SERVER_SOURCE) const recordFile = join(root, 'batch-write-params.json') + const pidFile = join(root, 'app-server.pid') return { recordFile, + pidFile, request: { invocation: { command: process.execPath, @@ -110,7 +113,8 @@ function createStubRequest(options: { scenario: options.scenario, hooks: options.hooks, cwd: root, - recordFile + recordFile, + pidFile }) }, timeoutMs: options.timeoutMs ?? 10_000 @@ -234,7 +238,7 @@ describe('runCodexHookTrustGrantSession', () => { it('kills a hung server at the session deadline', async () => { const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] - const { request } = createStubRequest({ + const { request, pidFile } = createStubRequest({ scenario: 'hang', hooks: keys.map((key) => managedHook(key)), expectedTrustKeys: keys, @@ -249,6 +253,8 @@ describe('runCodexHookTrustGrantSession', () => { // Why: the reap path must not stack the grace periods on top of the // deadline — a wedged server may ignore everything but SIGKILL. expect(Date.now() - startedAt).toBeLessThan(5_000) + const childPid = Number(readFileSync(pidFile, 'utf8')) + expect(() => process.kill(childPid, 0)).toThrow() }) it('surfaces spawn failures as regular errors, not capability signals', async () => { diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index bb1fccf48c7..e3aa69117bc 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -87,6 +87,7 @@ type CodexHookListing = { const JSON_RPC_METHOD_NOT_FOUND = -32601 const STDERR_TAIL_MAX_BYTES = 8192 +const STDOUT_LINE_MAX_BYTES = 1024 * 1024 function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') @@ -180,10 +181,20 @@ export async function runCodexHookTrustGrantSession( child.stderr.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES) }) + // Why: a child can exit between the liveness check and stdin.write(); an + // EPIPE must reject the RPC instead of becoming an unhandled stream error. + child.stdin.on('error', (error) => { + failPending(error) + }) let stdoutBuffer = '' child.stdout.on('data', (chunk: Buffer) => { stdoutBuffer += chunk.toString('utf8') + if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + child.kill('SIGKILL') + failPending(new Error('codex app-server emitted an oversized JSONL response')) + return + } let newlineIndex while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { const line = stdoutBuffer.slice(0, newlineIndex).trim() @@ -342,7 +353,6 @@ export async function runCodexHookTrustGrantSession( } throw error } finally { - clearTimeout(deadline) try { child.stdin.end() } catch { @@ -358,5 +368,6 @@ export async function runCodexHookTrustGrantSession( await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) } } + clearTimeout(deadline) } } diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index ef03183b162..1c960e15129 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -4,10 +4,10 @@ import { join } from 'node:path' import { CodexAppServerTimeoutError, CodexAppServerUnsupportedError, - isCodexAppServerUnsupportedError, type CodexHookTrustGrantRequest, type CodexHookTrustGrantSessionResult } from './codex-app-server-client' +import type { GrantEntryEnvelope } from './codex-app-server-grant-envelope' // Why: hook install/refresh is synchronous launch prep — a Codex pane must // not start before its trust is settled — but a stdio JSON-RPC session needs @@ -15,63 +15,26 @@ import { // ELECTRON_RUN_AS_NODE entry (same pattern as the daemon and parcel-watcher // entries) that runs the session and reports one JSON envelope on stdout. -export type GrantEntryEnvelope = - | { ok: true; result: CodexHookTrustGrantSessionResult } - | { ok: false; errorName: string; message: string; unsupported?: boolean } - -export function buildGrantEntryEnvelope( - run: Promise -): Promise { - return run.then( - (result) => ({ ok: true as const, result }), - (error: unknown) => ({ - ok: false as const, - errorName: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : String(error), - ...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {}) - }) - ) -} - const GRANT_ENTRY_FILE_NAME = 'codex-app-server-grant-entry.js' // Why: spawnSync must outlive the session deadline so the entry's own timeout // (and its result envelope) win the race; the margin only reaps a hung entry. const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - return ( - (require('electron') as { app?: { getAppPath(): string; isPackaged: boolean } }).app ?? null - ) - } catch { - return null - } -} - export function resolveCodexGrantEntryPath( pathExists: (candidate: string) => boolean = existsSync ): string | null { - const app = loadElectronApp() - let appPath: string | undefined - try { - appPath = app?.getAppPath() - } catch { - appPath = undefined - } - // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the - // packaged entry must run from app.asar.unpacked (out/main/codex/** is in - // the asarUnpack list). - const unpackedAppPath = - app?.isPackaged && appPath ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + // Why: this module is also bundled into a plain-Node CLI entry, so entry + // discovery cannot import Electron. Replacing the module's own asar segment + // finds the unpacked sibling in packaged builds and is a no-op in dev. + const unpackedModuleDir = __dirname.replace('app.asar', 'app.asar.unpacked') + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath const candidates = [ - // Dev/E2E: electron-vite's appPath is already out/main. - unpackedAppPath ? join(unpackedAppPath, 'codex', GRANT_ENTRY_FILE_NAME) : null, - unpackedAppPath ? join(unpackedAppPath, 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) : null, - // Plain-node CLI context (no electron): resolve relative to this chunk. - join(__dirname, 'codex', GRANT_ENTRY_FILE_NAME), - join(__dirname, '..', 'codex', GRANT_ENTRY_FILE_NAME) + resourcesPath + ? join(resourcesPath, 'app.asar.unpacked', 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) + : null, + join(unpackedModuleDir, 'codex', GRANT_ENTRY_FILE_NAME), + join(unpackedModuleDir, '..', 'codex', GRANT_ENTRY_FILE_NAME) ].filter((candidate): candidate is string => candidate !== null) for (const candidate of candidates) { if (pathExists(candidate)) { diff --git a/src/main/codex/codex-app-server-grant-entry.ts b/src/main/codex/codex-app-server-grant-entry.ts index b4613461a78..6fe201b30c3 100644 --- a/src/main/codex/codex-app-server-grant-entry.ts +++ b/src/main/codex/codex-app-server-grant-entry.ts @@ -4,7 +4,8 @@ // JSONL RPC session itself needs a live event loop. Reads the request JSON // from stdin, writes a single result-envelope JSON line to stdout, and never // imports electron (see PLAIN_NODE_ENTRY_NAMES in the build guard). -import { buildGrantEntryEnvelope } from './codex-app-server-grant-bridge' +import { buildGrantEntryEnvelope } from './codex-app-server-grant-envelope' +import { writeSync } from 'node:fs' import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest @@ -39,7 +40,10 @@ async function main(): Promise { // suspend mid-session); exiting closes the codex child's stdio so it // exits on EOF instead of orphaning. const hardExit = setTimeout(() => { - process.stdout.write( + // Why: process.exit() does not flush asynchronous stdout pipes; write the + // timeout envelope synchronously so the parent can classify the fallback. + writeSync( + process.stdout.fd, `${JSON.stringify({ ok: false, errorName: 'CodexAppServerTimeoutError', @@ -54,7 +58,9 @@ async function main(): Promise { } void main().then( - () => process.exit(0), + () => { + process.exitCode = 0 + }, (error: unknown) => { process.stdout.write( `${JSON.stringify({ @@ -63,6 +69,6 @@ void main().then( message: error instanceof Error ? error.message : String(error) })}\n` ) - process.exit(0) + process.exitCode = 0 } ) diff --git a/src/main/codex/codex-app-server-grant-envelope.ts b/src/main/codex/codex-app-server-grant-envelope.ts new file mode 100644 index 00000000000..a9665b5c2ce --- /dev/null +++ b/src/main/codex/codex-app-server-grant-envelope.ts @@ -0,0 +1,22 @@ +import { + isCodexAppServerUnsupportedError, + type CodexHookTrustGrantSessionResult +} from './codex-app-server-client' + +export type GrantEntryEnvelope = + | { ok: true; result: CodexHookTrustGrantSessionResult } + | { ok: false; errorName: string; message: string; unsupported?: boolean } + +export function buildGrantEntryEnvelope( + run: Promise +): Promise { + return run.then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ + ok: false as const, + errorName: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error), + ...(isCodexAppServerUnsupportedError(error) ? { unsupported: true as const } : {}) + }) + ) +} diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 37046f17d47..0b26f04f9ff 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { CodexAppServerUnsupportedError, - type CodexHookTrustGrantRequest + type CodexHookTrustGrantRequest, + type CodexHookTrustGrantSessionResult } from './codex-app-server-client' import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' import { _internals, + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, getCodexTrustGrantDiagnostics, grantManagedCodexHookTrust, type CodexManagedTrustGrantPlan @@ -35,6 +37,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() _internals.setGrantSessionRunnerSync(null) codexAppServerCapabilityCache.clear() if (previousUserDataPath === undefined) { @@ -158,7 +161,7 @@ describe('grantManagedCodexHookTrust', () => { it('marks the host unsupported only for the unsupported error class', () => { const entries = [managedEntry('session_start')] - const runner = vi.fn(() => { + const runner = vi.fn((): CodexHookTrustGrantSessionResult => { throw new CodexAppServerUnsupportedError('no such method') }) _internals.setGrantSessionRunnerSync(runner) @@ -178,16 +181,27 @@ describe('grantManagedCodexHookTrust', () => { expect(runner).toHaveBeenCalledTimes(1) }) - it('falls back without poisoning the capability on transient errors', () => { + it('backs off transient failures without poisoning the capability', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) const entries = [managedEntry('session_start')] - const runner = vi.fn(() => { + const runner = vi.fn((): CodexHookTrustGrantSessionResult => { throw new Error('spawn ETIMEDOUT') }) _internals.setGrantSessionRunnerSync(runner) const plan = buildPlan(entries) expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) - expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'retry-cached' + }) + expect(runner).toHaveBeenCalledTimes(1) + expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) + + runner.mockImplementation(() => grantedSessionResult(entries)) + vi.setSystemTime(1_000 + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) expect(runner).toHaveBeenCalledTimes(2) }) @@ -204,6 +218,37 @@ describe('grantManagedCodexHookTrust', () => { expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) }) + it('restores exact config bytes before fallback after a mutating RPC error', () => { + const entries = [managedEntry('session_start')] + const plan = buildPlan(entries) + const original = '# user formatting\r\n[hooks]\r\n' + mkdirSync(runtimeHomeDir, { recursive: true }) + writeFileSync(plan.tomlPath, original) + _internals.setGrantSessionRunnerSync(() => { + writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') + throw new Error('post-write transport failure') + }) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'fallback', reason: 'error' }) + expect(readFileSync(plan.tomlPath, 'utf8')).toBe(original) + }) + + it('removes an RPC-created config before fallback when none existed', () => { + const entries = [managedEntry('session_start')] + const plan = buildPlan(entries) + mkdirSync(runtimeHomeDir, { recursive: true }) + _internals.setGrantSessionRunnerSync(() => { + writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') + return { outcome: 'verify-failed', reason: 'post-write listing failed' } + }) + + expect(grantManagedCodexHookTrust(plan)).toMatchObject({ + lane: 'fallback', + reason: 'verify-failed' + }) + expect(existsSync(plan.tomlPath)).toBe(false) + }) + it('honors the ops kill switch env flag', () => { process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' const entries = [managedEntry('session_start')] diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index 69cf30db302..ec8edecfaaa 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -26,12 +26,16 @@ import { type CodexTrustEntry } from './config-toml-trust' import { getCodexHookTrustSignature } from './codex-hook-identity' +import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' // Why: grants must never make launch prep slower than the codex TUI's own // startup on the same host. Native sessions complete in ~100ms; WSL pays // wsl.exe + login-shell + possible cold-distro costs, so it gets more room. const NATIVE_GRANT_TIMEOUT_MS = 10_000 const WSL_GRANT_TIMEOUT_MS = 30_000 +// Why: a transiently hung app-server must not block launch prep on every pane. +// The legacy lane remains available while a short, host-scoped cooldown runs. +export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 /** Ops escape hatch (not a setting): forces the unchanged fallback lane. */ const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' @@ -58,6 +62,7 @@ export type CodexTrustGrantFallbackReason = | 'unsupported' | 'unsupported-cached' | 'verify-failed' + | 'retry-cached' | 'error' export type CodexManagedTrustGrantOutcome = @@ -79,6 +84,7 @@ const diagnostics: CodexTrustGrantDiagnostics = { verifyFailed: 0, lastFallbackReason: null } +const transientRetryAfterByHost = new Map() export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics { return { ...diagnostics } @@ -244,22 +250,44 @@ export function grantManagedCodexHookTrust( if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { return fallback(plan, 'unsupported-cached') } + const transientRetryAfter = transientRetryAfterByHost.get(hostKey) + if (transientRetryAfter !== undefined) { + if (Date.now() < transientRetryAfter) { + return fallback(plan, 'retry-cached') + } + transientRetryAfterByHost.delete(hostKey) + } const startedAtMs = Date.now() + // Why: the RPC may rewrite config.toml before a later RPC fails. Restore + // its exact pre-session bytes before the legacy lane runs so every fallback + // has the same input and output as the pre-RPC implementation. + const configSnapshot = captureCodexTrustConfig(plan.tomlPath) let result: CodexHookTrustGrantSessionResult try { result = runSessionSync(buildGrantRequest(plan, expected)) } catch (error) { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) if (isCodexAppServerUnsupportedError(error)) { + transientRetryAfterByHost.delete(hostKey) codexAppServerCapabilityCache.rememberUnsupported(hostKey) return fallback(plan, 'unsupported', error) } + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) return fallback(plan, 'error', error) } // Why: the RPC surface answered, even if our entries were not verifiable — // remember support so a later drift event retries the preferred lane. codexAppServerCapabilityCache.rememberSupported(hostKey) if (result.outcome === 'verify-failed') { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) return fallback(plan, 'verify-failed', result.reason) } @@ -269,6 +297,11 @@ export function grantManagedCodexHookTrust( for (const granted of result.entries) { const match = byNormalizedKey.get(granted.normalizedKey) if (!match) { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`) } grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) @@ -278,8 +311,14 @@ export function grantManagedCodexHookTrust( } } if (grantedEntries.length !== expected.length) { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries') } + transientRetryAfterByHost.delete(hostKey) try { writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { binary: currentStamp, @@ -311,5 +350,6 @@ export const _internals = { diagnostics.fellBack = 0 diagnostics.verifyFailed = 0 diagnostics.lastFallbackReason = null + transientRetryAfterByHost.clear() } } diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts new file mode 100644 index 00000000000..57e2cd0252a --- /dev/null +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -0,0 +1,32 @@ +import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' + +export type CodexTrustConfigSnapshot = + | { existed: false } + | { existed: true; contents: Buffer; mode: number } + +export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { + if (!existsSync(tomlPath)) { + return { existed: false } + } + return { + existed: true, + contents: readFileSync(tomlPath), + mode: statSync(tomlPath).mode + } +} + +export function restoreCodexTrustConfig( + tomlPath: string, + snapshot: CodexTrustConfigSnapshot +): void { + if (!snapshot.existed) { + if (existsSync(tomlPath)) { + unlinkSync(tomlPath) + } + return + } + if (existsSync(tomlPath) && readFileSync(tomlPath).equals(snapshot.contents)) { + return + } + writeFileSync(tomlPath, snapshot.contents, { mode: snapshot.mode }) +} diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index ce34c048f03..1877fe8ebd8 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -429,6 +429,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { tempRoots.push(userDataDir) previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = userDataDir + trustGrantInternals.resetDiagnostics() codexAppServerCapabilityCache.clear() }) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index afd1b7ea4c3..22a54eb0cde 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -1466,6 +1466,11 @@ describe('CodexHookService', () => { }) describe('CodexHookService app-server trust grant lane', () => { + beforeEach(() => { + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() + }) + afterEach(() => { trustGrantInternals.setGrantSessionRunnerSync(null) trustGrantInternals.resetDiagnostics() @@ -1649,4 +1654,33 @@ describe('CodexHookService app-server trust grant lane', () => { }) ) }) + + it('keeps fallback output byte-identical after an RPC mutates config and then fails', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + const service = new CodexHookService() + + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const baseline = readFileSync(join(managedCodexHome, 'config.toml')) + + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(managedCodexHome, { recursive: true, force: true }) + trustGrantInternals.resetDiagnostics() + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + expect(codexHome).toBeTruthy() + writeFileSync( + join(codexHome!, 'config.toml'), + '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' + ) + throw new Error('transport failed after config/batchWrite') + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(service.install().state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedCodexHome, 'config.toml'))).toEqual(baseline) + }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1011d595ac3..a7032ede08d 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -649,10 +649,11 @@ function readLedgerHomeForCleanup(runtimeHomePath: string): CodexTrustGrantLedge function addLedgerRecognizedHash( recognizedHashes: Set, ledgerHome: CodexTrustGrantLedgerHome | null, - key: string + key: string, + expectedEntry: CodexTrustEntry ): void { const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] - if (granted?.trustedHash) { + if (granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry)) { recognizedHashes.add(granted.trustedHash) } } @@ -698,7 +699,7 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { continue } @@ -748,7 +749,7 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } @@ -802,7 +803,7 @@ function removeStaleWslRuntimeManagedHookTrustEntries( computeTrustedHash(expectedEntry), computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key) + addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { ourKeys.push(key) } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 28b754102a2..608989630df 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -457,6 +457,7 @@ const codexTrustGrantSchema = z 'unsupported', 'unsupported-cached', 'verify-failed', + 'retry-cached', 'error' ]) .optional() From e15425a64377beb25790149e2e2ea8858f6f812a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:04:49 -0700 Subject: [PATCH 15/45] fix(codex): install cross-volume session backfill copies atomically On a real Codex home whose filesystem supports no hardlinks (exFAT/FAT, some network mounts), the staged cross-volume copy was installed with a non-atomic copyFile(..., COPYFILE_EXCL) straight into the final rollout-*.jsonl name. An install interrupted mid-copy (app quit, crash, ENOSPC during the deferred run) could strand a truncated rollout that the next run then skips as already-present, defeating the staging design's own guarantee that a failed copy never leaves a partial session behind. Install the fully-staged copy with an atomic rename instead, guarded by an existence re-check so it keeps the never-overwrite contract (and the rename source is the same immutable managed rollout, so any clobber would be byte-identical). Cover the no-hardlink-support target and an interrupted install that must leave no partial in the user's sessions tree. --- src/main/codex/codex-session-backfill-copy.ts | 33 ++++++++-- src/main/codex/codex-session-backfill.test.ts | 66 +++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-session-backfill-copy.ts b/src/main/codex/codex-session-backfill-copy.ts index 0455eb4b0f0..bb2f7621058 100644 --- a/src/main/codex/codex-session-backfill-copy.ts +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' -import { constants } from 'node:fs' -import { copyFile, link, rm, writeFile } from 'node:fs/promises' +import { copyFile, link, lstat, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' export async function copySessionFileWithoutOverwrite( @@ -21,9 +20,16 @@ export async function copySessionFileWithoutOverwrite( if (isExistsError(installLinkError)) { throw installLinkError } - // Some target filesystems do not support hardlinks at all. COPYFILE_EXCL - // preserves the collision contract while retaining the staged snapshot. - await copyFile(temporaryPath, targetPath, constants.COPYFILE_EXCL) + // Why: some target filesystems support no hardlinks at all. Install the + // fully-staged copy with an atomic rename — never a raw copy into the + // rollout filename — so an interrupted install cannot strand a truncated + // session that a later run skips as already-present. Re-check existence + // first because rename, unlike the hardlink above, would clobber a target + // that appeared mid-run. + if (await pathEntryExists(targetPath)) { + throw makeTargetExistsError(targetPath) + } + await rename(temporaryPath, targetPath) } } finally { try { @@ -36,6 +42,23 @@ export async function copySessionFileWithoutOverwrite( } } +/** Existence via lstat so a broken symlink at the target still counts as taken. */ +async function pathEntryExists(entryPath: string): Promise { + try { + await lstat(entryPath) + return true + } catch { + return false + } +} + function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } + +/** EEXIST so a rename-install collision routes to the skip path, not a failure. */ +function makeTargetExistsError(targetPath: string): NodeJS.ErrnoException { + const error = new Error(`EEXIST: backfill target already exists: ${targetPath}`) + ;(error as NodeJS.ErrnoException).code = 'EEXIST' + return error +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index f62296f678a..126f134502e 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -23,6 +23,8 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, + failInstallLink: false, + failInstallRename: false, failCopy: false, failDirectoryPath: null as string | null, failLstatPath: null as string | null @@ -60,8 +62,25 @@ vi.mock('node:fs/promises', async () => { error.code = 'EXDEV' throw error } + // Simulate a target filesystem with no hardlink support: even the + // same-volume staged-copy install link (.orca-backfill-*.tmp) fails. + if (fsMockState.failInstallLink && String(args[0]).includes('.orca-backfill-')) { + const error = new Error('EPERM: hardlinks unsupported') as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } return actual.link(...args) }, + rename: (...args: Parameters) => { + // Simulate an interrupted atomic install (crash/ENOSPC mid-rename) on a + // hardlink-less target, exercising the no-partial-stranded guarantee. + if (fsMockState.failInstallRename && String(args[0]).includes('.orca-backfill-')) { + const error = new Error('EIO: rename interrupted') as NodeJS.ErrnoException + error.code = 'EIO' + throw error + } + return actual.rename(...args) + }, copyFile: async (...args: Parameters) => { if (fsMockState.failCopy) { // Simulate a copy that fails after opening its destination, which is @@ -134,6 +153,8 @@ function readAuditActions(): string[] { beforeEach(() => { fsMockState.failLink = false + fsMockState.failInstallLink = false + fsMockState.failInstallRename = false fsMockState.failCopy = false fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null @@ -289,6 +310,51 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy', 'run-summary']) }) + it('installs via atomic rename when the target volume has no hardlink support', async () => { + // Why: exFAT/FAT and some network targets support no hardlinks, so even the + // staged-copy install link fails and the atomic-rename fallback must run. + fsMockState.failLink = true + fsMockState.failInstallLink = true + const managedPath = writeManagedSession( + join('2026', '05', '26', 'rollout-a.jsonl'), + '{"id":"a"}\n' + ) + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) + // Staged temp file is renamed into place, not stranded in the sessions tree. + expect( + readdirSync(dirname(targetPath)).filter((name) => name.includes('.orca-backfill-')) + ).toEqual([]) + expect(readAuditActions()).toEqual(['copy', 'run-summary']) + }) + + it('never strands a partial rollout when the atomic install is interrupted', async () => { + // Why: a hardlink-less target plus an interrupted install must leave the + // real rollout filename absent (not truncated), so the next run retries + // instead of skipping a partial session as already-present. + fsMockState.failLink = true + fsMockState.failInstallLink = true + fsMockState.failInstallRename = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ failedFiles: 1, copiedFiles: 0, linkedFiles: 0 }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(existsSync(targetPath)).toBe(false) + // No partial rollout and no leftover staging file in the user's tree. + expect(readdirSync(dirname(targetPath))).toEqual([]) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + it('records per-file failures without aborting the run', async () => { fsMockState.failLink = true fsMockState.failCopy = true From 442cde8d99993cd4962fc5dffc02ba72b3bb2576 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:26:26 -0700 Subject: [PATCH 16/45] fix(codex): resolve grant entry from __dirname so plain-node CLI entries stay electron-free The build guard rejects any electron require reachable from plain-node entries; the bridge now maps app.asar to app.asar.unpacked by string replacement instead of consulting electron app paths. CLI typecheck project lists the new trust-grant module graph. --- config/tsconfig.cli.json | 8 +++---- .../codex/codex-app-server-grant-bridge.ts | 24 +++++++++---------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 67b0c33fda8..81792bba35e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,14 +11,14 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", - "../src/main/codex/codex-config-mirror.ts", - "../src/main/codex/codex-config-path-reference-rewrite.ts", - "../src/main/codex/codex-home-paths.ts", - "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", + "../src/main/codex/codex-config-mirror.ts", + "../src/main/codex/codex-config-path-reference-rewrite.ts", + "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-trust-config-rollback.ts", "../src/main/codex/codex-trust-grant-ledger.ts", diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index 1c960e15129..a42c2a3b7ac 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -24,19 +24,17 @@ const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 export function resolveCodexGrantEntryPath( pathExists: (candidate: string) => boolean = existsSync ): string | null { - // Why: this module is also bundled into a plain-Node CLI entry, so entry - // discovery cannot import Electron. Replacing the module's own asar segment - // finds the unpacked sibling in packaged builds and is a no-op in dev. - const unpackedModuleDir = __dirname.replace('app.asar', 'app.asar.unpacked') - const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath - const candidates = [ - resourcesPath - ? join(resourcesPath, 'app.asar.unpacked', 'out', 'main', 'codex', GRANT_ENTRY_FILE_NAME) - : null, - join(unpackedModuleDir, 'codex', GRANT_ENTRY_FILE_NAME), - join(unpackedModuleDir, '..', 'codex', GRANT_ENTRY_FILE_NAME) - ].filter((candidate): candidate is string => candidate !== null) - for (const candidate of candidates) { + // Why: resolved from __dirname (not electron's app paths) so this module + // stays loadable in plain-node CLI entries — the build guard rejects any + // electron require reachable from them. The emitted bridge chunk sits in + // out/main or out/main/chunks, so the entry is one or two levels up. + // ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must + // run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked). + const baseDirs = [__dirname, join(__dirname, '..')].map((dir) => + dir.includes('app.asar') ? dir.replace('app.asar', 'app.asar.unpacked') : dir + ) + for (const baseDir of baseDirs) { + const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME) if (pathExists(candidate)) { return candidate } From d4f549364bddb71fffd90e0fd91187b9001772c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:34:20 -0700 Subject: [PATCH 17/45] fix(codex): harden trust grant reconciliation --- config/tsconfig.cli.json | 2 + .../codex/codex-app-server-client.test.ts | 49 +++- .../codex/codex-app-server-grant-bridge.ts | 21 +- src/main/codex/codex-hook-trust-grant.test.ts | 29 +++ src/main/codex/codex-hook-trust-grant.ts | 105 +++----- .../codex-managed-trust-reconciliation.ts | 151 +++++++++++ .../codex/codex-trust-config-rollback.test.ts | 54 ++++ src/main/codex/codex-trust-config-rollback.ts | 59 ++++- src/main/codex/codex-trust-grant-host.test.ts | 49 ++++ src/main/codex/codex-trust-grant-host.ts | 94 +++++++ .../codex/codex-wsl-hook-install-plan.test.ts | 13 +- src/main/codex/codex-wsl-hook-install-plan.ts | 6 +- .../codex/hook-service-trust-grant.test.ts | 236 ++++++++++++++++++ .../codex/hook-service-wsl-runtime.test.ts | 58 +++++ src/main/codex/hook-service.test.ts | 232 +---------------- src/main/codex/hook-service.ts | 215 ++++------------ 16 files changed, 891 insertions(+), 482 deletions(-) create mode 100644 src/main/codex/codex-managed-trust-reconciliation.ts create mode 100644 src/main/codex/codex-trust-config-rollback.test.ts create mode 100644 src/main/codex/codex-trust-grant-host.test.ts create mode 100644 src/main/codex/codex-trust-grant-host.ts create mode 100644 src/main/codex/hook-service-trust-grant.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 81792bba35e..0d278a1e854 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -20,7 +20,9 @@ "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", + "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/codex/codex-trust-config-rollback.ts", + "../src/main/codex/codex-trust-grant-host.ts", "../src/main/codex/codex-trust-grant-ledger.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index f59f888568a..2e263f12384 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -9,7 +9,10 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' -import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge' +import { + resolveCodexGrantEntryPath, + runCodexHookTrustGrantSessionSync +} from './codex-app-server-grant-bridge' // Stub codex app-server speaking the same JSONL protocol: initialize → // initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven @@ -331,4 +334,48 @@ describe('runCodexHookTrustGrantSessionSync', () => { /produced no result \(exit 7\)/ ) }) + + it('classifies the spawnSync deadline as a typed timeout', () => { + const entryPath = writeEntryFixture(`setInterval(() => {}, 1000)`) + const request = { + ...baseRequest, + invocation: { ...baseRequest.invocation, timeoutMs: 20 } + } + expect(() => + runCodexHookTrustGrantSessionSync(request, { entryPath, timeoutMarginMs: 20 }) + ).toThrow(CodexAppServerTimeoutError) + }) +}) + +describe('resolveCodexGrantEntryPath', () => { + const entryName = 'codex-app-server-grant-entry.js' + + it('finds the sibling entry from emitted main and chunk directories', () => { + const mainDir = join('/opt', 'orca', 'out', 'main') + expect( + resolveCodexGrantEntryPath( + (candidate) => candidate === join(mainDir, 'codex', entryName), + mainDir + ) + ).toBe(join(mainDir, 'codex', entryName)) + + const chunkDir = join(mainDir, 'chunks') + expect( + resolveCodexGrantEntryPath( + (candidate) => candidate === join(mainDir, 'codex', entryName), + chunkDir + ) + ).toBe(join(mainDir, 'codex', entryName)) + }) + + it('redirects app.asar to unpacked without double-unpacking an existing path', () => { + const resourcesDir = join('/Applications', 'Orca.app', 'Contents', 'Resources') + const expected = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'codex', entryName) + for (const archiveDir of ['app.asar', 'app.asar.unpacked']) { + const moduleDir = join(resourcesDir, archiveDir, 'out', 'main', 'chunks') + expect(resolveCodexGrantEntryPath((candidate) => candidate === expected, moduleDir)).toBe( + expected + ) + } + }) }) diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index a42c2a3b7ac..88f8d6ef80a 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -22,7 +22,8 @@ const GRANT_ENTRY_TIMEOUT_MARGIN_MS = 5_000 const GRANT_ENTRY_MAX_BUFFER_BYTES = 16 * 1024 * 1024 export function resolveCodexGrantEntryPath( - pathExists: (candidate: string) => boolean = existsSync + pathExists: (candidate: string) => boolean = existsSync, + moduleDir = __dirname ): string | null { // Why: resolved from __dirname (not electron's app paths) so this module // stays loadable in plain-node CLI entries — the build guard rejects any @@ -30,9 +31,9 @@ export function resolveCodexGrantEntryPath( // out/main or out/main/chunks, so the entry is one or two levels up. // ELECTRON_RUN_AS_NODE bypasses asar integration, so packaged builds must // run the copy under app.asar.unpacked (out/main/codex/** is asarUnpacked). - const baseDirs = [__dirname, join(__dirname, '..')].map((dir) => - dir.includes('app.asar') ? dir.replace('app.asar', 'app.asar.unpacked') : dir - ) + const toUnpackedDir = (dir: string): string => + dir.replace(/([\\/])app\.asar(?=([\\/]|$))/, '$1app.asar.unpacked') + const baseDirs = [moduleDir, join(moduleDir, '..')].map(toUnpackedDir) for (const baseDir of baseDirs) { const candidate = join(baseDir, 'codex', GRANT_ENTRY_FILE_NAME) if (pathExists(candidate)) { @@ -45,6 +46,8 @@ export function resolveCodexGrantEntryPath( export type RunGrantSessionSyncOptions = { entryPath?: string nodeCommand?: string + /** Test-only override; production keeps enough margin for child cleanup. */ + timeoutMarginMs?: number } /** @@ -67,12 +70,20 @@ export function runCodexHookTrustGrantSessionSync( const spawned = spawnSync(options.nodeCommand ?? process.execPath, [entryPath], { input: JSON.stringify(request), encoding: 'utf8', - timeout: request.invocation.timeoutMs + GRANT_ENTRY_TIMEOUT_MARGIN_MS, + timeout: + request.invocation.timeoutMs + (options.timeoutMarginMs ?? GRANT_ENTRY_TIMEOUT_MARGIN_MS), killSignal: 'SIGKILL', maxBuffer: GRANT_ENTRY_MAX_BUFFER_BYTES, windowsHide: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }) + if ((spawned.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT') { + // Why: spawnSync reports its own deadline through error.code before the + // signal field; preserve the typed timeout so cooldown diagnostics work. + throw new CodexAppServerTimeoutError( + `codex trust-grant entry exceeded ${request.invocation.timeoutMs}ms session deadline` + ) + } if (spawned.error) { throw spawned.error } diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 0b26f04f9ff..5f184355b58 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -13,6 +13,7 @@ import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, getCodexTrustGrantDiagnostics, grantManagedCodexHookTrust, + setCodexTrustGrantTelemetry, type CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' @@ -39,6 +40,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers() _internals.setGrantSessionRunnerSync(null) + setCodexTrustGrantTelemetry(() => {}) codexAppServerCapabilityCache.clear() if (previousUserDataPath === undefined) { delete process.env.ORCA_USER_DATA_PATH @@ -218,6 +220,33 @@ describe('grantManagedCodexHookTrust', () => { expect(getCodexTrustGrantDiagnostics()).toMatchObject({ verifyFailed: 1 }) }) + it('rejects duplicate granted keys instead of treating another key as covered', () => { + const entries = [managedEntry('session_start'), managedEntry('stop')] + const duplicated = grantedSessionResult([entries[0]!, entries[0]!]) + _internals.setGrantSessionRunnerSync(() => duplicated) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'verify-failed' + }) + expect(readCodexTrustGrantLedgerHome(runtimeHomeDir)).toBeNull() + }) + + it('keeps grant and fallback outcomes stable when telemetry throws', () => { + const entries = [managedEntry('session_start')] + setCodexTrustGrantTelemetry(() => { + throw new Error('telemetry unavailable') + }) + _internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries)) + + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' }) + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ + lane: 'fallback', + reason: 'disabled' + }) + }) + it('restores exact config bytes before fallback after a mutating RPC error', () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index ec8edecfaaa..b66c63b65cb 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -1,6 +1,3 @@ -import { resolveCodexCommand } from '../codex-cli/command' -import { getSpawnArgsForWindows } from '../win32-utils' -import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' import { isCodexAppServerUnsupportedError, type CodexHookTrustGrantRequest, @@ -12,9 +9,6 @@ import { getCodexAppServerHostKey } from './codex-app-server-capability-cache' import { - binaryStampsMatch, - buildNativeCodexBinaryStamp, - readCodexTrustGrantLedgerHome, writeCodexTrustGrantLedgerHome, type CodexTrustGrantBinaryStamp, type CodexTrustGrantLedgerEntry @@ -27,12 +21,12 @@ import { } from './config-toml-trust' import { getCodexHookTrustSignature } from './codex-hook-identity' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' +import { + readCodexTrustGrantLedgerHomeMatchingStamp, + resolveCodexTrustGrantHost, + type CodexTrustGrantHost +} from './codex-trust-grant-host' -// Why: grants must never make launch prep slower than the codex TUI's own -// startup on the same host. Native sessions complete in ~100ms; WSL pays -// wsl.exe + login-shell + possible cold-distro costs, so it gets more room. -const NATIVE_GRANT_TIMEOUT_MS = 10_000 -const WSL_GRANT_TIMEOUT_MS = 30_000 // Why: a transiently hung app-server must not block launch prep on every pane. // The legacy lane remains available while a short, host-scoped cooldown runs. export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 @@ -40,10 +34,6 @@ export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 /** Ops escape hatch (not a setting): forces the unchanged fallback lane. */ const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' -export type CodexTrustGrantHost = - | { kind: 'native' } - | { kind: 'wsl'; distro: string; linuxRuntimeHome: string } - export type CodexManagedTrustGrantPlan = { /** Host-visible runtime home path (UNC for WSL) — ledger key + config reads. */ runtimeHomePath: string @@ -105,6 +95,16 @@ export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): telemetry = tracker } +function emitTelemetry(event: Parameters[0]): void { + try { + telemetry(event) + } catch (error) { + // Why: observability must never turn a verified grant into fallback or + // violate this launch-prep API's no-throw contract. + console.warn('[codex-trust-grant] failed to emit telemetry', error) + } +} + type GrantSessionRunnerSync = ( request: CodexHookTrustGrantRequest ) => CodexHookTrustGrantSessionResult @@ -125,7 +125,7 @@ function fallback( `[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`, detail ?? '' ) - telemetry({ + emitTelemetry({ outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback', hostKind: plan.host.kind, reason @@ -147,24 +147,13 @@ function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): ExpectedManaged })) } -function resolveCurrentBinaryStamp(host: CodexTrustGrantHost): CodexTrustGrantBinaryStamp | null { - if (host.kind === 'wsl') { - return { kind: 'wsl', distro: host.distro } - } - const command = resolveCodexCommand() - // Why: an unresolved bare command cannot be stat'ed; a null stamp still - // allows ledger skips (config + signature checks gate them) and heals to a - // real stamp on the next grant once the binary is resolvable. - return command === 'codex' ? null : buildNativeCodexBinaryStamp(command) -} - function findLedgerGrant( plan: CodexManagedTrustGrantPlan, expected: ExpectedManagedEntry[], currentStamp: CodexTrustGrantBinaryStamp | null ): CodexTrustEntry[] | null { - const home = readCodexTrustGrantLedgerHome(plan.runtimeHomePath) - if (!home || !binaryStampsMatch(home.binary, currentStamp)) { + const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp) + if (!home) { return null } let trustStates: ReturnType @@ -187,39 +176,6 @@ function findLedgerGrant( return entries } -function buildGrantRequest( - plan: CodexManagedTrustGrantPlan, - expected: ExpectedManagedEntry[] -): CodexHookTrustGrantRequest { - if (plan.host.kind === 'wsl') { - return { - invocation: { - command: 'wsl.exe', - args: buildWslCodexAppServerArgs(plan.host.distro, plan.host.linuxRuntimeHome), - timeoutMs: WSL_GRANT_TIMEOUT_MS - }, - hooksListCwd: plan.host.linuxRuntimeHome, - expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - managedCommand: plan.managedCommand - } - } - const codexCommand = resolveCodexCommand() - // Why: npm-installed codex on Windows is a .cmd shim that spawn cannot run - // without cmd.exe /c; args-array + shell:true would hit DEP0190 instead. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['app-server']) - return { - invocation: { - command: spawnCmd, - args: spawnArgs, - env: { CODEX_HOME: plan.runtimeHomePath }, - timeoutMs: NATIVE_GRANT_TIMEOUT_MS - }, - hooksListCwd: plan.runtimeHomePath, - expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - managedCommand: plan.managedCommand - } -} - /** * Grants trust for Orca's managed Codex hooks through codex's own app-server * RPCs, verified by re-list. Returns the granted entries carrying Codex's @@ -239,7 +195,8 @@ export function grantManagedCodexHookTrust( return fallback(plan, 'no-managed-entries') } const expected = buildExpectedEntries(plan) - const currentStamp = resolveCurrentBinaryStamp(plan.host) + const resolvedHost = resolveCodexTrustGrantHost(plan.host) + const currentStamp = resolvedHost.binaryStamp const ledgerEntries = findLedgerGrant(plan, expected, currentStamp) if (ledgerEntries !== null) { diagnostics.ledgerHits += 1 @@ -265,7 +222,13 @@ export function grantManagedCodexHookTrust( const configSnapshot = captureCodexTrustConfig(plan.tomlPath) let result: CodexHookTrustGrantSessionResult try { - result = runSessionSync(buildGrantRequest(plan, expected)) + result = runSessionSync( + resolvedHost.buildRequest({ + runtimeHomePath: plan.runtimeHomePath, + managedCommand: plan.managedCommand, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey) + }) + ) } catch (error) { restoreCodexTrustConfig(plan.tomlPath, configSnapshot) if (isCodexAppServerUnsupportedError(error)) { @@ -292,6 +255,7 @@ export function grantManagedCodexHookTrust( } const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item])) + const seenNormalizedKeys = new Set() const grantedEntries: CodexTrustEntry[] = [] const ledgerRecord: Record = {} for (const granted of result.entries) { @@ -304,13 +268,22 @@ export function grantManagedCodexHookTrust( ) return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`) } + if (seenNormalizedKeys.has(granted.normalizedKey)) { + restoreCodexTrustConfig(plan.tomlPath, configSnapshot) + transientRetryAfterByHost.set( + hostKey, + Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + ) + return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`) + } + seenNormalizedKeys.add(granted.normalizedKey) grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash }) ledgerRecord[granted.normalizedKey] = { signature: match.signature, trustedHash: granted.trustedHash } } - if (grantedEntries.length !== expected.length) { + if (seenNormalizedKeys.size !== expected.length) { restoreCodexTrustConfig(plan.tomlPath, configSnapshot) transientRetryAfterByHost.set( hostKey, @@ -333,7 +306,7 @@ export function grantManagedCodexHookTrust( `[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` + `(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)` ) - telemetry({ outcome: 'granted', hostKind: plan.host.kind }) + emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind }) return { lane: 'rpc', entries: grantedEntries } } catch (error) { return fallback(plan, 'error', error) diff --git a/src/main/codex/codex-managed-trust-reconciliation.ts b/src/main/codex/codex-managed-trust-reconciliation.ts new file mode 100644 index 00000000000..e422f2a4c32 --- /dev/null +++ b/src/main/codex/codex-managed-trust-reconciliation.ts @@ -0,0 +1,151 @@ +import { + computeTrustKey, + computeTrustedHash, + getCodexCanonicalTrustPath, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + removeHookTrustEntries, + type CodexEventLabel, + type CodexTrustEntry +} from './config-toml-trust' +import { getCodexHookTrustSignature } from './codex-hook-identity' +import { + readCodexTrustGrantLedgerHome, + removeCodexTrustGrantLedgerHome, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +export function readCodexTrustGrantLedgerHomeForReconciliation( + runtimeHomePath: string +): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHome(runtimeHomePath) + } catch { + return null + } +} + +export function getCodexLedgerTrustedHash( + ledgerHome: CodexTrustGrantLedgerHome | null, + key: string, + expectedEntry: CodexTrustEntry +): string | null { + const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] + return granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry) + ? granted.trustedHash + : null +} + +function addLedgerRecognizedHashes( + hashes: Set, + ledgerHomes: readonly (CodexTrustGrantLedgerHome | null)[], + key: string, + expectedEntry: CodexTrustEntry +): void { + for (const ledgerHome of ledgerHomes) { + const hash = getCodexLedgerTrustedHash(ledgerHome, key, expectedEntry) + if (hash) { + hashes.add(hash) + } + } +} + +export function removeCodexManagedHookTrustEntries(options: { + tomlPath: string + runtimeHomePath: string + sourcePath: string + command: string + managedEventLabels: ReadonlySet + timeoutSec: number +}): void { + const existingEntries = readHookTrustEntries(options.tomlPath) + const ledgerHome = readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath) + const canonicalSourcePath = getCodexCanonicalTrustPath(options.sourcePath) + const ownedKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if ( + !parts || + getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalSourcePath || + !options.managedEventLabels.has(parts.eventLabel) + ) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: options.sourcePath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command: options.command, + timeoutSec: options.timeoutSec + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + addLedgerRecognizedHashes(recognizedHashes, [ledgerHome], key, expectedEntry) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ownedKeys.push(key) + } + } + if (ownedKeys.length > 0) { + removeHookTrustEntries(options.tomlPath, ownedKeys) + } + // Why: retain the ledger until trust removal succeeds so a later retry can + // still prove ownership of Codex-computed hashes. + removeCodexTrustGrantLedgerHome(options.runtimeHomePath) +} + +export function removeStaleWslCodexManagedHookTrustEntries(options: { + tomlPath: string + runtimeHomePath: string + desiredEntries: readonly CodexTrustEntry[] + managedEventLabels: ReadonlySet + timeoutSec: number + buildManagedCommand: (linuxRuntimeHome: string) => string + priorLedgerHomes?: readonly CodexTrustGrantLedgerHome[] +}): void { + const desiredKeys = new Set( + options.desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) + ) + const ledgerHomes = [ + readCodexTrustGrantLedgerHomeForReconciliation(options.runtimeHomePath), + ...(options.priorLedgerHomes ?? []) + ] + const ownedKeys: string[] = [] + for (const [key, state] of readHookTrustEntries(options.tomlPath)) { + if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { + continue + } + const parts = parseTrustKey(key) + if (!parts || !options.managedEventLabels.has(parts.eventLabel)) { + continue + } + // Why: this cleanup owns only guest-side WSL trust. A runtime config can + // still contain user Windows/remote hooks, which must remain untouched. + if (!parts.sourcePath.startsWith('/') || !parts.sourcePath.endsWith('/hooks.json')) { + continue + } + const linuxRuntimeHome = parts.sourcePath.slice(0, -'/hooks.json'.length) + const expectedEntry: CodexTrustEntry = { + sourcePath: parts.sourcePath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command: options.buildManagedCommand(linuxRuntimeHome), + timeoutSec: options.timeoutSec + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + addLedgerRecognizedHashes(recognizedHashes, ledgerHomes, key, expectedEntry) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ownedKeys.push(key) + } + } + if (ownedKeys.length > 0) { + removeHookTrustEntries(options.tomlPath, ownedKeys) + } +} diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts new file mode 100644 index 00000000000..ae53cfaaadb --- /dev/null +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function tempConfigPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-rollback-')) + roots.push(root) + return join(root, 'config.toml') +} + +describe('Codex trust config rollback', () => { + it('treats a missing config as absent and tolerates it remaining absent', () => { + const configPath = tempConfigPath() + const snapshot = captureCodexTrustConfig(configPath) + + expect(snapshot).toEqual({ existed: false }) + expect(() => restoreCodexTrustConfig(configPath, snapshot)).not.toThrow() + }) + + it('removes a config created after an absent snapshot', () => { + const configPath = tempConfigPath() + const snapshot = captureCodexTrustConfig(configPath) + writeFileSync(configPath, 'rpc mutation') + + restoreCodexTrustConfig(configPath, snapshot) + expect(() => readFileSync(configPath)).toThrowError(/ENOENT/) + }) + + it('atomically recreates exact contents and mode after the file disappears', () => { + const configPath = tempConfigPath() + const original = Buffer.from('# comment\r\n[hooks]\r\n') + writeFileSync(configPath, original) + chmodSync(configPath, 0o640) + const snapshot = captureCodexTrustConfig(configPath) + rmSync(configPath) + + restoreCodexTrustConfig(configPath, snapshot) + + expect(readFileSync(configPath)).toEqual(original) + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o640) + } + }) +}) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index 57e2cd0252a..74cea0d425a 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -1,17 +1,31 @@ -import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { closeSync, fstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = | { existed: false } | { existed: true; contents: Buffer; mode: number } export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { - if (!existsSync(tomlPath)) { - return { existed: false } + let descriptor: number + try { + descriptor = openSync(tomlPath, 'r') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { existed: false } + } + throw error } - return { - existed: true, - contents: readFileSync(tomlPath), - mode: statSync(tomlPath).mode + try { + // Why: read and stat the same open file so replacement between two path + // lookups cannot pair one file's contents with another file's mode. + return { + existed: true, + contents: readFileSync(descriptor), + mode: fstatSync(descriptor).mode + } + } finally { + closeSync(descriptor) } } @@ -20,13 +34,36 @@ export function restoreCodexTrustConfig( snapshot: CodexTrustConfigSnapshot ): void { if (!snapshot.existed) { - if (existsSync(tomlPath)) { + try { unlinkSync(tomlPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } } return } - if (existsSync(tomlPath) && readFileSync(tomlPath).equals(snapshot.contents)) { - return + try { + if (readFileSync(tomlPath).equals(snapshot.contents)) { + return + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + // Why: rollback protects config integrity too; direct truncating writes can + // leave Codex unusable if Orca exits midway through recovery. + const tempPath = `${tomlPath}.${process.pid}.${randomUUID()}.rollback.tmp` + try { + writeFileSync(tempPath, snapshot.contents, { mode: snapshot.mode }) + renameFileWithWindowsRetry(tempPath, tomlPath) + } catch (error) { + try { + unlinkSync(tempPath) + } catch { + // Best effort; preserve the rollback failure as the actionable error. + } + throw error } - writeFileSync(tomlPath, snapshot.contents, { mode: snapshot.mode }) } diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts new file mode 100644 index 00000000000..4fb2296abdc --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolveCodexCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' + +beforeEach(() => { + resolveCodexCommandMock.mockReset() + resolveCodexCommandMock.mockReturnValue(process.execPath) +}) + +describe('resolveCodexTrustGrantHost', () => { + it('resolves the native command once for both the binary stamp and request', () => { + const host = resolveCodexTrustGrantHost({ kind: 'native' }) + const input = { + runtimeHomePath: '/tmp/codex-home', + managedCommand: '/bin/sh codex-hook.sh', + expectedTrustKeys: ['managed-key'] + } + + expect(host.binaryStamp).toMatchObject({ kind: 'native', path: process.execPath }) + expect(host.buildRequest(input).invocation.command).toBe(process.execPath) + expect(host.buildRequest(input).invocation.command).toBe(process.execPath) + // Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing + // the resolved command keeps one grant at one scan regardless of consumers. + expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1) + }) + + it('builds WSL requests without scanning the native PATH', () => { + const host = resolveCodexTrustGrantHost({ + kind: 'wsl', + distro: 'Ubuntu', + linuxRuntimeHome: '/home/alice/.codex-runtime' + }) + const request = host.buildRequest({ + runtimeHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex-runtime', + managedCommand: '/bin/sh codex-hook.sh', + expectedTrustKeys: ['managed-key'] + }) + + expect(host.binaryStamp).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(request.invocation.command).toBe('wsl.exe') + expect(resolveCodexCommandMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts new file mode 100644 index 00000000000..bf51c800e10 --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.ts @@ -0,0 +1,94 @@ +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { + binaryStampsMatch, + buildNativeCodexBinaryStamp, + readCodexTrustGrantLedgerHome, + type CodexTrustGrantBinaryStamp, + type CodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' + +// Why: native sessions finish in ~100ms; WSL also pays cold-distro and +// login-shell startup, but both stay hard-bounded on launch prep. +const NATIVE_GRANT_TIMEOUT_MS = 10_000 +const WSL_GRANT_TIMEOUT_MS = 30_000 + +export type CodexTrustGrantHost = + | { kind: 'native' } + | { kind: 'wsl'; distro: string; linuxRuntimeHome: string } + +type CodexTrustGrantRequestInput = { + runtimeHomePath: string + managedCommand: string + expectedTrustKeys: string[] +} + +export type ResolvedCodexTrustGrantHost = { + binaryStamp: CodexTrustGrantBinaryStamp | null + buildRequest: (input: CodexTrustGrantRequestInput) => CodexHookTrustGrantRequest +} + +export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost { + if (host.kind === 'wsl') { + return { + binaryStamp: { kind: 'wsl', distro: host.distro }, + buildRequest: (input) => ({ + invocation: { + command: 'wsl.exe', + args: buildWslCodexAppServerArgs(host.distro, host.linuxRuntimeHome), + timeoutMs: WSL_GRANT_TIMEOUT_MS + }, + hooksListCwd: host.linuxRuntimeHome, + expectedTrustKeys: input.expectedTrustKeys, + managedCommand: input.managedCommand + }) + } + } + + // Why: command resolution scans PATH/version-manager directories. Resolve + // once per grant and reuse it for both the binary stamp and invocation. + const command = resolveCodexCommand() + return { + binaryStamp: command === 'codex' ? null : buildNativeCodexBinaryStamp(command), + buildRequest: (input) => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) + return { + invocation: { + command: spawnCmd, + args: spawnArgs, + env: { CODEX_HOME: input.runtimeHomePath }, + timeoutMs: NATIVE_GRANT_TIMEOUT_MS + }, + hooksListCwd: input.runtimeHomePath, + expectedTrustKeys: input.expectedTrustKeys, + managedCommand: input.managedCommand + } + } + } +} + +export function readCodexTrustGrantLedgerHomeMatchingStamp( + runtimeHomePath: string, + currentStamp: CodexTrustGrantBinaryStamp | null +): CodexTrustGrantLedgerHome | null { + const home = readCodexTrustGrantLedgerHome(runtimeHomePath) + return home && binaryStampsMatch(home.binary, currentStamp) ? home : null +} + +export function readCurrentCodexTrustGrantLedgerHome( + runtimeHomePath: string, + host: CodexTrustGrantHost +): CodexTrustGrantLedgerHome | null { + try { + return readCodexTrustGrantLedgerHomeMatchingStamp( + runtimeHomePath, + resolveCodexTrustGrantHost(host).binaryStamp + ) + } catch { + // Why: status is diagnostic and best-effort; unreadable ledger/binary + // paths must trigger conservative self-hash handling, not throw. + return null + } +} diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index 1c485abf934..83ab0141a34 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { _internals } from './codex-wsl-hook-install-plan' +import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan' const originalPlatform = process.platform @@ -24,6 +24,17 @@ afterEach(() => { }) describe('canonicalizeWslLinuxPath', () => { + it('joins guest paths without producing a double slash at the filesystem root', () => { + const plan = createCodexWslRuntimeHookInstallPlan( + 'C:\\runtime', + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + () => '/' + ) + + expect(plan?.commandScriptPath).toBe('/.orca/agent-hooks/codex-hook.sh') + expect(plan?.trustConfigPath).toBe('/hooks.json') + }) + it('returns the path unchanged off Windows without spawning wsl.exe', () => { setPlatform('linux') expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice') diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 0f63d036443..4db2bdea93f 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { win32 as pathWin32 } from 'node:path' +import { posix as pathPosix, win32 as pathWin32 } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' export type CodexWslRuntimeHookTarget = { @@ -188,8 +188,8 @@ export function createCodexWslRuntimeHookInstallPlan( configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), - commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, - trustConfigPath: `${linuxRuntimeHome}/hooks.json`, + commandScriptPath: pathPosix.join(linuxRuntimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + trustConfigPath: pathPosix.join(linuxRuntimeHome, 'hooks.json'), wslDistro: distro, linuxRuntimeHome } diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts new file mode 100644 index 00000000000..b317f5b3b65 --- /dev/null +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import { + computeTrustedHash, + parseTrustKey, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' +import { _internals as trustGrantInternals } from './codex-hook-trust-grant' +import { + readCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) + +import { CodexHookService } from './hook-service' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() +}) + +afterEach(() => { + trustGrantInternals.setGrantSessionRunnerSync(null) + trustGrantInternals.resetDiagnostics() + codexAppServerCapabilityCache.clear() + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function installCodexLikeGrantRunner(): ReturnType { + const codexHash = (key: string): string => + `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + expect(codexHome).toBeTruthy() + const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { + const parsed = parseTrustKey(key)! + return { + ...parsed, + command: request.managedCommand, + trustedHash: codexHash(key) + } + }) + upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: key, + trustedHash: codexHash(key) + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + return runner +} + +function prepareSystemHome(): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) +} + +describe('CodexHookService app-server trust grant lane', () => { + it('treats Codex hashes as authoritative and records the verified grant', () => { + prepareSystemHome() + const runner = installCodexLikeGrantRunner() + + const status = new CodexHookService().install() + + expect(status.state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const trustConfig = readFileSync(join(managedHome, 'config.toml'), 'utf-8') + expect(trustConfig).toContain('sha256:codex-session_start') + const selfComputed = computeTrustedHash({ + sourcePath: join(managedHome, 'hooks.json'), + eventLabel: 'session_start', + groupIndex: 0, + handlerIndex: 0, + command: wrapPosixHookCommand(join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')), + timeoutSec: 10 + }) + expect(trustConfig).not.toContain(selfComputed) + expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(6) + }) + + it('keeps config byte-stable and skips the session on a repeat ledger hit', () => { + prepareSystemHome() + const runner = installCodexLikeGrantRunner() + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const firstToml = readFileSync(join(managedHome, 'config.toml')) + + expect(service.install().state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) + }) + + it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => { + prepareSystemHome() + installCodexLikeGrantRunner() + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const ledger = readCodexTrustGrantLedgerHome(managedHome)! + writeCodexTrustGrantLedgerHome(managedHome, { + ...ledger, + binary: { kind: 'native', path: '/definitely/not/current/codex', size: 1, mtimeMs: 1 } + }) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + detail: expect.stringContaining('Trust entry missing or stale') + }) + }) + + it('upgrades self-computed trust in place without duplicate tables', () => { + prepareSystemHome() + const service = new CodexHookService() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + installCodexLikeGrantRunner() + + expect(service.install().state).toBe('installed') + const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8') + for (const eventLabel of [ + 'session_start', + 'user_prompt_submit', + 'pre_tool_use', + 'permission_request', + 'post_tool_use', + 'stop' + ]) { + const count = upgraded + .split('\n') + .filter( + (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) + ).length + expect(count, `duplicate trust tables for ${eventLabel}`).toBe(1) + } + }) + + it('leaves user trust byte-untouched while granting managed entries', () => { + prepareSystemHome() + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedHome, { recursive: true }) + const userBlock = [ + '[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]', + 'enabled = false', + 'trusted_hash = "sha256:user-owned-hash"' + ].join('\n') + writeFileSync(join(managedHome, 'config.toml'), `${userBlock}\n`) + installCodexLikeGrantRunner() + + expect(new CodexHookService().install().state).toBe('installed') + expect(readFileSync(join(managedHome, 'config.toml'), 'utf-8')).toContain(userBlock) + }) + + it('keeps the forced fallback on self-computed writes', () => { + prepareSystemHome() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + const runner = vi.fn() + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(new CodexHookService().install().state).toBe('installed') + expect(runner).not.toHaveBeenCalled() + }) + + it('restores exact config bytes before fallback after a mutating RPC failure', () => { + prepareSystemHome() + const service = new CodexHookService() + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' + expect(service.install().state).toBe('installed') + const managedHome = join(userDataDir, 'codex-runtime-home', 'home') + const baseline = readFileSync(join(managedHome, 'config.toml')) + + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + rmSync(managedHome, { recursive: true, force: true }) + trustGrantInternals.resetDiagnostics() + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const codexHome = request.invocation.env?.CODEX_HOME + writeFileSync( + join(codexHome!, 'config.toml'), + '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' + ) + throw new Error('transport failed after config/batchWrite') + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + expect(service.install().state).toBe('installed') + expect(runner).toHaveBeenCalledTimes(1) + expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline) + }) +}) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 1877fe8ebd8..cc887208f4c 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -9,6 +9,7 @@ import { computeTrustKey, computeTrustedHash, normalizeHookTrustKeyForLookup, + parseTrustKey, readHookTrustEntries, upsertHookTrustEntries, type CodexTrustEntry @@ -516,4 +517,61 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { trustedHash: computeTrustedHash(managedTrustEntry) }) }) + + it('uses the previous ledger to remove stale Codex hashes after a canonical path change', () => { + const basePlan = createTestPlan() + writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8') + writeFileSync(basePlan.tomlPath, '', 'utf-8') + const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + const entries = request.expectedTrustKeys.map((key) => { + const parsed = parseTrustKey(key)! + return { + sourcePath: parsed.sourcePath, + eventLabel: parsed.eventLabel, + groupIndex: parsed.groupIndex, + handlerIndex: parsed.handlerIndex, + command: request.managedCommand, + trustedHash: `sha256:codex-verbatim-${parsed.eventLabel}` + } + }) + upsertHookTrustEntries(basePlan.tomlPath, entries) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: request.expectedTrustKeys.map((key) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash: `sha256:codex-verbatim-${parseTrustKey(key)!.eventLabel}` + })) + } + }) + trustGrantInternals.setGrantSessionRunnerSync(runner) + + const oldPlan = { + ...basePlan, + commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/old/home/hooks.json', + linuxRuntimeHome: '/old/home' + } + expect(_internals.installManagedHooksIntoWslRuntime(oldPlan).state).toBe('installed') + const oldKey = computeTrustKey( + getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath)) + ) + + const newPlan = { + ...basePlan, + commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/new/home/hooks.json', + linuxRuntimeHome: '/new/home' + } + expect(_internals.installManagedHooksIntoWslRuntime(newPlan).state).toBe('installed') + const newKey = computeTrustKey( + getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath)) + ) + const trustEntries = readHookTrustEntries(basePlan.tomlPath) + + expect(runner).toHaveBeenCalledTimes(2) + expect(trustEntries.has(oldKey)).toBe(false) + expect(trustEntries.get(newKey)?.trustedHash).toBe('sha256:codex-verbatim-user_prompt_submit') + }) }) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 22a54eb0cde..526a64d99ab 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -17,17 +17,7 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils' -import { - computeTrustedHash, - upsertHookTrustEntries, - upsertHookTrustEntriesInContent, - parseTrustKey, - type CodexTrustEntry -} from './config-toml-trust' -import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' -import { _internals as trustGrantInternals } from './codex-hook-trust-grant' -import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' -import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -1464,223 +1454,3 @@ describe('CodexHookService', () => { expect(trustConfig).not.toContain('model = "runtime-model"') }) }) - -describe('CodexHookService app-server trust grant lane', () => { - beforeEach(() => { - trustGrantInternals.resetDiagnostics() - codexAppServerCapabilityCache.clear() - }) - - afterEach(() => { - trustGrantInternals.setGrantSessionRunnerSync(null) - trustGrantInternals.resetDiagnostics() - codexAppServerCapabilityCache.clear() - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - }) - - // Simulates codex's side of a successful grant: write trusted_hash blocks - // through codex's shape (trusted_hash only, no enabled line) and report the - // entries trusted, exactly like the real app-server session result. - function installCodexLikeGrantRunner(): { - runner: ReturnType - codexHash: (key: string) => string - } { - const codexHash = (key: string): string => - `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { - const codexHome = request.invocation.env?.CODEX_HOME - expect(codexHome).toBeTruthy() - const entries: CodexTrustEntry[] = request.expectedTrustKeys.map((key) => { - const parsed = parseTrustKey(key)! - return { - sourcePath: parsed.sourcePath, - eventLabel: parsed.eventLabel, - groupIndex: parsed.groupIndex, - handlerIndex: parsed.handlerIndex, - command: request.managedCommand, - trustedHash: codexHash(key) - } - }) - upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) - return { - outcome: 'granted' as const, - wroteTrust: true, - entries: request.expectedTrustKeys.map((key) => ({ - key, - normalizedKey: key, - trustedHash: codexHash(key) - })) - } - }) - trustGrantInternals.setGrantSessionRunnerSync(runner) - return { runner, codexHash } - } - - it('grants managed trust through codex and treats the codex hash as authoritative', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const { runner } = installCodexLikeGrantRunner() - - const status = new CodexHookService().install() - - // Why: the codex-written hash intentionally differs from - // computeTrustedHash — a drifted replica must no longer read as - // partial/stale (that misreport was the #7896/#7110/#8699 bug class). - expect(status.state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain('sha256:codex-session_start') - expect(trustConfig).toContain('sha256:codex-stop') - - const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') - const command = wrapPosixHookCommand(scriptPath) - const selfComputedHash = computeTrustedHash({ - sourcePath: join(managedCodexHome, 'hooks.json'), - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command, - timeoutSec: 10 - }) - expect(trustConfig).not.toContain(selfComputedHash) - - const ledgerHome = readCodexTrustGrantLedgerHome(managedCodexHome) - expect(ledgerHome).not.toBeNull() - expect(Object.keys(ledgerHome!.entries)).toHaveLength(6) - }) - - it('keeps config byte-stable and skips the session on a repeat install (ledger hit)', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const { runner } = installCodexLikeGrantRunner() - const service = new CodexHookService() - - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const tomlAfterFirst = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - const hooksAfterFirst = readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') - - const secondStatus = service.install() - - expect(secondStatus.state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toBe(tomlAfterFirst) - expect(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')).toBe(hooksAfterFirst) - }) - - it('upgrades a home carrying self-computed trust entries in place without duplicates', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const service = new CodexHookService() - - // Old-Orca state: fallback lane writes the self-computed hashes. - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const legacyToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(legacyToml).toContain('trusted_hash = "sha256:') - - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - installCodexLikeGrantRunner() - expect(service.install().state).toBe('installed') - - const upgradedToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(upgradedToml).toContain('sha256:codex-session_start') - for (const eventLabel of [ - 'session_start', - 'user_prompt_submit', - 'pre_tool_use', - 'permission_request', - 'post_tool_use', - 'stop' - ]) { - const headerCount = upgradedToml - .split('\n') - .filter( - (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) - ).length - expect(headerCount, `duplicate trust tables for ${eventLabel}`).toBe(1) - } - }) - - it('leaves user hook trust byte-untouched while granting managed entries', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - mkdirSync(managedCodexHome, { recursive: true }) - // Why: only the block itself is asserted — comments around runtime-owned - // sections are the config mirror's pre-existing concern, and codex-writer - // comment preservation is proven against the real binary instead. - const userBlock = [ - '[hooks.state."/home/user/.codex/hooks.json:stop:3:1"]', - 'enabled = false', - 'trusted_hash = "sha256:user-owned-hash"' - ].join('\n') - writeFileSync(join(managedCodexHome, 'config.toml'), `${userBlock}\n`, 'utf-8') - - installCodexLikeGrantRunner() - expect(new CodexHookService().install().state).toBe('installed') - - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain(userBlock) - expect(trustConfig).toContain('sha256:codex-session_start') - }) - - it('routes the forced-fallback lane through the unchanged self-computed writes', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - const runner = vi.fn() - trustGrantInternals.setGrantSessionRunnerSync(runner) - - const status = new CodexHookService().install() - - expect(status.state).toBe('installed') - expect(runner).not.toHaveBeenCalled() - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const scriptPath = join(tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') - const command = wrapPosixHookCommand(scriptPath) - const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain( - computeTrustedHash({ - sourcePath: join(managedCodexHome, 'hooks.json'), - eventLabel: 'session_start', - groupIndex: 0, - handlerIndex: 0, - command, - timeoutSec: 10 - }) - ) - }) - - it('keeps fallback output byte-identical after an RPC mutates config and then fails', () => { - const systemCodexHome = join(tmpHome, '.codex') - mkdirSync(systemCodexHome, { recursive: true }) - const service = new CodexHookService() - - process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const baseline = readFileSync(join(managedCodexHome, 'config.toml')) - - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - rmSync(managedCodexHome, { recursive: true, force: true }) - trustGrantInternals.resetDiagnostics() - const runner = vi.fn((request: CodexHookTrustGrantRequest) => { - const codexHome = request.invocation.env?.CODEX_HOME - expect(codexHome).toBeTruthy() - writeFileSync( - join(codexHome!, 'config.toml'), - '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' - ) - throw new Error('transport failed after config/batchWrite') - }) - trustGrantInternals.setGrantSessionRunnerSync(runner) - - expect(service.install().state).toBe('installed') - expect(runner).toHaveBeenCalledTimes(1) - expect(readFileSync(join(managedCodexHome, 'config.toml'))).toEqual(baseline) - }) -}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a7032ede08d..522c37f84e1 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -67,11 +67,14 @@ import { snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' import { - readCodexTrustGrantLedgerHome, - removeCodexTrustGrantLedgerHome, - type CodexTrustGrantLedgerHome -} from './codex-trust-grant-ledger' + getCodexLedgerTrustedHash, + readCodexTrustGrantLedgerHomeForReconciliation, + removeCodexManagedHookTrustEntries, + removeStaleWslCodexManagedHookTrustEntries +} from './codex-managed-trust-reconciliation' +import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -636,79 +639,16 @@ function cleanupLegacyManagedHookRepresentations(): void { } } -function readLedgerHomeForCleanup(runtimeHomePath: string): CodexTrustGrantLedgerHome | null { - try { - return readCodexTrustGrantLedgerHome(runtimeHomePath) - } catch { - return null - } -} - -// Why: RPC-granted entries carry Codex's hash, which need not equal the -// self-computed one — the grant ledger is what proves those blocks are ours. -function addLedgerRecognizedHash( - recognizedHashes: Set, - ledgerHome: CodexTrustGrantLedgerHome | null, - key: string, - expectedEntry: CodexTrustEntry -): void { - const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(key)] - if (granted?.trustedHash && granted.signature === getCodexHookTrustSignature(expectedEntry)) { - recognizedHashes.add(granted.trustedHash) - } -} - function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { - const tomlPath = getCodexConfigTomlPath() - const existingEntries = readHookTrustEntries(tomlPath) - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - // Why: only drop entries WE wrote. The same config.toml can contain - // user-approved trust entries for non-Orca commands, so match by hash - // equivalence to our managed command — a sourcePath-only filter would - // wipe the user's manually-approved entries. - const ourKeys: string[] = [] - const canonicalConfigPath = getCodexCanonicalTrustPath(configPath) - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedEntry: CodexTrustEntry = { - sourcePath: configPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - // Why: match the timeout install() wrote, or remove() would fail to - // recognize (and clean up) its own managed trust entries. - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { - continue - } - ourKeys.push(key) - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } - removeCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath()) + removeCodexManagedHookTrustEntries({ + tomlPath: getCodexConfigTomlPath(), + runtimeHomePath: getOrcaManagedCodexHomePath(), + sourcePath: configPath, + command: getManagedCommand(getManagedScriptPath()), + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) } catch (error) { // Best effort — stale trust entries are harmless once hooks.json no // longer references the hook. Log so a programmer error doesn't disappear silently. @@ -718,46 +658,14 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { try { - const existingEntries = readHookTrustEntries(plan.tomlPath) - const command = wrapReadablePosixHookCommand(plan.commandScriptPath) - const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(plan.tomlPath)) - const managedEventLabels = new Set( - CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) - ) - const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - const parts = parseTrustKey(key) - if (parts === null) { - continue - } - if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { - continue - } - if (!managedEventLabels.has(parts.eventLabel)) { - continue - } - const expectedEntry: CodexTrustEntry = { - sourcePath: plan.trustConfigPath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { - ourKeys.push(key) - } - } - if (ourKeys.length > 0) { - removeHookTrustEntries(plan.tomlPath, ourKeys) - } - removeCodexTrustGrantLedgerHome(pathWin32.dirname(plan.tomlPath)) + removeCodexManagedHookTrustEntries({ + tomlPath: plan.tomlPath, + runtimeHomePath: pathWin32.dirname(plan.tomlPath), + sourcePath: plan.trustConfigPath, + command: wrapReadablePosixHookCommand(plan.commandScriptPath), + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) } catch (error) { // Why: removing disabled WSL status hooks should be best-effort like the // host cleanup path; stale trust is inert once hooks.json no longer points at us. @@ -767,50 +675,19 @@ function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstal function removeStaleWslRuntimeManagedHookTrustEntries( tomlPath: string, - desiredEntries: readonly CodexTrustEntry[] + desiredEntries: readonly CodexTrustEntry[], + priorLedgerHomes: readonly CodexTrustGrantLedgerHome[] = [] ): void { - const desiredKeys = new Set( - desiredEntries.map((entry) => normalizeHookTrustKeyForLookup(computeTrustKey(entry))) - ) - const existingEntries = readHookTrustEntries(tomlPath) - const ledgerHome = readLedgerHomeForCleanup(pathWin32.dirname(tomlPath)) - const ourKeys: string[] = [] - for (const [key, state] of existingEntries) { - if (desiredKeys.has(normalizeHookTrustKeyForLookup(key))) { - continue - } - const parts = parseTrustKey(key) - if (!parts || !CODEX_MANAGED_EVENT_LABELS.has(parts.eventLabel)) { - continue - } - const sourcePath = parts.sourcePath - // Why: this cleanup owns only guest-side WSL trust. A runtime config can - // still contain user Windows/remote hooks, which must remain untouched. - if (!sourcePath.startsWith('/') || !sourcePath.endsWith('/hooks.json')) { - continue - } - const runtimeHome = sourcePath.slice(0, -'/hooks.json'.length) - const command = wrapReadablePosixHookCommand(`${runtimeHome}/.orca/agent-hooks/codex-hook.sh`) - const expectedEntry: CodexTrustEntry = { - sourcePath: parts.sourcePath, - eventLabel: parts.eventLabel, - groupIndex: parts.groupIndex, - handlerIndex: parts.handlerIndex, - command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - } - const recognizedHashes = new Set([ - computeTrustedHash(expectedEntry), - computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - ]) - addLedgerRecognizedHash(recognizedHashes, ledgerHome, key, expectedEntry) - if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { - ourKeys.push(key) - } - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } + removeStaleWslCodexManagedHookTrustEntries({ + tomlPath, + runtimeHomePath: pathWin32.dirname(tomlPath), + desiredEntries, + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS, + buildManagedCommand: (linuxRuntimeHome) => + wrapReadablePosixHookCommand(`${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`), + priorLedgerHomes + }) } function getManagedScript(target: 'local' | 'posix' = 'local'): string { @@ -961,15 +838,23 @@ function installManagedHooksIntoWslRuntime( try { // Why: same grant-then-fallback split as the host install — codex runs // inside the distro so the hash authority matches the codex the pane runs. + const runtimeHomePath = pathWin32.dirname(plan.tomlPath) + // Why: a successful re-grant replaces the ledger. Keep the previous + // records long enough to prove ownership of stale canonical-path keys. + const previousLedgerHome = readCodexTrustGrantLedgerHomeForReconciliation(runtimeHomePath) const grant = grantManagedCodexHookTrust({ - runtimeHomePath: pathWin32.dirname(plan.tomlPath), + runtimeHomePath, tomlPath: plan.tomlPath, managedCommand: command, managedEntries: trustEntries, host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } }) if (grant.lane === 'rpc') { - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, grant.entries) + removeStaleWslRuntimeManagedHookTrustEntries( + plan.tomlPath, + grant.entries, + previousLedgerHome ? [previousLedgerHome] : [] + ) } else { // Why: WSL runtime homes may carry user hook approvals we did not rebuild // here; only upsert Orca's entries instead of sweeping the whole source. @@ -1183,7 +1068,9 @@ export class CodexHookService { // Why: RPC-granted entries store Codex's own hash, which is authoritative // even when it differs from computeTrustedHash — that difference is the // drift bug class this lane exists to absorb, not a stale entry. - const ledgerHome = readLedgerHomeForCleanup(getOrcaManagedCodexHomePath()) + const ledgerHome = readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { + kind: 'native' + }) const missing: string[] = [] const trustMissing: string[] = [] @@ -1230,9 +1117,9 @@ export class CodexHookService { } const trustKey = computeTrustKey(trustInput) const validHashes = new Set([computeTrustedHash(trustInput)]) - const granted = ledgerHome?.entries[normalizeHookTrustKeyForLookup(trustKey)] - if (granted && granted.signature === getCodexHookTrustSignature(trustInput)) { - validHashes.add(granted.trustedHash) + const grantedHash = getCodexLedgerTrustedHash(ledgerHome, trustKey, trustInput) + if (grantedHash) { + validHashes.add(grantedHash) } const actualState = trustEntries.get(trustKey) if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) { From 8f9da3ed4ce297d9692e24a043383ed766e595bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:52:46 -0700 Subject: [PATCH 18/45] fix(codex): restore trust config permissions on rollback --- .../codex/codex-trust-config-rollback.test.ts | 16 ++++++++++++++++ src/main/codex/codex-trust-config-rollback.ts | 13 ++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts index ae53cfaaadb..4d4658f3543 100644 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -51,4 +51,20 @@ describe('Codex trust config rollback', () => { expect(statSync(configPath).mode & 0o777).toBe(0o640) } }) + + it.skipIf(process.platform === 'win32')( + 'restores the captured mode when the contents already match', + () => { + const configPath = tempConfigPath() + writeFileSync(configPath, '[hooks]\n') + chmodSync(configPath, 0o640) + const snapshot = captureCodexTrustConfig(configPath) + chmodSync(configPath, 0o600) + + restoreCodexTrustConfig(configPath, snapshot) + + expect(readFileSync(configPath, 'utf8')).toBe('[hooks]\n') + expect(statSync(configPath).mode & 0o777).toBe(0o640) + } + ) }) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index 74cea0d425a..6ed05f3766d 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -1,4 +1,12 @@ -import { closeSync, fstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + chmodSync, + closeSync, + fstatSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { randomUUID } from 'node:crypto' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' @@ -45,6 +53,9 @@ export function restoreCodexTrustConfig( } try { if (readFileSync(tomlPath).equals(snapshot.contents)) { + // Why: the RPC may change permissions without changing bytes; rollback + // restores the complete captured file state, not only its contents. + chmodSync(tomlPath, snapshot.mode) return } } catch (error) { From 5eb0750bf9c56b6800a1f89e4bab86691a8886c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:59:52 -0700 Subject: [PATCH 19/45] fix(codex): harden real-home routing cleanup and retries --- .../runtime-home-service.test.ts | 29 +++++- .../codex-accounts/runtime-home-service.ts | 18 ++-- .../codex-real-home-hook-install.test.ts | 80 ++++++++++++++++ .../codex/codex-real-home-hook-install.ts | 93 ++++++++++--------- src/main/codex/codex-real-home-path.ts | 20 ++++ 5 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 src/main/codex/codex-real-home-path.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 3f3b10970f8..c3fbdea2bc1 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -991,14 +991,39 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) - it('routes host system default to the real home (null) when the flag is ON', async () => { + it('routes host system default to the real home when the flag is ON', async () => { const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) expect(service.prepareForCodexLaunch()).toBeNull() - expect(service.prepareForRateLimitFetch()).toBeNull() + const previousCodexHome = process.env.CODEX_HOME + const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME + process.env.CODEX_HOME = getRuntimeCodexHomePath() + process.env.ORCA_CODEX_HOME = getRuntimeCodexHomePath() + try { + // Background fetchers prefer ambient CODEX_HOME when passed null, so an + // explicit path proves nested Orca launches cannot poll the managed home. + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + process.env.CODEX_HOME = getSystemCodexHomePath() + delete process.env.ORCA_CODEX_HOME + expect(service.isHostSystemDefaultRealHome()).toBe(true) + process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home') + expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + if (previousOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = previousOrcaCodexHome + } + } }) it('keeps the managed home for a host MANAGED account even when the flag is ON', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index c9990fcf9b0..e46bcee0307 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -61,6 +61,7 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' +import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' type CodexAuthIdentity = { email: string | null @@ -210,13 +211,14 @@ export class CodexRuntimeHomeService { } // Why: real-home routing applies only to the host system-default selection - // (no managed account chosen for host) with the staged flag ON. Managed host - // accounts keep the isolated runtime home for hot-swap and token persistence. + // with the staged flag ON. Managed accounts keep hot-swap isolation; custom + // CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes. isHostSystemDefaultRealHomeSelected(): boolean { const settings = this.store.getSettings() return ( isCodexSystemDefaultRealHomeEnabled(settings) && - normalizeCodexRuntimeSelection(settings).host === null + normalizeCodexRuntimeSelection(settings).host === null && + !hasCustomCodexHomeOverride() ) } @@ -288,11 +290,11 @@ export class CodexRuntimeHomeService { return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } if (this.isHostSystemDefaultRealHome()) { - // Why (flag ON, system default): read usage/auth from the user's own - // ~/.codex. Returning null makes the fetcher fall back to ~/.codex and - // its auth-presence gate check the real auth.json, so the background - // poller never spawns Codex against the managed home (the #5370 auth war). - return null + // Why: null lets the fetcher fall back to the main process's inherited + // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the + // managed home, restarting the background OAuth conflict (#5370), so + // pin this non-interactive lane to the native home explicitly. + return getSystemCodexHomePath() } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index f6f1f3a054d..db62745f246 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -4,6 +4,12 @@ import type * as NodeOs from 'node:os' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' +import { + computeTrustKey, + readHookTrustEntries, + upsertHookTrustEntriesInContent, + type CodexTrustEntry +} from './config-toml-trust' const { homedirMock, grantMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>(), @@ -16,6 +22,7 @@ vi.mock('node:os', async () => { }) vi.mock('./codex-hook-trust-grant', () => ({ + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000, grantManagedCodexHookTrust: grantMock })) @@ -34,6 +41,10 @@ function getRealHooksJsonPath(): string { return join(fakeHomeDir, '.codex', 'hooks.json') } +function getRealConfigTomlPath(): string { + return join(fakeHomeDir, '.codex', 'config.toml') +} + function readRealHooksJson(): { hooks?: Record [key: string]: unknown @@ -148,6 +159,21 @@ describe('ensureRealHomeCodexHookState (install)', () => { expect(existsSync(getRealHooksJsonPath())).toBe(false) }) + it('does no hook-file or grant work on repeated unsupported launches', () => { + grantUnavailable() + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + expect(existsSync(getRealHooksJsonPath())).toBe(false) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + + expect(grantMock).toHaveBeenCalledTimes(1) + expect(existsSync(getRealHooksJsonPath())).toBe(false) + }) + it('leaves an unparseable hooks.json untouched and keeps the managed lane', () => { writeFileSync(getRealHooksJsonPath(), '{not json', 'utf-8') @@ -205,4 +231,58 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { expect(lane).toBe('removed') expect(existsSync(getRealHooksJsonPath())).toBe(false) }) + + it('removes only hash-proven Orca trust from a mixed hook group', () => { + const material = getCodexManagedHookInstallMaterial() + const userCommand = 'my-user-hook.sh' + writeFileSync( + getRealHooksJsonPath(), + `${JSON.stringify( + { + hooks: { + Stop: [ + { + hooks: [ + { type: 'command', command: userCommand }, + { type: 'command', command: material.command, timeout: 10 } + ] + } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + const entries: CodexTrustEntry[] = [ + { + sourcePath: getRealHooksJsonPath(), + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: userCommand + }, + { + sourcePath: getRealHooksJsonPath(), + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 1, + command: material.command, + timeoutSec: 10 + } + ] + writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8') + + expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( + 'removed' + ) + + expect(readRealHooksJson().hooks?.Stop).toEqual([ + { hooks: [{ type: 'command', command: userCommand }] } + ]) + const trust = readHookTrustEntries(getRealConfigTomlPath()) + expect(trust.has(computeTrustKey(entries[0]!))).toBe(true) + expect(trust.has(computeTrustKey(entries[1]!))).toBe(false) + }) }) diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 9dbec577037..42a24fd1838 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,17 +1,9 @@ -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' import { buildManagedCommandHook, createManagedCommandMatcher, - hookDefinitionHasManagedCommand, MANAGED_HOOK_TIMEOUT_SECONDS, readHooksJson, removeManagedCommands, @@ -20,12 +12,16 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' -import { getSystemCodexHomePath } from './codex-home-paths' import { getCodexManagedScriptFileName } from './codex-hook-identity' -import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' -import { removeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, + grantManagedCodexHookTrust, + type CodexTrustGrantFallbackReason +} from './codex-hook-trust-grant' +import { removeCodexManagedHookTrustEntries } from './codex-managed-trust-reconciliation' import { getCodexManagedHookInstallMaterial } from './hook-service' -import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' +import { getSystemCodexHomePath } from './codex-home-paths' +import type { CodexTrustEntry } from './config-toml-trust' /** * Real-home Codex hook lane for the system-default selection (flag ON). @@ -42,6 +38,7 @@ import { computeTrustKey, removeHookTrustEntries, type CodexTrustEntry } from '. export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'removed' let currentLane: RealHomeCodexHookLane = 'pending' +let installRetryAfterMs = 0 export function getRealHomeCodexHookLane(): RealHomeCodexHookLane { return currentLane @@ -80,13 +77,24 @@ export function ensureRealHomeCodexHookState(args: { hooksEnabled: boolean userDataPath: string }): RealHomeCodexHookLane { + // Why: the grant client caches failed probes, but mutating and rolling back + // hooks.json before consulting it still adds synchronous work to every pane. + if (args.hooksEnabled && currentLane === 'unavailable' && Date.now() < installRetryAfterMs) { + return currentLane + } try { currentLane = args.hooksEnabled ? installRealHomeCodexHook(args.userDataPath) : sweepRealHomeCodexHook() + if (!args.hooksEnabled || currentLane === 'installed') { + installRetryAfterMs = 0 + } } catch (error) { console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error) currentLane = args.hooksEnabled ? 'unavailable' : currentLane + if (args.hooksEnabled) { + installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + } } return currentLane } @@ -99,6 +107,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { // Why: an unparseable user file must never be clobbered; without a hook // entry the managed lane keeps status working for this host. console.warn('[codex-real-home-hooks] could not parse', hooksJsonPath, '- managed lane kept') + installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS return 'unavailable' } @@ -161,12 +170,19 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { // bytes and keep this host on the managed-home lane; the grant client // already logged the fallback reason. restoreRealHomeHooksJson(hooksJsonPath, previousRaw) + installRetryAfterMs = getInstallRetryAfterMs(grant.reason) console.warn( `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` ) return 'unavailable' } +function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number { + return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled' + ? Number.POSITIVE_INFINITY + : Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS +} + function sweepRealHomeCodexHook(): RealHomeCodexHookLane { const hooksJsonPath = getRealHomeHooksJsonPath() const config = readHooksJson(hooksJsonPath) @@ -176,33 +192,16 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const material = getCodexManagedHookInstallMaterial() const nextHooks: Record = { ...config.hooks } - const removedTrustKeys: string[] = [] let removedAny = false for (const [eventName, definitions] of Object.entries(nextHooks)) { if (!Array.isArray(definitions)) { continue } - definitions.forEach((definition, groupIndex) => { - if (!hookDefinitionHasManagedCommand(definition, isManagedCommand)) { - return - } - const eventLabel = material.eventLabel[eventName as (typeof material.events)[number]] - if (!eventLabel) { - return - } - removedTrustKeys.push( - computeTrustKey({ - sourcePath: hooksJsonPath, - eventLabel, - groupIndex, - handlerIndex: 0, - command: material.command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) - ) - }) const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length !== definitions.length) { + if ( + cleaned.length !== definitions.length || + cleaned.some((definition, index) => definition !== definitions[index]) + ) { removedAny = true } if (cleaned.length === 0) { @@ -215,17 +214,20 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; // dropping them keeps the user's config.toml from accumulating orphans. - // Their removal never shifts user trust keys because Orca appends last. + // Verify ownership by the expected hash or grant ledger: stale/mixed hook + // groups must never make Orca delete a user's trust record at the same key. try { - removeHookTrustEntries(getRealHomeConfigTomlPath(), removedTrustKeys) + removeCodexManagedHookTrustEntries({ + tomlPath: getRealHomeConfigTomlPath(), + runtimeHomePath: getSystemCodexHomePath(), + sourcePath: hooksJsonPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) } catch (error) { console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error) } - try { - removeCodexTrustGrantLedgerHome(getSystemCodexHomePath()) - } catch { - // Ledger cleanup is bookkeeping only; the next grant rebuilds it. - } } return 'removed' } @@ -260,9 +262,9 @@ function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | n } return } - const tmpPath = `${hooksJsonPath}.${process.pid}.rollback.tmp` - writeFileSync(tmpPath, previousRaw, 'utf-8') - renameSync(tmpPath, hooksJsonPath) + // Why: rollback is part of the safety boundary. Use the shared atomic + // writer so Windows file-lock retries and failed-temp cleanup are covered. + writeFileAtomically(hooksJsonPath, previousRaw) } catch (error) { console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error) } @@ -271,5 +273,6 @@ function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | n export const _internals = { setLaneForTesting(lane: RealHomeCodexHookLane): void { currentLane = lane + installRetryAfterMs = 0 } } diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts new file mode 100644 index 00000000000..ede4983615c --- /dev/null +++ b/src/main/codex/codex-real-home-path.ts @@ -0,0 +1,20 @@ +import { resolve } from 'node:path' +import { getSystemCodexHomePath } from './codex-home-paths' + +/** True when the user points Codex outside its standard native home. */ +export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { + const codexHome = env.CODEX_HOME?.trim() + const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() + // Why: phase 1 owns only ~/.codex and can clean that path on downgrade. A + // custom home needs cross-home ownership tracking before Orca may mutate it. + return Boolean( + codexHome && + codexHome !== orcaCodexHome && + normalizePathForComparison(codexHome) !== normalizePathForComparison(getSystemCodexHomePath()) + ) +} + +function normalizePathForComparison(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} From 1581e324a9d5778d9c9e59041ff61c8a471f5036 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:07:01 -0700 Subject: [PATCH 20/45] fix(codex): preserve unicode trust RPC responses --- .../codex/codex-app-server-client.test.ts | 31 +++++++++++++++- src/main/codex/codex-app-server-client.ts | 10 +++--- src/main/codex/codex-trust-grant-host.ts | 13 ++++--- .../codex/hook-service-trust-grant.test.ts | 15 ++++++-- src/main/codex/hook-service.ts | 35 ++++++++++++++++--- 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 2e263f12384..82df78a57d2 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -22,7 +22,19 @@ const config = JSON.parse(process.env.STUB_CONFIG) require('node:fs').writeFileSync(config.pidFile, String(process.pid)) const trusted = new Set(config.hooks.filter(h => h.trustStatus === 'trusted').map(h => h.key)) let buffer = '' -function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') } +function send(message) { + const serialized = Buffer.from(JSON.stringify(message) + '\\n') + if (config.scenario === 'split-unicode') { + const marker = Buffer.from('é') + const markerIndex = serialized.indexOf(marker) + if (markerIndex !== -1) { + process.stdout.write(serialized.subarray(0, markerIndex + 1)) + setTimeout(() => process.stdout.write(serialized.subarray(markerIndex + 1)), 5) + return + } + } + process.stdout.write(serialized) +} function listing() { return { data: [{ @@ -212,6 +224,23 @@ describe('runCodexHookTrustGrantSession', () => { expect(result.outcome).toBe('verify-failed') }) + it('decodes JSONL when a non-ASCII hook path is split across stdout chunks', async () => { + const command = "/bin/sh '/tmp/rené/codex-hook.sh'" + const key = '/home/rené/.codex/hooks.json:session_start:0:0' + const { request } = createStubRequest({ + scenario: 'split-unicode', + hooks: [{ ...managedHook(key), command }], + expectedTrustKeys: [key], + managedCommand: command, + timeoutMs: 2_000 + }) + + await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({ + outcome: 'granted', + entries: [{ key }] + }) + }) + it('throws the unsupported error class for unknown JSON-RPC methods', async () => { const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] const { request } = createStubRequest({ diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index e3aa69117bc..0273893fcc8 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -178,8 +178,10 @@ export async function runCodexHookTrustGrantSession( child.on('close', () => { failPending(buildEarlyExitError()) }) - child.stderr.on('data', (chunk: Buffer) => { - stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_MAX_BYTES) + // Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a + // multibyte character split across pipe chunks or the response becomes invalid JSON. + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) }) // Why: a child can exit between the liveness check and stdin.write(); an // EPIPE must reject the RPC instead of becoming an unhandled stream error. @@ -188,8 +190,8 @@ export async function runCodexHookTrustGrantSession( }) let stdoutBuffer = '' - child.stdout.on('data', (chunk: Buffer) => { - stdoutBuffer += chunk.toString('utf8') + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdoutBuffer += chunk if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { child.kill('SIGKILL') failPending(new Error('codex app-server emitted an oversized JSONL response')) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index bf51c800e10..63cd81eb425 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -82,10 +82,15 @@ export function readCurrentCodexTrustGrantLedgerHome( host: CodexTrustGrantHost ): CodexTrustGrantLedgerHome | null { try { - return readCodexTrustGrantLedgerHomeMatchingStamp( - runtimeHomePath, - resolveCodexTrustGrantHost(host).binaryStamp - ) + const home = readCodexTrustGrantLedgerHome(runtimeHomePath) + if (!home) { + // Why: fallback-only installs have no ledger. Avoid a synchronous PATH + // and version-manager scan when there is no recorded stamp to validate. + return null + } + return binaryStampsMatch(home.binary, resolveCodexTrustGrantHost(host).binaryStamp) + ? home + : null } catch { // Why: status is diagnostic and best-effort; unreadable ledger/binary // paths must trigger conservative self-hash handling, not throw. diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index b317f5b3b65..4c51040fc38 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -18,9 +18,10 @@ import { } from './codex-trust-grant-ledger' import type { CodexHookTrustGrantRequest } from './codex-app-server-client' -const { getPathMock, homedirMock } = vi.hoisted(() => ({ +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), - homedirMock: vi.fn<() => string>() + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() })) vi.mock('electron', () => ({ app: { getPath: getPathMock } })) @@ -28,6 +29,7 @@ vi.mock('os', async (importOriginal) => { const actual = await importOriginal() return { ...actual, homedir: homedirMock } }) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) import { CodexHookService } from './hook-service' @@ -41,6 +43,7 @@ beforeEach(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(tmpHome) + resolveCodexCommandMock.mockReturnValue(process.execPath) getPathMock.mockImplementation((name: string) => { if (name === 'userData') { return userDataDir @@ -133,6 +136,9 @@ describe('CodexHookService app-server trust grant lane', () => { expect(service.install().state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) + // Why: each launch validates the binary stamp once; getStatus reuses the + // just-verified grant instead of repeating PATH/version-manager scans. + expect(resolveCodexCommandMock).toHaveBeenCalledTimes(2) expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) }) @@ -204,8 +210,11 @@ describe('CodexHookService app-server trust grant lane', () => { const runner = vi.fn() trustGrantInternals.setGrantSessionRunnerSync(runner) - expect(new CodexHookService().install().state).toBe('installed') + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + expect(service.getStatus().state).toBe('installed') expect(runner).not.toHaveBeenCalled() + expect(resolveCodexCommandMock).not.toHaveBeenCalled() }) it('restores exact config bytes before fallback after a mutating RPC failure', () => { diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 522c37f84e1..b1b3ac7a3b2 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1035,6 +1035,12 @@ export class CodexHookService { } getStatus(): AgentHookInstallStatus { + return this.getStatusAfterInstall(null) + } + + private getStatusAfterInstall( + recentGrantEntries: readonly CodexTrustEntry[] | null + ): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() const config = readHooksJson(configPath) @@ -1068,9 +1074,21 @@ export class CodexHookService { // Why: RPC-granted entries store Codex's own hash, which is authoritative // even when it differs from computeTrustedHash — that difference is the // drift bug class this lane exists to absorb, not a stale entry. - const ledgerHome = readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { - kind: 'native' - }) + // Why: install() already resolved the binary and either verified Codex's + // hashes or wrote fallback hashes. Re-resolving PATH here doubles sync launch work. + const ledgerHome = + recentGrantEntries === null + ? readCurrentCodexTrustGrantLedgerHome(getOrcaManagedCodexHomePath(), { kind: 'native' }) + : null + const recentGrantHashes = new Map() + for (const entry of recentGrantEntries ?? []) { + if (entry.trustedHash) { + recentGrantHashes.set(normalizeHookTrustKeyForLookup(computeTrustKey(entry)), { + signature: getCodexHookTrustSignature(entry), + trustedHash: entry.trustedHash + }) + } + } const missing: string[] = [] const trustMissing: string[] = [] @@ -1121,6 +1139,13 @@ export class CodexHookService { if (grantedHash) { validHashes.add(grantedHash) } + const recentGrant = recentGrantHashes.get(normalizeHookTrustKeyForLookup(trustKey)) + if ( + recentGrant?.signature === getCodexHookTrustSignature(trustInput) && + recentGrant.trustedHash + ) { + validHashes.add(recentGrant.trustedHash) + } const actualState = trustEntries.get(trustKey) if (!actualState?.trustedHash || !validHashes.has(actualState.trustedHash)) { trustMissing.push(eventName) @@ -1248,6 +1273,7 @@ export class CodexHookService { }) } const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries] + let recentGrantEntries: readonly CodexTrustEntry[] = [] config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) @@ -1272,6 +1298,7 @@ export class CodexHookService { host: { kind: 'native' } }) if (grant.lane === 'rpc') { + recentGrantEntries = grant.entries upsertHookTrustEntries(tomlPath, mirroredTrustEntries) removeStaleRuntimeHookTrustEntries(tomlPath, configPath, [ ...mirroredTrustEntries, @@ -1303,7 +1330,7 @@ export class CodexHookService { } catch (error) { console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) } - return this.getStatus() + return this.getStatusAfterInstall(recentGrantEntries) } async installRemote( From b552e719be893c26fdb149ad40d9c7ff424695ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:16:39 -0700 Subject: [PATCH 21/45] fix(codex): preserve remote env and complete real-home cleanup --- .../codex/hook-service-trust-grant.test.ts | 41 ++++++++++++++++++- src/main/codex/hook-service.ts | 31 +++++++++++++- src/main/ipc/pty.test.ts | 32 +++++++++++---- src/main/ipc/pty.ts | 28 +++++++------ 4 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index b317f5b3b65..b7028334721 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -5,8 +5,10 @@ import type * as Os from 'node:os' import { join } from 'node:path' import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { + computeTrustKey, computeTrustedHash, parseTrustKey, + readHookTrustEntries, upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' @@ -17,6 +19,7 @@ import { writeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { getCodexHookTrustSignature } from './codex-hook-identity' const { getPathMock, homedirMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -29,7 +32,7 @@ vi.mock('os', async (importOriginal) => { return { ...actual, homedir: homedirMock } }) -import { CodexHookService } from './hook-service' +import { CodexHookService, getCodexManagedHookInstallMaterial } from './hook-service' let tmpHome: string let userDataDir: string @@ -136,6 +139,42 @@ describe('CodexHookService app-server trust grant lane', () => { expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) }) + it('retries ledger-proven real-home trust cleanup after the hook is already gone', () => { + prepareSystemHome() + const systemHome = join(tmpHome, '.codex') + const hooksPath = join(systemHome, 'hooks.json') + const configPath = join(systemHome, 'config.toml') + const material = getCodexManagedHookInstallMaterial() + const trustedHash = 'sha256:codex-real-home-stop' + const entry: CodexTrustEntry = { + sourcePath: hooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: material.command, + timeoutSec: 10, + trustedHash + } + const trustKey = computeTrustKey(entry) + writeFileSync(hooksPath, `${JSON.stringify({ hooks: {} }, null, 2)}\n`) + upsertHookTrustEntries(configPath, [entry]) + writeCodexTrustGrantLedgerHome(systemHome, { + binary: null, + entries: { + [trustKey]: { + signature: getCodexHookTrustSignature(entry), + trustedHash + } + } + }) + installCodexLikeGrantRunner() + + expect(new CodexHookService().install().state).toBe('installed') + + expect(readHookTrustEntries(configPath).has(trustKey)).toBe(false) + expect(readCodexTrustGrantLedgerHome(systemHome)).toBeNull() + }) + it('does not accept a ledger hash after the recorded Codex binary stamp changes', () => { prepareSystemHome() installCodexLikeGrantRunner() diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a9f12bb38de..1668608dc80 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -228,7 +228,10 @@ function collectManagedTrustEntries( return entries } -function removeMatchingTrustEntries(configPath: string, entries: readonly CodexTrustEntry[]): void { +function removeSelfComputedMatchingTrustEntries( + configPath: string, + entries: readonly CodexTrustEntry[] +): void { if (entries.length === 0) { return } @@ -573,6 +576,17 @@ function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefi }) } +function removeSystemManagedHookTrustEntries(systemHomePath: string, hooksJsonPath: string): void { + removeCodexManagedHookTrustEntries({ + tomlPath: getSystemCodexConfigTomlPath(), + runtimeHomePath: systemHomePath, + sourcePath: hooksJsonPath, + command: getManagedCommand(getManagedScriptPath()), + managedEventLabels: CODEX_MANAGED_EVENT_LABELS, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) +} + function cleanupLegacySystemManagedHooks(): void { if (systemCodexHomeHookSweepSuppressed()) { return @@ -583,8 +597,14 @@ function cleanupLegacySystemManagedHooks(): void { return } + const systemHomePath = getSystemCodexHomePath() + const hasRecordedRealHomeGrant = + readCodexTrustGrantLedgerHomeForReconciliation(systemHomePath) !== null const config = readHooksJson(legacyConfigPath) if (!config?.hooks) { + if (hasRecordedRealHomeGrant) { + removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) + } return } @@ -623,8 +643,15 @@ function cleanupLegacySystemManagedHooks(): void { // Why: this is the user's system hooks file, not Orca's runtime copy. // Remove only stale Orca hook entries and preserve other managers' metadata. writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks }) + // Why: stale dev/version entries can reference an older managed script + // path that is not represented by the current grant ledger. + removeSelfComputedMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) + } + if (removedManagedHook || hasRecordedRealHomeGrant) { + // Why: the ledger recognizes Codex-computed hashes and remains a retry + // marker if a prior cleanup removed hooks.json but could not update TOML. + removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) } - removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) } function stripLegacyManagedProfileBlock(content: string): string { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a7cb4b1c9e2..a1c2120a104 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2383,7 +2383,12 @@ describe('registerPtyHandlers', () => { it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => { const sshSpawn = vi.fn( - async (_opts: { env: Record; paneKey?: string; tabId?: string }) => ({ + async (_opts: { + env: Record + envToDelete?: string[] + paneKey?: string + tabId?: string + }) => ({ id: 'ssh-pty' }) ) @@ -2420,7 +2425,8 @@ describe('registerPtyHandlers', () => { undefined, (() => ({ httpProxyUrl: 'http://proxy.example:8080', - httpProxyBypassRules: 'localhost' + httpProxyBypassRules: 'localhost', + codexSystemDefaultRealHomeEnabled: true })) as never, undefined, store as never @@ -2459,6 +2465,10 @@ describe('registerPtyHandlers', () => { expect(env.HTTPS_PROXY).toBeUndefined() expect(env.NO_PROXY).toBeUndefined() expect(env.FOO).toBe('bar') + // Why: real-home routing is host-only. A null local-home resolver on + // SSH must not become a request to alter the remote Codex environment. + expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME') + expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') expect(spawnOptions.paneKey).toBe(makePaneKey('tab-1', leafId)) expect(spawnOptions.tabId).toBe('tab-1') expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() @@ -5635,9 +5645,11 @@ describe('registerPtyHandlers', () => { } const savedRemoteHooks = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '0' - const remoteSpawn = vi.fn(async (_opts: { env?: Record }) => ({ - id: 'ssh:ssh-runtime-env@@relay-pty' - })) + const remoteSpawn = vi.fn( + async (_opts: { env?: Record; envToDelete?: string[] }) => ({ + id: 'ssh:ssh-runtime-env@@relay-pty' + }) + ) registerSshPtyProvider('ssh-runtime-env', { spawn: remoteSpawn, write: vi.fn(), @@ -5683,7 +5695,10 @@ describe('registerPtyHandlers', () => { mainWindow as never, runtime as never, undefined, - undefined, + (() => ({ + agentStatusHooksEnabled: false, + codexSystemDefaultRealHomeEnabled: true + })) as never, undefined, store as never ) @@ -5705,11 +5720,14 @@ describe('registerPtyHandlers', () => { persistHostSessionBinding: true }) - const env = remoteSpawn.mock.calls[0]?.[0].env + const spawnOptions = remoteSpawn.mock.calls[0]?.[0] + const env = spawnOptions.env expect(env).toMatchObject({ FOO: 'bar' }) expect(env?.ORCA_PANE_KEY).toBeUndefined() expect(env?.ORCA_TAB_ID).toBeUndefined() expect(env?.ORCA_WORKTREE_ID).toBeUndefined() + expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME') + expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith( expect.objectContaining({ targetId: 'ssh-runtime-env', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 72e499001c0..c938ec53001 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3093,12 +3093,14 @@ export function registerPtyHandlers( isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && !selectedCodexHomePath - const stripInheritedOrcaCodexHome = shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: getSettings?.() - }) + const stripInheritedOrcaCodexHome = + isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }) if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { throw new Error('Invalid PTY session id') @@ -3974,12 +3976,14 @@ export function registerPtyHandlers( isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && !selectedCodexHomePath - const stripInheritedOrcaCodexHome = shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: getSettings?.() - }) + const stripInheritedOrcaCodexHome = + isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: getSettings?.() + }) if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { // Should be unreachable: the expression above returns a string when From 7b7fa4eabf01e8b440bc788c745a6cd6794ef560 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:54:51 -0700 Subject: [PATCH 22/45] fix(codex): preserve real-home lane invariants --- .../runtime-home-service.test.ts | 18 +++++ .../codex-accounts/runtime-home-service.ts | 61 +++++++++++++--- .../codex-real-home-hook-install.test.ts | 43 +++++++++++ .../codex/codex-real-home-hook-install.ts | 51 +++++++++++-- src/main/daemon/pty-subprocess.test.ts | 73 +++++++++++++++++++ src/main/daemon/pty-subprocess.ts | 27 +++++-- src/main/daemon/types.ts | 4 +- src/main/index.ts | 24 +++--- src/main/ipc/pty.test.ts | 33 ++++++++- src/main/ipc/pty.ts | 38 +++++----- .../window/attach-main-window-services.ts | 5 +- 11 files changed, 317 insertions(+), 60 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c3fbdea2bc1..8fb5203ac3f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -988,6 +988,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) + expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) @@ -998,6 +999,23 @@ describe('CodexRuntimeHomeService', () => { expect(service.isHostSystemDefaultRealHome()).toBe(true) expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ + getRuntimeCodexHomePath(), + getSystemCodexHomePath() + ]) + const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') + expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false) + expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe( + getRuntimeCodexHomePath() + ) + writeFileSync( + join(testState.fakeHomeDir, '.zshrc'), + 'export CODEX_HOME="$HOME/shell-custom-codex-home"\n', + 'utf-8' + ) + const shellLaunchEnv = { HOME: testState.fakeHomeDir, SHELL: '/bin/zsh' } + expect(service.isHostSystemDefaultRealHome(shellLaunchEnv)).toBe(false) + expect(service.prepareForCodexLaunch(undefined, shellLaunchEnv)).toBe(getRuntimeCodexHomePath()) const previousCodexHome = process.env.CODEX_HOME const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME process.env.CODEX_HOME = getRuntimeCodexHomePath() diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index e46bcee0307..845ac0d7f0b 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -62,6 +62,7 @@ import { import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' +import { readShellStartupEnvVar } from '../pty/shell-startup-env' type CodexAuthIdentity = { email: string | null @@ -93,6 +94,20 @@ type CodexReadBackMatch = } | { kind: 'none' | 'ambiguous' } +function readLaunchEnvValue( + launchEnv: NodeJS.ProcessEnv, + key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' +): string | undefined { + return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key] +} + +function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { + CODEX_HOME: readLaunchEnvValue(launchEnv, 'CODEX_HOME'), + ORCA_CODEX_HOME: readLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') + } +} + export class CodexRuntimeHomeService { // Why: tracks whether the runtime auth.json currently mirrors a managed // account. When null, runtime auth follows the user's system-default @@ -140,7 +155,10 @@ export class CodexRuntimeHomeService { * Historical session bridging is requested in the background so launch setup * returns as soon as the active runtime home is ready. */ - prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { + prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) @@ -149,7 +167,7 @@ export class CodexRuntimeHomeService { this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } - if (this.isHostSystemDefaultRealHome()) { + if (this.isHostSystemDefaultRealHome(launchEnv)) { // Why (flag ON, system default): run Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; // sessions, auth, and config all live in the native home. No system-> @@ -197,8 +215,14 @@ export class CodexRuntimeHomeService { }) } - getHostRuntimeHomePath(): string { - return this.getRuntimeHomePath() + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + if (this.isHostSystemDefaultRealHomeSelected()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME; + // explicitly include the real lane so its sessions remain discoverable. + homes.push(getSystemCodexHomePath()) + } + return homes.filter((home, index) => homes.indexOf(home) === index) } // Why: the real-home hook installer flips this gate off when the trust-grant @@ -213,17 +237,32 @@ export class CodexRuntimeHomeService { // Why: real-home routing applies only to the host system-default selection // with the staged flag ON. Managed accounts keep hot-swap isolation; custom // CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes. - isHostSystemDefaultRealHomeSelected(): boolean { + isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { const settings = this.store.getSettings() - return ( - isCodexSystemDefaultRealHomeEnabled(settings) && - normalizeCodexRuntimeSelection(settings).host === null && - !hasCustomCodexHomeOverride() + if ( + !isCodexSystemDefaultRealHomeEnabled(settings) || + normalizeCodexRuntimeSelection(settings).host !== null + ) { + return false + } + // Why: PTY callers can overlay environment values that the Electron main + // process never inherited. Those custom homes must keep the managed lane. + const effectiveEnv = launchEnv ? getEffectiveCodexHomeEnv(launchEnv) : process.env + if (hasCustomCodexHomeOverride(effectiveEnv)) { + return false + } + // Why: Finder/Dock launches do not inherit shell exports, but the login + // shell can re-export a custom home after spawn and bypass the trusted lane. + const shellCodexHome = readShellStartupEnvVar( + 'CODEX_HOME', + launchEnv ? readLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME, + launchEnv ? readLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL ) + return !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome }) } - isHostSystemDefaultRealHome(): boolean { - return this.isHostSystemDefaultRealHomeSelected() && this.realHomeLaneGate() + isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() } syncActiveWslSelectionsBeforeRestart(): void { diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index db62745f246..06795b7d482 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -194,6 +194,49 @@ describe('ensureRealHomeCodexHookState (install)', () => { expect(lane).toBe('installed') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(firstRaw) }) + + it('keeps later user hook trust positions stable when reconciling an existing install', () => { + grantSucceeds() + const userBefore = { hooks: [{ type: 'command', command: 'before.sh' }] } + writeFileSync( + getRealHooksJsonPath(), + `${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`, + 'utf-8' + ) + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const installed = readRealHooksJson() + const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] } + installed.hooks!.Stop!.push(userAfter) + writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'installed' + ) + + const reconciled = readRealHooksJson().hooks?.Stop + expect(reconciled?.[0]).toEqual(userBefore) + expect(reconciled?.[2]).toEqual(userAfter) + const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan + expect(plan.managedEntries.find((entry) => entry.eventLabel === 'stop')?.groupIndex).toBe(1) + }) + + it("keeps later user handler trust positions stable inside Orca's hook group", () => { + grantSucceeds() + ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + const installed = readRealHooksJson() + const userAfter = { type: 'command', command: 'after.sh' } + installed.hooks!.Stop![0]!.hooks!.push(userAfter) + writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'installed' + ) + + expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter) + const plan = grantMock.mock.calls.at(-1)![0] as CodexManagedTrustGrantPlan + const stopEntry = plan.managedEntries.find((entry) => entry.eventLabel === 'stop') + expect(stopEntry).toMatchObject({ groupIndex: 0, handlerIndex: 0 }) + }) }) describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 42a24fd1838..83fc162951a 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -120,16 +120,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { const managedEntries: CodexTrustEntry[] = [] for (const eventName of material.events) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - // Why: append LAST. Codex trust keys are positional - // (source:event:group:handler); prepending would shift every user entry - // and invalidate the user's own hook trust records. - nextHooks[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(material.command)] }] + const reconciled = reconcileManagedHookDefinition(current, isManagedCommand, material.command) + nextHooks[eventName] = reconciled.definitions managedEntries.push({ sourcePath: hooksJsonPath, eventLabel: material.eventLabel[eventName], - groupIndex: cleaned.length, - handlerIndex: 0, + groupIndex: reconciled.groupIndex, + handlerIndex: reconciled.handlerIndex, command: material.command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS }) @@ -177,6 +174,46 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { return 'unavailable' } +function reconcileManagedHookDefinition( + current: HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean, + command: string +): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } { + const directCommandKeys = ['command', 'bash', 'powershell'] as const + const hasManagedDirectCommand = current.some((definition) => + directCommandKeys.some((key) => isManagedCommand(definition[key])) + ) + const nestedLocations = current.flatMap((definition, groupIndex) => + Array.isArray(definition.hooks) + ? definition.hooks.flatMap((hook, handlerIndex) => + isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : [] + ) + : [] + ) + if (!hasManagedDirectCommand && nestedLocations.length === 1) { + const { groupIndex, handlerIndex } = nestedLocations[0]! + const definition = current[groupIndex]! + const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string') + if (definition.matcher === undefined && !hasDirectCommand) { + const definitions = [...current] + // Why: users can append groups or handlers after Orca's first install. + // Reusing the exact slot preserves all later positional trust keys. + const hooks = [...definition.hooks!] + hooks[handlerIndex] = buildManagedCommandHook(command) + definitions[groupIndex] = { ...definition, hooks } + return { definitions, groupIndex, handlerIndex } + } + } + + const cleaned = removeManagedCommands(current, isManagedCommand) + // Why: first install appends LAST so no existing user trust position shifts. + return { + definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }], + groupIndex: cleaned.length, + handlerIndex: 0 + } +} + function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number { return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled' ? Number.POSITIVE_INFINITY diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 472c59a619f..0edb09b696a 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -329,6 +329,11 @@ describe('createPtySubprocess', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21) }) + it('uses a new daemon protocol for daemon-local Codex env ownership', () => { + expect(PROTOCOL_VERSION).toBeGreaterThan(22) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) + }) + it('resolves a missing Unix default before spawning node-pty', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -1871,6 +1876,74 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.CODEX_HOME).toBeUndefined() }) + it('deletes daemon-owned Codex overlay pairs when the private marker is requested', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousCodexHome = process.env.CODEX_HOME + const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME + process.env.CODEX_HOME = '/daemon/managed/codex-home' + process.env.ORCA_CODEX_HOME = '/daemon/managed/codex-home' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + envToDelete: ['ORCA_CODEX_HOME'] + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + if (previousOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = previousOrcaCodexHome + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.CODEX_HOME).toBeUndefined() + expect(env.ORCA_CODEX_HOME).toBeUndefined() + }) + + it('preserves a daemon-owned custom Codex home while deleting a stale private marker', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const previousCodexHome = process.env.CODEX_HOME + const previousOrcaCodexHome = process.env.ORCA_CODEX_HOME + process.env.CODEX_HOME = '/daemon/user/codex-home' + process.env.ORCA_CODEX_HOME = '/daemon/stale/managed-home' + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { SHELL: '/bin/bash' }, + envToDelete: ['ORCA_CODEX_HOME'] + }) + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = previousCodexHome + } + if (previousOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = previousOrcaCodexHome + } + } + + const env = spawnMock.mock.calls.at(-1)![2].env + expect(env.CODEX_HOME).toBe('/daemon/user/codex-home') + expect(env.ORCA_CODEX_HOME).toBeUndefined() + }) + it('honors explicit terminal env overrides after deleting requested defaults', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index b0e69655a08..ce911777c0b 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -122,6 +122,25 @@ export type PtySubprocessOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } +function deleteRequestedDaemonEnvKeys( + env: Record, + keys: readonly string[] | undefined +): void { + // Why: the persistent daemon's inherited env can differ from Electron's. + // Compare ownership here so real-home routing neither leaks an Orca overlay + // nor deletes a user-owned CODEX_HOME chosen by the daemon's host context. + const deleteOrcaOwnedCodexHome = + keys?.includes('ORCA_CODEX_HOME') === true && + env.ORCA_CODEX_HOME !== undefined && + env.CODEX_HOME === env.ORCA_CODEX_HOME + for (const key of keys ?? []) { + delete env[key] + } + if (deleteOrcaOwnedCodexHome) { + delete env.CODEX_HOME + } +} + /** * Returns a stable default working directory for daemon-spawned PTYs. */ @@ -584,9 +603,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl FORCE_HYPERLINK: '1' } as Record composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) - for (const key of opts.envToDelete ?? []) { - delete env[key] - } + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) if (opts.env?.TERM) { env.TERM = opts.env.TERM } @@ -760,9 +777,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } else { // Why: relay-side launch modes can ask for host defaults to stay scrubbed // even after environment normalization above. - for (const key of opts.envToDelete ?? []) { - delete env[key] - } + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) if (opts.env?.TERM) { env.TERM = opts.env.TERM } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index e9e7fb3c7a6..6f48e1ccdbf 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -16,10 +16,10 @@ import type { TuiAgent } from '../../shared/types' // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 22 +export const PROTOCOL_VERSION = 23 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 ] as const // ─── Session State Machine ────────────────────────────────────────── diff --git a/src/main/index.ts b/src/main/index.ts index 2e4317ce8ed..0a3bcab2848 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -737,8 +737,14 @@ function startTerminalRuntimeStartupServices(): Promise { return firstWindowStartupServicesReady } -function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { - if (target?.runtime !== 'wsl' && codexRuntimeHome!.isHostSystemDefaultRealHomeSelected()) { +function prepareCodexRuntimeHomeForLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv +): string | null { + if ( + target?.runtime !== 'wsl' && + codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) + ) { // Why (flag ON, system default): the hook entry must exist — appended last // and trusted by codex's own app-server grant — in the real ~/.codex before // the pane spawns. An incapable grant flips the lane gate so the launch @@ -748,8 +754,8 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): userDataPath: app.getPath('userData') }) } - const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) - if (runtimeHomePath === null && codexRuntimeHome!.isHostSystemDefaultRealHome()) { + const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv) + if (runtimeHomePath === null && target?.runtime !== 'wsl') { // Why: Codex runs on the user's real ~/.codex; the managed-home hook // install below would target a home Codex never reads on this lane. return null @@ -1000,7 +1006,7 @@ function openMainWindow(): BrowserWindow { keybindings, { getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], + codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], onBeforeRelaunch: async () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() @@ -1882,7 +1888,7 @@ app.whenReady().then(async () => { // aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH // hosts; the window-only registerCoreHandlers path never runs under serve. getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], + codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) @@ -2020,15 +2026,13 @@ app.whenReady().then(async () => { } } if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { - // Why: host-connect seam — install (or sweep, when opted out) the trusted - // real-home status hook before the first pane can spawn, so the very first - // launch already knows whether this host's grant lane is usable. + // Why: establish the lane before background rate-limit polling starts, so + // an incapable grant host never polls a home its PTYs will not use. ensureRealHomeCodexHookState({ hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()), userDataPath: app.getPath('userData') }) } - app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { name: details.name, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a1c2120a104..048895045bb 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -749,7 +749,10 @@ describe('registerPtyHandlers', () => { async function spawnAndGetEnv( argsEnv?: Record, processEnvOverrides?: Record, - getSelectedCodexHomePath?: () => string | null, + getSelectedCodexHomePath?: ( + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, + launchEnv?: NodeJS.ProcessEnv + ) => string | null, getSettings?: () => { enableGitHubAttribution?: boolean agentStatusHooksEnabled?: boolean @@ -1398,6 +1401,28 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBeUndefined() }) + it('lets the resolver keep a per-spawn custom CODEX_HOME on the managed lane', async () => { + const customHome = '/home/me/.config/codex' + let resolvedCodexHome: string | undefined + const resolveHome = vi.fn((_target: unknown, launchEnv?: NodeJS.ProcessEnv) => { + resolvedCodexHome = launchEnv?.CODEX_HOME + return launchEnv?.CODEX_HOME === customHome ? TEST_CODEX_HOME : null + }) + + const env = await spawnAndGetEnv( + { CODEX_HOME: customHome }, + { CODEX_HOME: undefined, ORCA_CODEX_HOME: undefined }, + resolveHome, + () => ({ codexSystemDefaultRealHomeEnabled: true }) as never + ) + + expect(resolveHome).toHaveBeenCalledTimes(1) + expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) + expect(resolvedCodexHome).toBe(customHome) + expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) + expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) + }) + it('injects explicit proxy settings into local PTY env', async () => { const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({ httpProxyUrl: 'http://proxy.example:8080', @@ -1759,9 +1784,9 @@ describe('registerPtyHandlers', () => { ) expect(spawnOptions.env.CODEX_HOME).toBeUndefined() expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() - expect(spawnOptions.envToDelete).toEqual( - expect.arrayContaining(['CODEX_HOME', 'ORCA_CODEX_HOME']) - ) + expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['ORCA_CODEX_HOME'])) + // The daemon compares its own merged values before deleting CODEX_HOME. + expect(spawnOptions.envToDelete).not.toContain('CODEX_HOME') }) it('preserves a daemon-inherited user CODEX_HOME for real-home routing', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c938ec53001..99cddb0aa78 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -647,16 +647,14 @@ const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const // the marker so a shell-ready wrapper cannot restore the managed home. A // user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { - for (const key of getInheritedOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { delete baseEnv[key] } } -// Why: the daemon spawns the PTY from its own inherited environment and honors -// only spawnOptions.envToDelete, so mutating the sparse env object is not enough -// to strip an Orca-owned CODEX_HOME the daemon already carries. Return the exact -// keys to delete, preserving a user-owned CODEX_HOME. -function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { +// Why: in-process spawns share main's inherited environment, so equality with +// the private marker is authoritative here. Persistent daemons compare locally. +function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME const keysToDelete = ['ORCA_CODEX_HOME'] @@ -666,7 +664,10 @@ function getInheritedOrcaCodexHomeEnvKeysToDelete(env: Record): return keysToDelete } -type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null +type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv +) => string | null type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -1604,7 +1605,7 @@ export function registerPtyHandlers( : { runtime: 'host' } const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { @@ -3086,7 +3087,7 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null ) : null const skipCodexHomeEnv = @@ -3144,12 +3145,11 @@ export function registerPtyHandlers( CODEX_HOME_ENV_KEYS ) } else if (stripInheritedOrcaCodexHome) { - // Why: the daemon inherits its own CODEX_HOME; strip the Orca-owned - // override there too, preserving a user-set CODEX_HOME. - spawnOptions.envToDelete = mergePtyEnvDeletions( - spawnOptions.envToDelete, - getInheritedOrcaCodexHomeEnvKeysToDelete(env ?? {}) - ) + // Why: the daemon owns a persistent inherited environment that may + // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. + spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [ + 'ORCA_CODEX_HOME' + ]) } deleteRequestedEnvKeys(env, spawnOptions.envToDelete) promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) @@ -3969,7 +3969,7 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null ) : null const skipCodexHomeEnv = @@ -4055,9 +4055,9 @@ export function registerPtyHandlers( ), skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] ), - // Why: real-home routing strips the Orca-owned override the daemon - // inherits, while preserving a user-set CODEX_HOME. - stripInheritedOrcaCodexHome ? getInheritedOrcaCodexHomeEnvKeysToDelete(spawnEnv ?? {}) : [] + // Why: the persistent daemon compares its own merged CODEX_HOME pair; + // main cannot safely decide ownership for a process it may not parent. + stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] ) deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c598..4d1891f373a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -68,7 +68,10 @@ export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: (target?: CodexAccountSelectionTarget) => string | null, + getSelectedCodexHomePath?: ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ) => string | null, prepareClaudeAuth?: ( target?: ClaudeAccountSelectionTarget ) => Promise, From 65fb31c0b92afc551669f22765b510321a7db60e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:00:42 -0700 Subject: [PATCH 23/45] test(terminal): isolate replacement idle reset assertion --- .../src/components/terminal-pane/pty-connection.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ac0598f5396..85a3d89ad63 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -16652,6 +16652,8 @@ describe('connectPanePty', () => { paneKey, stateHistory: [] } + // Ignore cursor resets from setup so this assertion only covers the replacement idle event. + pane.terminal.write.mockClear() idleHandler('Claude done') await vi.advanceTimersByTimeAsync(800) await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) From 60e8af99ad5b4920b1b00316c6d6404836263679 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:09:55 -0700 Subject: [PATCH 24/45] fix(codex): preserve real-home dotfile links --- src/main/agent-hooks/installer-utils.ts | 11 ++- .../codex-real-home-hook-install.test.ts | 74 +++++++++++++++- .../codex/codex-real-home-hook-install.ts | 85 ++++++++++++++----- .../codex/codex-trust-config-rollback.test.ts | 28 +++++- src/main/codex/codex-trust-config-rollback.ts | 32 +++++-- 5 files changed, 197 insertions(+), 33 deletions(-) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 893cb53d465..d9e36edcbfd 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, + statSync, writeFileSync, chmodSync, copyFileSync, @@ -353,7 +354,11 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void { } } -export function writeHooksJson(configPath: string, config: HooksConfig): void { +export function writeHooksJson( + configPath: string, + config: HooksConfig, + options?: { preserveMode?: boolean } +): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) @@ -368,6 +373,8 @@ export function writeHooksJson(configPath: string, config: HooksConfig): void { // UUID suffix makes the tmp path unique per call. const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) const serialized = `${JSON.stringify(config, null, 2)}\n` + const existingMode = + options?.preserveMode === true && existsSync(configPath) ? statSync(configPath).mode : undefined // Why: skip the write (and therefore the .bak rotation) when the on-disk // content is already identical. Without this, every install() rewrites the @@ -386,7 +393,7 @@ export function writeHooksJson(configPath: string, config: HooksConfig): void { } try { - writeFileSync(tmpPath, serialized, 'utf-8') + writeFileSync(tmpPath, serialized, { encoding: 'utf-8', mode: existingMode }) // Why: single rolling backup — one file, no accumulation in ~/.claude. // Protects against a merge-logic bug producing bad JSON; the original is // always recoverable from .bak until the next write. diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index 06795b7d482..6dfa04f4749 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' import type * as NodeOs from 'node:os' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -138,6 +149,67 @@ describe('ensureRealHomeCodexHookState (install)', () => { ).toBe(`${JSON.stringify(userConfig, null, 2)}\n`) }) + it('updates a symlinked hooks.json target without replacing the symlink', () => { + grantSucceeds() + const dotfilesDir = join(fakeHomeDir, 'dotfiles') + const targetPath = join(dotfilesDir, 'hooks.json') + mkdirSync(dotfilesDir, { recursive: true }) + writeFileSync( + targetPath, + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n`, + 'utf-8' + ) + symlinkSync(targetPath, getRealHooksJsonPath()) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'installed' + ) + + expect(lstatSync(getRealHooksJsonPath()).isSymbolicLink()).toBe(true) + expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toHaveLength(2) + }) + + it('keeps the managed lane and original bytes when the pristine backup cannot be created', () => { + grantSucceeds() + const original = `${JSON.stringify({ hooks: { Stop: [] } }, null, 2)}\n` + writeFileSync(getRealHooksJsonPath(), original, 'utf-8') + writeFileSync(join(userDataDir, 'codex-real-home-hooks'), 'blocks backup directory', 'utf-8') + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original) + expect(grantMock).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform === 'win32')('preserves restrictive hooks.json permissions', () => { + grantSucceeds() + writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') + chmodSync(getRealHooksJsonPath(), 0o600) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'installed' + ) + + expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) + }) + + it.skipIf(process.platform === 'win32')( + 'restores restrictive hooks.json permissions after grant fallback', + () => { + grantUnavailable() + writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') + chmodSync(getRealHooksJsonPath(), 0o600) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + + expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) + } + ) + it('rolls the file back byte-exactly when the grant lane is unavailable', () => { grantUnavailable() const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n` diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 83fc162951a..47eb718d8a9 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,4 +1,12 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + unlinkSync +} from 'node:fs' import { join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -61,6 +69,30 @@ function getRealHomeConfigTomlPath(): string { return join(getSystemCodexHomePath(), 'config.toml') } +function resolveRealHomeHooksWritePath(hooksJsonPath: string): string { + let isSymlink = false + try { + isSymlink = lstatSync(hooksJsonPath).isSymbolicLink() + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return hooksJsonPath + } + throw error + } + if (!isSymlink) { + return hooksJsonPath + } + try { + // Why: replacing the link itself would silently disconnect a user's + // dotfiles-managed hooks.json. Atomic writes belong at its real target. + return realpathSync.native(hooksJsonPath) + } catch (error) { + throw new Error(`Could not resolve symlinked Codex hooks file ${hooksJsonPath}`, { + cause: error + }) + } +} + /** Orca-side state dir; nothing extra is ever written into the user's ~/.codex. */ function getRealHomeHookStateDir(userDataPath: string): string { return join(userDataPath, 'codex-real-home-hooks') @@ -102,6 +134,7 @@ export function ensureRealHomeCodexHookState(args: { function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { const material = getCodexManagedHookInstallMaterial() const hooksJsonPath = getRealHomeHooksJsonPath() + const hooksWritePath = resolveRealHomeHooksWritePath(hooksJsonPath) const config = readHooksJson(hooksJsonPath) if (!config) { // Why: an unparseable user file must never be clobbered; without a hook @@ -146,10 +179,13 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { } const previousRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null - backupRealHomeHooksJsonOnce(userDataPath, hooksJsonPath, previousRaw) + const previousMode = previousRaw === null ? undefined : statSync(hooksWritePath).mode + backupRealHomeHooksJsonOnce(userDataPath, previousRaw) // Why: unknown top-level fields belong to the user (other managers' // metadata); unlike the managed-home writer, preserve them verbatim. - writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks } as HooksConfig, { + preserveMode: true + }) const grant = grantManagedCodexHookTrust({ runtimeHomePath: getSystemCodexHomePath(), @@ -166,7 +202,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane { // would surface as "Hooks need review". Roll the file back to its prior // bytes and keep this host on the managed-home lane; the grant client // already logged the fallback reason. - restoreRealHomeHooksJson(hooksJsonPath, previousRaw) + restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode) installRetryAfterMs = getInstallRetryAfterMs(grant.reason) console.warn( `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` @@ -248,7 +284,14 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { } } if (removedAny) { - writeHooksJson(hooksJsonPath, { ...config, hooks: nextHooks } as HooksConfig) + writeHooksJson( + resolveRealHomeHooksWritePath(hooksJsonPath), + { + ...config, + hooks: nextHooks + } as HooksConfig, + { preserveMode: true } + ) // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; // dropping them keeps the user's config.toml from accumulating orphans. // Verify ownership by the expected hash or grant ledger: stale/mixed hook @@ -270,28 +313,26 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { } /** One-time pristine copy of the user's file, kept under Orca's userData. */ -function backupRealHomeHooksJsonOnce( - userDataPath: string, - hooksJsonPath: string, - previousRaw: string | null -): void { +function backupRealHomeHooksJsonOnce(userDataPath: string, previousRaw: string | null): void { if (previousRaw === null) { return } - try { - const backupDir = getRealHomeHookStateDir(userDataPath) - const backupPath = join(backupDir, 'hooks.json.pre-orca') - if (existsSync(backupPath)) { - return - } - mkdirSync(backupDir, { recursive: true }) - copyFileSync(hooksJsonPath, backupPath) - } catch (error) { - console.warn('[codex-real-home-hooks] failed to write pristine backup:', error) + const backupDir = getRealHomeHookStateDir(userDataPath) + const backupPath = join(backupDir, 'hooks.json.pre-orca') + if (existsSync(backupPath)) { + return } + // Why: this lane mutates the user's real Codex home. If the required + // pristine recovery copy cannot be created, keep the managed lane intact. + mkdirSync(backupDir, { recursive: true }) + writeFileAtomically(backupPath, previousRaw, { mode: 0o600 }) } -function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | null): void { +function restoreRealHomeHooksJson( + hooksJsonPath: string, + previousRaw: string | null, + previousMode?: number +): void { try { if (previousRaw === null) { if (existsSync(hooksJsonPath)) { @@ -301,7 +342,7 @@ function restoreRealHomeHooksJson(hooksJsonPath: string, previousRaw: string | n } // Why: rollback is part of the safety boundary. Use the shared atomic // writer so Windows file-lock retries and failed-temp cleanup are covered. - writeFileAtomically(hooksJsonPath, previousRaw) + writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) } catch (error) { console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error) } diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts index 4d4658f3543..80904906adc 100644 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, it } from 'vitest' -import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' @@ -52,6 +62,22 @@ describe('Codex trust config rollback', () => { } }) + it('restores a symlink target without replacing the config.toml symlink', () => { + const configPath = tempConfigPath() + const targetDir = join(configPath, '..', 'dotfiles') + const targetPath = join(targetDir, 'codex-config.toml') + mkdirSync(targetDir) + writeFileSync(targetPath, '# original\n') + symlinkSync(targetPath, configPath) + const snapshot = captureCodexTrustConfig(configPath) + rmSync(targetPath) + + restoreCodexTrustConfig(configPath, snapshot) + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(readFileSync(targetPath, 'utf8')).toBe('# original\n') + }) + it.skipIf(process.platform === 'win32')( 'restores the captured mode when the contents already match', () => { diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index 6ed05f3766d..ccd33b59d77 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -2,8 +2,10 @@ import { chmodSync, closeSync, fstatSync, + lstatSync, openSync, readFileSync, + realpathSync, unlinkSync, writeFileSync } from 'node:fs' @@ -12,12 +14,24 @@ import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = | { existed: false } - | { existed: true; contents: Buffer; mode: number } + | { existed: true; contents: Buffer; mode: number; restorePath: string } + +function resolveConfigRestorePath(tomlPath: string): string { + try { + return lstatSync(tomlPath).isSymbolicLink() ? realpathSync.native(tomlPath) : tomlPath + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return tomlPath + } + throw error + } +} export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { + const restorePath = resolveConfigRestorePath(tomlPath) let descriptor: number try { - descriptor = openSync(tomlPath, 'r') + descriptor = openSync(restorePath, 'r') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { existed: false } @@ -30,7 +44,8 @@ export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnaps return { existed: true, contents: readFileSync(descriptor), - mode: fstatSync(descriptor).mode + mode: fstatSync(descriptor).mode, + restorePath } } finally { closeSync(descriptor) @@ -51,11 +66,12 @@ export function restoreCodexTrustConfig( } return } + const { restorePath } = snapshot try { - if (readFileSync(tomlPath).equals(snapshot.contents)) { + if (readFileSync(restorePath).equals(snapshot.contents)) { // Why: the RPC may change permissions without changing bytes; rollback // restores the complete captured file state, not only its contents. - chmodSync(tomlPath, snapshot.mode) + chmodSync(restorePath, snapshot.mode) return } } catch (error) { @@ -65,10 +81,12 @@ export function restoreCodexTrustConfig( } // Why: rollback protects config integrity too; direct truncating writes can // leave Codex unusable if Orca exits midway through recovery. - const tempPath = `${tomlPath}.${process.pid}.${randomUUID()}.rollback.tmp` + // Why: Codex's writer preserves config.toml symlinks. Restore through their + // real target too, or Orca's atomic rename would disconnect dotfiles users. + const tempPath = `${restorePath}.${process.pid}.${randomUUID()}.rollback.tmp` try { writeFileSync(tempPath, snapshot.contents, { mode: snapshot.mode }) - renameFileWithWindowsRetry(tempPath, tomlPath) + renameFileWithWindowsRetry(tempPath, restorePath) } catch (error) { try { unlinkSync(tempPath) From 87db673fcd8acdbd1b660174444f31ca732f57ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:13:18 -0700 Subject: [PATCH 25/45] fix(codex): preserve verified trust grants across launch prep --- config/tsconfig.cli.json | 1 + src/main/codex/codex-app-server-client.test.ts | 12 +++++++++++- src/main/codex/codex-app-server-client.ts | 6 +++--- src/main/codex/codex-config-mirror.test.ts | 8 ++++++++ src/main/codex/codex-config-mirror.ts | 5 ++++- src/main/codex/codex-process-exit-deadline.ts | 18 ++++++++++++++++++ .../codex/hook-service-trust-grant.test.ts | 17 ++++++++++------- .../codex/hook-service-wsl-runtime.test.ts | 5 +++++ src/main/codex/hook-service.ts | 16 ++++++++-------- 9 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 src/main/codex/codex-process-exit-deadline.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0d278a1e854..a4df2dab4a9 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -21,6 +21,7 @@ "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", + "../src/main/codex/codex-process-exit-deadline.ts", "../src/main/codex/codex-trust-config-rollback.ts", "../src/main/codex/codex-trust-grant-host.ts", "../src/main/codex/codex-trust-grant-ledger.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 82df78a57d2..5bb01ae5614 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -90,6 +90,7 @@ function writeFileSyncSafe(file, contents) { require('node:fs').writeFileSync(fi let tempRoots: string[] = [] afterEach(() => { + vi.restoreAllMocks() for (const root of tempRoots) { rmSync(root, { recursive: true, force: true }) } @@ -149,6 +150,8 @@ function managedHook(key: string, trustStatus = 'untrusted'): StubHook { describe('runCodexHookTrustGrantSession', () => { it('grants and verifies exactly the expected managed entries', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') const keys = [ '/home/a/.codex/hooks.json:session_start:0:0', '/home/a/.codex/hooks.json:stop:0:0' @@ -191,6 +194,13 @@ describe('runCodexHookTrustGrantSession', () => { expect(written.edits[0].mergeStrategy).toBe('upsert') expect(Object.keys(written.edits[0].value).sort()).toEqual([...keys].sort()) expect(written.reloadUserConfig).toBe(true) + // Why: the entry process waits on these handles after setting exitCode, so + // an uncleared grace timer adds its full delay to synchronous launch prep. + const timerHandles = setTimeoutSpy.mock.results.map(({ value }) => value) + expect(timerHandles).toHaveLength(2) + expect(clearTimeoutSpy.mock.calls.map(([handle]) => handle)).toEqual( + expect.arrayContaining(timerHandles) + ) }) it('skips config/batchWrite when every expected entry is already trusted', async () => { diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 0273893fcc8..ab5dced425c 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' +import { waitForProcessExitUntil } from './codex-process-exit-deadline' // Why: Codex gates hooks on a `trusted_hash` it computes from a private // canonical-JSON identity. Orca used to replicate that algorithm @@ -363,11 +364,10 @@ export async function runCodexHookTrustGrantSession( if (!exited) { // Why: the server exits promptly on stdin EOF; the grace period only // bounds a wedged child before the guaranteed SIGKILL reap. - const grace = new Promise((resolve) => setTimeout(resolve, 1500)) - await Promise.race([exitPromise, grace]) + await waitForProcessExitUntil(exitPromise, 1500) if (!exited) { child.kill('SIGKILL') - await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) + await waitForProcessExitUntil(exitPromise, 1000) } } clearTimeout(deadline) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 5022fff5cc0..4b425e2e2ef 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -268,6 +268,9 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { [ 'model = "runtime-model"', '', + '[hooks.state]', + '# runtime-owned parent', + '', '[hooks.state."runtime-hooks:stop:0:0"]', 'enabled = false', 'trusted_hash = "sha256:runtime"', @@ -286,6 +289,9 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { [ 'model = "system-model"', '', + '[hooks.state]', + '# system-owned parent', + '', '[projects."/repo"] # explicit revocation', 'trust_level = "untrusted"', '', @@ -310,6 +316,8 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).toContain('[projects."/system-only"]') expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).toContain('# runtime-owned parent') + expect(runtimeConfig).not.toContain('# system-owned parent') expect(runtimeConfig).toContain('trust_level = "untrusted"') expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index e6ca4c0b834..26933c1ba02 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -275,7 +275,10 @@ function isRuntimePreservedTomlSection(header: string): boolean { } function isRuntimeHookTrustTomlSection(header: string): boolean { - return header.trimStart().startsWith('[hooks.state.') + const trimmed = header.trim() + // Why: Codex's config writer materializes the parent table on Windows. It is + // part of runtime-owned trust and must survive the next config mirror too. + return trimmed === '[hooks.state]' || trimmed.startsWith('[hooks.state.') } function isRuntimeProjectTomlSection(header: string): boolean { diff --git a/src/main/codex/codex-process-exit-deadline.ts b/src/main/codex/codex-process-exit-deadline.ts new file mode 100644 index 00000000000..d6eaf1eda2c --- /dev/null +++ b/src/main/codex/codex-process-exit-deadline.ts @@ -0,0 +1,18 @@ +export async function waitForProcessExitUntil( + exitPromise: Promise, + timeoutMs: number +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + try { + await Promise.race([exitPromise, timeout]) + } finally { + // Why: this runs in a short-lived entry; a live grace timer delays the + // parent spawnSync even after the app-server process has already exited. + if (timer !== undefined) { + clearTimeout(timer) + } + } +} diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 4c51040fc38..c308fde1cc0 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -7,6 +7,7 @@ import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { computeTrustedHash, parseTrustKey, + readHookTrustEntries, upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' @@ -160,7 +161,7 @@ describe('CodexHookService app-server trust grant lane', () => { }) }) - it('upgrades self-computed trust in place without duplicate tables', () => { + it('upgrades self-computed trust in place without duplicate logical entries', () => { prepareSystemHome() const service = new CodexHookService() process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' @@ -171,6 +172,9 @@ describe('CodexHookService app-server trust grant lane', () => { expect(service.install().state).toBe('installed') const upgraded = readFileSync(join(managedHome, 'config.toml'), 'utf-8') + // Why: the legacy Windows fallback intentionally writes slash variants; + // duplicate detection is about the normalized trust identity. + const upgradedEntries = readHookTrustEntries(join(managedHome, 'config.toml')) for (const eventLabel of [ 'session_start', 'user_prompt_submit', @@ -179,13 +183,12 @@ describe('CodexHookService app-server trust grant lane', () => { 'post_tool_use', 'stop' ]) { - const count = upgraded - .split('\n') - .filter( - (line) => line.startsWith('[hooks.state.') && line.includes(`:${eventLabel}:0:0`) - ).length - expect(count, `duplicate trust tables for ${eventLabel}`).toBe(1) + const count = [...upgradedEntries.keys()].filter((key) => + key.endsWith(`:${eventLabel}:0:0`) + ).length + expect(count, `duplicate trust entries for ${eventLabel}`).toBe(1) } + expect(upgraded).toContain('sha256:codex-session_start') }) it('leaves user trust byte-untouched while granting managed entries', () => { diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index cc887208f4c..c3d2ce9ca37 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -522,7 +522,11 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { const basePlan = createTestPlan() writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8') writeFileSync(basePlan.tomlPath, '', 'utf-8') + let staleKeyExpectedRemoved: string | null = null const runner = vi.fn((request: CodexHookTrustGrantRequest) => { + if (staleKeyExpectedRemoved) { + expect(readHookTrustEntries(basePlan.tomlPath).has(staleKeyExpectedRemoved)).toBe(false) + } const entries = request.expectedTrustKeys.map((key) => { const parsed = parseTrustKey(key)! return { @@ -557,6 +561,7 @@ describe('Codex WSL runtime hook install app-server grant lane', () => { const oldKey = computeTrustKey( getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath)) ) + staleKeyExpectedRemoved = oldKey const newPlan = { ...basePlan, diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index b1b3ac7a3b2..ab680f6edca 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -842,6 +842,13 @@ function installManagedHooksIntoWslRuntime( // Why: a successful re-grant replaces the ledger. Keep the previous // records long enough to prove ownership of stale canonical-path keys. const previousLedgerHome = readCodexTrustGrantLedgerHomeForReconciliation(runtimeHomePath) + // Why: Codex's verified RPC write must be the final config mutation. A + // host-side rewrite after verification can race or invalidate that grant. + removeStaleWslRuntimeManagedHookTrustEntries( + plan.tomlPath, + trustEntries, + previousLedgerHome ? [previousLedgerHome] : [] + ) const grant = grantManagedCodexHookTrust({ runtimeHomePath, tomlPath: plan.tomlPath, @@ -849,17 +856,10 @@ function installManagedHooksIntoWslRuntime( managedEntries: trustEntries, host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } }) - if (grant.lane === 'rpc') { - removeStaleWslRuntimeManagedHookTrustEntries( - plan.tomlPath, - grant.entries, - previousLedgerHome ? [previousLedgerHome] : [] - ) - } else { + if (grant.lane === 'fallback') { // Why: WSL runtime homes may carry user hook approvals we did not rebuild // here; only upsert Orca's entries instead of sweeping the whole source. upsertHookTrustEntries(plan.tomlPath, trustEntries) - removeStaleWslRuntimeManagedHookTrustEntries(plan.tomlPath, trustEntries) } } catch (error) { return { From b4125822d9fd0736c1c84d81868e45f94f0f0813 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:22:06 -0700 Subject: [PATCH 26/45] fix(codex): preserve dangling config symlinks on rollback --- .../codex/codex-trust-config-rollback.test.ts | 15 +++++++++++++ src/main/codex/codex-trust-config-rollback.ts | 22 +++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts index 80904906adc..136b5a22466 100644 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -46,6 +46,21 @@ describe('Codex trust config rollback', () => { expect(() => readFileSync(configPath)).toThrowError(/ENOENT/) }) + it('removes an RPC-created dangling-symlink target without deleting the user link', () => { + const configPath = tempConfigPath() + const targetDir = join(configPath, '..', 'dotfiles') + const targetPath = join(targetDir, 'codex-config.toml') + mkdirSync(targetDir) + symlinkSync(join('dotfiles', 'codex-config.toml'), configPath) + const snapshot = captureCodexTrustConfig(configPath) + writeFileSync(targetPath, 'rpc mutation') + + restoreCodexTrustConfig(configPath, snapshot) + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(() => readFileSync(targetPath)).toThrowError(/ENOENT/) + }) + it('atomically recreates exact contents and mode after the file disappears', () => { const configPath = tempConfigPath() const original = Buffer.from('# comment\r\n[hooks]\r\n') diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index ccd33b59d77..0642b834483 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -5,20 +5,34 @@ import { lstatSync, openSync, readFileSync, + readlinkSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' +import { dirname, resolve } from 'node:path' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = - | { existed: false } + | { existed: false; restorePath?: string } | { existed: true; contents: Buffer; mode: number; restorePath: string } function resolveConfigRestorePath(tomlPath: string): string { try { - return lstatSync(tomlPath).isSymbolicLink() ? realpathSync.native(tomlPath) : tomlPath + if (!lstatSync(tomlPath).isSymbolicLink()) { + return tomlPath + } + try { + return realpathSync.native(tomlPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + // Why: a dangling dotfiles link is still user-owned state. Target the + // lexical destination so rollback removes an RPC-created file, not the link. + return resolve(dirname(tomlPath), readlinkSync(tomlPath)) + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return tomlPath @@ -34,7 +48,7 @@ export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnaps descriptor = openSync(restorePath, 'r') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { existed: false } + return restorePath === tomlPath ? { existed: false } : { existed: false, restorePath } } throw error } @@ -58,7 +72,7 @@ export function restoreCodexTrustConfig( ): void { if (!snapshot.existed) { try { - unlinkSync(tomlPath) + unlinkSync(snapshot.restorePath ?? tomlPath) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error From caf6cac3dfebdcb82fb1681c885da567ce0076fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:24:30 -0700 Subject: [PATCH 27/45] fix(codex): don't revoke a just-granted WSL home on a false 'missing' probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async wsl.exe canonical-path settlement could report the runtime home 'missing' immediately after a verified RPC grant (a false negative — codex had just written and re-listed trust there), which drove the reconciliation 'remove' branch to delete all six granted [hooks.state] tables, leaving a bare [hooks.state] the launching pane read as 'hooks need review'. A 'missing' settlement now revokes only when no successful install ran this generation; a genuinely moved home still resolves to a different path and reinstalls. --- .../codex/hook-service-wsl-runtime.test.ts | 29 +++++++++++++++---- src/main/codex/hook-service.ts | 21 ++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index c3d2ce9ca37..28c230ff160 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -209,7 +209,8 @@ describe('Codex WSL runtime hook install', () => { settlement: { status: 'unavailable' }, isCurrentGeneration: true, installedTrustConfigPath: '/mnt/d/home/hooks.json', - resolvedTrustConfigPath: null + resolvedTrustConfigPath: null, + installSucceeded: false }) ).toBe('none') @@ -218,7 +219,8 @@ describe('Codex WSL runtime hook install', () => { settlement: { status: 'missing' }, isCurrentGeneration: false, installedTrustConfigPath: '/mnt/d/home/hooks.json', - resolvedTrustConfigPath: null + resolvedTrustConfigPath: null, + installSucceeded: false }) ).toBe('none') @@ -227,25 +229,42 @@ describe('Codex WSL runtime hook install', () => { settlement: { status: 'missing' }, isCurrentGeneration: true, installedTrustConfigPath: '/mnt/d/home/hooks.json', - resolvedTrustConfigPath: null + resolvedTrustConfigPath: null, + installSucceeded: false }) ).toBe('remove') + // Why: a `missing` probe right after a verified grant is a false negative; + // revoking would delete the fresh trust the launching pane reads (#8847). + expect( + _internals.getWslHookReconciliationAction({ + settlement: { status: 'missing' }, + isCurrentGeneration: true, + installedTrustConfigPath: '/mnt/d/home/hooks.json', + resolvedTrustConfigPath: null, + installSucceeded: true + }) + ).toBe('none') + expect( _internals.getWslHookReconciliationAction({ settlement: { status: 'resolved', canonicalPath: '/windows/d/home' }, isCurrentGeneration: true, installedTrustConfigPath: '/windows/d/home/hooks.json', - resolvedTrustConfigPath: '/windows/d/home/hooks.json' + resolvedTrustConfigPath: '/windows/d/home/hooks.json', + installSucceeded: true }) ).toBe('none') + // Why: a genuinely moved home resolves to a different path and still + // reinstalls, even though the original install succeeded. expect( _internals.getWslHookReconciliationAction({ settlement: { status: 'resolved', canonicalPath: '/windows/d/home' }, isCurrentGeneration: true, installedTrustConfigPath: '/mnt/d/home/hooks.json', - resolvedTrustConfigPath: '/windows/d/home/hooks.json' + resolvedTrustConfigPath: '/windows/d/home/hooks.json', + installSucceeded: true }) ).toBe('reinstall') }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index ab680f6edca..378d06174ed 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -930,12 +930,19 @@ function getWslHookReconciliationAction(args: { isCurrentGeneration: boolean installedTrustConfigPath: string | null resolvedTrustConfigPath: string | null + /** Whether the synchronous install for this generation wrote trust. */ + installSucceeded: boolean }): 'none' | 'remove' | 'reinstall' { if (!args.isCurrentGeneration) { return 'none' } if (args.settlement.status === 'missing') { - return 'remove' + // Why: a `missing` directory probe right after a verified install/grant is + // a false negative — the RPC (or fallback) just wrote and read trust in + // that home, so it exists. Revoking here would delete the fresh grant the + // launching pane needs, resurfacing "hooks need review". A genuinely moved + // home resolves to a different path and takes the `reinstall` branch below. + return args.installSucceeded ? 'none' : 'remove' } if ( args.settlement.status !== 'resolved' || @@ -972,6 +979,10 @@ export class CodexHookService { ): AgentHookInstallStatus | null { const generation = this.supersedeWslReconciliation(runtimeHomePath) let installedTrustConfigPath: string | null = null + // Why: JS is single-threaded, so the synchronous install below finishes + // before any async `wsl.exe` settlement callback runs — this flag is + // always set by the time the callback reads it. + let installSucceeded = false const onCanonicalPathSettled = (settlement: WslCanonicalPathSettlement): void => { if (!runtimeHomePath) { return @@ -989,7 +1000,8 @@ export class CodexHookService { settlement, isCurrentGeneration: this.wslReconciliationGeneration.get(key) === generation, installedTrustConfigPath, - resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null + resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null, + installSucceeded }) if (action === 'none') { return @@ -1014,6 +1026,7 @@ export class CodexHookService { return } installedTrustConfigPath = resolvedPlan.trustConfigPath + installSucceeded = status.state === 'installed' } const wslPlan = createCodexWslRuntimeHookInstallPlan( runtimeHomePath, @@ -1022,7 +1035,9 @@ export class CodexHookService { onCanonicalPathSettled ) installedTrustConfigPath = wslPlan?.trustConfigPath ?? null - return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + const status = wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + installSucceeded = status?.state === 'installed' + return status } refreshRuntimeUserHooksForRuntimeHome( From 1f9ab948e8d7795bca0da83f438faaea4785b79b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:24:32 -0700 Subject: [PATCH 28/45] test(codex): model codex config/batchWrite faithfully on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grant-lane stub simulated codex by calling Orca's upsertHookTrustEntries, which writes both separator variants for a Windows key (a fallback-lane compat shim real codex never does) — fabricating duplicate tables and whitespace the RPC path never produces, so the byte-stable and no-duplicate assertions failed on win32. Replace it with a single-variant, blank-line-separated writer that matches the real 0.144.x binary's output. --- .../codex/hook-service-trust-grant.test.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index c308fde1cc0..20ffe6b245b 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -3,12 +3,14 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' +import { existsSync } from 'node:fs' import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { + computeTrustKey, computeTrustedHash, + escapeTomlString, parseTrustKey, readHookTrustEntries, - upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' @@ -70,6 +72,32 @@ afterEach(() => { vi.clearAllMocks() }) +// Why: model codex's own config/batchWrite — exactly one +// `[hooks.state.""]` table per reported key, keyed verbatim, blank-line +// separated (the shape the real 0.144.x binary writes). Orca's +// upsertHookTrustEntries writes BOTH separator variants for a Windows key (a +// fallback-lane compat shim real codex never does), which would fabricate +// duplicate tables on win32 that the RPC path never produces. +function writeCodexLikeTrust(configPath: string, entries: CodexTrustEntry[]): void { + let content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + if (!/^\[hooks\.state\][ \t]*$/m.test(content)) { + const separator = content.length === 0 ? '' : content.endsWith('\n') ? '' : '\n' + content += `${separator}[hooks.state]\n` + } + for (const entry of entries) { + const header = `[hooks.state."${escapeTomlString(computeTrustKey(entry))}"]` + // Why: replace any existing table for this exact key so re-grants upgrade + // in place instead of duplicating (mirrors codex's upsert merge strategy). + const existingBlock = new RegExp( + `(?:\\n)?${header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n(?:[^[\\n].*\\n?|\\n)*`, + 'g' + ) + content = content.replace(existingBlock, '') + content += `${content.endsWith('\n') ? '' : '\n'}\n${header}\ntrusted_hash = "${escapeTomlString(entry.trustedHash!)}"\n` + } + writeFileSync(configPath, content) +} + function installCodexLikeGrantRunner(): ReturnType { const codexHash = (key: string): string => `sha256:codex-${parseTrustKey(key)?.eventLabel ?? 'unknown'}` @@ -84,7 +112,7 @@ function installCodexLikeGrantRunner(): ReturnType { trustedHash: codexHash(key) } }) - upsertHookTrustEntries(join(codexHome!, 'config.toml'), entries) + writeCodexLikeTrust(join(codexHome!, 'config.toml'), entries) return { outcome: 'granted' as const, wroteTrust: true, From 22dc271b91b42420035a058fa4434bb0bbb7ecde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:33:59 -0700 Subject: [PATCH 29/45] feat(codex): collapse duplicate session listings across Codex roots Backfilled/bridged rollouts are hardlinked into both the real ~/.codex and Orca's managed runtime home, so AI Vault listed each session once per root (#7521). Dedup candidates by rollout file name pre-parse and parsed sessions by session id post-parse, keeping the canonical root: host real home first (unprefixed resume), then the managed runtime home, then other homes. Applies to local, WSL, and SSH-remote scans. --- .../ai-vault/codex-session-root-dedup.test.ts | 186 ++++++++++++++++++ src/main/ai-vault/codex-session-root-dedup.ts | 116 +++++++++++ .../remote-session-scanner-sources.ts | 1 + .../ai-vault/remote-session-scanner-types.ts | 3 + .../ai-vault/remote-session-scanner.test.ts | 34 ++++ src/main/ai-vault/remote-session-scanner.ts | 28 ++- .../session-scanner-codex-dual-root.test.ts | 116 +++++++++++ src/main/ai-vault/session-scanner-types.ts | 3 + src/main/ai-vault/session-scanner.ts | 42 ++-- src/shared/ai-vault-resume-command.test.ts | 15 ++ 10 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 src/main/ai-vault/codex-session-root-dedup.test.ts create mode 100644 src/main/ai-vault/codex-session-root-dedup.ts create mode 100644 src/main/ai-vault/session-scanner-codex-dual-root.test.ts diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts new file mode 100644 index 00000000000..4d710e368bf --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' + +const REAL_HOME_ROLLOUT = + '/Users/ada/.codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' +const MANAGED_HOME_ROLLOUT = + '/Users/ada/Library/Application Support/orca/codex-runtime-home/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' +const MANAGED_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + +function codexSession(overrides: Partial): AiVaultSession { + return { + id: `local:codex:${overrides.sessionId ?? 'session-1'}:${overrides.filePath ?? '/tmp/x.jsonl'}`, + executionHostId: 'local', + agent: 'codex', + sessionId: 'session-1', + title: 'Session', + cwd: '/repo/app', + branch: null, + model: null, + filePath: '/tmp/x.jsonl', + codexHome: null, + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + modifiedAt: '2026-07-01T10:05:00.000Z', + messageCount: 1, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume session-1', + subagent: null, + ...overrides + } as AiVaultSession +} + +describe('dedupeCodexRolloutFileAliases', () => { + type Candidate = { agent: string; path: string; codexHome: string | null } + const accessors = { + isCodex: (candidate: Candidate) => candidate.agent === 'codex', + getFilePath: (candidate: Candidate) => candidate.path, + getCodexHome: (candidate: Candidate) => candidate.codexHome + } + + it('keeps the real-home alias when the same rollout exists in both roots', () => { + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + expect(dedupeCodexRolloutFileAliases([managed, real], accessors)).toEqual([real]) + expect(dedupeCodexRolloutFileAliases([real, managed], accessors)).toEqual([real]) + }) + + it('prefers the managed runtime home over other non-default homes', () => { + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const wslReal = { + agent: 'codex', + path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl`, + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + } + expect(dedupeCodexRolloutFileAliases([wslReal, managed], accessors)).toEqual([managed]) + }) + + it('recognizes the managed runtime home with backslash separators', () => { + const managed = { + agent: 'codex', + path: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + } + const custom = { + agent: 'codex', + path: 'D:\\codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: 'D:\\codex' + } + expect(dedupeCodexRolloutFileAliases([custom, managed], accessors)).toEqual([managed]) + }) + + it('keeps distinct rollouts, non-codex candidates, and non-rollout file names', () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const other = { + agent: 'codex', + path: '/Users/ada/.codex/sessions/2026/07/02/rollout-2026-07-02T09-00-00-029f0000-1111-7222-8333-555555555555.jsonl', + codexHome: null + } + const oddName = { + agent: 'codex', + path: `${MANAGED_HOME}/sessions/notes.jsonl`, + codexHome: MANAGED_HOME + } + const claude = { agent: 'claude', path: REAL_HOME_ROLLOUT, codexHome: null } + expect(dedupeCodexRolloutFileAliases([real, other, oddName, claude], accessors)).toEqual([ + real, + other, + oddName, + claude + ]) + }) +}) + +describe('dedupeCodexSessionsBySessionId', () => { + it('collapses a both-roots session to the real-home row', () => { + const managed = codexSession({ + filePath: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + id: `local:codex:session-1:${MANAGED_HOME_ROLLOUT}` + }) + const real = codexSession({ + filePath: REAL_HOME_ROLLOUT, + codexHome: null, + id: `local:codex:session-1:${REAL_HOME_ROLLOUT}` + }) + expect(dedupeCodexSessionsBySessionId([managed, real])).toEqual([real]) + expect(dedupeCodexSessionsBySessionId([real, managed])).toEqual([real]) + }) + + it('keeps managed-only and real-only sessions unchanged', () => { + const managedOnly = codexSession({ + sessionId: 'managed-only', + filePath: `${MANAGED_HOME}/sessions/2026/07/01/rollout-a.jsonl`, + codexHome: MANAGED_HOME + }) + const realOnly = codexSession({ + sessionId: 'real-only', + filePath: REAL_HOME_ROLLOUT, + codexHome: null + }) + expect(dedupeCodexSessionsBySessionId([managedOnly, realOnly])).toEqual([managedOnly, realOnly]) + }) + + it('never collapses across execution hosts or agents', () => { + const local = codexSession({ sessionId: 'session-1', executionHostId: 'local' }) + const remote = codexSession({ + sessionId: 'session-1', + executionHostId: 'ssh:build-box', + id: 'ssh:build-box:codex:session-1:/home/ada/.codex/sessions/x.jsonl' + }) + const claude = codexSession({ sessionId: 'session-1', agent: 'claude' }) + expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude]) + }) + + it('resolves same-rank id collisions to the newest row, then stable path order', () => { + const older = codexSession({ + sessionId: 'collision', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-old.jsonl', + codexHome: null, + updatedAt: '2026-07-01T10:00:00.000Z', + modifiedAt: '2026-07-01T10:00:00.000Z' + }) + const newer = codexSession({ + sessionId: 'collision', + filePath: '/Users/ada/.codex/sessions/2026/07/02/rollout-new.jsonl', + codexHome: null, + updatedAt: '2026-07-02T10:00:00.000Z', + modifiedAt: '2026-07-02T10:00:00.000Z' + }) + expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([newer]) + + const tieA = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-a.jsonl', + codexHome: null + }) + const tieB = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-b.jsonl', + codexHome: null + }) + expect(dedupeCodexSessionsBySessionId([tieB, tieA])).toEqual([tieA]) + }) + + it('prefers the managed runtime home over a WSL real home when no host real-home row exists', () => { + const wslManaged = codexSession({ + sessionId: 'wsl-pair', + filePath: + '\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\rollout-a.jsonl', + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home' + }) + const wslReal = codexSession({ + sessionId: 'wsl-pair', + filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-a.jsonl', + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + }) + expect(dedupeCodexSessionsBySessionId([wslReal, wslManaged])).toEqual([wslManaged]) + }) +}) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts new file mode 100644 index 00000000000..dbb66fb674c --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -0,0 +1,116 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { sessionSortTime } from './session-scanner-accumulator' + +// Why: the session bridge and the real-home backfill hardlink one physical +// Codex rollout into multiple scanned roots (managed runtime home and the +// user's own ~/.codex), so every bridged/backfilled session used to list once +// per root (#7521). These helpers collapse those aliases to one canonical row. + +// Matches Codex rollout logs: rollout--.jsonl. The +// bridge and backfill both preserve the sessions/YYYY/MM/DD layout, so an +// identical rollout file name across Codex roots is the same session. +const CODEX_ROLLOUT_FILE_NAME_PATTERN = /^rollout-.+\.jsonl$/ + +// Why: not node:path.basename — a posix host scans remote/WSL win32 paths, so +// separators must be handled independently of the local platform. +function lastPathSegment(filePath: string): string { + return filePath.split(/[\\/]/).at(-1) ?? '' +} + +/** + * Ranks a Codex session root for canonical-alias selection, lowest wins. + * + * Host real home (null) is canonical: after the real-home flip the managed + * home's auth.json is no longer refreshed, so resume must not stamp it. The + * Orca managed runtime home still beats other homes (WSL/remote real homes, + * custom CODEX_HOMEs) because those lanes have not flipped — their launches + * keep managed auth, so resume keeps the managed stamp as today. + */ +function codexSessionRootRank(codexHome: string | null): number { + if (codexHome === null) { + return 0 + } + const segments = codexHome.split(/[\\/]/).filter(Boolean) + return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' ? 1 : 2 +} + +/** + * Drops pre-parse Codex rollout candidates that alias an already-kept rollout + * file name in a preferred root, so duplicate aliases never consume the parse + * budget or crowd the capped listing. + */ +export function dedupeCodexRolloutFileAliases( + candidates: readonly T[], + accessors: { + isCodex: (candidate: T) => boolean + getFilePath: (candidate: T) => string + getCodexHome: (candidate: T) => string | null + } +): T[] { + const bestByFileName = new Map() + for (const candidate of candidates) { + if (!accessors.isCodex(candidate)) { + continue + } + const filePath = accessors.getFilePath(candidate) + const fileName = lastPathSegment(filePath) + if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { + continue + } + const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) + const best = bestByFileName.get(fileName) + if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { + bestByFileName.set(fileName, { candidate, rank, filePath }) + } + } + return candidates.filter((candidate) => { + if (!accessors.isCodex(candidate)) { + return true + } + const fileName = lastPathSegment(accessors.getFilePath(candidate)) + const best = bestByFileName.get(fileName) + return !best || best.candidate === candidate + }) +} + +/** + * Collapses parsed Codex sessions that share a session id on one execution + * host, keeping the canonical root's row (see codexSessionRootRank). Catches + * aliases the file-name pass cannot see: cross-volume backfill copies and + * session_meta ids that differ from the rollout file name. + */ +export function dedupeCodexSessionsBySessionId( + sessions: readonly AiVaultSession[] +): AiVaultSession[] { + const bestByKey = new Map() + for (const session of sessions) { + if (session.agent !== 'codex') { + continue + } + const key = `${session.executionHostId}:${session.sessionId}` + const best = bestByKey.get(key) + if (!best || codexSessionAliasBeats(session, best)) { + bestByKey.set(key, session) + } + } + return sessions.filter((session) => { + if (session.agent !== 'codex') { + return true + } + return bestByKey.get(`${session.executionHostId}:${session.sessionId}`) === session + }) +} + +function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { + const candidateRank = codexSessionRootRank(candidate.codexHome) + const bestRank = codexSessionRootRank(best.codexHome) + if (candidateRank !== bestRank) { + return candidateRank < bestRank + } + const candidateTime = sessionSortTime(candidate) + const bestTime = sessionSortTime(best) + if (candidateTime !== bestTime) { + return candidateTime > bestTime + } + return candidate.filePath < best.filePath +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..bc589ee769f 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -156,6 +156,7 @@ function remoteCodexSources( ].map((codexHome) => ({ agent: 'codex', rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + codexHome, extensions: ['.jsonl'], parse: (file, content, context) => parseCodexSessionContent({ diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..b501998e595 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -19,6 +19,9 @@ export type RemoteParserOptions = { export type RemoteSessionSource = { agent: AiVaultAgent rootDir: string + // Codex sources only: the CODEX_HOME the root belongs to, so bridged or + // backfilled rollout aliases across remote roots collapse to one canonical row. + codexHome?: string extensions: readonly string[] filePredicate?: (path: string) => boolean // Claude layout: count `/subagents/*.jsonl` siblings from the walked diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..a432bef1db8 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -160,6 +160,40 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('collapses a bridged rollout present in both remote Codex homes to one row', async () => { + const provider = new MemoryRemoteProvider() + const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + const transcript = codexTranscript({ + sessionId: '019f0000-1111-7222-8333-444444444444', + title: 'Bridged both-homes session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T10:00:00.000Z' + }) + // Same rollout name in both homes — the in-distro bridge/backfill hardlink. + provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000) + provider.addFile( + `/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`, + transcript, + 3_000 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:build-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + // Remote lanes have not flipped to the real home: the managed runtime-home + // row stays canonical so resume keeps Orca-refreshed auth, as today. + expect(result.sessions[0]).toMatchObject({ + sessionId: '019f0000-1111-7222-8333-444444444444', + codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home' + }) + }) + it('parses non-Codex transcripts through the same remote scanner', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..7dddc858db1 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -11,6 +11,10 @@ import type { FileStat, IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -41,21 +45,29 @@ export async function scanRemoteAiVaultSessions(args: { hostPlatform: args.hostPlatform, titleCaches: new Map() } - const candidates = ( - await mapRemoteScanConcurrently( - remoteSessionSources(args.remoteHome, args.hostPlatform), - (source) => discoverRemoteSourceCandidates({ source, context, issues }) + const candidates = dedupeCodexRolloutFileAliases( + ( + await mapRemoteScanConcurrently( + remoteSessionSources(args.remoteHome, args.hostPlatform), + (source) => discoverRemoteSourceCandidates({ source, context, issues }) + ) ) + .flat() + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + { + isCodex: (candidate) => candidate.source.agent === 'codex', + getFilePath: (candidate) => candidate.file.path, + getCodexHome: (candidate) => candidate.source.codexHome ?? null + } ) - .flat() - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) - const cappedSessions = parsed.sessions + const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) + const cappedSessions = parsedSessions .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) - const parsedScopeSessions = parsed.sessions.filter((session) => + const parsedScopeSessions = parsedSessions.filter((session) => isRemoteSessionInScope(session, scopePaths) ) const extraScopeSessions = await scanRemoteInScopeSessions({ diff --git a/src/main/ai-vault/session-scanner-codex-dual-root.test.ts b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts new file mode 100644 index 00000000000..d9d65b88141 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts @@ -0,0 +1,116 @@ +import { link, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +// Scan-level coverage for the canonical-root rule when one physical Codex +// rollout is visible through both the real ~/.codex and the managed runtime +// home (the layout the session backfill and bridge produce via hardlinks). + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions codex dual-root dedup', () => { + it('lists a backfilled both-roots session once, attributed to the real home', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-dedup-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Sandbox "real ~/.codex" and Orca managed runtime home, hardlinked the + // same way the session backfill links managed rollouts into the real home. + const realHome = join(root, 'real-codex-home') + const realSessionsDir = join(realHome, 'sessions') + const managedHome = join(root, 'codex-runtime-home', 'home') + const managedSessionsDir = join(managedHome, 'sessions') + const rolloutName = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + await mkdir(join(managedSessionsDir, '2026', '07', '01'), { recursive: true }) + await mkdir(join(realSessionsDir, '2026', '07', '01'), { recursive: true }) + + await writeFile( + join(managedSessionsDir, '2026', '07', '01', rolloutName), + jsonLines([ + { + timestamp: '2026-07-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' } + }, + { + timestamp: '2026-07-01T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Backfilled both-roots session' }] + } + } + ]) + ) + await link( + join(managedSessionsDir, '2026', '07', '01', rolloutName), + join(realSessionsDir, '2026', '07', '01', rolloutName) + ) + // A managed-only session (e.g. a backfill copy failure) must keep its + // managed-home stamp so resume still targets the home that has it. + await mkdir(join(managedSessionsDir, '2026', '07', '02'), { recursive: true }) + await writeFile( + join( + managedSessionsDir, + '2026', + '07', + '02', + 'rollout-2026-07-02T09-00-00-029f0000-1111-7222-8333-555555555555.jsonl' + ), + jsonLines([ + { + timestamp: '2026-07-02T09:00:00.000Z', + type: 'session_meta', + payload: { id: '029f0000-1111-7222-8333-555555555555', cwd: '/repo/app' } + }, + { + timestamp: '2026-07-02T09:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed-only session' }] + } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + codexSessionsDir: realSessionsDir, + defaultCodexHomeDir: realHome, + additionalCodexSessionsDirs: [managedSessionsDir], + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + const codexSessions = result.sessions.filter((session) => session.agent === 'codex') + expect(codexSessions).toHaveLength(2) + + const backfilled = codexSessions.find( + (session) => session.sessionId === '019f0000-1111-7222-8333-444444444444' + ) + expect(backfilled).toMatchObject({ + codexHome: null, + filePath: join(realSessionsDir, '2026', '07', '01', rolloutName), + resumeCommand: "cd '/repo/app' && codex resume '019f0000-1111-7222-8333-444444444444'" + }) + expect(backfilled?.resumeCommand).not.toContain('CODEX_HOME') + + const managedOnly = codexSessions.find( + (session) => session.sessionId === '029f0000-1111-7222-8333-555555555555' + ) + expect(managedOnly).toMatchObject({ + codexHome: managedHome, + resumeCommand: `cd '/repo/app' && CODEX_HOME='${managedHome}' codex resume '029f0000-1111-7222-8333-555555555555'` + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..6f28d7ce117 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -10,6 +10,9 @@ export type AiVaultScanOptions = { claudeProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] + // Why: tests inject a sandbox "real ~/.codex" so real-home attribution + // (codexHome null → unprefixed resume) is testable without the user's home. + defaultCodexHomeDir?: string wslHomeDirs?: readonly string[] geminiSessionsDir?: string copilotSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..caab77500f8 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,6 +6,10 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -56,20 +60,30 @@ export async function scanAiVaultSessions( const parseStats = createSessionParseStats() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - const candidates = discoveries - .flatMap((discovery) => - discovery.files.map( - (file): SessionFileCandidate => ({ - agent: discovery.agent, - file, - codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null - }) + const candidates = dedupeCodexRolloutFileAliases( + discoveries + .flatMap((discovery) => + discovery.files.map( + (file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir( + discovery.rootDir, + options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR + ) + : null + }) + ) ) - ) - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + { + isCodex: (candidate) => candidate.agent === 'codex', + getFilePath: (candidate) => candidate.file.path, + getCodexHome: (candidate) => candidate.codexHome + } + ) const parsedSessions = await parseSessionCandidates({ candidates, @@ -80,7 +94,7 @@ export async function scanAiVaultSessions( parseStats }) - const cappedSessions = parsedSessions + const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e..ec94a0f82d4 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -29,6 +29,21 @@ describe('buildAiVaultResumeCommand', () => { ) }) + it('emits no CODEX_HOME stamp for real-home canonical sessions', () => { + // Backfilled sessions dedupe to the real-home row (codexHome null); their + // resume must run against the user's own ~/.codex, never the frozen + // managed home whose auth.json stops refreshing after the flip. + const command = buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: null + }) + expect(command).toBe("cd '/repo/app' && codex resume 'session-1'") + expect(command).not.toContain('CODEX_HOME') + }) + it('carries non-default Codex homes in copied resume commands', () => { expect( buildAiVaultResumeCommand({ From 2f41d62d37276adf6b19371f79aaa2e65a99b924 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:34:15 -0700 Subject: [PATCH 30/45] feat(codex): background sqlite index heal for backfilled sessions Codex's own state-DB metadata backfill is one-shot, so rollouts hardlinked in by Orca's session backfill never become visible to Codex's DB-driven surfaces. Extract the app-server stdio JSONL transport into codex-app-server-session (shared with the trust-grant client) and add a bounded, resumable background pass that drives Codex's lazy indexing via thread/read per backfilled session: recent-first, batched onto one short-lived server per batch with small concurrency, ledger + marker so steady-state startups are a no-op, stop-aware on quit, and capability-aware on CLIs without the app-server surface. --- config/tsconfig.cli.json | 1 + src/main/codex/codex-app-server-client.ts | 329 +++---------- src/main/codex/codex-app-server-session.ts | 264 ++++++++++ .../codex/codex-session-index-heal-state.ts | 197 ++++++++ .../codex/codex-session-index-heal.test.ts | 451 ++++++++++++++++++ src/main/codex/codex-session-index-heal.ts | 235 +++++++++ src/main/index.ts | 13 +- 7 files changed, 1214 insertions(+), 276 deletions(-) create mode 100644 src/main/codex/codex-app-server-session.ts create mode 100644 src/main/codex/codex-session-index-heal-state.ts create mode 100644 src/main/codex/codex-session-index-heal.test.ts create mode 100644 src/main/codex/codex-session-index-heal.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0d278a1e854..dffdba0d872 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -15,6 +15,7 @@ "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", + "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 0273893fcc8..82b62b0e154 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,5 +1,6 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawn } from 'node:child_process' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' +import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' // Why: Codex gates hooks on a `trusted_hash` it computes from a private // canonical-JSON identity. Orca used to replicate that algorithm @@ -11,14 +12,12 @@ import { normalizeHookTrustKeyForLookup } from './config-toml-trust' // Codex as the only hash authority. See upstream codex-rs/tui/src/hooks_rpc.rs // and codex-rs/tui/src/startup_hooks_review.rs. -export type CodexAppServerInvocation = { - command: string - args: string[] - /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ - env?: Record - /** Whole-session deadline. The codex child is SIGKILLed when it lapses. */ - timeoutMs: number -} +export { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + type CodexAppServerInvocation +} from './codex-app-server-session' export type CodexHookTrustGrantRequest = { invocation: CodexAppServerInvocation @@ -51,33 +50,6 @@ export type CodexHookTrustGrantSessionResult = } | { outcome: 'verify-failed'; reason: string } -/** Codex-side absence of the trust-grant RPC surface (old CLI without the - * app-server subcommand, or a server without hooks/list / config/batchWrite). - * This is the ONLY error class the capability cache marks unsupported. */ -export class CodexAppServerUnsupportedError extends Error { - constructor(message: string) { - super(message) - this.name = 'CodexAppServerUnsupportedError' - } -} - -export class CodexAppServerTimeoutError extends Error { - constructor(message: string) { - super(message) - this.name = 'CodexAppServerTimeoutError' - } -} - -export function isCodexAppServerUnsupportedError(error: unknown): boolean { - return error instanceof Error && error.name === 'CodexAppServerUnsupportedError' -} - -type JsonRpcResponse = { - id?: number - result?: unknown - error?: { code?: number; message?: string } -} - type CodexHookListing = { key: string command: string | null @@ -85,20 +57,6 @@ type CodexHookListing = { trustStatus: string } -const JSON_RPC_METHOD_NOT_FOUND = -32601 -const STDERR_TAIL_MAX_BYTES = 8192 -const STDOUT_LINE_MAX_BYTES = 1024 * 1024 - -function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { - return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') -} - -// Why: a CLI predating the app-server subcommand fails argv parsing before -// speaking any JSON-RPC; that shape is a capability signal, not a transient. -function stderrIndicatesMissingAppServer(stderrTail: string): boolean { - return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail) -} - function collectHookListings(result: unknown): CodexHookListing[] { const data = result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data) @@ -137,239 +95,64 @@ function collectHookListings(result: unknown): CodexHookListing[] { * Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL) * and grants trust for exactly the expected managed entries: * initialize → initialized → hooks/list → config/batchWrite → hooks/list. - * The child is reaped on every path; the session deadline SIGKILLs it. */ export async function runCodexHookTrustGrantSession( request: CodexHookTrustGrantRequest, spawnImpl: typeof spawn = spawn ): Promise { - const { invocation } = request - const child = spawnImpl(invocation.command, invocation.args, { - env: { ...process.env, ...invocation.env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) as ChildProcessWithoutNullStreams - - let stderrTail = '' - let exited = false - let nextRequestId = 1 - let timedOut = false - const pending = new Map< - number, - { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void } - >() - - const exitPromise = new Promise((resolve) => { - child.on('exit', () => { - exited = true - resolve() - }) - }) - // Why: 'error' fires instead of 'exit' when the spawn itself fails - // (ENOENT); surface it to every in-flight request or they wait forever. - let spawnError: Error | null = null - child.on('error', (error) => { - spawnError = error - exited = true - failPending(error) - }) - // Why: 'close' (not 'exit') guarantees the stderr tail is complete, so an - // early death classifies correctly as missing-subcommand vs transient. - child.on('close', () => { - failPending(buildEarlyExitError()) - }) - // Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a - // multibyte character split across pipe chunks or the response becomes invalid JSON. - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) - }) - // Why: a child can exit between the liveness check and stdin.write(); an - // EPIPE must reject the RPC instead of becoming an unhandled stream error. - child.stdin.on('error', (error) => { - failPending(error) - }) - - let stdoutBuffer = '' - child.stdout.setEncoding('utf8').on('data', (chunk: string) => { - stdoutBuffer += chunk - if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { - child.kill('SIGKILL') - failPending(new Error('codex app-server emitted an oversized JSONL response')) - return - } - let newlineIndex - while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { - const line = stdoutBuffer.slice(0, newlineIndex).trim() - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) - if (!line) { - continue - } - let message: JsonRpcResponse - try { - message = JSON.parse(line) as JsonRpcResponse - } catch { - continue - } - if (typeof message.id === 'number' && pending.has(message.id)) { - const waiter = pending.get(message.id)! - pending.delete(message.id) - waiter.resolve(message) + return runCodexAppServerSession( + request.invocation, + async (rpc) => { + const expectedKeys = new Set(request.expectedTrustKeys) + const matchManaged = (listing: CodexHookListing): boolean => + listing.command === request.managedCommand && + expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key)) + + const listResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] }) + const managedListings = collectHookListings(listResult).filter(matchManaged) + if (managedListings.length !== expectedKeys.size) { + return { + outcome: 'verify-failed', + reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries` + } } - } - }) - - function failPending(error: Error): void { - for (const waiter of pending.values()) { - waiter.reject(error) - } - pending.clear() - } - - const deadline = setTimeout(() => { - timedOut = true - child.kill('SIGKILL') - failPending( - new CodexAppServerTimeoutError( - `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` - ) - ) - }, invocation.timeoutMs) - function sendLine(payload: Record): void { - child.stdin.write(`${JSON.stringify(payload)}\n`) - } - - async function requestRpc(method: string, params?: Record): Promise { - if (spawnError) { - throw spawnError - } - if (timedOut) { - throw new CodexAppServerTimeoutError('codex app-server session already timed out') - } - if (exited) { - throw buildEarlyExitError() - } - const id = nextRequestId++ - const response = await new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - const payload: Record = { method, id } - if (params !== undefined) { - payload.params = params - } - try { - sendLine(payload) - } catch (error) { - pending.delete(id) - reject(error instanceof Error ? error : new Error(String(error))) - } - }) - if (response.error) { - if (isMethodNotFoundError(response.error)) { - throw new CodexAppServerUnsupportedError( - `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` - ) - } - throw new Error( - `codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}` - ) - } - return response.result - } - - function buildEarlyExitError(): Error { - if (stderrIndicatesMissingAppServer(stderrTail)) { - return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` - ) - } - return new Error( - `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` - ) - } - - try { - await requestRpc('initialize', { - clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } - }) - sendLine({ method: 'initialized' }) - - const expectedKeys = new Set(request.expectedTrustKeys) - const matchManaged = (listing: CodexHookListing): boolean => - listing.command === request.managedCommand && - expectedKeys.has(normalizeHookTrustKeyForLookup(listing.key)) - - const listResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) - const managedListings = collectHookListings(listResult).filter(matchManaged) - if (managedListings.length !== expectedKeys.size) { - return { - outcome: 'verify-failed', - reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries` + const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (needingTrust.length > 0) { + // Why: same wire shape as the Codex TUI "Trust all" flow — one upsert + // edit under hooks.state with each key's Codex-computed current hash. + const value: Record = {} + for (const listing of needingTrust) { + value[listing.key] = { trusted_hash: listing.currentHash } + } + await rpc.request('config/batchWrite', { + edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }], + reloadUserConfig: true + }) } - } - const needingTrust = managedListings.filter((listing) => listing.trustStatus !== 'trusted') - if (needingTrust.length > 0) { - // Why: same wire shape as the Codex TUI "Trust all" flow — one upsert - // edit under hooks.state with each key's Codex-computed current hash. - const value: Record = {} - for (const listing of needingTrust) { - value[listing.key] = { trusted_hash: listing.currentHash } + const verifyResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] }) + const verifiedListings = collectHookListings(verifyResult).filter(matchManaged) + const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted') + if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) { + return { + outcome: 'verify-failed', + reason: + untrusted.length > 0 + ? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}` + : `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries` + } } - await requestRpc('config/batchWrite', { - edits: [{ keyPath: 'hooks.state', value, mergeStrategy: 'upsert' }], - reloadUserConfig: true - }) - } - - const verifyResult = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) - const verifiedListings = collectHookListings(verifyResult).filter(matchManaged) - const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted') - if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) { return { - outcome: 'verify-failed', - reason: - untrusted.length > 0 - ? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}` - : `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries` + outcome: 'granted', + wroteTrust: needingTrust.length > 0, + entries: verifiedListings.map((listing) => ({ + key: listing.key, + normalizedKey: normalizeHookTrustKeyForLookup(listing.key), + trustedHash: listing.currentHash + })) } - } - return { - outcome: 'granted', - wroteTrust: needingTrust.length > 0, - entries: verifiedListings.map((listing) => ({ - key: listing.key, - normalizedKey: normalizeHookTrustKeyForLookup(listing.key), - trustedHash: listing.currentHash - })) - } - } catch (error) { - if ( - error instanceof Error && - !(error instanceof CodexAppServerUnsupportedError) && - !(error instanceof CodexAppServerTimeoutError) && - stderrIndicatesMissingAppServer(stderrTail) - ) { - throw new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` - ) - } - throw error - } finally { - try { - child.stdin.end() - } catch { - // stdin may already be destroyed after a kill; reaping below still runs. - } - if (!exited) { - // Why: the server exits promptly on stdin EOF; the grace period only - // bounds a wedged child before the guaranteed SIGKILL reap. - const grace = new Promise((resolve) => setTimeout(resolve, 1500)) - await Promise.race([exitPromise, grace]) - if (!exited) { - child.kill('SIGKILL') - await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) - } - } - clearTimeout(deadline) - } + }, + spawnImpl + ) } diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts new file mode 100644 index 00000000000..ac074006343 --- /dev/null +++ b/src/main/codex/codex-app-server-session.ts @@ -0,0 +1,264 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' + +// Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned +// state (hook trust hashes, the sqlite thread index). This module owns the +// stdio JSONL transport — spawn, handshake, framing, deadline, reap — so every +// RPC consumer (trust grant, session index heal) shares one hardened lifecycle. + +export type CodexAppServerInvocation = { + command: string + args: string[] + /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ + env?: Record + /** Whole-session deadline. The codex child is SIGKILLed when it lapses. */ + timeoutMs: number +} + +/** Codex-side absence of the requested app-server RPC surface (old CLI without + * the app-server subcommand, or a server without the called methods). + * This is the ONLY error class capability caches mark unsupported. */ +export class CodexAppServerUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerUnsupportedError' + } +} + +export class CodexAppServerTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexAppServerTimeoutError' + } +} + +export function isCodexAppServerUnsupportedError(error: unknown): boolean { + return error instanceof Error && error.name === 'CodexAppServerUnsupportedError' +} + +type JsonRpcResponse = { + id?: number + result?: unknown + error?: { code?: number; message?: string } +} + +export type CodexAppServerRpc = { + request: (method: string, params?: Record) => Promise + notify: (method: string, params?: Record) => void +} + +const JSON_RPC_METHOD_NOT_FOUND = -32601 +const STDERR_TAIL_MAX_BYTES = 8192 +const STDOUT_LINE_MAX_BYTES = 1024 * 1024 + +function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { + return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') +} + +// Why: a CLI predating the app-server subcommand fails argv parsing before +// speaking any JSON-RPC; that shape is a capability signal, not a transient. +function stderrIndicatesMissingAppServer(stderrTail: string): boolean { + return /unrecognized subcommand|unexpected argument|invalid subcommand/i.test(stderrTail) +} + +/** + * Runs one short-lived `codex app-server` session over stdio JSON-RPC (JSONL): + * spawn → initialize → initialized → body(rpc) → EOF/reap. The child is reaped + * on every path; the session deadline SIGKILLs it. + */ +export async function runCodexAppServerSession( + invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise, + spawnImpl: typeof spawn = spawn +): Promise { + const child = spawnImpl(invocation.command, invocation.args, { + env: { ...process.env, ...invocation.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) as ChildProcessWithoutNullStreams + + let stderrTail = '' + let exited = false + let nextRequestId = 1 + let timedOut = false + const pending = new Map< + number, + { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void } + >() + + const exitPromise = new Promise((resolve) => { + child.on('exit', () => { + exited = true + resolve() + }) + }) + // Why: 'error' fires instead of 'exit' when the spawn itself fails + // (ENOENT); surface it to every in-flight request or they wait forever. + let spawnError: Error | null = null + child.on('error', (error) => { + spawnError = error + exited = true + failPending(error) + }) + // Why: 'close' (not 'exit') guarantees the stderr tail is complete, so an + // early death classifies correctly as missing-subcommand vs transient. + child.on('close', () => { + failPending(buildEarlyExitError()) + }) + // Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a + // multibyte character split across pipe chunks or the response becomes invalid JSON. + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + // Why: a child can exit between the liveness check and stdin.write(); an + // EPIPE must reject the RPC instead of becoming an unhandled stream error. + child.stdin.on('error', (error) => { + failPending(error) + }) + + let stdoutBuffer = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdoutBuffer += chunk + if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + child.kill('SIGKILL') + failPending(new Error('codex app-server emitted an oversized JSONL response')) + return + } + let newlineIndex + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + if (!line) { + continue + } + let message: JsonRpcResponse + try { + message = JSON.parse(line) as JsonRpcResponse + } catch { + continue + } + if (typeof message.id === 'number' && pending.has(message.id)) { + const waiter = pending.get(message.id)! + pending.delete(message.id) + waiter.resolve(message) + } + } + }) + + function failPending(error: Error): void { + for (const waiter of pending.values()) { + waiter.reject(error) + } + pending.clear() + } + + const deadline = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + failPending( + new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + ) + }, invocation.timeoutMs) + + function sendLine(payload: Record): void { + child.stdin.write(`${JSON.stringify(payload)}\n`) + } + + function notify(method: string, params?: Record): void { + const payload: Record = { method } + if (params !== undefined) { + payload.params = params + } + try { + sendLine(payload) + } catch { + // Notifications are fire-and-forget; a dead child fails the next request. + } + } + + async function requestRpc(method: string, params?: Record): Promise { + if (spawnError) { + throw spawnError + } + if (timedOut) { + throw new CodexAppServerTimeoutError('codex app-server session already timed out') + } + if (exited) { + throw buildEarlyExitError() + } + const id = nextRequestId++ + const response = await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + const payload: Record = { method, id } + if (params !== undefined) { + payload.params = params + } + try { + sendLine(payload) + } catch (error) { + pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + if (response.error) { + if (isMethodNotFoundError(response.error)) { + throw new CodexAppServerUnsupportedError( + `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` + ) + } + throw new Error( + `codex app-server ${method} failed: ${response.error.message ?? 'unknown error'}` + ) + } + return response.result + } + + function buildEarlyExitError(): Error { + if (stderrIndicatesMissingAppServer(stderrTail)) { + return new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + return new Error( + `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` + ) + } + + try { + await requestRpc('initialize', { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } + }) + notify('initialized') + return await body({ request: requestRpc, notify }) + } catch (error) { + if ( + error instanceof Error && + !(error instanceof CodexAppServerUnsupportedError) && + !(error instanceof CodexAppServerTimeoutError) && + stderrIndicatesMissingAppServer(stderrTail) + ) { + throw new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` + ) + } + throw error + } finally { + try { + child.stdin.end() + } catch { + // stdin may already be destroyed after a kill; reaping below still runs. + } + if (!exited) { + // Why: the server exits promptly on stdin EOF; the grace period only + // bounds a wedged child before the guaranteed SIGKILL reap. + const grace = new Promise((resolve) => setTimeout(resolve, 1500)) + await Promise.race([exitPromise, grace]) + if (!exited) { + child.kill('SIGKILL') + await Promise.race([exitPromise, new Promise((resolve) => setTimeout(resolve, 1000))]) + } + } + clearTimeout(deadline) + } +} diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts new file mode 100644 index 00000000000..2db9a679705 --- /dev/null +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -0,0 +1,197 @@ +import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { dirname } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +// State files for the session index heal: which backfilled rollouts exist +// (the backfill audit ledger), which thread ids this pass already processed +// (the heal ledger), and the completion marker that makes steady-state +// startups a two-stat no-op. + +// Bump to re-drive the heal for every host after a semantics change; already +// processed thread ids are re-read because ledger lines are version-scoped. +export const CODEX_SESSION_INDEX_HEAL_VERSION = 1 + +// Why: an unsupported CLI stays unsupported until upgraded; re-probing once a +// day is enough to notice an upgrade without a per-startup spawn. +const HEAL_UNSUPPORTED_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 + +const CODEX_ROLLOUT_THREAD_ID_PATTERN = + /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + +export type CodexSessionIndexHealPaths = { + auditLogPath: string + systemSessionsRoot: string + healLedgerPath: string + healMarkerPath: string +} + +export type HealLedgerOutcome = 'healed' | 'missing' | 'failed' + +export type PendingHealThread = { + threadId: string + /** Timestamp segment of the rollout file name; lexicographic recency order. */ + rolloutStamp: string +} + +export type HealMarkerSummary = { + healedThreads: number + missingThreads: number + failedThreads: number +} + +/** + * Diffs the backfill audit ledger against the heal ledger: every hardlinked or + * copied rollout whose thread id has not been processed yet, most recent first. + */ +export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { + const processedThreadIds = readProcessedHealThreadIds(paths.healLedgerPath) + const pendingByThreadId = new Map() + for (const line of readJsonlLines(paths.auditLogPath)) { + if ((line.action !== 'hardlink' && line.action !== 'copy') || typeof line.target !== 'string') { + continue + } + const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) + if (!match) { + continue + } + const threadId = match[2].toLowerCase() + if (processedThreadIds.has(threadId)) { + continue + } + pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1] }) + } + return [...pendingByThreadId.values()].sort((left, right) => + left.rolloutStamp < right.rolloutStamp ? 1 : left.rolloutStamp > right.rolloutStamp ? -1 : 0 + ) +} + +function lastPathSegment(filePath: string): string { + return filePath.split(/[\\/]/).at(-1) ?? '' +} + +function readProcessedHealThreadIds(healLedgerPath: string): Set { + const processed = new Set() + for (const line of readJsonlLines(healLedgerPath)) { + if (line.v === CODEX_SESSION_INDEX_HEAL_VERSION && typeof line.threadId === 'string') { + processed.add(line.threadId.toLowerCase()) + } + } + return processed +} + +export function appendHealLedgerRecord( + healLedgerPath: string, + threadId: string, + outcome: HealLedgerOutcome +): void { + try { + mkdirSync(dirname(healLedgerPath), { recursive: true }) + appendFileSync( + healLedgerPath, + `${JSON.stringify({ + v: CODEX_SESSION_INDEX_HEAL_VERSION, + threadId, + outcome, + at: new Date().toISOString() + })}\n` + ) + } catch (error) { + // Why: losing a ledger line only costs one redundant thread/read on the + // next pass; it must not fail the heal. + console.warn('[codex-session-index-heal] Failed to append heal ledger record:', error) + } +} + +function readJsonlLines(filePath: string): Record[] { + let contents: string + try { + contents = readFileSync(filePath, 'utf-8') + } catch { + return [] + } + const lines: Record[] = [] + for (const raw of contents.split('\n')) { + if (!raw.trim()) { + continue + } + try { + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + lines.push(parsed as Record) + } + } catch { + // Skip torn/corrupt lines; both ledgers are append-only diagnostics. + } + } + return lines +} + +export function readAuditLogSize(auditLogPath: string): number { + try { + return statSync(auditLogPath).size + } catch { + return 0 + } +} + +export function isHealMarkerCurrent( + paths: CodexSessionIndexHealPaths, + auditBytes: number +): boolean { + try { + const parsed: unknown = JSON.parse(readFileSync(paths.healMarkerPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false + } + const marker = parsed as { + version?: unknown + systemSessionsRoot?: unknown + auditBytes?: unknown + unsupportedAt?: unknown + } + if ( + marker.version !== CODEX_SESSION_INDEX_HEAL_VERSION || + marker.systemSessionsRoot !== paths.systemSessionsRoot + ) { + return false + } + if (typeof marker.unsupportedAt === 'number') { + return Date.now() - marker.unsupportedAt < HEAL_UNSUPPORTED_RETRY_INTERVAL_MS + } + // Why: the audit ledger is append-only, so an unchanged byte size means no + // new backfilled sessions since this marker was written. + return marker.auditBytes === auditBytes + } catch { + return false + } +} + +export function writeHealMarker( + paths: CodexSessionIndexHealPaths, + auditBytes: number, + summary: HealMarkerSummary, + unsupportedAt?: number +): void { + try { + mkdirSync(dirname(paths.healMarkerPath), { recursive: true }) + writeFileAtomically( + paths.healMarkerPath, + `${JSON.stringify( + { + version: CODEX_SESSION_INDEX_HEAL_VERSION, + systemSessionsRoot: paths.systemSessionsRoot, + auditBytes, + healedThreads: summary.healedThreads, + missingThreads: summary.missingThreads, + failedThreads: summary.failedThreads, + ...(unsupportedAt === undefined ? {} : { unsupportedAt }), + completedAt: Date.now() + }, + null, + 2 + )}\n` + ) + } catch (error) { + console.warn('[codex-session-index-heal] Failed to write heal marker:', error) + } +} diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts new file mode 100644 index 00000000000..a8f478a1be8 --- /dev/null +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -0,0 +1,451 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + appendFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { CodexAppServerInvocation } from './codex-app-server-session' +import { + runCodexSessionIndexHeal, + type CodexSessionIndexHealPaths +} from './codex-session-index-heal' + +// Stub codex app-server speaking the JSONL protocol for the heal pass: +// initialize → initialized → thread/read×N. Scenario-driven via STUB_CONFIG; +// every thread/read is appended to readLogFile so tests can assert order, +// batching (one spawn appends a server-start marker), and skip behavior. +const STUB_SERVER_SOURCE = ` +const fs = require('node:fs') +const config = JSON.parse(process.env.STUB_CONFIG) +fs.appendFileSync(config.readLogFile, JSON.stringify({ serverStart: true }) + '\\n') +let buffer = '' +let inFlight = 0 +let maxInFlight = 0 +function send(message) { + process.stdout.write(JSON.stringify(message) + '\\n') +} +if (config.scenario === 'no-subcommand') { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => { + buffer += chunk + let index + while ((index = buffer.indexOf('\\n')) !== -1) { + const line = buffer.slice(0, index).trim() + buffer = buffer.slice(index + 1) + if (!line) continue + const message = JSON.parse(line) + if (message.method === 'initialize') { + send({ id: message.id, result: { userAgent: 'stub/0.0.0', codexHome: process.env.CODEX_HOME } }) + continue + } + if (message.method === 'initialized') continue + if (message.method === 'thread/read') { + const threadId = message.params.threadId + if (config.scenario === 'unknown-method') { + send({ id: message.id, error: { code: -32601, message: 'Method not found' } }) + continue + } + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + setTimeout(() => { + inFlight -= 1 + fs.appendFileSync(config.readLogFile, JSON.stringify({ threadId, maxInFlight }) + '\\n') + if ((config.missingThreadIds || []).includes(threadId)) { + send({ id: message.id, error: { code: -32600, message: 'no rollout found for thread id ' + threadId } }) + return + } + if ((config.failingThreadIds || []).includes(threadId)) { + send({ id: message.id, error: { code: -32600, message: 'failed to parse rollout' } }) + return + } + if (config.scenario === 'die-mid-batch' && threadId === config.dieOnThreadId) { + process.exit(7) + } + send({ id: message.id, result: { thread: { id: threadId } } }) + }, 5) + continue + } + } +}) +process.stdin.on('end', () => process.exit(0)) +` + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function threadId(suffix: string): string { + return `019f0000-1111-7222-8333-${suffix.padStart(12, '0')}` +} + +function rolloutTarget(sessionsRoot: string, stamp: string, id: string): string { + return join(sessionsRoot, '2026', '07', '01', `rollout-${stamp}-${id}.jsonl`) +} + +function createHealRig(options: { + scenario?: string + auditedThreads?: { stamp: string; id: string; action?: string }[] + missingThreadIds?: string[] + failingThreadIds?: string[] + dieOnThreadId?: string +}): { + paths: CodexSessionIndexHealPaths + readLogFile: string + buildInvocation: (systemCodexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + readLog: () => { serverStarts: number; threadIds: string[]; maxInFlight: number } +} { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-heal-')) + tempRoots.push(root) + const systemSessionsRoot = join(root, 'real-home', 'sessions') + const stateDir = join(root, 'state') + mkdirSync(stateDir, { recursive: true }) + const paths: CodexSessionIndexHealPaths = { + auditLogPath: join(stateDir, 'audit.jsonl'), + systemSessionsRoot, + healLedgerPath: join(stateDir, 'index-heal-ledger.jsonl'), + healMarkerPath: join(stateDir, 'index-heal-complete.json') + } + for (const audited of options.auditedThreads ?? []) { + appendFileSync( + paths.auditLogPath, + `${JSON.stringify({ + at: '2026-07-01T00:00:00.000Z', + action: audited.action ?? 'hardlink', + source: '/managed/sessions/x.jsonl', + target: rolloutTarget(systemSessionsRoot, audited.stamp, audited.id) + })}\n` + ) + } + const stubPath = join(root, 'stub-app-server.cjs') + writeFileSync(stubPath, STUB_SERVER_SOURCE) + const readLogFile = join(root, 'reads.jsonl') + writeFileSync(readLogFile, '') + return { + paths, + readLogFile, + buildInvocation: (_systemCodexHomePath, timeoutMs) => ({ + command: process.execPath, + args: [stubPath], + env: { + STUB_CONFIG: JSON.stringify({ + scenario: options.scenario ?? 'ok', + readLogFile, + missingThreadIds: options.missingThreadIds ?? [], + failingThreadIds: options.failingThreadIds ?? [], + dieOnThreadId: options.dieOnThreadId + }) + }, + timeoutMs + }), + readLog: () => { + const lines = readFileSync(readLogFile, 'utf-8') + .split('\n') + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { serverStart?: boolean; threadId?: string; maxInFlight?: number } + ) + return { + serverStarts: lines.filter((line) => line.serverStart).length, + threadIds: lines.map((line) => line.threadId).filter((id): id is string => Boolean(id)), + maxInFlight: Math.max(0, ...lines.map((line) => line.maxInFlight ?? 0)) + } + } + } +} + +function readLedgerOutcomes(paths: CodexSessionIndexHealPaths): Record { + let contents = '' + try { + contents = readFileSync(paths.healLedgerPath, 'utf-8') + } catch { + return {} + } + const outcomes: Record = {} + for (const line of contents.split('\n').filter(Boolean)) { + const record = JSON.parse(line) as { threadId: string; outcome: string } + outcomes[record.threadId] = record.outcome + } + return outcomes +} + +describe('runCodexSessionIndexHeal', () => { + it('reads every backfilled session recent-first and completes with a marker', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-01T10-00-00', id: threadId('1') }, + { stamp: '2026-07-03T10-00-00', id: threadId('3'), action: 'copy' }, + { stamp: '2026-07-02T10-00-00', id: threadId('2') } + ] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ + outcome: 'completed', + pendingThreads: 3, + healedThreads: 3, + missingThreads: 0, + failedThreads: 0 + }) + expect(rig.readLog().threadIds).toEqual([threadId('3'), threadId('2'), threadId('1')]) + expect(readLedgerOutcomes(rig.paths)).toEqual({ + [threadId('1')]: 'healed', + [threadId('2')]: 'healed', + [threadId('3')]: 'healed' + }) + const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { + systemSessionsRoot: string + healedThreads: number + } + expect(marker.systemSessionsRoot).toBe(rig.paths.systemSessionsRoot) + expect(marker.healedThreads).toBe(3) + }) + + it('is a no-op when the marker matches the audit ledger size', async () => { + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] + }) + const first = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(first.outcome).toBe('completed') + const second = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(second.outcome).toBe('up-to-date') + // One spawn from the first run only — the no-op run must not hit the CLI. + expect(rig.readLog().serverStarts).toBe(1) + }) + + it('resumes only unprocessed sessions when the audit ledger grows', async () => { + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] + }) + await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + appendFileSync( + rig.paths.auditLogPath, + `${JSON.stringify({ + action: 'hardlink', + target: rolloutTarget(rig.paths.systemSessionsRoot, '2026-07-04T10-00-00', threadId('4')) + })}\n` + ) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([threadId('1'), threadId('4')]) + }) + + it('records missing and failed sessions without retrying them next run', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-01T10-00-00', id: threadId('1') }, + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-03T10-00-00', id: threadId('3') } + ], + missingThreadIds: [threadId('2')], + failingThreadIds: [threadId('1')] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ + outcome: 'completed', + healedThreads: 1, + missingThreads: 1, + failedThreads: 1 + }) + expect(readLedgerOutcomes(rig.paths)).toEqual({ + [threadId('1')]: 'failed', + [threadId('2')]: 'missing', + [threadId('3')]: 'healed' + }) + + const again = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(again.outcome).toBe('up-to-date') + }) + + it('splits work into batches with one server session each and bounded concurrency', async () => { + const rig = createHealRig({ + auditedThreads: Array.from({ length: 5 }, (_, index) => ({ + stamp: `2026-07-0${index + 1}T10-00-00`, + id: threadId(String(index + 1)) + })) + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readsPerServerSession: 2, + readConcurrency: 2, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 5 }) + const log = rig.readLog() + expect(log.serverStarts).toBe(3) + expect(log.maxInFlight).toBeLessThanOrEqual(2) + }) + + it('stops promptly when shouldStop flips and resumes on the next pass', async () => { + const rig = createHealRig({ + auditedThreads: Array.from({ length: 4 }, (_, index) => ({ + stamp: `2026-07-0${index + 1}T10-00-00`, + id: threadId(String(index + 1)) + })) + }) + let reads = 0 + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readsPerServerSession: 1, + interBatchDelayMs: 0, + shouldStop: () => reads++ >= 2 + }) + expect(summary.outcome).toBe('stopped') + expect(summary.healedThreads).toBeLessThan(4) + + const resumed = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(resumed.outcome).toBe('completed') + expect(resumed.healedThreads + summary.healedThreads).toBe(4) + }) + + it('marks the pass unsupported without ledger writes when thread/read is unavailable', async () => { + const rig = createHealRig({ + scenario: 'unknown-method', + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(summary.outcome).toBe('unsupported') + expect(summary.healedThreads).toBe(0) + expect(readLedgerOutcomes(rig.paths)).toEqual({}) + + // Within the retry interval the unsupported marker suppresses re-probing. + const again = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(again.outcome).toBe('up-to-date') + expect(rig.readLog().serverStarts).toBe(1) + }) + + it('marks the pass unsupported when the CLI lacks the app-server subcommand', async () => { + const rig = createHealRig({ + scenario: 'no-subcommand', + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] + }) + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(summary.outcome).toBe('unsupported') + }) + + it('aborts without recording when the server dies mid-batch, then retries next pass', async () => { + const rig = createHealRig({ + scenario: 'die-mid-batch', + auditedThreads: [ + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-01T10-00-00', id: threadId('1') } + ], + dieOnThreadId: threadId('2') + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readConcurrency: 1, + interBatchDelayMs: 0 + }) + expect(summary.outcome).toBe('aborted') + expect(readLedgerOutcomes(rig.paths)[threadId('1')]).toBeUndefined() + + const retried = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: (home, timeoutMs) => { + const invocation = rig.buildInvocation(home, timeoutMs) + return { + ...invocation, + env: { + STUB_CONFIG: JSON.stringify({ + scenario: 'ok', + readLogFile: rig.readLogFile, + missingThreadIds: [], + failingThreadIds: [] + }) + } + } + }, + interBatchDelayMs: 0 + }) + expect(retried.outcome).toBe('completed') + expect(retried.healedThreads).toBe(2) + }) + + it('completes immediately with no server spawn when there is nothing to heal', async () => { + const rig = createHealRig({ auditedThreads: [] }) + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 0 }) + expect(rig.readLog().serverStarts).toBe(0) + }) + + it('ignores audit records outside the backfill link/copy actions', async () => { + const rig = createHealRig({ auditedThreads: [] }) + appendFileSync( + rig.paths.auditLogPath, + `${[ + JSON.stringify({ action: 'run-summary', scannedFiles: 3 }), + JSON.stringify({ action: 'scan-failed', source: '/managed/sessions/2026' }), + JSON.stringify({ + action: 'failed', + target: rolloutTarget(rig.paths.systemSessionsRoot, '2026-07-01T10-00-00', threadId('9')) + }), + 'not-json', + '' + ].join('\n')}\n` + ) + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 0 }) + expect(rig.readLog().serverStarts).toBe(0) + }) +}) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts new file mode 100644 index 00000000000..d31c9bba17a --- /dev/null +++ b/src/main/codex/codex-session-index-heal.ts @@ -0,0 +1,235 @@ +import { dirname, join } from 'node:path' +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' +import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' +import { + appendHealLedgerRecord, + collectPendingHealThreads, + isHealMarkerCurrent, + readAuditLogSize, + writeHealMarker, + type CodexSessionIndexHealPaths, + type PendingHealThread +} from './codex-session-index-heal-state' +import { + isCodexAppServerUnsupportedError, + runCodexAppServerSession, + type CodexAppServerInvocation +} from './codex-app-server-session' + +export type { CodexSessionIndexHealPaths } from './codex-session-index-heal-state' + +// Why: Codex's own sqlite metadata backfill is one-shot (backfill_state is +// stamped `complete` on first app-server startup), so rollouts that Orca's +// session backfill hardlinks in later never reach the state DB on their own. +// `thread/read` is Codex's sanctioned lazy-indexing path: it parses the +// rollout and upserts the thread row, making backfilled sessions visible to +// Codex's DB-driven surfaces. Orca never writes Codex's sqlite schema itself. + +// Why: one server session per batch bounds child memory and keeps a wedged +// server from stalling the whole pass; small in-session concurrency keeps the +// disk/CPU cost background-grade instead of a thundering read storm. +const HEAL_READS_PER_SERVER_SESSION = 50 +const HEAL_READ_CONCURRENCY = 2 +const HEAL_INTER_BATCH_DELAY_MS = 500 +const HEAL_BATCH_TIMEOUT_BASE_MS = 15_000 +const HEAL_BATCH_TIMEOUT_PER_READ_MS = 2_000 + +export type CodexSessionIndexHealSummary = { + outcome: 'completed' | 'stopped' | 'unsupported' | 'aborted' | 'up-to-date' + pendingThreads: number + healedThreads: number + missingThreads: number + failedThreads: number +} + +export type CodexSessionIndexHealOptions = { + /** Polled between reads and batches; true stops promptly, progress is kept. */ + shouldStop?: () => boolean + buildInvocation?: (systemCodexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + readsPerServerSession?: number + readConcurrency?: number + interBatchDelayMs?: number +} + +let backgroundHealTask: Promise | null = null + +export function resolveCodexSessionIndexHealPaths( + systemCodexHomePathOverride?: string +): CodexSessionIndexHealPaths { + const backfillPaths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) + const stateDir = getCodexSessionBackfillStateDirPath() + return { + auditLogPath: backfillPaths.auditLogPath, + systemSessionsRoot: backfillPaths.systemSessionsRoot, + healLedgerPath: join(stateDir, 'index-heal-ledger.jsonl'), + healMarkerPath: join(stateDir, 'index-heal-complete.json') + } +} + +/** + * Starts a single background index-heal pass for backfilled Codex sessions. + * + * Concurrent callers share the in-flight task; an up-to-date marker resolves + * without reading the audit ledger or spawning any app-server. + */ +export function startCodexSessionIndexHealInBackground( + options: CodexSessionIndexHealOptions = {}, + systemCodexHomePathOverride?: string +): Promise { + if (backgroundHealTask) { + return backgroundHealTask + } + const task = runCodexSessionIndexHeal( + resolveCodexSessionIndexHealPaths(systemCodexHomePathOverride), + options + ).catch((error: unknown) => { + console.warn('[codex-session-index-heal] Background index heal failed:', error) + return null + }) + backgroundHealTask = task + void task.finally(() => { + if (backgroundHealTask === task) { + backgroundHealTask = null + } + }) + return task +} + +/** + * Drives Codex's lazy thread indexing (`thread/read`) for every backfilled + * session recorded in the backfill audit ledger that this pass has not + * processed yet, most recent sessions first. + */ +export async function runCodexSessionIndexHeal( + paths: CodexSessionIndexHealPaths, + options: CodexSessionIndexHealOptions = {} +): Promise { + const auditBytes = readAuditLogSize(paths.auditLogPath) + if (isHealMarkerCurrent(paths, auditBytes)) { + return { + outcome: 'up-to-date', + pendingThreads: 0, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + } + + const pending = collectPendingHealThreads(paths) + const summary: CodexSessionIndexHealSummary = { + outcome: 'completed', + pendingThreads: pending.length, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + if (pending.length === 0) { + writeHealMarker(paths, auditBytes, summary) + return summary + } + + const systemCodexHomePath = dirname(paths.systemSessionsRoot) + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const readsPerServerSession = options.readsPerServerSession ?? HEAL_READS_PER_SERVER_SESSION + const readConcurrency = options.readConcurrency ?? HEAL_READ_CONCURRENCY + const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS + const shouldStop = options.shouldStop ?? ((): boolean => false) + + for (let offset = 0; offset < pending.length; offset += readsPerServerSession) { + if (shouldStop()) { + summary.outcome = 'stopped' + return summary + } + if (offset > 0 && interBatchDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) + } + const batch = pending.slice(offset, offset + readsPerServerSession) + const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length + try { + await runCodexAppServerSession( + buildInvocation(systemCodexHomePath, timeoutMs), + async (rpc) => { + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < batch.length && !shouldStop()) { + const thread = batch[nextIndex] + nextIndex += 1 + await healOneThread(rpc, thread, paths, summary) + } + } + await Promise.all(Array.from({ length: Math.max(1, readConcurrency) }, () => worker())) + } + ) + } catch (error) { + if (isCodexAppServerUnsupportedError(error)) { + // Why: no retry churn on old CLIs — remember unsupported and re-probe + // after the retry interval or a version bump; nothing is marked healed. + writeHealMarker(paths, auditBytes, summary, Date.now()) + summary.outcome = 'unsupported' + return summary + } + // Transport failure (timeout, early exit, spawn error): unprocessed ids + // were never appended to the ledger, so the next pass resumes them. + console.warn('[codex-session-index-heal] Heal batch aborted:', error) + summary.outcome = 'aborted' + return summary + } + } + + if (shouldStop()) { + summary.outcome = 'stopped' + return summary + } + writeHealMarker(paths, auditBytes, summary) + return summary +} + +async function healOneThread( + rpc: { request: (method: string, params?: Record) => Promise }, + thread: PendingHealThread, + paths: CodexSessionIndexHealPaths, + summary: CodexSessionIndexHealSummary +): Promise { + try { + await rpc.request('thread/read', { threadId: thread.threadId }) + summary.healedThreads += 1 + appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'healed') + } catch (error) { + if (isCodexAppServerUnsupportedError(error)) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + if (!message.startsWith('codex app-server thread/read failed')) { + // Not an RPC-level response: the server died or timed out. Abort the + // batch without recording, so the id is retried on the next pass. + throw error + } + if (/no rollout found/i.test(message)) { + // The backfilled rollout was deleted after the audit was written. + summary.missingThreads += 1 + appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'missing') + return + } + summary.failedThreads += 1 + appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'failed') + } +} + +function buildNativeHealInvocation( + systemCodexHomePath: string, + timeoutMs: number +): CodexAppServerInvocation { + const command = resolveCodexCommand() + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) + return { + command: spawnCmd, + args: spawnArgs, + // Why: pin the real home explicitly — nested Orca launches can inherit a + // managed CODEX_HOME from the daemon environment, which would index the + // wrong sqlite DB. + env: { CODEX_HOME: systemCodexHomePath }, + timeoutMs + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 26105bb8b82..ddfd90e9ffd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,6 +135,7 @@ import { } from './codex/codex-real-home-hook-install' import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant' import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' @@ -1807,9 +1808,15 @@ app.whenReady().then(async () => { // #8612). Deferred so startup and first PTY spawns never compete with the // sessions tree walk. setTimeout(() => { - void startCodexSessionBackfillInBackground( - {}, - resolveHostCodexSessionSourceHome(store!.getSettings()) + const systemCodexHomePathOverride = resolveHostCodexSessionSourceHome(store!.getSettings()) + // Why: the heal pass chains after the backfill settles so thread/read only + // runs once the audit ledger covers this startup's newly linked rollouts; + // it also drains sessions left pending by an interrupted earlier pass. + void startCodexSessionBackfillInBackground({}, systemCodexHomePathOverride).then(() => + startCodexSessionIndexHealInBackground( + { shouldStop: () => isQuitting }, + systemCodexHomePathOverride + ) ) }, 15_000) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) From d74aa56813900835b94fd697c1461f9b5ac08cab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:35:44 -0700 Subject: [PATCH 31/45] fix(codex): preserve session identity during dedup heal --- .../ai-vault/codex-session-root-dedup.test.ts | 92 +++++++++-- src/main/ai-vault/codex-session-root-dedup.ts | 76 +++++++-- src/main/ai-vault/remote-session-file-stat.ts | 37 +++++ src/main/ai-vault/remote-session-scanner.ts | 42 ++--- .../session-scanner-codex-dual-root.test.ts | 150 +++++++++++++++++- .../ai-vault/session-scanner-discovery.ts | 5 +- src/main/ai-vault/session-scanner-types.ts | 7 +- src/main/ai-vault/session-scanner.ts | 9 +- .../codex/codex-session-index-heal-state.ts | 32 +++- .../codex/codex-session-index-heal.test.ts | 92 +++++++++++ src/main/codex/codex-session-index-heal.ts | 27 +++- 11 files changed, 492 insertions(+), 77 deletions(-) create mode 100644 src/main/ai-vault/remote-session-file-stat.ts diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index 4d710e368bf..dcff2f0143c 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -38,26 +38,48 @@ function codexSession(overrides: Partial): AiVaultSession { } describe('dedupeCodexRolloutFileAliases', () => { - type Candidate = { agent: string; path: string; codexHome: string | null } + type Candidate = { + agent: string + path: string + codexHome: string | null + hardlinkIdentity?: string + } const accessors = { isCodex: (candidate: Candidate) => candidate.agent === 'codex', getFilePath: (candidate: Candidate) => candidate.path, - getCodexHome: (candidate: Candidate) => candidate.codexHome + getCodexHome: (candidate: Candidate) => candidate.codexHome, + getHardlinkIdentity: (candidate: Candidate) => candidate.hardlinkIdentity ?? null } it('keeps the real-home alias when the same rollout exists in both roots', () => { - const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } - const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const managed = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + hardlinkIdentity: '1:42' + } + const real = { + agent: 'codex', + path: REAL_HOME_ROLLOUT, + codexHome: null, + hardlinkIdentity: '1:42' + } expect(dedupeCodexRolloutFileAliases([managed, real], accessors)).toEqual([real]) expect(dedupeCodexRolloutFileAliases([real, managed], accessors)).toEqual([real]) }) it('prefers the managed runtime home over other non-default homes', () => { - const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const managed = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + hardlinkIdentity: '1:42' + } const wslReal = { agent: 'codex', path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl`, - codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex', + hardlinkIdentity: '1:42' } expect(dedupeCodexRolloutFileAliases([wslReal, managed], accessors)).toEqual([managed]) }) @@ -66,12 +88,14 @@ describe('dedupeCodexRolloutFileAliases', () => { const managed = { agent: 'codex', path: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', - codexHome: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + codexHome: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home', + hardlinkIdentity: '7:9' } const custom = { agent: 'codex', path: 'D:\\codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', - codexHome: 'D:\\codex' + codexHome: 'D:\\codex', + hardlinkIdentity: '7:9' } expect(dedupeCodexRolloutFileAliases([custom, managed], accessors)).toEqual([managed]) }) @@ -96,6 +120,35 @@ describe('dedupeCodexRolloutFileAliases', () => { claude ]) }) + + it('keeps same-name files unless a shared hardlink identity proves they alias', () => { + const real = { + agent: 'codex', + path: REAL_HOME_ROLLOUT, + codexHome: null, + hardlinkIdentity: '1:10' + } + const differentFile = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + hardlinkIdentity: '1:11' + } + const unprovenCopy = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME + } + + expect(dedupeCodexRolloutFileAliases([real, differentFile], accessors)).toEqual([ + real, + differentFile + ]) + expect(dedupeCodexRolloutFileAliases([real, unprovenCopy], accessors)).toEqual([ + real, + unprovenCopy + ]) + }) }) describe('dedupeCodexSessionsBySessionId', () => { @@ -129,17 +182,26 @@ describe('dedupeCodexSessionsBySessionId', () => { }) it('never collapses across execution hosts or agents', () => { - const local = codexSession({ sessionId: 'session-1', executionHostId: 'local' }) + const local = codexSession({ + sessionId: 'session-1', + executionHostId: 'local', + filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl' + }) const remote = codexSession({ sessionId: 'session-1', executionHostId: 'ssh:build-box', + filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl', id: 'ssh:build-box:codex:session-1:/home/ada/.codex/sessions/x.jsonl' }) - const claude = codexSession({ sessionId: 'session-1', agent: 'claude' }) + const claude = codexSession({ + sessionId: 'session-1', + agent: 'claude', + filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl' + }) expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude]) }) - it('resolves same-rank id collisions to the newest row, then stable path order', () => { + it('preserves same-host session-id collisions when rollout file names differ', () => { const older = codexSession({ sessionId: 'collision', filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-old.jsonl', @@ -154,16 +216,18 @@ describe('dedupeCodexSessionsBySessionId', () => { updatedAt: '2026-07-02T10:00:00.000Z', modifiedAt: '2026-07-02T10:00:00.000Z' }) - expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([newer]) + expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([older, newer]) + }) + it('resolves same-rollout aliases with a stable path tie-break', () => { const tieA = codexSession({ sessionId: 'tie', - filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-a.jsonl', + filePath: '/Users/ada/a/.codex/sessions/2026/07/01/rollout-tie.jsonl', codexHome: null }) const tieB = codexSession({ sessionId: 'tie', - filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-b.jsonl', + filePath: '/Users/ada/b/.codex/sessions/2026/07/01/rollout-tie.jsonl', codexHome: null }) expect(dedupeCodexSessionsBySessionId([tieB, tieA])).toEqual([tieA]) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index dbb66fb674c..b54434eb2d1 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -7,8 +7,8 @@ import { sessionSortTime } from './session-scanner-accumulator' // per root (#7521). These helpers collapse those aliases to one canonical row. // Matches Codex rollout logs: rollout--.jsonl. The -// bridge and backfill both preserve the sessions/YYYY/MM/DD layout, so an -// identical rollout file name across Codex roots is the same session. +// bridge and backfill preserve the name, but the name alone is not identity: +// pre-parse dedup also requires a shared inode and post-parse requires the id. const CODEX_ROLLOUT_FILE_NAME_PATTERN = /^rollout-.+\.jsonl$/ // Why: not node:path.basename — a posix host scans remote/WSL win32 paths, so @@ -17,6 +17,28 @@ function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } +/** Returns a pre-parse alias key only when metadata proves a shared hardlink. */ +export function codexRolloutHardlinkIdentity(file: { + dev?: number + ino?: number + nlink?: number +}): string | null { + const { dev, ino, nlink } = file + if ( + typeof dev !== 'number' || + typeof ino !== 'number' || + typeof nlink !== 'number' || + !Number.isSafeInteger(dev) || + !Number.isSafeInteger(ino) || + !Number.isSafeInteger(nlink) || + nlink <= 1 || + (dev === 0 && ino === 0) + ) { + return null + } + return `${dev}:${ino}` +} + /** * Ranks a Codex session root for canonical-alias selection, lowest wins. * @@ -36,8 +58,8 @@ function codexSessionRootRank(codexHome: string | null): number { /** * Drops pre-parse Codex rollout candidates that alias an already-kept rollout - * file name in a preferred root, so duplicate aliases never consume the parse - * budget or crowd the capped listing. + * hardlink in a preferred root, so proven aliases never consume the parse + * budget. Same-name copies remain until parsed identity proves they alias. */ export function dedupeCodexRolloutFileAliases( candidates: readonly T[], @@ -45,9 +67,10 @@ export function dedupeCodexRolloutFileAliases( isCodex: (candidate: T) => boolean getFilePath: (candidate: T) => string getCodexHome: (candidate: T) => string | null + getHardlinkIdentity: (candidate: T) => string | null } ): T[] { - const bestByFileName = new Map() + const bestByAlias = new Map() for (const candidate of candidates) { if (!accessors.isCodex(candidate)) { continue @@ -57,10 +80,15 @@ export function dedupeCodexRolloutFileAliases( if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { continue } + const hardlinkIdentity = accessors.getHardlinkIdentity(candidate) + if (!hardlinkIdentity) { + continue + } + const aliasKey = `${fileName}\0${hardlinkIdentity}` const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) - const best = bestByFileName.get(fileName) + const best = bestByAlias.get(aliasKey) if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { - bestByFileName.set(fileName, { candidate, rank, filePath }) + bestByAlias.set(aliasKey, { candidate, rank, filePath }) } } return candidates.filter((candidate) => { @@ -68,39 +96,55 @@ export function dedupeCodexRolloutFileAliases( return true } const fileName = lastPathSegment(accessors.getFilePath(candidate)) - const best = bestByFileName.get(fileName) + const hardlinkIdentity = accessors.getHardlinkIdentity(candidate) + if (!hardlinkIdentity) { + return true + } + const best = bestByAlias.get(`${fileName}\0${hardlinkIdentity}`) return !best || best.candidate === candidate }) } /** - * Collapses parsed Codex sessions that share a session id on one execution - * host, keeping the canonical root's row (see codexSessionRootRank). Catches - * aliases the file-name pass cannot see: cross-volume backfill copies and - * session_meta ids that differ from the rollout file name. + * Collapses parsed Codex sessions that share a rollout name and session id on + * one execution host, keeping the canonical root's row. Requiring both the + * parsed id and rollout name preserves id collisions and same-name files whose + * parsed ids differ. */ export function dedupeCodexSessionsBySessionId( sessions: readonly AiVaultSession[] ): AiVaultSession[] { const bestByKey = new Map() for (const session of sessions) { - if (session.agent !== 'codex') { + const key = codexSessionAliasKey(session) + if (!key) { continue } - const key = `${session.executionHostId}:${session.sessionId}` const best = bestByKey.get(key) if (!best || codexSessionAliasBeats(session, best)) { bestByKey.set(key, session) } } return sessions.filter((session) => { - if (session.agent !== 'codex') { + const key = codexSessionAliasKey(session) + if (!key) { return true } - return bestByKey.get(`${session.executionHostId}:${session.sessionId}`) === session + return bestByKey.get(key) === session }) } +function codexSessionAliasKey(session: AiVaultSession): string | null { + if (session.agent !== 'codex') { + return null + } + const fileName = lastPathSegment(session.filePath) + if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { + return null + } + return `${session.executionHostId}\0${session.sessionId}\0${fileName}` +} + function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { const candidateRank = codexSessionRootRank(candidate.codexHome) const bestRank = codexSessionRootRank(best.codexHome) diff --git a/src/main/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts new file mode 100644 index 00000000000..30504e99462 --- /dev/null +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -0,0 +1,37 @@ +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' + +export async function statRemoteSessionFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteSessionMtimeMs(stat) + return { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: stat.size, + ...(typeof stat.dev === 'number' ? { dev: stat.dev } : {}), + ...(typeof stat.ino === 'number' ? { ino: stat.ino } : {}), + ...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {}) + } + } catch (error) { + issues.push({ executionHostId, agent, path, message: errorMessage(error) }) + return null + } +} + +function remoteSessionMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 7dddc858db1..0e5031de7e9 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,21 +1,22 @@ import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' import { + codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import { statRemoteSessionFile } from './remote-session-file-stat' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { remoteSessionSources } from './remote-session-scanner-sources' @@ -57,7 +58,8 @@ export async function scanRemoteAiVaultSessions(args: { { isCodex: (candidate) => candidate.source.agent === 'codex', getFilePath: (candidate) => candidate.file.path, - getCodexHome: (candidate) => candidate.source.codexHome ?? null + getCodexHome: (candidate) => candidate.source.codexHome ?? null, + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) } ) @@ -77,9 +79,13 @@ export async function scanRemoteAiVaultSessions(args: { scopePaths, alreadyParsedFilePaths: parsed.parsedFilePaths }) + const scopeSessions = dedupeCodexSessionsBySessionId([ + ...parsedScopeSessions, + ...extraScopeSessions + ]) return { - sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]), + sessions: mergeRemoteSessions(cappedSessions, scopeSessions), issues, scannedAt: new Date().toISOString() } @@ -100,7 +106,7 @@ async function discoverRemoteSourceCandidates(args: { : null const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( + statRemoteSessionFile( args.context.provider, path, args.source.agent, @@ -172,6 +178,8 @@ async function parseRemoteSessionCandidates(args: { batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) ) sessions.push(...results.filter(isAiVaultSession)) + const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) + sessions.splice(0, sessions.length, ...uniqueSessions) index += batch.length } @@ -266,30 +274,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-codex-dual-root.test.ts b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts index d9d65b88141..81c73232f4f 100644 --- a/src/main/ai-vault/session-scanner-codex-dual-root.test.ts +++ b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts @@ -1,4 +1,4 @@ -import { link, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { copyFile, link, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -113,4 +113,152 @@ describe('scanAiVaultSessions codex dual-root dedup', () => { resumeCommand: `cd '/repo/app' && CODEX_HOME='${managedHome}' codex resume '029f0000-1111-7222-8333-555555555555'` }) }) + + it('keeps different same-name rollouts from separate roots', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-collision-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const realHome = join(root, 'real-codex-home') + const realSessionsDir = join(realHome, 'sessions') + const managedHome = join(root, 'codex-runtime-home', 'home') + const managedSessionsDir = join(managedHome, 'sessions') + const rolloutName = 'rollout-2026-07-01T10-00-00-collision.jsonl' + const realPath = join(realSessionsDir, rolloutName) + const managedPath = join(managedSessionsDir, rolloutName) + await mkdir(realSessionsDir, { recursive: true }) + await mkdir(managedSessionsDir, { recursive: true }) + await writeFile( + realPath, + jsonLines([ + { + timestamp: '2026-07-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'real-session', cwd: '/repo/real' } + }, + { + timestamp: '2026-07-01T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Real content' }] + } + } + ]) + ) + await writeFile( + managedPath, + jsonLines([ + { + timestamp: '2026-07-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'managed-session', cwd: '/repo/managed' } + }, + { + timestamp: '2026-07-01T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed content' }] + } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + codexSessionsDir: realSessionsDir, + defaultCodexHomeDir: realHome, + additionalCodexSessionsDirs: [managedSessionsDir], + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId).sort()).toEqual([ + 'managed-session', + 'real-session' + ]) + }) + + it('fills the listing past cross-volume-style copy aliases', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-copy-cap-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const realHome = join(root, 'real-codex-home') + const realSessionsDir = join(realHome, 'sessions') + const managedHome = join(root, 'codex-runtime-home', 'home') + const managedSessionsDir = join(managedHome, 'sessions') + const aliasName = 'rollout-2026-07-02T10-00-00-copy-alias.jsonl' + const realAliasPath = join(realSessionsDir, aliasName) + const managedAliasPath = join(managedSessionsDir, aliasName) + const uniquePath = join(managedSessionsDir, 'rollout-2026-07-01T10-00-00-unique.jsonl') + await mkdir(realSessionsDir, { recursive: true }) + await mkdir(managedSessionsDir, { recursive: true }) + await writeFile( + realAliasPath, + jsonLines([ + { + timestamp: '2026-07-02T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'copied-session', cwd: '/repo/copied' } + }, + { + timestamp: '2026-07-02T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Copied content' }] + } + } + ]) + ) + await copyFile(realAliasPath, managedAliasPath) + await writeFile( + uniquePath, + jsonLines([ + { + timestamp: '2026-07-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'unique-session', cwd: '/repo/unique' } + }, + { + timestamp: '2026-07-01T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Unique content' }] + } + } + ]) + ) + const newest = new Date('2026-07-02T10:00:00.000Z') + const older = new Date('2026-07-01T10:00:00.000Z') + await utimes(realAliasPath, newest, newest) + await utimes(managedAliasPath, newest, newest) + await utimes(uniquePath, older, older) + + const result = await scanAiVaultSessions({ + ...roots, + codexSessionsDir: realSessionsDir, + defaultCodexHomeDir: realHome, + additionalCodexSessionsDirs: [managedSessionsDir], + platform: 'darwin', + limit: 2 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId).sort()).toEqual([ + 'copied-session', + 'unique-session' + ]) + expect(result.sessions.find((session) => session.sessionId === 'copied-session')).toMatchObject( + { + codexHome: null, + filePath: realAliasPath + } + ) + }) }) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..235d5828193 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -26,7 +26,10 @@ export async function discoverFiles(args: { path, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink }) } catch (err) { args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 6f28d7ce117..efcc67dcb92 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -47,8 +47,13 @@ export type FileWithMtime = { modifiedAt: string // Present when discovery statted the file; lets the parse cache detect // unchanged/truncated files without a second stat. Synthetic candidates - // (OpenCode SQLite rows, remote files) omit it. + // such as OpenCode SQLite rows omit it. sizeBytes?: number + // Present when discovery can prove filesystem identity. Codex dual-root + // scans use a multi-link inode to collapse only actual hardlink aliases. + dev?: number + ino?: number + nlink?: number } export type SessionFileCandidate = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index caab77500f8..5c1b45833f5 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -7,6 +7,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/exec import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' import { + codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' @@ -81,7 +82,8 @@ export async function scanAiVaultSessions( { isCodex: (candidate) => candidate.agent === 'codex', getFilePath: (candidate) => candidate.file.path, - getCodexHome: (candidate) => candidate.codexHome + getCodexHome: (candidate) => candidate.codexHome, + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) } ) @@ -217,6 +219,11 @@ async function parseSessionCandidates(args: { } } + // Why: cross-volume backfill copies have no shared inode, so collapse + // parsed aliases before they can crowd the unique-session parse budget. + const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) + sessions.splice(0, sessions.length, ...uniqueSessions) + index += batchSize } diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 2db9a679705..f01573b2f0d 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -1,5 +1,9 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { dirname } from 'node:path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' // State files for the session index heal: which backfilled rollouts exist @@ -9,7 +13,7 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' // Bump to re-drive the heal for every host after a semantics change; already // processed thread ids are re-read because ledger lines are version-scoped. -export const CODEX_SESSION_INDEX_HEAL_VERSION = 1 +export const CODEX_SESSION_INDEX_HEAL_VERSION = 2 // Why: an unsupported CLI stays unsupported until upgraded; re-probing once a // day is enough to notice an upgrade without a per-startup spawn. @@ -44,12 +48,17 @@ export type HealMarkerSummary = { * copied rollout whose thread id has not been processed yet, most recent first. */ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { - const processedThreadIds = readProcessedHealThreadIds(paths.healLedgerPath) + const processedThreadIds = readProcessedHealThreadIds(paths) const pendingByThreadId = new Map() for (const line of readJsonlLines(paths.auditLogPath)) { if ((line.action !== 'hardlink' && line.action !== 'copy') || typeof line.target !== 'string') { continue } + // Why: the append-only audit can contain runs for several custom Codex + // homes; only thread/read ids whose rollout lives in this invocation's DB. + if (!isPathInsideOrEqual(paths.systemSessionsRoot, line.target)) { + continue + } const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) if (!match) { continue @@ -69,10 +78,16 @@ function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } -function readProcessedHealThreadIds(healLedgerPath: string): Set { +function readProcessedHealThreadIds(paths: CodexSessionIndexHealPaths): Set { const processed = new Set() - for (const line of readJsonlLines(healLedgerPath)) { - if (line.v === CODEX_SESSION_INDEX_HEAL_VERSION && typeof line.threadId === 'string') { + const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) + for (const line of readJsonlLines(paths.healLedgerPath)) { + if ( + line.v === CODEX_SESSION_INDEX_HEAL_VERSION && + typeof line.threadId === 'string' && + typeof line.systemSessionsRoot === 'string' && + normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot + ) { processed.add(line.threadId.toLowerCase()) } } @@ -80,16 +95,17 @@ function readProcessedHealThreadIds(healLedgerPath: string): Set { } export function appendHealLedgerRecord( - healLedgerPath: string, + paths: CodexSessionIndexHealPaths, threadId: string, outcome: HealLedgerOutcome ): void { try { - mkdirSync(dirname(healLedgerPath), { recursive: true }) + mkdirSync(dirname(paths.healLedgerPath), { recursive: true }) appendFileSync( - healLedgerPath, + paths.healLedgerPath, `${JSON.stringify({ v: CODEX_SESSION_INDEX_HEAL_VERSION, + systemSessionsRoot: paths.systemSessionsRoot, threadId, outcome, at: new Date().toISOString() diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index a8f478a1be8..3a4f990c90d 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexAppServerInvocation } from './codex-app-server-session' +import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' import { runCodexSessionIndexHeal, type CodexSessionIndexHealPaths @@ -66,6 +67,10 @@ process.stdin.on('data', (chunk) => { send({ id: message.id, error: { code: -32600, message: 'failed to parse rollout' } }) return } + if ((config.busyThreadIds || []).includes(threadId)) { + send({ id: message.id, error: { code: -32600, message: 'database is locked' } }) + return + } if (config.scenario === 'die-mid-batch' && threadId === config.dieOnThreadId) { process.exit(7) } @@ -100,6 +105,7 @@ function createHealRig(options: { auditedThreads?: { stamp: string; id: string; action?: string }[] missingThreadIds?: string[] failingThreadIds?: string[] + busyThreadIds?: string[] dieOnThreadId?: string }): { paths: CodexSessionIndexHealPaths @@ -145,6 +151,7 @@ function createHealRig(options: { readLogFile, missingThreadIds: options.missingThreadIds ?? [], failingThreadIds: options.failingThreadIds ?? [], + busyThreadIds: options.busyThreadIds ?? [], dieOnThreadId: options.dieOnThreadId }) }, @@ -317,6 +324,25 @@ describe('runCodexSessionIndexHeal', () => { expect(log.maxInFlight).toBeLessThanOrEqual(2) }) + it('caps overrides at the production batch and concurrency limits', async () => { + const rig = createHealRig({ + auditedThreads: Array.from({ length: 51 }, (_, index) => ({ + stamp: `2026-07-${String(index + 1).padStart(2, '0')}T10-00-00`, + id: threadId(String(index + 1)) + })) + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readsPerServerSession: 1_000, + readConcurrency: 1_000, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 51 }) + expect(rig.readLog()).toMatchObject({ serverStarts: 2, maxInFlight: 2 }) + }) + it('stops promptly when shouldStop flips and resumes on the next pass', async () => { const rig = createHealRig({ auditedThreads: Array.from({ length: 4 }, (_, index) => ({ @@ -416,6 +442,39 @@ describe('runCodexSessionIndexHeal', () => { expect(retried.healedThreads).toBe(2) }) + it('retries transient sqlite contention instead of marking the thread failed', async () => { + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }], + busyThreadIds: [threadId('1')] + }) + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(summary.outcome).toBe('aborted') + expect(readLedgerOutcomes(rig.paths)).toEqual({}) + + const retried = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: (home, timeoutMs) => { + const invocation = rig.buildInvocation(home, timeoutMs) + return { + ...invocation, + env: { + STUB_CONFIG: JSON.stringify({ + scenario: 'ok', + readLogFile: rig.readLogFile, + missingThreadIds: [], + failingThreadIds: [], + busyThreadIds: [] + }) + } + } + }, + interBatchDelayMs: 0 + }) + expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 }) + }) + it('completes immediately with no server spawn when there is nothing to heal', async () => { const rig = createHealRig({ auditedThreads: [] }) const summary = await runCodexSessionIndexHeal(rig.paths, { @@ -448,4 +507,37 @@ describe('runCodexSessionIndexHeal', () => { expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 0 }) expect(rig.readLog().serverStarts).toBe(0) }) + + it('scopes audit and processed ledger records to the current Codex home', async () => { + const currentId = threadId('1') + const foreignId = threadId('2') + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: currentId }] + }) + const foreignRoot = `${rig.paths.systemSessionsRoot}-other` + appendFileSync( + rig.paths.auditLogPath, + `${JSON.stringify({ + action: 'hardlink', + target: rolloutTarget(foreignRoot, '2026-07-02T10-00-00', foreignId) + })}\n` + ) + appendFileSync( + rig.paths.healLedgerPath, + `${JSON.stringify({ + v: CODEX_SESSION_INDEX_HEAL_VERSION, + systemSessionsRoot: foreignRoot, + threadId: currentId, + outcome: 'healed' + })}\n` + ) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([currentId]) + }) }) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index d31c9bba17a..137f24ab7d0 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -132,8 +132,11 @@ export async function runCodexSessionIndexHeal( const systemCodexHomePath = dirname(paths.systemSessionsRoot) const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation - const readsPerServerSession = options.readsPerServerSession ?? HEAL_READS_PER_SERVER_SESSION - const readConcurrency = options.readConcurrency ?? HEAL_READ_CONCURRENCY + const readsPerServerSession = resolveHealWorkLimit( + options.readsPerServerSession, + HEAL_READS_PER_SERVER_SESSION + ) + const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS const shouldStop = options.shouldStop ?? ((): boolean => false) @@ -159,7 +162,7 @@ export async function runCodexSessionIndexHeal( await healOneThread(rpc, thread, paths, summary) } } - await Promise.all(Array.from({ length: Math.max(1, readConcurrency) }, () => worker())) + await Promise.all(Array.from({ length: readConcurrency }, () => worker())) } ) } catch (error) { @@ -195,7 +198,7 @@ async function healOneThread( try { await rpc.request('thread/read', { threadId: thread.threadId }) summary.healedThreads += 1 - appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'healed') + appendHealLedgerRecord(paths, thread.threadId, 'healed') } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -209,12 +212,24 @@ async function healOneThread( if (/no rollout found/i.test(message)) { // The backfilled rollout was deleted after the audit was written. summary.missingThreads += 1 - appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'missing') + appendHealLedgerRecord(paths, thread.threadId, 'missing') return } + if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { + // Why: an active Codex process can briefly own sqlite; leave the id off + // the ledger and abort this pass so a later startup resumes it. + throw error + } summary.failedThreads += 1 - appendHealLedgerRecord(paths.healLedgerPath, thread.threadId, 'failed') + appendHealLedgerRecord(paths, thread.threadId, 'failed') + } +} + +function resolveHealWorkLimit(value: number | undefined, maximum: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return maximum } + return Math.min(Math.floor(value), maximum) } function buildNativeHealInvocation( From 8ce03b4652dd528c277e2b095267c45ebf32b97e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:47:37 -0700 Subject: [PATCH 32/45] fix(codex): harden real-home heal boundaries --- .../runtime-home-service.test.ts | 3 ++ .../codex-accounts/runtime-home-service.ts | 2 +- .../codex/codex-app-server-client.test.ts | 37 +++++++++++++++++++ src/main/codex/codex-app-server-client.ts | 30 +++++++++++++-- src/main/codex/codex-app-server-session.ts | 29 ++++++++++----- src/main/codex/codex-real-home-path.test.ts | 25 +++++++++++++ src/main/codex/codex-real-home-path.ts | 10 +++-- .../codex/codex-session-backfill-audit.ts | 30 ++++++++++----- src/main/codex/codex-session-backfill.test.ts | 25 +++++++++++++ .../codex/codex-session-index-heal-state.ts | 20 ++++++++-- .../codex/codex-session-index-heal.test.ts | 18 ++++++++- 11 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 src/main/codex/codex-real-home-path.test.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8fb5203ac3f..3d811cbd5c3 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1003,6 +1003,9 @@ describe('CodexRuntimeHomeService', () => { getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) + service.setRealHomeLaneGate(() => false) + expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) + service.setRealHomeLaneGate(() => true) const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false) expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 845ac0d7f0b..1e26274330c 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -217,7 +217,7 @@ export class CodexRuntimeHomeService { getHostCodexHomePathsForSessionDiscovery(): string[] { const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHomeSelected()) { + if (this.isHostSystemDefaultRealHome()) { // Why: nested Orca processes can retain an ambient managed CODEX_HOME; // explicitly include the real lane so its sessions remain discoverable. homes.push(getSystemCodexHomePath()) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 5bb01ae5614..38f6e1405d6 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -9,6 +9,7 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' +import { runCodexAppServerSession } from './codex-app-server-session' import { resolveCodexGrantEntryPath, runCodexHookTrustGrantSessionSync @@ -234,6 +235,24 @@ describe('runCodexHookTrustGrantSession', () => { expect(result.outcome).toBe('verify-failed') }) + it('rejects duplicate normalized aliases that conceal a missing expected key', async () => { + const aliasedKey = 'C:\\Users\\Ada\\.codex\\hooks.json:session_start:0:0' + const { request, recordFile } = createStubRequest({ + scenario: 'happy', + hooks: [ + managedHook(aliasedKey), + managedHook('c:/users/ada/.codex/hooks.json:session_start:0:0') + ], + expectedTrustKeys: [aliasedKey, 'C:\\Users\\Ada\\.codex\\hooks.json:stop:0:0'], + managedCommand: MANAGED_COMMAND + }) + + await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({ + outcome: 'verify-failed' + }) + expect(existsSync(recordFile)).toBe(false) + }) + it('decodes JSONL when a non-ASCII hook path is split across stdout chunks', async () => { const command = "/bin/sh '/tmp/rené/codex-hook.sh'" const key = '/home/rené/.codex/hooks.json:session_start:0:0' @@ -299,6 +318,24 @@ describe('runCodexHookTrustGrantSession', () => { expect(() => process.kill(childPid, 0)).toThrow() }) + it('bounds a callback that stalls between RPC requests', async () => { + const { request, pidFile } = createStubRequest({ + scenario: 'happy', + hooks: [], + expectedTrustKeys: [], + managedCommand: MANAGED_COMMAND, + timeoutMs: 500 + }) + + const startedAt = Date.now() + await expect( + runCodexAppServerSession(request.invocation, async () => new Promise(() => {})) + ).rejects.toBeInstanceOf(CodexAppServerTimeoutError) + expect(Date.now() - startedAt).toBeLessThan(5_000) + const childPid = Number(readFileSync(pidFile, 'utf8')) + expect(() => process.kill(childPid, 0)).toThrow() + }) + it('surfaces spawn failures as regular errors, not capability signals', async () => { const request: CodexHookTrustGrantRequest = { invocation: { diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 82b62b0e154..646f763851d 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -110,10 +110,14 @@ export async function runCodexHookTrustGrantSession( const listResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] }) const managedListings = collectHookListings(listResult).filter(matchManaged) - if (managedListings.length !== expectedKeys.size) { + const managedKeyCoverage = normalizedKeyCoverage(managedListings) + if ( + managedListings.length !== expectedKeys.size || + !setContainsEvery(managedKeyCoverage, expectedKeys) + ) { return { outcome: 'verify-failed', - reason: `hooks/list reported ${managedListings.length} of ${expectedKeys.size} expected managed entries` + reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries` } } @@ -133,14 +137,19 @@ export async function runCodexHookTrustGrantSession( const verifyResult = await rpc.request('hooks/list', { cwds: [request.hooksListCwd] }) const verifiedListings = collectHookListings(verifyResult).filter(matchManaged) + const verifiedKeyCoverage = normalizedKeyCoverage(verifiedListings) const untrusted = verifiedListings.filter((listing) => listing.trustStatus !== 'trusted') - if (verifiedListings.length !== expectedKeys.size || untrusted.length > 0) { + if ( + verifiedListings.length !== expectedKeys.size || + !setContainsEvery(verifiedKeyCoverage, expectedKeys) || + untrusted.length > 0 + ) { return { outcome: 'verify-failed', reason: untrusted.length > 0 ? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}` - : `post-grant verify reported ${verifiedListings.length} of ${expectedKeys.size} entries` + : `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries` } } return { @@ -156,3 +165,16 @@ export async function runCodexHookTrustGrantSession( spawnImpl ) } + +function normalizedKeyCoverage(listings: readonly CodexHookListing[]): Set { + return new Set(listings.map((listing) => normalizeHookTrustKeyForLookup(listing.key))) +} + +function setContainsEvery(values: ReadonlySet, expected: ReadonlySet): boolean { + for (const value of expected) { + if (!values.has(value)) { + return false + } + } + return true +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index b82f1723b54..6fbf202ac28 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -152,14 +152,18 @@ export async function runCodexAppServerSession( pending.clear() } + let rejectDeadline: (error: Error) => void = () => {} + const deadlinePromise = new Promise((_resolve, reject) => { + rejectDeadline = reject + }) const deadline = setTimeout(() => { timedOut = true - child.kill('SIGKILL') - failPending( - new CodexAppServerTimeoutError( - `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` - ) + const error = new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` ) + child.kill('SIGKILL') + failPending(error) + rejectDeadline(error) }, invocation.timeoutMs) function sendLine(payload: Record): void { @@ -227,11 +231,16 @@ export async function runCodexAppServerSession( } try { - await requestRpc('initialize', { - clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } - }) - notify('initialized') - return await body({ request: requestRpc, notify }) + const session = async (): Promise => { + await requestRpc('initialize', { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' } + }) + notify('initialized') + return body({ request: requestRpc, notify }) + } + // Why: the timeout owns the whole callback, including time between RPCs; + // killing the child alone cannot settle a callback awaiting unrelated work. + return await Promise.race([session(), deadlinePromise]) } catch (error) { if ( error instanceof Error && diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts new file mode 100644 index 00000000000..3d91682d7cc --- /dev/null +++ b/src/main/codex/codex-real-home-path.test.ts @@ -0,0 +1,25 @@ +import { sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { hasCustomCodexHomeOverride } from './codex-real-home-path' + +describe('hasCustomCodexHomeOverride', () => { + it('recognizes normalized aliases of Orca-owned CODEX_HOME', () => { + const managedHome = `${process.cwd()}${sep}codex-runtime-home${sep}home` + + expect( + hasCustomCodexHomeOverride({ + CODEX_HOME: `${managedHome}${sep}.`, + ORCA_CODEX_HOME: managedHome + }) + ).toBe(false) + }) + + it('preserves a genuinely custom CODEX_HOME', () => { + expect( + hasCustomCodexHomeOverride({ + CODEX_HOME: `${process.cwd()}${sep}custom-codex-home`, + ORCA_CODEX_HOME: `${process.cwd()}${sep}codex-runtime-home${sep}home` + }) + ).toBe(true) + }) +}) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index ede4983615c..6f6db65710a 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -5,12 +5,16 @@ import { getSystemCodexHomePath } from './codex-home-paths' export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { const codexHome = env.CODEX_HOME?.trim() const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() + const normalizedCodexHome = codexHome ? normalizePathForComparison(codexHome) : undefined + const normalizedOrcaCodexHome = orcaCodexHome + ? normalizePathForComparison(orcaCodexHome) + : undefined // Why: phase 1 owns only ~/.codex and can clean that path on downgrade. A // custom home needs cross-home ownership tracking before Orca may mutate it. return Boolean( - codexHome && - codexHome !== orcaCodexHome && - normalizePathForComparison(codexHome) !== normalizePathForComparison(getSystemCodexHomePath()) + normalizedCodexHome && + normalizedCodexHome !== normalizedOrcaCodexHome && + normalizedCodexHome !== normalizePathForComparison(getSystemCodexHomePath()) ) } diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 0b9f06bf685..465f0e2d2a4 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -7,18 +7,30 @@ export function createCodexSessionBackfillAuditWriter( auditLogPath: string ): CodexSessionBackfillAuditWriter { let auditDirectoryReady: Promise | undefined + const appendRecord = async (serializedRecord: string): Promise => { + auditDirectoryReady ??= mkdir(dirname(auditLogPath), { recursive: true }).catch( + (error: unknown) => { + auditDirectoryReady = undefined + throw error + } + ) + await auditDirectoryReady + await appendFile(auditLogPath, serializedRecord, { encoding: 'utf-8' }) + } return async (record): Promise => { + const serializedRecord = `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n` + try { + await appendRecord(serializedRecord) + return + } catch { + // Why: the heal consumes this ledger as its work queue. Retry the same + // record once so a transient mkdir/write failure cannot omit a session. + } try { - auditDirectoryReady ??= mkdir(dirname(auditLogPath), { recursive: true }) - await auditDirectoryReady - await appendFile( - auditLogPath, - `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, - { encoding: 'utf-8' } - ) + await appendRecord(serializedRecord) } catch (error) { - // Why: the audit trail is diagnostics; losing a line must not fail the - // backfill or leave a half-linked tree unrecorded in the summary counts. + // Why: a published hardlink/copy may already be in use, so persistent + // ledger failure is reported but cannot safely roll back the backfill. console.warn('[codex-session-backfill] Failed to append audit record:', error) } } diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 126f134502e..b923993ce2c 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -26,6 +26,7 @@ const { fsMockState } = vi.hoisted(() => ({ failInstallLink: false, failInstallRename: false, failCopy: false, + failAuditMkdirOnce: false, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -48,6 +49,17 @@ vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises') return { ...actual, + mkdir: (...args: Parameters) => { + if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { + fsMockState.failAuditMkdirOnce = false + const error = new Error( + 'EACCES: transient audit directory failure' + ) as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.mkdir(...args) + }, lstat: (...args: Parameters) => { if (args[0] === fsMockState.failLstatPath) { const error = new Error('EACCES: path inaccessible') as NodeJS.ErrnoException @@ -156,6 +168,7 @@ beforeEach(() => { fsMockState.failInstallLink = false fsMockState.failInstallRename = false fsMockState.failCopy = false + fsMockState.failAuditMkdirOnce = false fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -292,6 +305,18 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(second).toMatchObject({ linkedFiles: 0, copiedFiles: 0, skippedExistingFiles: 1 }) }) + it('retries the same audit record after a transient directory failure', async () => { + fsMockState.failAuditMkdirOnce = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) + expect(readAuditActions()).toEqual(['hardlink', 'run-summary']) + }) + it('falls back to copy when hardlinking fails across volumes', async () => { fsMockState.failLink = true const managedPath = writeManagedSession( diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index f01573b2f0d..16953b30aa8 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -50,7 +50,7 @@ export type HealMarkerSummary = { export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { const processedThreadIds = readProcessedHealThreadIds(paths) const pendingByThreadId = new Map() - for (const line of readJsonlLines(paths.auditLogPath)) { + for (const line of readJsonlLines(paths.auditLogPath, true)) { if ((line.action !== 'hardlink' && line.action !== 'copy') || typeof line.target !== 'string') { continue } @@ -118,11 +118,16 @@ export function appendHealLedgerRecord( } } -function readJsonlLines(filePath: string): Record[] { +function readJsonlLines(filePath: string, throwOnReadFailure = false): Record[] { let contents: string try { contents = readFileSync(filePath, 'utf-8') - } catch { + } catch (error) { + if (throwOnReadFailure && !isNotFoundError(error)) { + // Why: the audit is the heal work queue. Treating EACCES/EIO as empty + // would write a completion marker that permanently skips every session. + throw error + } return [] } const lines: Record[] = [] @@ -145,11 +150,18 @@ function readJsonlLines(filePath: string): Record[] { export function readAuditLogSize(auditLogPath: string): number { try { return statSync(auditLogPath).size - } catch { + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } return 0 } } +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + export function isHealMarkerCurrent( paths: CodexSessionIndexHealPaths, auditBytes: number diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 3a4f990c90d..ad65d3f466c 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { appendFileSync, + existsSync, mkdtempSync, mkdirSync, readFileSync, @@ -8,7 +9,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { CodexAppServerInvocation } from './codex-app-server-session' import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' import { @@ -540,4 +541,19 @@ describe('runCodexSessionIndexHeal', () => { expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 1 }) expect(rig.readLog().threadIds).toEqual([currentId]) }) + + it('does not mark the heal complete when the audit cannot be read', async () => { + const rig = createHealRig({}) + rig.paths.auditLogPath = dirname(rig.paths.auditLogPath) + + await expect( + runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + ).rejects.toBeInstanceOf(Error) + + expect(rig.readLog().serverStarts).toBe(0) + expect(existsSync(rig.paths.healMarkerPath)).toBe(false) + }) }) From 9ecaee715e96d44ee053ff7a06e61bdf5584de77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:53:31 -0700 Subject: [PATCH 33/45] fix(codex): fail closed on unsafe backfill install --- src/main/codex/codex-session-backfill-copy.ts | 56 ++++++++------- src/main/codex/codex-session-backfill.test.ts | 69 +++++++++---------- src/main/codex/codex-session-backfill.ts | 16 ++++- 3 files changed, 81 insertions(+), 60 deletions(-) diff --git a/src/main/codex/codex-session-backfill-copy.ts b/src/main/codex/codex-session-backfill-copy.ts index bb2f7621058..8c033abf97d 100644 --- a/src/main/codex/codex-session-backfill-copy.ts +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto' -import { copyFile, link, lstat, rename, rm, writeFile } from 'node:fs/promises' +import { copyFile, link, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +const ATOMIC_NO_REPLACE_UNSUPPORTED_CODE = 'ORCA_ATOMIC_NO_REPLACE_UNSUPPORTED' + export async function copySessionFileWithoutOverwrite( sourcePath: string, targetPath: string @@ -20,16 +22,12 @@ export async function copySessionFileWithoutOverwrite( if (isExistsError(installLinkError)) { throw installLinkError } - // Why: some target filesystems support no hardlinks at all. Install the - // fully-staged copy with an atomic rename — never a raw copy into the - // rollout filename — so an interrupted install cannot strand a truncated - // session that a later run skips as already-present. Re-check existence - // first because rename, unlike the hardlink above, would clobber a target - // that appeared mid-run. - if (await pathEntryExists(targetPath)) { - throw makeTargetExistsError(targetPath) + if (!isHardlinkUnsupportedError(installLinkError)) { + throw installLinkError } - await rename(temporaryPath, targetPath) + // Why: Node has no portable atomic rename-if-absent. Fail closed on a + // hardlink-less target instead of risking replacement of a concurrent file. + throw makeAtomicNoReplaceUnsupportedError(targetPath, installLinkError) } } finally { try { @@ -42,23 +40,33 @@ export async function copySessionFileWithoutOverwrite( } } -/** Existence via lstat so a broken symlink at the target still counts as taken. */ -async function pathEntryExists(entryPath: string): Promise { - try { - await lstat(entryPath) - return true - } catch { - return false - } -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } -/** EEXIST so a rename-install collision routes to the skip path, not a failure. */ -function makeTargetExistsError(targetPath: string): NodeJS.ErrnoException { - const error = new Error(`EEXIST: backfill target already exists: ${targetPath}`) - ;(error as NodeJS.ErrnoException).code = 'EEXIST' +function isHardlinkUnsupportedError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return ( + code === 'EPERM' || + code === 'EACCES' || + code === 'ENOTSUP' || + code === 'EOPNOTSUPP' || + code === 'ENOSYS' + ) +} + +function makeAtomicNoReplaceUnsupportedError( + targetPath: string, + cause: unknown +): NodeJS.ErrnoException { + const error = new Error( + `Cannot atomically install backfill without overwrite on this filesystem: ${targetPath}`, + { cause } + ) as NodeJS.ErrnoException + error.code = ATOMIC_NO_REPLACE_UNSUPPORTED_CODE return error } + +export function isAtomicNoReplaceUnsupportedError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === ATOMIC_NO_REPLACE_UNSUPPORTED_CODE +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index b923993ce2c..089a52f08ef 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -24,7 +24,7 @@ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, failInstallLink: false, - failInstallRename: false, + failInstallLinkTransiently: false, failCopy: false, failAuditMkdirOnce: false, failDirectoryPath: null as string | null, @@ -81,17 +81,12 @@ vi.mock('node:fs/promises', async () => { error.code = 'EPERM' throw error } - return actual.link(...args) - }, - rename: (...args: Parameters) => { - // Simulate an interrupted atomic install (crash/ENOSPC mid-rename) on a - // hardlink-less target, exercising the no-partial-stranded guarantee. - if (fsMockState.failInstallRename && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EIO: rename interrupted') as NodeJS.ErrnoException + if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) { + const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException error.code = 'EIO' throw error } - return actual.rename(...args) + return actual.link(...args) }, copyFile: async (...args: Parameters) => { if (fsMockState.failCopy) { @@ -166,7 +161,7 @@ function readAuditActions(): string[] { beforeEach(() => { fsMockState.failLink = false fsMockState.failInstallLink = false - fsMockState.failInstallRename = false + fsMockState.failInstallLinkTransiently = false fsMockState.failCopy = false fsMockState.failAuditMkdirOnce = false fsMockState.failDirectoryPath = null @@ -335,48 +330,40 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy', 'run-summary']) }) - it('installs via atomic rename when the target volume has no hardlink support', async () => { - // Why: exFAT/FAT and some network targets support no hardlinks, so even the - // staged-copy install link fails and the atomic-rename fallback must run. + it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true fsMockState.failInstallLink = true - const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a.jsonl'), - '{"id":"a"}\n' - ) + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) + expect(summary).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedUnsupportedFilesystemFiles: 1, + failedFiles: 0 + }) const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) - // Staged temp file is renamed into place, not stranded in the sessions tree. - expect( - readdirSync(dirname(targetPath)).filter((name) => name.includes('.orca-backfill-')) - ).toEqual([]) - expect(readAuditActions()).toEqual(['copy', 'run-summary']) + expect(existsSync(targetPath)).toBe(false) + expect(readdirSync(dirname(targetPath))).toEqual([]) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) - it('never strands a partial rollout when the atomic install is interrupted', async () => { - // Why: a hardlink-less target plus an interrupted install must leave the - // real rollout filename absent (not truncated), so the next run retries - // instead of skipping a partial session as already-present. + it('keeps transient install failures retryable', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true - fsMockState.failInstallRename = true + fsMockState.failInstallLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ failedFiles: 1, copiedFiles: 0, linkedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') - expect(existsSync(targetPath)).toBe(false) - // No partial rollout and no leftover staging file in the user's tree. - expect(readdirSync(dirname(targetPath))).toEqual([]) + expect(summary).toMatchObject({ + skippedUnsupportedFilesystemFiles: 0, + failedFiles: 1 + }) expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) @@ -423,6 +410,18 @@ describe('startCodexSessionBackfillInBackground', () => { ).toBe(false) }) + it('does not retry a stable hardlink-less filesystem limitation', async () => { + fsMockState.failLink = true + fsMockState.failInstallLink = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + + expect(await startCodexSessionBackfillInBackground()).toBeNull() + }) + it('leaves the marker unset when any file fails so the next startup retries', async () => { fsMockState.failLink = true fsMockState.failCopy = true diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index d7489126dd1..7b24808c23a 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -11,7 +11,10 @@ import { createCodexSessionBackfillAuditWriter, type CodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' -import { copySessionFileWithoutOverwrite } from './codex-session-backfill-copy' +import { + copySessionFileWithoutOverwrite, + isAtomicNoReplaceUnsupportedError +} from './codex-session-backfill-copy' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' @@ -26,6 +29,7 @@ export type CodexSessionBackfillSummary = { skippedExistingFiles: number skippedUnexpectedFiles: number skippedSymlinkFiles: number + skippedUnsupportedFilesystemFiles: number failedDirectories: number failedFiles: number } @@ -122,6 +126,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( skippedExistingFiles: 0, skippedUnexpectedFiles: 0, skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, failedDirectories: 0, failedFiles: 0 } @@ -263,6 +268,15 @@ async function backfillOneManagedSessionFile( summary.skippedExistingFiles += 1 return } + if (isAtomicNoReplaceUnsupportedError(copyError)) { + summary.skippedUnsupportedFilesystemFiles += 1 + await appendAuditRecord({ + action: 'copy-unsupported', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + return + } summary.failedFiles += 1 await appendAuditRecord({ action: 'failed', From fe278b48a8cfd20bcbd7c90ae2f133d987423a23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:19:48 -0700 Subject: [PATCH 34/45] fix(ai-vault): preserve execution boundaries and reap children --- .../ai-vault/codex-session-root-dedup.test.ts | 39 ++++++++++++- src/main/ai-vault/codex-session-root-dedup.ts | 17 +++++- .../codex/codex-app-server-client.test.ts | 56 ++++++++++++++++++- src/main/codex/codex-app-server-session.ts | 44 +++++++++++++-- 4 files changed, 146 insertions(+), 10 deletions(-) diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index dcff2f0143c..9121249ad5e 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -71,8 +71,8 @@ describe('dedupeCodexRolloutFileAliases', () => { it('prefers the managed runtime home over other non-default homes', () => { const managed = { agent: 'codex', - path: MANAGED_HOME_ROLLOUT, - codexHome: MANAGED_HOME, + path: `\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl`, + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home', hardlinkIdentity: '1:42' } const wslReal = { @@ -149,6 +149,24 @@ describe('dedupeCodexRolloutFileAliases', () => { unprovenCopy ]) }) + + it('never treats matching host and WSL inode tuples as one hardlink', () => { + const rolloutName = REAL_HOME_ROLLOUT.split('/').at(-1) + const host = { + agent: 'codex', + path: `C:\\Users\\ada\\.codex\\sessions\\${rolloutName}`, + codexHome: null, + hardlinkIdentity: '1:42' + } + const wsl = { + agent: 'codex', + path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\${rolloutName}`, + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex', + hardlinkIdentity: '1:42' + } + + expect(dedupeCodexRolloutFileAliases([host, wsl], accessors)).toEqual([host, wsl]) + }) }) describe('dedupeCodexSessionsBySessionId', () => { @@ -247,4 +265,21 @@ describe('dedupeCodexSessionsBySessionId', () => { }) expect(dedupeCodexSessionsBySessionId([wslReal, wslManaged])).toEqual([wslManaged]) }) + + it('never collapses matching host and WSL session identities', () => { + const rolloutName = REAL_HOME_ROLLOUT.split('/').at(-1) + const host = codexSession({ + sessionId: 'shared-id', + filePath: `C:\\Users\\ada\\.codex\\sessions\\${rolloutName}`, + codexHome: null + }) + const wsl = codexSession({ + sessionId: 'shared-id', + filePath: `\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\${rolloutName}`, + codexHome: + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home' + }) + + expect(dedupeCodexSessionsBySessionId([host, wsl])).toEqual([host, wsl]) + }) }) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index b54434eb2d1..0f4a39cf8d2 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -1,4 +1,5 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseWslUncPath } from '../../shared/wsl-paths' import { sessionSortTime } from './session-scanner-accumulator' // Why: the session bridge and the real-home backfill hardlink one physical @@ -17,6 +18,14 @@ function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } +// Why: local Windows discovery scans both the host and every WSL distro under +// one `local` host id, even though hardlinks and resume identity cannot cross +// those execution boundaries. +function codexPathExecutionNamespace(filePath: string): string { + const wslPath = parseWslUncPath(filePath) + return wslPath ? `wsl:${wslPath.distro.toLowerCase()}` : 'native' +} + /** Returns a pre-parse alias key only when metadata proves a shared hardlink. */ export function codexRolloutHardlinkIdentity(file: { dev?: number @@ -84,7 +93,7 @@ export function dedupeCodexRolloutFileAliases( if (!hardlinkIdentity) { continue } - const aliasKey = `${fileName}\0${hardlinkIdentity}` + const aliasKey = `${codexPathExecutionNamespace(filePath)}\0${fileName}\0${hardlinkIdentity}` const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) const best = bestByAlias.get(aliasKey) if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { @@ -100,7 +109,9 @@ export function dedupeCodexRolloutFileAliases( if (!hardlinkIdentity) { return true } - const best = bestByAlias.get(`${fileName}\0${hardlinkIdentity}`) + const best = bestByAlias.get( + `${codexPathExecutionNamespace(accessors.getFilePath(candidate))}\0${fileName}\0${hardlinkIdentity}` + ) return !best || best.candidate === candidate }) } @@ -142,7 +153,7 @@ function codexSessionAliasKey(session: AiVaultSession): string | null { if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { return null } - return `${session.executionHostId}\0${session.sessionId}\0${fileName}` + return `${session.executionHostId}\0${codexPathExecutionNamespace(session.filePath)}\0${session.sessionId}\0${fileName}` } function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 38f6e1405d6..879fcc5cab3 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess, spawn } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -9,7 +11,7 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' -import { runCodexAppServerSession } from './codex-app-server-session' +import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session' import { resolveCodexGrantEntryPath, runCodexHookTrustGrantSessionSync @@ -149,6 +151,58 @@ function managedHook(key: string, trustStatus = 'untrusted'): StubHook { return { key, command: MANAGED_COMMAND, currentHash: `sha256:hash-of-${key}`, trustStatus } } +describe('killCodexAppServerProcessTree', () => { + it('kills the Windows wrapper and all app-server descendants', () => { + const child = { + pid: 1234, + kill: vi.fn(() => true) as ChildProcess['kill'] + } + const killer = new EventEmitter() as EventEmitter & { unref: ReturnType } + killer.unref = vi.fn() + const spawnImpl = vi.fn(() => killer) as unknown as typeof spawn + + killCodexAppServerProcessTree(child, { platform: 'win32', spawnImpl }) + + expect(spawnImpl).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + expect(killer.unref).toHaveBeenCalledOnce() + expect(child.kill).not.toHaveBeenCalled() + + killer.emit('error', new Error('taskkill unavailable')) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('falls back when taskkill starts but cannot terminate the process tree', () => { + const child = { + pid: 1234, + kill: vi.fn(() => true) as ChildProcess['kill'] + } + const killer = new EventEmitter() as EventEmitter & { unref: ReturnType } + killer.unref = vi.fn() + const spawnImpl = vi.fn(() => killer) as unknown as typeof spawn + + killCodexAppServerProcessTree(child, { platform: 'win32', spawnImpl }) + killer.emit('exit', 1) + + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills the direct app-server process on non-Windows hosts', () => { + const child = { + pid: 1234, + kill: vi.fn(() => true) as ChildProcess['kill'] + } + const spawnImpl = vi.fn() as unknown as typeof spawn + + killCodexAppServerProcessTree(child, { platform: 'linux', spawnImpl }) + + expect(spawnImpl).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) + describe('runCodexHookTrustGrantSession', () => { it('grants and verifies exactly the expected managed entries', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 6fbf202ac28..9c332729958 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' import { waitForProcessExitUntil } from './codex-process-exit-deadline' // Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned @@ -51,6 +51,42 @@ const JSON_RPC_METHOD_NOT_FOUND = -32601 const STDERR_TAIL_MAX_BYTES = 8192 const STDOUT_LINE_MAX_BYTES = 1024 * 1024 +export function killCodexAppServerProcessTree( + child: Pick, + options: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn } = {} +): void { + const platform = options.platform ?? process.platform + const spawnImpl = options.spawnImpl ?? spawn + if (platform === 'win32' && child.pid) { + try { + // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper + // leaves the app-server child alive after a timeout or failed shutdown. + const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + let fellBack = false + const killDirectChild = (): void => { + if (!fellBack) { + fellBack = true + child.kill('SIGKILL') + } + } + killer.on('error', killDirectChild) + killer.on('exit', (code) => { + if (code !== 0) { + killDirectChild() + } + }) + killer.unref() + return + } catch { + // Fall through to the direct-child best effort when taskkill cannot start. + } + } + child.kill('SIGKILL') +} + function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') } @@ -120,7 +156,7 @@ export async function runCodexAppServerSession( child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdoutBuffer += chunk if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { - child.kill('SIGKILL') + killCodexAppServerProcessTree(child) failPending(new Error('codex app-server emitted an oversized JSONL response')) return } @@ -161,7 +197,7 @@ export async function runCodexAppServerSession( const error = new CodexAppServerTimeoutError( `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` ) - child.kill('SIGKILL') + killCodexAppServerProcessTree(child) failPending(error) rejectDeadline(error) }, invocation.timeoutMs) @@ -264,7 +300,7 @@ export async function runCodexAppServerSession( // bounds a wedged child before the guaranteed SIGKILL reap. await waitForProcessExitUntil(exitPromise, 1500) if (!exited) { - child.kill('SIGKILL') + killCodexAppServerProcessTree(child) await waitForProcessExitUntil(exitPromise, 1000) } } From 1f451e5fe225cd9ae12c3652fb5ab88b3215c175 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:44:48 -0700 Subject: [PATCH 35/45] fix(codex): enforce real-home resume and heal boundaries --- src/main/index.ts | 15 ++++++++--- src/main/runtime/orca-runtime.test.ts | 8 ++++-- src/main/runtime/orca-runtime.ts | 24 +++++++++++++++++- .../rpc/methods/session-tabs-schemas.ts | 1 + .../runtime/rpc/methods/session-tabs.test.ts | 2 ++ src/main/runtime/rpc/methods/session-tabs.ts | 1 + src/main/runtime/rpc/methods/terminal.ts | 2 ++ src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../terminal-pane/pty-connection-types.ts | 1 + .../terminal-pane/pty-connection.ts | 1 + .../terminal-pane/pty-transport-types.ts | 2 ++ .../terminal-pane/pty-transport.test.ts | 14 +++++++++++ .../components/terminal-pane/pty-transport.ts | 4 +++ .../remote-runtime-pty-transport.test.ts | 2 ++ .../remote-runtime-pty-transport.ts | 3 +++ src/renderer/src/hooks/useIpcEvents.ts | 1 + .../src/lib/ai-vault-resume-command.test.ts | 25 ++++++++++++++++++- .../src/lib/ai-vault-resume-command.ts | 21 ++++++++++++++-- .../src/lib/launch-ai-vault-session.test.ts | 4 +++ .../src/lib/launch-ai-vault-session.ts | 3 +++ .../src/runtime/web-runtime-session.test.ts | 2 ++ .../src/runtime/web-runtime-session.ts | 2 ++ src/renderer/src/store/slices/terminals.ts | 3 +++ src/shared/runtime-types.ts | 1 + 25 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ddfd90e9ffd..9b710023f23 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1812,12 +1812,19 @@ app.whenReady().then(async () => { // Why: the heal pass chains after the backfill settles so thread/read only // runs once the audit ledger covers this startup's newly linked rollouts; // it also drains sessions left pending by an interrupted earlier pass. - void startCodexSessionBackfillInBackground({}, systemCodexHomePathOverride).then(() => - startCodexSessionIndexHealInBackground( - { shouldStop: () => isQuitting }, + void startCodexSessionBackfillInBackground({}, systemCodexHomePathOverride).then(() => { + // Why: flag-OFF, managed-account, and custom-home lanes must never spawn + // an app-server against the user's real sqlite index. + if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { + return + } + return startCodexSessionIndexHealInBackground( + { + shouldStop: () => isQuitting || codexRuntimeHome?.isHostSystemDefaultRealHome() !== true + }, systemCodexHomePathOverride ) - ) + }) }, 15_000) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 994328da875..b3a73f947ec 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9946,10 +9946,13 @@ describe('OrcaRuntimeService', () => { ORCA_AGENT_HOOK_PORT: '1111', ORCA_AGENT_HOOK_TOKEN: 'stale-token', ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' - } + }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) - const spawnCall = spawn.mock.calls[0]?.[0] as { env?: Record } | undefined + const spawnCall = spawn.mock.calls[0]?.[0] as + | { env?: Record; envToDelete?: string[] } + | undefined expect(spawnCall?.env).toEqual( expect.objectContaining({ ORCA_AGENT_HOOK_PORT: '5678', @@ -9962,6 +9965,7 @@ describe('OrcaRuntimeService', () => { }) ) expect(spawnCall?.env?.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(spawnCall?.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) }) it.each([ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6d8c041e019..abee4e89f2c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1027,6 +1027,7 @@ type TerminalCreateOptions = { claudeAgentTeamsSourceCommand?: string cwd?: string env?: Record + envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent @@ -1049,6 +1050,14 @@ type TerminalCreateOptions = { deferMobileSessionPublish?: boolean } +function mergeTerminalEnvDeletionKeys( + first: readonly string[] | undefined, + second: readonly string[] | undefined +): string[] | undefined { + const merged = [...new Set([...(first ?? []), ...(second ?? [])])] + return merged.length > 0 ? merged : undefined +} + type PtyForegroundAgentRefresh = { promise: Promise startedAfterTitleObservation: number @@ -18031,7 +18040,10 @@ export class OrcaRuntimeService { commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, env, - envToDelete: agentTeamsPlan?.envToDelete, + envToDelete: mergeTerminalEnvDeletionKeys( + launchOpts.envToDelete, + agentTeamsPlan?.envToDelete + ), telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, @@ -18229,6 +18241,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18274,6 +18287,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18310,6 +18324,7 @@ export class OrcaRuntimeService { command: startupCommand.command, cwd, env: startupCommand.env, + envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, @@ -18359,6 +18374,7 @@ export class OrcaRuntimeService { command: startupCommand.command, cwd, ...(startupCommand.env ? { env: startupCommand.env } : {}), + ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), @@ -18423,6 +18439,7 @@ export class OrcaRuntimeService { command: startupCommand.command, cwd, env: startupCommand.env, + envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, @@ -18468,6 +18485,7 @@ export class OrcaRuntimeService { opts: { command?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18476,6 +18494,7 @@ export class OrcaRuntimeService { ): Promise<{ command?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent @@ -18484,6 +18503,7 @@ export class OrcaRuntimeService { return { command: opts.command, env: opts.env, + envToDelete: opts.envToDelete, launchConfig: opts.launchConfig, launchAgent: opts.launchAgent, startupCommandDelivery: opts.startupCommandDelivery @@ -18547,6 +18567,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent @@ -18566,6 +18587,7 @@ export class OrcaRuntimeService { command: opts.command, cwd, env: opts.env, + envToDelete: opts.envToDelete, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 1b8afbd2f46..14f9fa81882 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -115,6 +115,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ command: z.string().optional(), cwd: z.string().min(1).optional(), env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), launchConfig: sleepingAgentLaunchConfigSchema, launchToken: z.string().min(1).max(128).optional(), diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index d81faa169cd..a46f3725fc0 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -142,6 +142,7 @@ describe('session tab RPC methods', () => { command: 'zsh', cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchToken: 'launch-token-123', launchConfig: { agentArgs: '--model gpt-5', @@ -160,6 +161,7 @@ describe('session tab RPC methods', () => { command: 'zsh', cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], startupCommandDelivery: undefined, agent: undefined, launchToken: 'launch-token-123', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 55be4d0b21e..85b18ee228f 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -48,6 +48,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ command: params.command, cwd: params.cwd, ...(params.env ? { env: params.env } : {}), + ...(params.envToDelete ? { envToDelete: params.envToDelete } : {}), startupCommandDelivery: params.startupCommandDelivery, agent: params.agent, ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..af4e949437c 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -827,6 +827,7 @@ const TerminalCreateParams = z.object({ command: OptionalString, startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), launchConfig: z .object({ agentCommand: z.string().optional(), @@ -1289,6 +1290,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ command: params.command, startupCommandDelivery: params.startupCommandDelivery, env: params.env, + envToDelete: params.envToDelete, ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3aa7782f32b..a517a032b69 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1238,6 +1238,7 @@ export type PreloadApi = { cwd?: string cwdFallback?: 'worktree' env?: Record + envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 8342d8b1057..13242cdd86f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -830,6 +830,7 @@ const api = { cwd?: string cwdFallback?: 'worktree' env?: Record + envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 8e151c6ab5c..927a2a57a88 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -20,6 +20,7 @@ export type PtyConnectionDeps = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dba323e3d36..938bd457236 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3171,6 +3171,7 @@ export function connectPanePty( // resolve cwd on another host and must keep exact cwd semantics. ...(runtimeEnvironmentId === null && !connectionId ? { cwdFallback: 'worktree' as const } : {}), env: paneEnv, + ...(paneStartup?.envToDelete ? { envToDelete: paneStartup.envToDelete } : {}), command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, startupCommandDelivery: shouldDeliverStartupViaTerminalPaste ? undefined diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1777091c376..a60a21228a5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -84,6 +84,7 @@ export type PtyTransport = { initiallyHidden?: boolean command?: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent @@ -140,6 +141,7 @@ export type IpcPtyTransportOptions = { cwd?: string cwdFallback?: 'worktree' env?: Record + envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index aebc26fd73b..6289d35e134 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -82,6 +82,20 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('forwards requested environment deletions to the PTY spawn', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const transport = createIpcPtyTransport({ + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] + }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) + ) + }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 1047477e69f..956d21db0ca 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -499,6 +499,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra cwd, cwdFallback, env, + envToDelete, command, launchConfig, launchToken, @@ -738,6 +739,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra cwd, ...(shouldSendLocalCwdFallback ? { cwdFallback } : {}), env: options.env ?? env, + ...((options.envToDelete ?? envToDelete) + ? { envToDelete: options.envToDelete ?? envToDelete } + : {}), command: options.command ?? command, ...((options.launchConfig ?? launchConfig) ? { launchConfig: options.launchConfig ?? launchConfig } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 60b64c4a5be..b5ba43f59c2 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -780,6 +780,7 @@ describe('createRemoteRuntimePtyTransport', () => { tabId: 'tab-1', leafId: 'pane:1', command: "codex 'linked issue context'", + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], startupCommandDelivery: 'shell-ready' }) @@ -791,6 +792,7 @@ describe('createRemoteRuntimePtyTransport', () => { method: 'terminal.create', params: expect.objectContaining({ command: "codex 'linked issue context'", + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], startupCommandDelivery: 'shell-ready' }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index d6ae8b4cf7d..b972f25d9bd 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -64,6 +64,7 @@ export function createRemoteRuntimePtyTransport( command, startupCommandDelivery, env, + envToDelete, launchConfig, launchToken, launchAgent, @@ -635,6 +636,7 @@ export function createRemoteRuntimePtyTransport( const startupCommandDeliveryToSend = options.startupCommandDelivery ?? startupCommandDelivery const envToSend = options.env ?? env + const envToDeleteToSend = options.envToDelete ?? envToDelete const launchConfigToSend = options.launchConfig ?? launchConfig const launchTokenToSend = options.launchToken ?? launchToken const launchAgentToSend = options.launchAgent ?? launchAgent @@ -645,6 +647,7 @@ export function createRemoteRuntimePtyTransport( ? { startupCommandDelivery: startupCommandDeliveryToSend } : {}), ...(envToSend !== undefined ? { env: envToSend } : {}), + ...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}), ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..c5fb780522c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1772,6 +1772,7 @@ export function useIpcEvents(): void { store.queueTabStartupCommand(tab.id, { command: data.command, ...(data.env ? { env: data.env } : {}), + ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 68021efdb53..1fb9c2a7217 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -390,6 +390,26 @@ describe('ai vault resume command runtime', () => { ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) + it('deletes inherited Codex homes when resuming a real-home session', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null + } + }) + ).toMatchObject({ + command: "Set-Location -LiteralPath '/home/alice/repo'; codex 'resume' 'session one'", + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] + }) + }) + it('returns the remote resume command verbatim for non-local host sessions', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never @@ -407,7 +427,10 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" }) + ).toEqual({ + command: "CODEX_HOME='/root/.codex' codex resume 'session one'", + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] + }) }) it('bypasses the resume pipeline even when the command override is blank', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 09cda526fd1..62a9d8352a9 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -33,6 +33,7 @@ type AiVaultResumeCommandSession = Pick< export type AiVaultResumeStartup = { command: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig } @@ -70,7 +71,10 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault args.session.resumeCommand && !args.commandOverride?.trim() ) { - return { command: args.session.resumeCommand } + return { + command: args.session.resumeCommand, + ...realHomeCodexResumeEnvDeletion(args.session) + } } const platform = args.session.executionHostId && @@ -115,6 +119,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...realHomeCodexResumeEnvDeletion(args.session), launchConfig: startupPlan.launchConfig } } @@ -135,8 +140,20 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // Why: non-resumable agents queue through this fallback too, so it must // quote for the live Windows shell like the startup-plan branch above. shell: liveShell - }) + }), + ...realHomeCodexResumeEnvDeletion(args.session) + } +} + +function realHomeCodexResumeEnvDeletion( + session: AiVaultResumeCommandSession +): { envToDelete: string[] } | Record { + if (session.agent !== 'codex' || session.codexHome !== null) { + return {} } + // Why: a bare real-home resume must override workspace account routing and + // the persistent daemon's inherited Orca home, not only its sparse env patch. + return { envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] } } function getAiVaultResumeCodexHome( diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index ef0425b58e6..e84e8979696 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -99,6 +99,7 @@ describe('launchAiVaultSessionInNewTab', () => { worktreeId: 'wt-1', command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, + envToDelete: ['CODEX_HOME'], launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', @@ -109,6 +110,7 @@ describe('launchAiVaultSessionInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, + envToDelete: ['CODEX_HOME'], launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', @@ -146,6 +148,7 @@ describe('launchAiVaultSessionInNewTab', () => { targetGroupId: 'group-1', command: "codex resume 'session-1'", env: { CODEX_PROFILE: 'runtime' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'codex', agentArgs: '', @@ -160,6 +163,7 @@ describe('launchAiVaultSessionInNewTab', () => { targetGroupId: 'group-1', command: "codex resume 'session-1'", env: { CODEX_PROFILE: 'runtime' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'codex', agentArgs: '', diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index e04e353452e..306cd6d8088 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -19,6 +19,7 @@ export function launchAiVaultSessionInNewTab(args: { worktreeId: string command: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig targetGroupId?: string splitDirection?: TabSplitDirection @@ -33,6 +34,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(targetGroupId ? { targetGroupId } : {}), command: args.command, ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), launchAgent: args.agent, activate: true @@ -59,6 +61,7 @@ export function launchAiVaultSessionInNewTab(args: { store.queueTabStartupCommand(tab.id, { command: args.command, ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index af47e5a0350..f4b312cf0b3 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -484,6 +484,7 @@ describe('createWebRuntimeSessionTerminal', () => { command: "codex 'linked issue context'", cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], startupCommandDelivery: 'shell-ready', launchConfig: { agentArgs: '--model gpt-5', @@ -505,6 +506,7 @@ describe('createWebRuntimeSessionTerminal', () => { command: "codex 'linked issue context'", cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], startupCommandDelivery: 'shell-ready', launchConfig: { agentArgs: '--model gpt-5', diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 8cfc23eb19c..59b46b9bead 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -52,6 +52,7 @@ export async function createWebRuntimeSessionTerminal(args: { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: StartupCommandDelivery launchConfig?: SleepingAgentLaunchConfig agent?: TuiAgent @@ -82,6 +83,7 @@ export async function createWebRuntimeSessionTerminal(args: { command: args.command, cwd: args.cwd, ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), startupCommandDelivery: args.startupCommandDelivery, ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), agent: args.agent, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e127c1973a8..21332e21e3c 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -507,6 +507,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string @@ -692,6 +693,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string @@ -709,6 +711,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 41a800eb3da..e6a2b753f64 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -495,6 +495,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { command?: string cwd?: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent From 18e741476176b35350cf4f09f5d4c633a1fcddaf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:54:11 -0700 Subject: [PATCH 36/45] fix(codex): stop index heal before delayed spawn --- .../codex/codex-session-index-heal.test.ts | 22 +++++++++++++++++++ src/main/codex/codex-session-index-heal.ts | 6 +++++ 2 files changed, 28 insertions(+) diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index ad65d3f466c..24e150a2bd9 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -369,6 +369,28 @@ describe('runCodexSessionIndexHeal', () => { expect(resumed.healedThreads + summary.healedThreads).toBe(4) }) + it('does not spawn another server when stop flips during the inter-batch delay', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-01T10-00-00', id: threadId('1') } + ] + }) + let stopChecks = 0 + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readsPerServerSession: 1, + interBatchDelayMs: 1, + // False through the second batch's pre-delay check, then model opt-out + // while the delay is in progress. + shouldStop: () => stopChecks++ >= 3 + }) + + expect(summary).toMatchObject({ outcome: 'stopped', healedThreads: 1 }) + expect(rig.readLog()).toMatchObject({ serverStarts: 1, threadIds: [threadId('2')] }) + }) + it('marks the pass unsupported without ledger writes when thread/read is unavailable', async () => { const rig = createHealRig({ scenario: 'unknown-method', diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 137f24ab7d0..e095a95469f 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -147,6 +147,12 @@ export async function runCodexSessionIndexHeal( } if (offset > 0 && interBatchDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) + if (shouldStop()) { + // Why: opt-out can happen during the throttle delay; do not spawn a + // real-home app-server after the lane has been disabled. + summary.outcome = 'stopped' + return summary + } } const batch = pending.slice(offset, offset + readsPerServerSession) const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length From 26e7ef6787f5fe945b548f14dab1d8fccf5dd776 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:04:09 -0700 Subject: [PATCH 37/45] fix(ai-vault): preserve resume env deletion through drag --- .../right-sidebar/AiVaultSessionRow.tsx | 1 + .../tab-group/AiVaultSessionDropLayer.tsx | 1 + .../src/lib/ai-vault-session-drag.test.ts | 19 +++++++++++++++++++ src/renderer/src/lib/ai-vault-session-drag.ts | 14 +++++++++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 448be77c122..b5303199ce0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -100,6 +100,7 @@ export function VaultSessionRow({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), + ...(resumeStartup.envToDelete ? { envToDelete: resumeStartup.envToDelete } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 2ae7765022a..6d20c74c159 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -204,6 +204,7 @@ export default function AiVaultSessionDropLayer({ worktreeId, command: payload.command, ...(payload.env ? { env: payload.env } : {}), + ...(payload.envToDelete ? { envToDelete: payload.envToDelete } : {}), ...(payload.launchConfig ? { launchConfig: payload.launchConfig } : {}), targetGroupId: dropTarget.groupId, splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index e51a0c78efd..87da752157a 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -50,6 +50,7 @@ describe('Session History session drag data', () => { command: "cd '/repo' && claude --resume session-1", sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', agentArgs: '--dangerously-skip-permissions', @@ -110,6 +111,24 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toBeNull() }) + it('rejects malformed env deletion lists', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'codex', + sessionId: 'session-1', + title: 'Malformed env deletion', + command: 'codex resume session-1', + envToDelete: ['CODEX_HOME', ''] + }) + ) + + expect(readAiVaultSessionDragData(transfer)).toBeNull() + }) + it('rejects array-shaped launch config env records', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 7b5ec941dd7..b4bc124ae06 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -17,8 +17,9 @@ export type AiVaultSessionDragPayload = { // WSL) to reject SSH panes that cannot reach it. sessionFilePath?: string sessionExecutionHostId?: ExecutionHostId - // Why: drag/drop resume must preserve planned env/default args, not just the shell command. + // Why: drag/drop resume must preserve planned env mutations/default args, not just the command. env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig } @@ -44,6 +45,14 @@ function isStringRecord(value: unknown): value is Record { return Object.values(value).every((entry) => typeof entry === 'string') } +function isEnvDeletionList(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= 32 && + value.every((entry) => typeof entry === 'string' && entry.length > 0 && entry.length <= 256) + ) +} + function isLaunchConfig(value: unknown): value is SleepingAgentLaunchConfig { if (!value || typeof value !== 'object') { return false @@ -72,6 +81,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD (payload.sessionExecutionHostId === undefined || Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && (payload.env === undefined || isStringRecord(payload.env)) && + (payload.envToDelete === undefined || isEnvDeletionList(payload.envToDelete)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) } @@ -126,6 +136,7 @@ export function readAiVaultSessionDragData( sessionFilePath, sessionExecutionHostId, env, + envToDelete, launchConfig } = parsed return { @@ -136,6 +147,7 @@ export function readAiVaultSessionDragData( ...(sessionFilePath ? { sessionFilePath } : {}), ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(env ? { env } : {}), + ...(envToDelete ? { envToDelete } : {}), ...(launchConfig ? { launchConfig } : {}) } } catch { From a3457c8b877766d2706388aded2aa035de8f5eaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:12:28 -0700 Subject: [PATCH 38/45] fix(codex): strip inherited Codex homes on mobile real-home resume The mobile resume surface types a bare real-home codex resume into a freshly created pane, but never asked for CODEX_HOME/ORCA_CODEX_HOME deletion at pane spawn, so an agentDefaultEnv-pinned or daemon-inherited Codex home rerouted the resume away from the user's real ~/.codex while the same session resumed correctly on desktop. Share the deletion helper from the AI Vault resume builders and forward it through the mobile launch and session.tabs.createTerminal call. --- .../session/ai-vault-resume-launch.test.ts | 32 +++++++++++++++++++ mobile/src/session/ai-vault-resume-launch.ts | 11 +++++-- .../src/lib/ai-vault-resume-command.ts | 12 +------ src/shared/ai-vault-types.ts | 12 +++++++ 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index be7de3ed683..fa096be4a03 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -144,6 +144,36 @@ describe('buildMobileAiVaultResumeLaunch', () => { agentArgs: '--model opus', agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }) + // Only bare real-home Codex resumes request env deletion. + expect(launch.envToDelete).toBeUndefined() + }) + + it('deletes inherited Codex homes when resuming a real-home session like desktop', () => { + // Regression: a user agentDefaultEnv CODEX_HOME (or a stale daemon- + // inherited home) must not reroute a bare real-home resume typed into the + // created pane; desktop already strips the pair at pane spawn. + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ agent: 'codex', sessionId: 'codex-1', codexHome: null }), + hostPlatform: 'darwin', + settings: { + agentDefaultEnv: { codex: { CODEX_HOME: '/Users/ada/.codex-pinned' } } + } + }) + expect(launch.command).not.toContain('CODEX_HOME=') + expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME']) + }) + + it('keeps managed-home Codex resumes free of env deletion', () => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + codexHome: '/Users/ada/.orca/codex-runtime-home/home' + }), + hostPlatform: 'darwin' + }) + expect(launch.command).toContain("CODEX_HOME='/Users/ada/.orca/codex-runtime-home/home'") + expect(launch.envToDelete).toBeUndefined() }) }) @@ -161,6 +191,7 @@ describe('resumeAiVaultSessionInTerminal', () => { resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'claude --resume abc', env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'claude', agentArgs: '', @@ -176,6 +207,7 @@ describe('resumeAiVaultSessionInTerminal', () => { { worktree: 'id:worktree-1', env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { agentCommand: 'claude', agentArgs: '', diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 61144c0de2b..2ae7463da5a 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -1,7 +1,8 @@ import type { AiVaultSession } from '../../../src/shared/ai-vault-types' import { buildAiVaultResumeCommand, - buildAiVaultResumeShellCommand + buildAiVaultResumeShellCommand, + realHomeCodexResumeEnvDeletion } from '../../../src/shared/ai-vault-types' import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' @@ -71,6 +72,7 @@ export type MobileAiVaultResumeSettings = { export type MobileAiVaultResumeLaunch = { command: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent } @@ -111,6 +113,9 @@ export function buildMobileAiVaultResumeLaunch(args: { shell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), + // Why: the resume command is typed into the created pane, so the bare + // real-home override must strip Codex homes at pane spawn like desktop. + ...realHomeCodexResumeEnvDeletion(args.session), launchConfig: startupPlan.launchConfig, launchAgent: startupPlan.agent } @@ -122,7 +127,8 @@ export function buildMobileAiVaultResumeLaunch(args: { hostPlatform: args.hostPlatform, hostTerminalWindowsShell: args.hostTerminalWindowsShell, commandOverride - }) + }), + ...realHomeCodexResumeEnvDeletion(args.session) } } @@ -155,6 +161,7 @@ export async function resumeAiVaultSessionInTerminal( { worktree: `id:${worktreeId}`, ...(launch.env ? { env: launch.env } : {}), + ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 62a9d8352a9..1e8eb04f252 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -1,6 +1,7 @@ import { buildAiVaultResumeCommand, buildAiVaultResumeShellCommand, + realHomeCodexResumeEnvDeletion, type AiVaultSession } from '../../../shared/ai-vault-types' import { @@ -145,17 +146,6 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault } } -function realHomeCodexResumeEnvDeletion( - session: AiVaultResumeCommandSession -): { envToDelete: string[] } | Record { - if (session.agent !== 'codex' || session.codexHome !== null) { - return {} - } - // Why: a bare real-home resume must override workspace account routing and - // the persistent daemon's inherited Orca home, not only its sparse env patch. - return { envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] } -} - function getAiVaultResumeCodexHome( codexHome: string | null, platform: NodeJS.Platform diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index fe07aca6963..4e3f754ab81 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -275,6 +275,18 @@ function buildResumeShellCommandForShell(args: { return segments.join(separator) } +// Why: a bare real-home resume carries no CODEX_HOME prefix, so every surface +// that spawns the pane must drop account-routed or daemon-inherited Codex +// homes from its env, not only patch a sparse env on top. +export function realHomeCodexResumeEnvDeletion( + session: Pick +): { envToDelete: string[] } | Record { + if (session.agent !== 'codex' || session.codexHome !== null) { + return {} + } + return { envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] } +} + export function aiVaultAgentLabel(agent: AiVaultAgent): string { return AI_VAULT_AGENT_LABELS[agent] } From b1a557f3b64435544f853c102423c204b2647d83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:15:07 -0700 Subject: [PATCH 39/45] fix(codex): gate session migration on real-home lane --- src/main/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 9b710023f23..edee2e6f35a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1808,6 +1808,12 @@ app.whenReady().then(async () => { // #8612). Deferred so startup and first PTY spawns never compete with the // sessions tree walk. setTimeout(() => { + // Why: reverse-backfilling into the user's Codex home belongs exclusively + // to the real-home lane; flag-off, managed-account, and custom-CODEX_HOME + // launch lanes must remain byte-identical and leave that history untouched. + if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { + return + } const systemCodexHomePathOverride = resolveHostCodexSessionSourceHome(store!.getSettings()) // Why: the heal pass chains after the backfill settles so thread/read only // runs once the audit ledger covers this startup's newly linked rollouts; From 93791d59408c31d0f9b370ade1f1742004e3e7d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:26:45 -0700 Subject: [PATCH 40/45] fix(codex): stop session backfill after opt-out --- .../codex/codex-session-backfill-types.ts | 26 ++++++++++ src/main/codex/codex-session-backfill.test.ts | 18 +++++++ src/main/codex/codex-session-backfill.ts | 47 +++++++++---------- src/main/index.ts | 9 +++- 4 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-types.ts diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts new file mode 100644 index 00000000000..19d688b8e88 --- /dev/null +++ b/src/main/codex/codex-session-backfill-types.ts @@ -0,0 +1,26 @@ +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +export type CodexSessionBackfillSummary = { + stopped: boolean + scannedFiles: number + linkedFiles: number + copiedFiles: number + skippedExistingFiles: number + skippedUnexpectedFiles: number + skippedSymlinkFiles: number + skippedUnsupportedFilesystemFiles: number + failedDirectories: number + failedFiles: number +} + +export type CodexSessionBackfillPaths = { + managedSessionsRoot: string + systemSessionsRoot: string + auditLogPath: string + markerPath: string +} + +export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & { + /** Polled before each target mutation; true stops with progress preserved. */ + shouldStop?: () => boolean +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 089a52f08ef..87c7490fd51 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -394,6 +394,24 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { }) describe('startCodexSessionBackfillInBackground', () => { + it('stops target mutations after real-home opt-out and leaves the run retryable', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + writeManagedSession(join('2026', '05', '26', 'rollout-b.jsonl'), '{"id":"b"}\n') + let stopChecks = 0 + + const stopped = await startCodexSessionBackfillInBackground({ + yieldMs: 0, + shouldStop: () => stopChecks++ >= 1 + }) + + expect(stopped).toMatchObject({ stopped: true, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const resumed = await startCodexSessionBackfillInBackground({ yieldMs: 0 }) + expect(resumed).toMatchObject({ stopped: false, linkedFiles: 1, skippedExistingFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + it('writes a completion marker and skips the walk on later runs', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 7b24808c23a..32cd6d3deb8 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -16,31 +16,22 @@ import { isAtomicNoReplaceUnsupportedError } from './codex-session-backfill-copy' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' -import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' +import type { + CodexSessionBackfillOptions, + CodexSessionBackfillPaths, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' + +export type { + CodexSessionBackfillOptions, + CodexSessionBackfillPaths, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. const CODEX_SESSION_BACKFILL_MARKER_VERSION = 1 -export type CodexSessionBackfillSummary = { - scannedFiles: number - linkedFiles: number - copiedFiles: number - skippedExistingFiles: number - skippedUnexpectedFiles: number - skippedSymlinkFiles: number - skippedUnsupportedFilesystemFiles: number - failedDirectories: number - failedFiles: number -} - -export type CodexSessionBackfillPaths = { - managedSessionsRoot: string - systemSessionsRoot: string - auditLogPath: string - markerPath: string -} - let backgroundBackfillTask: Promise | null = null /** @@ -70,7 +61,7 @@ export function resolveCodexSessionBackfillPaths( * to null without walking the sessions tree. */ export function startCodexSessionBackfillInBackground( - options: CodexSessionBridgeIncrementalOptions = {}, + options: CodexSessionBackfillOptions = {}, systemCodexHomePathOverride?: string ): Promise { if (backgroundBackfillTask) { @@ -92,7 +83,7 @@ export function startCodexSessionBackfillInBackground( } async function runCodexSessionBackfillOncePerHost( - options: CodexSessionBridgeIncrementalOptions, + options: CodexSessionBackfillOptions, systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) @@ -102,7 +93,7 @@ async function runCodexSessionBackfillOncePerHost( const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) // Why: per-file failures (locked or unreadable files) leave the marker unset // so the next startup retries; skip-existing keeps those retries cheap. - if (summary.failedFiles === 0 && summary.failedDirectories === 0) { + if (!summary.stopped && summary.failedFiles === 0 && summary.failedDirectories === 0) { writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) } return summary @@ -117,9 +108,10 @@ async function runCodexSessionBackfillOncePerHost( */ export async function backfillManagedCodexSessionsIntoSystemHome( paths: CodexSessionBackfillPaths, - options: CodexSessionBridgeIncrementalOptions = {} + options: CodexSessionBackfillOptions = {} ): Promise { const summary: CodexSessionBackfillSummary = { + stopped: false, scannedFiles: 0, linkedFiles: 0, copiedFiles: 0, @@ -152,6 +144,12 @@ export async function backfillManagedCodexSessionsIntoSystemHome( }) } )) { + if (options.shouldStop?.()) { + // Why: disabling the real-home lane must bound further writes to at + // most the single file mutation already in flight. + summary.stopped = true + break + } summary.scannedFiles += 1 if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { summary.skippedUnexpectedFiles += 1 @@ -168,6 +166,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( ) } } + summary.stopped ||= options.shouldStop?.() === true await appendAuditRecord({ action: 'run-summary', ...summary }) return summary } diff --git a/src/main/index.ts b/src/main/index.ts index edee2e6f35a..9cf7a55af56 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1815,10 +1815,15 @@ app.whenReady().then(async () => { return } const systemCodexHomePathOverride = resolveHostCodexSessionSourceHome(store!.getSettings()) + const shouldStopSessionMigration = (): boolean => + isQuitting || codexRuntimeHome?.isHostSystemDefaultRealHome() !== true // Why: the heal pass chains after the backfill settles so thread/read only // runs once the audit ledger covers this startup's newly linked rollouts; // it also drains sessions left pending by an interrupted earlier pass. - void startCodexSessionBackfillInBackground({}, systemCodexHomePathOverride).then(() => { + void startCodexSessionBackfillInBackground( + { shouldStop: shouldStopSessionMigration }, + systemCodexHomePathOverride + ).then(() => { // Why: flag-OFF, managed-account, and custom-home lanes must never spawn // an app-server against the user's real sqlite index. if (!codexRuntimeHome?.isHostSystemDefaultRealHome()) { @@ -1826,7 +1831,7 @@ app.whenReady().then(async () => { } return startCodexSessionIndexHealInBackground( { - shouldStop: () => isQuitting || codexRuntimeHome?.isHostSystemDefaultRealHome() !== true + shouldStop: shouldStopSessionMigration }, systemCodexHomePathOverride ) From f29cb27f91f1a5d35ba4863a7c3964c8c46ab80a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:43:29 -0700 Subject: [PATCH 41/45] fix(codex): keep session heal failures retryable --- .../codex/codex-session-backfill-audit.ts | 8 +- .../codex/codex-session-backfill-marker.ts | 50 +++++++++++ .../codex/codex-session-backfill-types.ts | 1 + src/main/codex/codex-session-backfill.test.ts | 30 ++++++- src/main/codex/codex-session-backfill.ts | 88 ++++++++----------- .../codex/codex-session-index-heal-state.ts | 13 ++- .../codex/codex-session-index-heal.test.ts | 27 +++++- src/main/codex/codex-session-index-heal.ts | 17 +++- 8 files changed, 169 insertions(+), 65 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-marker.ts diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 465f0e2d2a4..8bc85c0d9f4 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,7 +1,7 @@ import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' -export type CodexSessionBackfillAuditWriter = (record: Record) => Promise +export type CodexSessionBackfillAuditWriter = (record: Record) => Promise export function createCodexSessionBackfillAuditWriter( auditLogPath: string @@ -17,21 +17,23 @@ export function createCodexSessionBackfillAuditWriter( await auditDirectoryReady await appendFile(auditLogPath, serializedRecord, { encoding: 'utf-8' }) } - return async (record): Promise => { + return async (record): Promise => { const serializedRecord = `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n` try { await appendRecord(serializedRecord) - return + return true } catch { // Why: the heal consumes this ledger as its work queue. Retry the same // record once so a transient mkdir/write failure cannot omit a session. } try { await appendRecord(serializedRecord) + return true } catch (error) { // Why: a published hardlink/copy may already be in use, so persistent // ledger failure is reported but cannot safely roll back the backfill. console.warn('[codex-session-backfill] Failed to append audit record:', error) + return false } } } diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts new file mode 100644 index 00000000000..59c11285109 --- /dev/null +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -0,0 +1,50 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' + +// Why: bump to re-run the backfill for every host after a layout or semantics +// change; the run itself stays skip-existing so re-runs never overwrite. +const CODEX_SESSION_BACKFILL_MARKER_VERSION = 2 + +export function hasCompletedCodexSessionBackfillMarker( + markerPath: string, + systemSessionsRoot: string +): boolean { + try { + const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false + } + const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } + // Why: changing the configured real Codex home must backfill the new + // target instead of honoring a marker written for a different history. + return ( + marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && + marker.systemSessionsRoot === systemSessionsRoot + ) + } catch { + return false + } +} + +export function writeCodexSessionBackfillMarker( + markerPath: string, + systemSessionsRoot: string, + summary: CodexSessionBackfillSummary +): void { + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileAtomically( + markerPath, + `${JSON.stringify( + { + version: CODEX_SESSION_BACKFILL_MARKER_VERSION, + systemSessionsRoot, + completedAt: Date.now(), + summary + }, + null, + 2 + )}\n` + ) +} diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index 19d688b8e88..d3f25b37bbf 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -11,6 +11,7 @@ export type CodexSessionBackfillSummary = { skippedUnsupportedFilesystemFiles: number failedDirectories: number failedFiles: number + failedHealAuditRecords: number } export type CodexSessionBackfillPaths = { diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index 87c7490fd51..fbf2c59f4c7 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -27,6 +27,7 @@ const { fsMockState } = vi.hoisted(() => ({ failInstallLinkTransiently: false, failCopy: false, failAuditMkdirOnce: false, + failAuditWrites: false, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -60,6 +61,14 @@ vi.mock('node:fs/promises', async () => { } return actual.mkdir(...args) }, + appendFile: (...args: Parameters) => { + if (fsMockState.failAuditWrites && String(args[0]).includes('codex-session-backfill')) { + const error = new Error('ENOSPC: audit write failed') as NodeJS.ErrnoException + error.code = 'ENOSPC' + throw error + } + return actual.appendFile(...args) + }, lstat: (...args: Parameters) => { if (args[0] === fsMockState.failLstatPath) { const error = new Error('EACCES: path inaccessible') as NodeJS.ErrnoException @@ -164,6 +173,7 @@ beforeEach(() => { fsMockState.failInstallLinkTransiently = false fsMockState.failCopy = false fsMockState.failAuditMkdirOnce = false + fsMockState.failAuditWrites = false fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -245,7 +255,7 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 0, skippedExistingFiles: 1 }) expect(readFileSync(collidingPath, 'utf-8')).toBe('user contents\n') - expect(readAuditActions()).toEqual(['run-summary']) + expect(readAuditActions()).toEqual(['existing', 'run-summary']) }) it('treats a broken symlink at the target as taken', async () => { @@ -418,6 +428,7 @@ describe('startCodexSessionBackfillInBackground', () => { const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 2 }) // A file appearing after the marker must not be backfilled again. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') @@ -428,6 +439,23 @@ describe('startCodexSessionBackfillInBackground', () => { ).toBe(false) }) + it('re-enqueues an installed rollout after its audit write fails', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + fsMockState.failAuditWrites = true + + const first = await startCodexSessionBackfillInBackground() + + expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + fsMockState.failAuditWrites = false + const second = await startCodexSessionBackfillInBackground() + + expect(second).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(readAuditActions()).toEqual(['existing', 'run-summary']) + expect(existsSync(getMarkerPath())).toBe(true) + }) + it('does not retry a stable hardlink-less filesystem limitation', async () => { fsMockState.failLink = true fsMockState.failInstallLink = true diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 32cd6d3deb8..c27af8e3352 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -1,7 +1,5 @@ -import { mkdirSync, readFileSync } from 'node:fs' import { link, lstat, mkdir } from 'node:fs/promises' import { dirname, join, relative, sep } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getCodexSessionBackfillStateDirPath, getOrcaManagedCodexHomePath, @@ -16,6 +14,10 @@ import { isAtomicNoReplaceUnsupportedError } from './codex-session-backfill-copy' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import { + hasCompletedCodexSessionBackfillMarker, + writeCodexSessionBackfillMarker +} from './codex-session-backfill-marker' import type { CodexSessionBackfillOptions, CodexSessionBackfillPaths, @@ -28,10 +30,6 @@ export type { CodexSessionBackfillSummary } from './codex-session-backfill-types' -// Why: bump to re-run the backfill for every host after a layout or semantics -// change; the run itself stays skip-existing so re-runs never overwrite. -const CODEX_SESSION_BACKFILL_MARKER_VERSION = 1 - let backgroundBackfillTask: Promise | null = null /** @@ -87,14 +85,19 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) - // Why: per-file failures (locked or unreadable files) leave the marker unset - // so the next startup retries; skip-existing keeps those retries cheap. - if (!summary.stopped && summary.failedFiles === 0 && summary.failedDirectories === 0) { - writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) + // Why: file or heal-queue failures leave the marker unset so the next + // startup retries; skip-existing keeps those retries cheap. + if ( + !summary.stopped && + summary.failedFiles === 0 && + summary.failedDirectories === 0 && + summary.failedHealAuditRecords === 0 + ) { + writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) } return summary } @@ -120,7 +123,8 @@ export async function backfillManagedCodexSessionsIntoSystemHome( skippedSymlinkFiles: 0, skippedUnsupportedFilesystemFiles: 0, failedDirectories: 0, - failedFiles: 0 + failedFiles: 0, + failedHealAuditRecords: 0 } const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath) const ensuredTargetDirectories = new Set() @@ -226,6 +230,13 @@ async function backfillOneManagedSessionFile( const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) if (await pathEntryExists(systemSessionFilePath)) { summary.skippedExistingFiles += 1 + // Why: this also recovers a rollout installed before a crash or audit + // failure; thread/read is idempotent for a pre-existing real-home file. + await appendHealAuditRecord(appendAuditRecord, summary, { + action: 'existing', + source: managedSessionFilePath, + target: systemSessionFilePath + }) return } @@ -239,7 +250,7 @@ async function backfillOneManagedSessionFile( } await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - await appendAuditRecord({ + await appendHealAuditRecord(appendAuditRecord, summary, { action: 'hardlink', source: managedSessionFilePath, target: systemSessionFilePath @@ -257,7 +268,7 @@ async function backfillOneManagedSessionFile( // truncated rollout, then installed without overwriting collisions. await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) summary.copiedFiles += 1 - await appendAuditRecord({ + await appendHealAuditRecord(appendAuditRecord, summary, { action: 'copy', source: managedSessionFilePath, target: systemSessionFilePath @@ -288,6 +299,16 @@ async function backfillOneManagedSessionFile( } } +async function appendHealAuditRecord( + appendAuditRecord: CodexSessionBackfillAuditWriter, + summary: CodexSessionBackfillSummary, + record: Record +): Promise { + if (!(await appendAuditRecord(record))) { + summary.failedHealAuditRecords += 1 + } +} + async function isSymbolicLink(filePath: string): Promise { try { return (await lstat(filePath)).isSymbolicLink() @@ -317,42 +338,3 @@ function isNotFoundError(error: unknown): boolean { function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } - -function hasCompletedBackfillMarker(markerPath: string, systemSessionsRoot: string): boolean { - try { - const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return false - } - const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } - // Why: changing the configured real Codex home must backfill the new - // target instead of honoring a marker written for a different history. - return ( - marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && - marker.systemSessionsRoot === systemSessionsRoot - ) - } catch { - return false - } -} - -function writeBackfillMarker( - markerPath: string, - systemSessionsRoot: string, - summary: CodexSessionBackfillSummary -): void { - mkdirSync(dirname(markerPath), { recursive: true }) - writeFileAtomically( - markerPath, - `${JSON.stringify( - { - version: CODEX_SESSION_BACKFILL_MARKER_VERSION, - systemSessionsRoot, - completedAt: Date.now(), - summary - }, - null, - 2 - )}\n` - ) -} diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 16953b30aa8..f597b1d019f 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -51,7 +51,10 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe const processedThreadIds = readProcessedHealThreadIds(paths) const pendingByThreadId = new Map() for (const line of readJsonlLines(paths.auditLogPath, true)) { - if ((line.action !== 'hardlink' && line.action !== 'copy') || typeof line.target !== 'string') { + if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') { + continue + } + if (typeof line.target !== 'string') { continue } // Why: the append-only audit can contain runs for several custom Codex @@ -98,7 +101,7 @@ export function appendHealLedgerRecord( paths: CodexSessionIndexHealPaths, threadId: string, outcome: HealLedgerOutcome -): void { +): boolean { try { mkdirSync(dirname(paths.healLedgerPath), { recursive: true }) appendFileSync( @@ -111,10 +114,12 @@ export function appendHealLedgerRecord( at: new Date().toISOString() })}\n` ) + return true } catch (error) { - // Why: losing a ledger line only costs one redundant thread/read on the - // next pass; it must not fail the heal. + // Why: the completion marker may only cover durably recorded outcomes; + // otherwise its audit-size fast path permanently suppresses the retry. console.warn('[codex-session-index-heal] Failed to append heal ledger record:', error) + return false } } diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 24e150a2bd9..3843f3b440e 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -196,7 +196,7 @@ describe('runCodexSessionIndexHeal', () => { auditedThreads: [ { stamp: '2026-07-01T10-00-00', id: threadId('1') }, { stamp: '2026-07-03T10-00-00', id: threadId('3'), action: 'copy' }, - { stamp: '2026-07-02T10-00-00', id: threadId('2') } + { stamp: '2026-07-02T10-00-00', id: threadId('2'), action: 'existing' } ] }) @@ -578,4 +578,29 @@ describe('runCodexSessionIndexHeal', () => { expect(rig.readLog().serverStarts).toBe(0) expect(existsSync(rig.paths.healMarkerPath)).toBe(false) }) + + it('does not mark the heal complete when a processed outcome cannot be persisted', async () => { + const id = threadId('1') + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id }] + }) + const healLedgerPath = rig.paths.healLedgerPath + rig.paths.healLedgerPath = dirname(healLedgerPath) + + const failed = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(failed.outcome).toBe('aborted') + expect(existsSync(rig.paths.healMarkerPath)).toBe(false) + + rig.paths.healLedgerPath = healLedgerPath + const retried = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([id, id]) + }) }) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index e095a95469f..bef28690e8e 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -10,6 +10,7 @@ import { readAuditLogSize, writeHealMarker, type CodexSessionIndexHealPaths, + type HealLedgerOutcome, type PendingHealThread } from './codex-session-index-heal-state' import { @@ -204,7 +205,7 @@ async function healOneThread( try { await rpc.request('thread/read', { threadId: thread.threadId }) summary.healedThreads += 1 - appendHealLedgerRecord(paths, thread.threadId, 'healed') + recordHealOutcome(paths, thread.threadId, 'healed') } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -218,7 +219,7 @@ async function healOneThread( if (/no rollout found/i.test(message)) { // The backfilled rollout was deleted after the audit was written. summary.missingThreads += 1 - appendHealLedgerRecord(paths, thread.threadId, 'missing') + recordHealOutcome(paths, thread.threadId, 'missing') return } if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { @@ -227,7 +228,17 @@ async function healOneThread( throw error } summary.failedThreads += 1 - appendHealLedgerRecord(paths, thread.threadId, 'failed') + recordHealOutcome(paths, thread.threadId, 'failed') + } +} + +function recordHealOutcome( + paths: CodexSessionIndexHealPaths, + threadId: string, + outcome: HealLedgerOutcome +): void { + if (!appendHealLedgerRecord(paths, threadId, outcome)) { + throw new Error(`Failed to persist Codex session index-heal outcome for ${threadId}`) } } From 57f5dbefe1cb2f4393814c28604d1524206c0f9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:04:02 -0700 Subject: [PATCH 42/45] fix(codex): keep session migration state recoverable --- .../runtime-home-service.test.ts | 19 +++ .../codex-accounts/runtime-home-service.ts | 16 +++ .../codex/codex-session-backfill-audit.ts | 31 ++++- .../codex/codex-session-backfill-marker.ts | 14 ++- src/main/codex/codex-session-backfill.test.ts | 82 ++++++++++++- src/main/codex/codex-session-backfill.ts | 50 ++++---- .../codex/codex-session-index-heal-state.ts | 24 +++- .../codex/codex-session-index-heal.test.ts | 112 +++++++++++++++++- src/main/codex/codex-session-index-heal.ts | 13 +- 9 files changed, 323 insertions(+), 38 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 3d811cbd5c3..c9f8d156ed9 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -982,11 +982,19 @@ describe('CodexRuntimeHomeService', () => { }) it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => { + const markerPath = join( + testState.userDataDir, + 'codex-session-backfill', + 'backfill-complete.json' + ) + mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true }) + writeFileSync(markerPath, '{}\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(markerPath)).toBe(false) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1005,12 +1013,23 @@ describe('CodexRuntimeHomeService', () => { ]) service.setRealHomeLaneGate(() => false) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) + const markerPath = join( + testState.userDataDir, + 'codex-session-backfill', + 'backfill-complete.json' + ) + mkdirSync(join(testState.userDataDir, 'codex-session-backfill'), { recursive: true }) + writeFileSync(markerPath, '{}\n', 'utf-8') + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(existsSync(markerPath)).toBe(false) service.setRealHomeLaneGate(() => true) const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') + writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false) expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe( getRuntimeCodexHomePath() ) + expect(existsSync(markerPath)).toBe(true) writeFileSync( join(testState.fakeHomeDir, '.zshrc'), 'export CODEX_HOME="$HOME/shell-custom-codex-home"\n', diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 1e26274330c..355052b5568 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -37,6 +37,7 @@ import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { writeFileAtomically } from './fs-utils' import { getOrcaManagedCodexHomePath, + getCodexSessionBackfillStateDirPath, getSystemCodexHomePath, syncCodexGlobalInstructionsIntoManagedHome, syncSystemCodexResourcesIntoManagedHome @@ -62,6 +63,7 @@ import { import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' +import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { readShellStartupEnvVar } from '../pty/shell-startup-env' type CodexAuthIdentity = { @@ -174,6 +176,7 @@ export class CodexRuntimeHomeService { // managed session bridge runs, so the real home stays the single source. return null } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -186,6 +189,19 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { + const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== null) { + return + } + const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv) + if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled(settings)) { + invalidateCodexSessionBackfillMarker( + join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') + ) + } + } + private startWslSessionBridgeForLaunch( target: CodexAccountSelectionTarget, runtimeHomePath: string | null diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 8bc85c0d9f4..5ab771d03d3 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,5 +1,6 @@ import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' +import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' export type CodexSessionBackfillAuditWriter = (record: Record) => Promise @@ -18,7 +19,9 @@ export function createCodexSessionBackfillAuditWriter( await appendFile(auditLogPath, serializedRecord, { encoding: 'utf-8' }) } return async (record): Promise => { - const serializedRecord = `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n` + // Why: a crash can leave a partial final JSON object. A leading newline + // quarantines that torn tail so this recovery record remains parseable. + const serializedRecord = `\n${JSON.stringify({ at: new Date().toISOString(), ...record })}\n` try { await appendRecord(serializedRecord) return true @@ -37,3 +40,29 @@ export function createCodexSessionBackfillAuditWriter( } } } + +export async function appendCodexSessionHealAuditRecord( + writer: CodexSessionBackfillAuditWriter, + summary: CodexSessionBackfillSummary, + record: Record +): Promise { + if (!(await writer(record))) { + summary.failedHealAuditRecords += 1 + } +} + +export async function recordExistingCodexSessionForHeal( + writer: CodexSessionBackfillAuditWriter, + summary: CodexSessionBackfillSummary, + source: string, + target: string +): Promise { + summary.skippedExistingFiles += 1 + // Why: this also recovers a rollout installed before a crash or audit + // failure; thread/read is idempotent for a pre-existing real-home file. + await appendCodexSessionHealAuditRecord(writer, summary, { + action: 'existing', + source, + target + }) +} diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index 59c11285109..d3d5a07e5f7 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -1,11 +1,11 @@ -import { mkdirSync, readFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. -const CODEX_SESSION_BACKFILL_MARKER_VERSION = 2 +const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 export function hasCompletedCodexSessionBackfillMarker( markerPath: string, @@ -48,3 +48,13 @@ export function writeCodexSessionBackfillMarker( )}\n` ) } + +export function invalidateCodexSessionBackfillMarker(markerPath: string): void { + try { + // Why: a managed-lane system-default launch can create new source + // rollouts, so a prior one-time marker must not suppress the next opt-in. + rmSync(markerPath, { force: true }) + } catch (error) { + console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error) + } +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index fbf2c59f4c7..bd5e0bca680 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -25,6 +25,7 @@ const { fsMockState } = vi.hoisted(() => ({ failLink: false, failInstallLink: false, failInstallLinkTransiently: false, + raceTargetIntoExistence: false, failCopy: false, failAuditMkdirOnce: false, failAuditWrites: false, @@ -77,7 +78,14 @@ vi.mock('node:fs/promises', async () => { } return actual.lstat(...args) }, - link: (...args: Parameters) => { + link: async (...args: Parameters) => { + if (fsMockState.raceTargetIntoExistence && String(args[0]).includes('codex-runtime-home')) { + fsMockState.raceTargetIntoExistence = false + await actual.writeFile(args[1], 'concurrent target\n', 'utf-8') + const error = new Error('EEXIST: concurrent target') as NodeJS.ErrnoException + error.code = 'EEXIST' + throw error + } if (fsMockState.failLink && String(args[0]).includes('codex-runtime-home')) { const error = new Error('EXDEV: cross-device link') as NodeJS.ErrnoException error.code = 'EXDEV' @@ -164,13 +172,20 @@ function readAuditActions(): string[] { return readFileSync(getAuditLogPath(), 'utf-8') .split('\n') .filter(Boolean) - .map((line) => (JSON.parse(line) as { action: string }).action) + .flatMap((line) => { + try { + return [(JSON.parse(line) as { action: string }).action] + } catch { + return [] + } + }) } beforeEach(() => { fsMockState.failLink = false fsMockState.failInstallLink = false fsMockState.failInstallLinkTransiently = false + fsMockState.raceTargetIntoExistence = false fsMockState.failCopy = false fsMockState.failAuditMkdirOnce = false fsMockState.failAuditWrites = false @@ -258,6 +273,36 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['existing', 'run-summary']) }) + it('enqueues a target that appears after the existence probe', async () => { + fsMockState.raceTargetIntoExistence = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(summary).toMatchObject({ linkedFiles: 0, skippedExistingFiles: 1 }) + expect(readFileSync(targetPath, 'utf-8')).toBe('concurrent target\n') + expect(readAuditActions()).toEqual(['existing', 'run-summary']) + }) + + it('keeps recovery records parseable after a torn audit tail', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, 'existing target\n', 'utf-8') + mkdirSync(dirname(getAuditLogPath()), { recursive: true }) + writeFileSync(getAuditLogPath(), '{"torn":', 'utf-8') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(readAuditActions()).toEqual(['existing', 'run-summary']) + }) + it('treats a broken symlink at the target as taken', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n') const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') @@ -422,13 +467,25 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) }) + it('does not publish completion when opt-out lands during final audit', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + let stopChecks = 0 + + const stopped = await startCodexSessionBackfillInBackground({ + shouldStop: () => stopChecks++ >= 2 + }) + + expect(stopped).toMatchObject({ stopped: true, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + }) + it('writes a completion marker and skips the walk on later runs', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) - expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) // A file appearing after the marker must not be backfilled again. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') @@ -439,6 +496,25 @@ describe('startCodexSessionBackfillInBackground', () => { ).toBe(false) }) + it('recovers an installed rollout after the completion marker write fails', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + mkdirSync(getMarkerPath(), { recursive: true }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const first = await startCodexSessionBackfillInBackground() + expect(first).toBeNull() + expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe( + true + ) + + rmSync(getMarkerPath(), { recursive: true }) + const resumed = await startCodexSessionBackfillInBackground() + expect(resumed).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + it('re-enqueues an installed rollout after its audit write fails', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') fsMockState.failAuditWrites = true diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index c27af8e3352..e0294a9a72a 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -6,7 +6,9 @@ import { getSystemCodexHomePath } from './codex-home-paths' import { + appendCodexSessionHealAuditRecord, createCodexSessionBackfillAuditWriter, + recordExistingCodexSessionForHeal, type CodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' import { @@ -93,6 +95,7 @@ async function runCodexSessionBackfillOncePerHost( // startup retries; skip-existing keeps those retries cheap. if ( !summary.stopped && + options.shouldStop?.() !== true && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 @@ -172,6 +175,9 @@ export async function backfillManagedCodexSessionsIntoSystemHome( } summary.stopped ||= options.shouldStop?.() === true await appendAuditRecord({ action: 'run-summary', ...summary }) + // Why: opt-out can land while the async summary append is pending; carry it + // back to the marker gate so a managed launch cannot be hidden by stale completion. + summary.stopped ||= options.shouldStop?.() === true return summary } @@ -229,14 +235,12 @@ async function backfillOneManagedSessionFile( const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) if (await pathEntryExists(systemSessionFilePath)) { - summary.skippedExistingFiles += 1 - // Why: this also recovers a rollout installed before a crash or audit - // failure; thread/read is idempotent for a pre-existing real-home file. - await appendHealAuditRecord(appendAuditRecord, summary, { - action: 'existing', - source: managedSessionFilePath, - target: systemSessionFilePath - }) + await recordExistingCodexSessionForHeal( + appendAuditRecord, + summary, + managedSessionFilePath, + systemSessionFilePath + ) return } @@ -250,14 +254,21 @@ async function backfillOneManagedSessionFile( } await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - await appendHealAuditRecord(appendAuditRecord, summary, { + await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { action: 'hardlink', source: managedSessionFilePath, target: systemSessionFilePath }) } catch (linkError) { if (isExistsError(linkError)) { - summary.skippedExistingFiles += 1 + // Why: another window can publish the target after our existence probe; + // enqueue it here too in case that writer died before its audit append. + await recordExistingCodexSessionForHeal( + appendAuditRecord, + summary, + managedSessionFilePath, + systemSessionFilePath + ) return } if (isNotFoundError(linkError)) { @@ -268,14 +279,19 @@ async function backfillOneManagedSessionFile( // truncated rollout, then installed without overwriting collisions. await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) summary.copiedFiles += 1 - await appendHealAuditRecord(appendAuditRecord, summary, { + await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { action: 'copy', source: managedSessionFilePath, target: systemSessionFilePath }) } catch (copyError) { if (isExistsError(copyError)) { - summary.skippedExistingFiles += 1 + await recordExistingCodexSessionForHeal( + appendAuditRecord, + summary, + managedSessionFilePath, + systemSessionFilePath + ) return } if (isAtomicNoReplaceUnsupportedError(copyError)) { @@ -299,16 +315,6 @@ async function backfillOneManagedSessionFile( } } -async function appendHealAuditRecord( - appendAuditRecord: CodexSessionBackfillAuditWriter, - summary: CodexSessionBackfillSummary, - record: Record -): Promise { - if (!(await appendAuditRecord(record))) { - summary.failedHealAuditRecords += 1 - } -} - async function isSymbolicLink(filePath: string): Promise { try { return (await lstat(filePath)).isSymbolicLink() diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index f597b1d019f..2b4d7c42032 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -13,11 +13,12 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' // Bump to re-drive the heal for every host after a semantics change; already // processed thread ids are re-read because ledger lines are version-scoped. -export const CODEX_SESSION_INDEX_HEAL_VERSION = 2 +export const CODEX_SESSION_INDEX_HEAL_VERSION = 3 // Why: an unsupported CLI stays unsupported until upgraded; re-probing once a // day is enough to notice an upgrade without a per-startup spawn. const HEAL_UNSUPPORTED_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 +const HEAL_FAILED_THREAD_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const CODEX_ROLLOUT_THREAD_ID_PATTERN = /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i @@ -89,6 +90,7 @@ function readProcessedHealThreadIds(paths: CodexSessionIndexHealPaths): Set = {} for (const line of contents.split('\n').filter(Boolean)) { - const record = JSON.parse(line) as { threadId: string; outcome: string } - outcomes[record.threadId] = record.outcome + try { + const record = JSON.parse(line) as { threadId: string; outcome: string } + outcomes[record.threadId] = record.outcome + } catch { + // Torn tails are quarantined by the next append and ignored by readers. + } } return outcomes } @@ -269,7 +273,7 @@ describe('runCodexSessionIndexHeal', () => { expect(rig.readLog().threadIds).toEqual([threadId('1'), threadId('4')]) }) - it('records missing and failed sessions without retrying them next run', async () => { + it('backs off failed sessions and retries them later while missing stays terminal', async () => { const rig = createHealRig({ auditedThreads: [ { stamp: '2026-07-01T10-00-00', id: threadId('1') }, @@ -302,6 +306,54 @@ describe('runCodexSessionIndexHeal', () => { interBatchDelayMs: 0 }) expect(again.outcome).toBe('up-to-date') + + const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { + retryableFailureAt: number + } + marker.retryableFailureAt = 0 + writeFileSync(rig.paths.healMarkerPath, `${JSON.stringify(marker)}\n`, 'utf-8') + const retried = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: (home, timeoutMs) => { + const invocation = rig.buildInvocation(home, timeoutMs) + return { + ...invocation, + env: { + STUB_CONFIG: JSON.stringify({ + scenario: 'ok', + readLogFile: rig.readLogFile, + missingThreadIds: [], + failingThreadIds: [] + }) + } + } + }, + interBatchDelayMs: 0 + }) + expect(retried).toMatchObject({ outcome: 'completed', pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds.at(-1)).toBe(threadId('1')) + expect(readLedgerOutcomes(rig.paths)[threadId('1')]).toBe('healed') + }) + + it('keeps a processed outcome readable after a torn heal-ledger tail', async () => { + const id = threadId('1') + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id }] + }) + writeFileSync(rig.paths.healLedgerPath, '{"torn":', 'utf-8') + + const first = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(first).toMatchObject({ outcome: 'completed', healedThreads: 1 }) + + rmSync(rig.paths.healMarkerPath) + const resumed = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(resumed).toMatchObject({ outcome: 'completed', pendingThreads: 0 }) + expect(rig.readLog().serverStarts).toBe(1) }) it('splits work into batches with one server session each and bounded concurrency', async () => { @@ -412,6 +464,30 @@ describe('runCodexSessionIndexHeal', () => { }) expect(again.outcome).toBe('up-to-date') expect(rig.readLog().serverStarts).toBe(1) + + const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { + unsupportedAt: number + } + marker.unsupportedAt = 0 + writeFileSync(rig.paths.healMarkerPath, `${JSON.stringify(marker)}\n`, 'utf-8') + const retried = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: (home, timeoutMs) => { + const invocation = rig.buildInvocation(home, timeoutMs) + return { + ...invocation, + env: { + STUB_CONFIG: JSON.stringify({ + scenario: 'ok', + readLogFile: rig.readLogFile, + missingThreadIds: [], + failingThreadIds: [] + }) + } + } + }, + interBatchDelayMs: 0 + }) + expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 }) }) it('marks the pass unsupported when the CLI lacks the app-server subcommand', async () => { @@ -603,4 +679,32 @@ describe('runCodexSessionIndexHeal', () => { expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 }) expect(rig.readLog().threadIds).toEqual([id, id]) }) + + it('rebuilds a failed completion marker without repeating processed reads', async () => { + const id = threadId('1') + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id }] + }) + mkdirSync(rig.paths.healMarkerPath, { recursive: true }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const first = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(first).toMatchObject({ outcome: 'completed', healedThreads: 1 }) + + rmSync(rig.paths.healMarkerPath, { recursive: true }) + const resumed = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(resumed).toMatchObject({ outcome: 'completed', pendingThreads: 0 }) + expect(rig.readLog().threadIds).toEqual([id]) + expect(JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8'))).toMatchObject({ + version: 3 + }) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) }) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index bef28690e8e..77009edd00f 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -174,9 +174,13 @@ export async function runCodexSessionIndexHeal( ) } catch (error) { if (isCodexAppServerUnsupportedError(error)) { + if (shouldStop()) { + summary.outcome = 'stopped' + return summary + } // Why: no retry churn on old CLIs — remember unsupported and re-probe // after the retry interval or a version bump; nothing is marked healed. - writeHealMarker(paths, auditBytes, summary, Date.now()) + writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) summary.outcome = 'unsupported' return summary } @@ -192,7 +196,12 @@ export async function runCodexSessionIndexHeal( summary.outcome = 'stopped' return summary } - writeHealMarker(paths, auditBytes, summary) + writeHealMarker( + paths, + auditBytes, + summary, + summary.failedThreads > 0 ? { retryableFailureAt: Date.now() } : undefined + ) return summary } From b31658b640dfb2eacd1fb55ed8c096428b17b426 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:18:40 -0700 Subject: [PATCH 43/45] fix(codex): retry republished missing session heals --- .../codex/codex-session-backfill-audit.ts | 9 +++- .../codex/codex-session-index-heal-state.ts | 42 +++++++++++++---- .../codex/codex-session-index-heal.test.ts | 45 ++++++++++++++++++- src/main/codex/codex-session-index-heal.ts | 12 ++--- 4 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 5ab771d03d3..c4740b95572 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' @@ -21,7 +22,13 @@ export function createCodexSessionBackfillAuditWriter( return async (record): Promise => { // Why: a crash can leave a partial final JSON object. A leading newline // quarantines that torn tail so this recovery record remains parseable. - const serializedRecord = `\n${JSON.stringify({ at: new Date().toISOString(), ...record })}\n` + const serializedRecord = `\n${JSON.stringify({ + at: new Date().toISOString(), + ...record, + // Why: a later managed-lane pass can recreate the same thread id, so a + // terminal heal outcome must identify this particular publication event. + recordId: randomUUID() + })}\n` try { await appendRecord(serializedRecord) return true diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 2b4d7c42032..4efc124316e 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -36,6 +36,8 @@ export type PendingHealThread = { threadId: string /** Timestamp segment of the rollout file name; lexicographic recency order. */ rolloutStamp: string + /** Publication event identity; null only for audit records written before event ids. */ + auditRecordId: string | null } export type HealMarkerSummary = { @@ -49,7 +51,7 @@ export type HealMarkerSummary = { * copied rollout whose thread id has not been processed yet, most recent first. */ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { - const processedThreadIds = readProcessedHealThreadIds(paths) + const processed = readProcessedHealThreads(paths) const pendingByThreadId = new Map() for (const line of readJsonlLines(paths.auditLogPath, true)) { if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') { @@ -68,10 +70,19 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe continue } const threadId = match[2].toLowerCase() - if (processedThreadIds.has(threadId)) { + const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null + if ( + processed.healedThreadIds.has(threadId) || + (auditRecordId + ? processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) + : processed.legacyMissingThreadIds.has(threadId)) + ) { + // Why: only the newest publication event for a thread matters. A later + // processed event must displace an older pending event from this scan. + pendingByThreadId.delete(threadId) continue } - pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1] }) + pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => left.rolloutStamp < right.rolloutStamp ? 1 : left.rolloutStamp > right.rolloutStamp ? -1 : 0 @@ -82,8 +93,14 @@ function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } -function readProcessedHealThreadIds(paths: CodexSessionIndexHealPaths): Set { - const processed = new Set() +function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { + healedThreadIds: Set + missingAuditRecords: Set + legacyMissingThreadIds: Set +} { + const healedThreadIds = new Set() + const missingAuditRecords = new Set() + const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) for (const line of readJsonlLines(paths.healLedgerPath)) { if ( @@ -93,16 +110,24 @@ function readProcessedHealThreadIds(paths: CodexSessionIndexHealPaths): Set { expect(readLedgerOutcomes(rig.paths)[threadId('1')]).toBe('healed') }) + it('retries a missing thread when a later backfill republishes its rollout', async () => { + const id = threadId('1') + const rig = createHealRig({ + auditedThreads: [{ stamp: '2026-07-01T10-00-00', id }], + missingThreadIds: [id] + }) + + const missing = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + expect(missing).toMatchObject({ outcome: 'completed', missingThreads: 1 }) + + await createCodexSessionBackfillAuditWriter(rig.paths.auditLogPath)({ + action: 'existing', + target: rolloutTarget(rig.paths.systemSessionsRoot, '2026-07-01T10-00-00', id) + }) + const healed = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: (home, timeoutMs) => { + const invocation = rig.buildInvocation(home, timeoutMs) + return { + ...invocation, + env: { + STUB_CONFIG: JSON.stringify({ + scenario: 'ok', + readLogFile: rig.readLogFile, + missingThreadIds: [], + failingThreadIds: [] + }) + } + } + }, + interBatchDelayMs: 0 + }) + + expect(healed).toMatchObject({ outcome: 'completed', pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([id, id]) + }) + it('keeps a processed outcome readable after a torn heal-ledger tail', async () => { const id = threadId('1') const rig = createHealRig({ diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 77009edd00f..49e575f4f12 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -214,7 +214,7 @@ async function healOneThread( try { await rpc.request('thread/read', { threadId: thread.threadId }) summary.healedThreads += 1 - recordHealOutcome(paths, thread.threadId, 'healed') + recordHealOutcome(paths, thread, 'healed') } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -228,7 +228,7 @@ async function healOneThread( if (/no rollout found/i.test(message)) { // The backfilled rollout was deleted after the audit was written. summary.missingThreads += 1 - recordHealOutcome(paths, thread.threadId, 'missing') + recordHealOutcome(paths, thread, 'missing') return } if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { @@ -237,17 +237,17 @@ async function healOneThread( throw error } summary.failedThreads += 1 - recordHealOutcome(paths, thread.threadId, 'failed') + recordHealOutcome(paths, thread, 'failed') } } function recordHealOutcome( paths: CodexSessionIndexHealPaths, - threadId: string, + thread: PendingHealThread, outcome: HealLedgerOutcome ): void { - if (!appendHealLedgerRecord(paths, threadId, outcome)) { - throw new Error(`Failed to persist Codex session index-heal outcome for ${threadId}`) + if (!appendHealLedgerRecord(paths, thread.threadId, outcome, thread.auditRecordId)) { + throw new Error(`Failed to persist Codex session index-heal outcome for ${thread.threadId}`) } } From 50b61c6022f5c4dd294d2e7993b8fb5152ad2689 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:23:47 -0700 Subject: [PATCH 44/45] fix(codex): harden trust grant lifecycle --- .../codex-accounts/wsl-codex-command.test.ts | 13 ++++++- src/main/codex-accounts/wsl-codex-command.ts | 13 +++++++ .../codex/codex-app-server-client.test.ts | 31 ++++++++++++++++- src/main/codex/codex-app-server-session.ts | 3 ++ src/main/codex/codex-real-home-flag.test.ts | 14 ++++++-- .../codex-real-home-hook-install.test.ts | 18 ++++++++++ .../codex/codex-real-home-hook-install.ts | 18 ++++------ src/main/codex/codex-trust-grant-host.test.ts | 23 +++++++++++-- src/main/codex/codex-trust-grant-host.ts | 27 +++++++++++++-- .../codex/codex-trust-grant-ledger.test.ts | 29 +++++++++++----- src/main/codex/codex-trust-grant-ledger.ts | 34 +++++++++++-------- .../codex/hook-service-trust-grant.test.ts | 9 ++++- 12 files changed, 188 insertions(+), 44 deletions(-) diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts index f696ddcde4f..18c92d63323 100644 --- a/src/main/codex-accounts/wsl-codex-command.test.ts +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' +import { + buildWslCodexAvailabilityArgs, + buildWslCodexIdentityArgs, + buildWslCodexLoginArgs +} from './wsl-codex-command' describe('WSL Codex commands', () => { it('checks the alias-neutral PATH from the distro login shell', () => { @@ -20,4 +24,11 @@ describe('WSL Codex commands', () => { expect(command).toContain('/home/alice/managed-home') expect(command).toContain('exec "\\$resolved" login') }) + + it('reports the login-shell binary path and version for identity checks', () => { + const command = buildWslCodexIdentityArgs('Ubuntu').at(-1) + + expect(command).toMatch(/printf .*"\\\$resolved"/) + expect(command).toContain('exec "\\$resolved" --version') + }) }) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts index 570129ea273..8b78fdf5570 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -12,6 +12,19 @@ export function buildWslCodexAvailabilityArgs(distro: string): string[] { return buildWslCodexShellArgs(distro, command) } +export function buildWslCodexIdentityArgs(distro: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + 'printf \'%s\\n\' "$resolved"', + 'exec "$resolved" --version' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string): string[] { const command = [ buildCodexPathLookup(), diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 879fcc5cab3..da5ca16fb8e 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import type { ChildProcess, spawn } from 'node:child_process' +import { PassThrough } from 'node:stream' +import type { ChildProcess, ChildProcessWithoutNullStreams, spawn } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -204,6 +205,34 @@ describe('killCodexAppServerProcessTree', () => { }) describe('runCodexHookTrustGrantSession', () => { + it('stops stdout before killing a server with an oversized response', async () => { + const child = new EventEmitter() as ChildProcessWithoutNullStreams + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + const kill = vi.fn(() => { + queueMicrotask(() => { + child.emit('exit', null, 'SIGKILL') + child.emit('close', null, 'SIGKILL') + }) + return true + }) + child.kill = kill as ChildProcess['kill'] + const spawnImpl = vi.fn(() => child) as unknown as typeof spawn + + const session = runCodexAppServerSession( + { command: 'codex', args: ['app-server'], timeoutMs: 2_000 }, + async () => undefined, + spawnImpl + ) + child.stdout.write('x'.repeat(1024 * 1024 + 1)) + child.stdout.write('more buffered output') + + await expect(session).rejects.toThrow('oversized JSONL response') + expect(child.stdout.destroyed).toBe(true) + expect(kill).toHaveBeenCalledTimes(1) + }) + it('grants and verifies exactly the expected managed entries', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 9c332729958..f9699ddbfaf 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -156,6 +156,9 @@ export async function runCodexAppServerSession( child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdoutBuffer += chunk if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + // Why: Windows process-tree termination is asynchronous; stop buffered + // chunks from spawning another taskkill for the same oversized response. + child.stdout.destroy() killCodexAppServerProcessTree(child) failPending(new Error('codex app-server emitted an oversized JSONL response')) return diff --git a/src/main/codex/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts index d5e9ab717a6..6c1ec8fa1eb 100644 --- a/src/main/codex/codex-real-home-flag.test.ts +++ b/src/main/codex/codex-real-home-flag.test.ts @@ -1,12 +1,22 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { isCodexSystemDefaultRealHomeEnabled } from './codex-real-home-flag' const ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' +let previousEnvFlag: string | undefined -afterEach(() => { +beforeEach(() => { + previousEnvFlag = process.env[ENV_FLAG] delete process.env[ENV_FLAG] }) +afterEach(() => { + if (previousEnvFlag === undefined) { + delete process.env[ENV_FLAG] + } else { + process.env[ENV_FLAG] = previousEnvFlag + } +}) + describe('isCodexSystemDefaultRealHomeEnabled', () => { it('is OFF by default (undefined settings)', () => { expect(isCodexSystemDefaultRealHomeEnabled(undefined)).toBe(false) diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index 6dfa04f4749..02945104606 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -231,6 +231,24 @@ describe('ensureRealHomeCodexHookState (install)', () => { expect(existsSync(getRealHooksJsonPath())).toBe(false) }) + it('surfaces rollback failures to the retry boundary', () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + grantMock.mockImplementation(() => { + rmSync(getRealHooksJsonPath()) + mkdirSync(getRealHooksJsonPath()) + return { lane: 'fallback', reason: 'unsupported' } + }) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + + expect(warning).toHaveBeenCalledWith( + '[codex-real-home-hooks] ensure failed; staying on managed lane:', + expect.any(Error) + ) + }) + it('does no hook-file or grant work on repeated unsupported launches', () => { grantUnavailable() expect(ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir })).toBe( diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 47eb718d8a9..ae13635cf5f 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -333,19 +333,15 @@ function restoreRealHomeHooksJson( previousRaw: string | null, previousMode?: number ): void { - try { - if (previousRaw === null) { - if (existsSync(hooksJsonPath)) { - unlinkSync(hooksJsonPath) - } - return + if (previousRaw === null) { + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath) } - // Why: rollback is part of the safety boundary. Use the shared atomic - // writer so Windows file-lock retries and failed-temp cleanup are covered. - writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) - } catch (error) { - console.warn('[codex-real-home-hooks] failed to roll back hooks.json:', error) + return } + // Why: rollback is part of the safety boundary. Use the shared atomic + // writer so Windows file-lock retries and failed-temp cleanup are covered. + writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) } export const _internals = { diff --git a/src/main/codex/codex-trust-grant-host.test.ts b/src/main/codex/codex-trust-grant-host.test.ts index 4fb2296abdc..b238b722235 100644 --- a/src/main/codex/codex-trust-grant-host.test.ts +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildWslCodexIdentityArgs } from '../codex-accounts/wsl-codex-command' -const resolveCodexCommandMock = vi.hoisted(() => vi.fn()) +const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn(), + resolveCodexCommandMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock @@ -9,6 +15,8 @@ vi.mock('../codex-cli/command', () => ({ import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' beforeEach(() => { + execFileSyncMock.mockReset() + execFileSyncMock.mockReturnValue('/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n') resolveCodexCommandMock.mockReset() resolveCodexCommandMock.mockReturnValue(process.execPath) }) @@ -28,6 +36,7 @@ describe('resolveCodexTrustGrantHost', () => { // Why: PATH/version-manager scans are synchronous launch-path I/O. Reusing // the resolved command keeps one grant at one scan regardless of consumers. expect(resolveCodexCommandMock).toHaveBeenCalledTimes(1) + expect(execFileSyncMock).not.toHaveBeenCalled() }) it('builds WSL requests without scanning the native PATH', () => { @@ -42,8 +51,18 @@ describe('resolveCodexTrustGrantHost', () => { expectedTrustKeys: ['managed-key'] }) - expect(host.binaryStamp).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(host.binaryStamp).toEqual({ + kind: 'wsl', + distro: 'Ubuntu', + path: '/home/alice/.local/bin/codex', + version: 'codex-cli 1.2.3' + }) expect(request.invocation.command).toBe('wsl.exe') + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + buildWslCodexIdentityArgs('Ubuntu'), + expect.objectContaining({ encoding: 'utf-8', timeout: 5_000, windowsHide: true }) + ) expect(resolveCodexCommandMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index 63cd81eb425..e79b2becb1c 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -1,6 +1,11 @@ +import { execFileSync } from 'node:child_process' import { resolveCodexCommand } from '../codex-cli/command' import { getSpawnArgsForWindows } from '../win32-utils' -import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' +import { + buildWslCodexAppServerArgs, + buildWslCodexIdentityArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from '../codex-accounts/wsl-codex-command' import type { CodexHookTrustGrantRequest } from './codex-app-server-client' import { binaryStampsMatch, @@ -33,7 +38,7 @@ export type ResolvedCodexTrustGrantHost = { export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedCodexTrustGrantHost { if (host.kind === 'wsl') { return { - binaryStamp: { kind: 'wsl', distro: host.distro }, + binaryStamp: buildWslCodexBinaryStamp(host.distro), buildRequest: (input) => ({ invocation: { command: 'wsl.exe', @@ -69,6 +74,24 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC } } +function buildWslCodexBinaryStamp(distro: string): CodexTrustGrantBinaryStamp | null { + try { + // Why: WSL PATH resolution happens inside the distro's login shell. The + // resolved path plus CLI version detects upgrades without assuming UNC access. + const output = execFileSync('wsl.exe', buildWslCodexIdentityArgs(distro), { + encoding: 'utf-8', + timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS, + windowsHide: true + }) + const lineBreak = output.indexOf('\n') + const path = lineBreak === -1 ? '' : output.slice(0, lineBreak).trim() + const version = lineBreak === -1 ? '' : output.slice(lineBreak + 1).trim() + return path && version ? { kind: 'wsl', distro, path, version } : null + } catch { + return null + } +} + export function readCodexTrustGrantLedgerHomeMatchingStamp( runtimeHomePath: string, currentStamp: CodexTrustGrantBinaryStamp | null diff --git a/src/main/codex/codex-trust-grant-ledger.test.ts b/src/main/codex/codex-trust-grant-ledger.test.ts index dfb8ecbd4a1..cb4c5450c6d 100644 --- a/src/main/codex/codex-trust-grant-ledger.test.ts +++ b/src/main/codex/codex-trust-grant-ledger.test.ts @@ -37,7 +37,12 @@ describe('codex trust grant ledger', () => { entries: { 'k1:session_start:0:0': { signature: 'sig-1', trustedHash: 'sha256:a' } } }) writeCodexTrustGrantLedgerHome(wslHome, { - binary: { kind: 'wsl', distro: 'Ubuntu' }, + binary: { + kind: 'wsl', + distro: 'Ubuntu', + path: '/home/alice/.local/bin/codex', + version: 'codex-cli 1.2.3' + }, entries: { '/home/alice/hooks.json:stop:0:0': { signature: 'sig-2', trustedHash: 'sha256:b' } } @@ -49,7 +54,9 @@ describe('codex trust grant ledger', () => { }) expect(readCodexTrustGrantLedgerHome(wslHome)?.binary).toEqual({ kind: 'wsl', - distro: 'Ubuntu' + distro: 'Ubuntu', + path: '/home/alice/.local/bin/codex', + version: 'codex-cli 1.2.3' }) removeCodexTrustGrantLedgerHome(hostHome) @@ -76,18 +83,22 @@ describe('codex trust grant ledger', () => { it('matches binary stamps only on identical identity', () => { const stamp = { kind: 'native' as const, path: '/bin/codex', size: 1, mtimeMs: 2 } + const wslStamp = { + kind: 'wsl' as const, + distro: 'Ubuntu', + path: '/home/alice/.local/bin/codex', + version: 'codex-cli 1.2.3' + } expect(binaryStampsMatch(stamp, { ...stamp })).toBe(true) expect(binaryStampsMatch(stamp, { ...stamp, mtimeMs: 3 })).toBe(false) expect(binaryStampsMatch(stamp, { ...stamp, size: 9 })).toBe(false) expect(binaryStampsMatch(stamp, { ...stamp, path: '/other/codex' })).toBe(false) expect(binaryStampsMatch(stamp, null)).toBe(false) expect(binaryStampsMatch(null, null)).toBe(true) - expect( - binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Ubuntu' }) - ).toBe(true) - expect( - binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, { kind: 'wsl', distro: 'Debian' }) - ).toBe(false) - expect(binaryStampsMatch({ kind: 'wsl', distro: 'Ubuntu' }, stamp)).toBe(false) + expect(binaryStampsMatch(wslStamp, { ...wslStamp })).toBe(true) + expect(binaryStampsMatch(wslStamp, { ...wslStamp, distro: 'Debian' })).toBe(false) + expect(binaryStampsMatch(wslStamp, { ...wslStamp, path: '/opt/codex' })).toBe(false) + expect(binaryStampsMatch(wslStamp, { ...wslStamp, version: 'codex-cli 1.2.4' })).toBe(false) + expect(binaryStampsMatch(wslStamp, stamp)).toBe(false) }) }) diff --git a/src/main/codex/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts index 5b7786ffb50..b1d20421bce 100644 --- a/src/main/codex/codex-trust-grant-ledger.ts +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { getOrcaManagedCodexHomePath } from './codex-home-paths' import { normalizeCodexProjectPathForLookup } from './config-toml-trust' @@ -12,11 +12,7 @@ import { normalizeCodexProjectPathForLookup } from './config-toml-trust' export type CodexTrustGrantBinaryStamp = | { kind: 'native'; path: string; size: number; mtimeMs: number } - // Why: there is no cheap way to stat the codex binary inside a WSL distro - // from the host, so WSL grants revalidate only on hook/config drift. A WSL - // codex upgrade that changes the hash algorithm re-grants on verify-fail of - // the next launch's status rather than pre-emptively. - | { kind: 'wsl'; distro: string } + | { kind: 'wsl'; distro: string; path: string; version: string } export type CodexTrustGrantLedgerEntry = { /** getCodexHookTrustSignature() of the granted hook identity. */ @@ -71,6 +67,14 @@ function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { } } +function persistLedgerFile(ledgerPath: string, file: CodexTrustGrantLedgerFile): void { + mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 }) + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) +} + export function readCodexTrustGrantLedgerHome( runtimeHomePath: string, ledgerPath = getCodexTrustGrantLedgerPath() @@ -92,10 +96,7 @@ export function writeCodexTrustGrantLedgerHome( ): void { const file = readLedgerFile(ledgerPath) file.homes[getCodexTrustGrantHomeKey(runtimeHomePath)] = home - writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { - encoding: 'utf-8', - mode: 0o600 - }) + persistLedgerFile(ledgerPath, file) } export function removeCodexTrustGrantLedgerHome( @@ -108,10 +109,7 @@ export function removeCodexTrustGrantLedgerHome( return } delete file.homes[homeKey] - writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { - encoding: 'utf-8', - mode: 0o600 - }) + persistLedgerFile(ledgerPath, file) } export function buildNativeCodexBinaryStamp(binaryPath: string): CodexTrustGrantBinaryStamp | null { @@ -133,7 +131,13 @@ export function binaryStampsMatch( return recorded === null && current === null } if (recorded.kind === 'wsl' || current.kind === 'wsl') { - return recorded.kind === 'wsl' && current.kind === 'wsl' && recorded.distro === current.distro + return ( + recorded.kind === 'wsl' && + current.kind === 'wsl' && + recorded.distro === current.distro && + recorded.path === current.path && + recorded.version === current.version + ) } return ( recorded.path === current.path && diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index cb3a6dc0ee1..583b8fe11f0 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -41,8 +41,11 @@ import { CodexHookService, getCodexManagedHookInstallMaterial } from './hook-ser let tmpHome: string let userDataDir: string let previousUserDataPath: string | undefined +let previousDisableTrustRpc: string | undefined beforeEach(() => { + previousDisableTrustRpc = process.env.ORCA_DISABLE_CODEX_TRUST_RPC + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -63,7 +66,11 @@ afterEach(() => { trustGrantInternals.setGrantSessionRunnerSync(null) trustGrantInternals.resetDiagnostics() codexAppServerCapabilityCache.clear() - delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + if (previousDisableTrustRpc === undefined) { + delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC + } else { + process.env.ORCA_DISABLE_CODEX_TRUST_RPC = previousDisableTrustRpc + } rmSync(tmpHome, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) if (previousUserDataPath === undefined) { From 25ccf9063dd61d779f692916736f0a59ff51ab09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:35:49 -0700 Subject: [PATCH 45/45] test(codex): type child.stdout as PassThrough for oversized-output write --- src/main/codex/codex-app-server-client.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index da5ca16fb8e..7030f7a9979 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -208,7 +208,8 @@ describe('runCodexHookTrustGrantSession', () => { it('stops stdout before killing a server with an oversized response', async () => { const child = new EventEmitter() as ChildProcessWithoutNullStreams child.stdin = new PassThrough() - child.stdout = new PassThrough() + const stdout = new PassThrough() + child.stdout = stdout child.stderr = new PassThrough() const kill = vi.fn(() => { queueMicrotask(() => { @@ -225,8 +226,8 @@ describe('runCodexHookTrustGrantSession', () => { async () => undefined, spawnImpl ) - child.stdout.write('x'.repeat(1024 * 1024 + 1)) - child.stdout.write('more buffered output') + stdout.write('x'.repeat(1024 * 1024 + 1)) + stdout.write('more buffered output') await expect(session).rejects.toThrow('oversized JSONL response') expect(child.stdout.destroyed).toBe(true)