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/config/tsconfig.cli.json b/config/tsconfig.cli.json index a8e5dcad27b..4b10e558fd0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,10 +11,21 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.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-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", "../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", "../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 +33,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/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/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/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/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..9121249ad5e --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -0,0 +1,285 @@ +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 + hardlinkIdentity?: string + } + const accessors = { + isCodex: (candidate: Candidate) => candidate.agent === 'codex', + getFilePath: (candidate: Candidate) => candidate.path, + 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, + 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: `\\\\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 = { + 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', + hardlinkIdentity: '1:42' + } + 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', + 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', + hardlinkIdentity: '7:9' + } + 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 + ]) + }) + + 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 + ]) + }) + + 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', () => { + 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', + 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', + filePath: '/home/ada/.codex/sessions/rollout-shared.jsonl' + }) + expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude]) + }) + + 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', + 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([older, newer]) + }) + + it('resolves same-rollout aliases with a stable path tie-break', () => { + const tieA = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/a/.codex/sessions/2026/07/01/rollout-tie.jsonl', + codexHome: null + }) + const tieB = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/b/.codex/sessions/2026/07/01/rollout-tie.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]) + }) + + 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 new file mode 100644 index 00000000000..0f4a39cf8d2 --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -0,0 +1,171 @@ +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 +// 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 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 +// separators must be handled independently of the local platform. +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 + 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. + * + * 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 + * 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[], + accessors: { + isCodex: (candidate: T) => boolean + getFilePath: (candidate: T) => string + getCodexHome: (candidate: T) => string | null + getHardlinkIdentity: (candidate: T) => string | null + } +): T[] { + const bestByAlias = 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 hardlinkIdentity = accessors.getHardlinkIdentity(candidate) + if (!hardlinkIdentity) { + continue + } + 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)) { + bestByAlias.set(aliasKey, { candidate, rank, filePath }) + } + } + return candidates.filter((candidate) => { + if (!accessors.isCodex(candidate)) { + return true + } + const fileName = lastPathSegment(accessors.getFilePath(candidate)) + const hardlinkIdentity = accessors.getHardlinkIdentity(candidate) + if (!hardlinkIdentity) { + return true + } + const best = bestByAlias.get( + `${codexPathExecutionNamespace(accessors.getFilePath(candidate))}\0${fileName}\0${hardlinkIdentity}` + ) + return !best || best.candidate === candidate + }) +} + +/** + * 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) { + const key = codexSessionAliasKey(session) + if (!key) { + continue + } + const best = bestByKey.get(key) + if (!best || codexSessionAliasBeats(session, best)) { + bestByKey.set(key, session) + } + } + return sessions.filter((session) => { + const key = codexSessionAliasKey(session) + if (!key) { + return true + } + 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${codexPathExecutionNamespace(session.filePath)}\0${session.sessionId}\0${fileName}` +} + +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-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts new file mode 100644 index 00000000000..238ea13aa65 --- /dev/null +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -0,0 +1,52 @@ +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[], + options?: { missingIsExpected?: boolean } +): 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) { + if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { + issues.push({ executionHostId, agent, path, message: errorMessage(error) }) + } + return null + } +} + +export function isMissingRemoteSessionPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(error)) +} + +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-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 1791baa82cd..14a47f36559 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -1,8 +1,8 @@ import { extname } from 'node:path' -import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -27,13 +27,13 @@ export async function discoverRemoteSourceCandidates(args: { : null const paths = partition ? partition.sessionFilePaths : walked const files = await mapDiscoveryConcurrently(paths, (path) => - statRemoteFile( + statRemoteSessionFile( args.context.provider, path, args.source.agent, args.context.executionHostId, args.issues, - Boolean(args.source.fixedChildFileSegments) + { missingIsExpected: Boolean(args.source.fixedChildFileSegments) } ) ) return files @@ -104,26 +104,6 @@ async function walkRemoteSessionFiles( return files } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[], - missingIsExpected: boolean -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - if (!missingIsExpected || !isMissingRemotePathError(err)) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - } - return null - } -} - function recordRemoteDirectoryIssue( source: RemoteSessionSource, executionHostId: ExecutionHostId, @@ -131,30 +111,11 @@ function recordRemoteDirectoryIssue( path: string, err: unknown ): void { - if (!isMissingRemotePathError(err)) { + if (!isMissingRemoteSessionPathError(err)) { issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) } } -function isMissingRemotePathError(err: unknown): boolean { - const code = - err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' - ? err.code - : null - if (code === 'ENOENT' || code === 'ENOTDIR') { - return true - } - // Relay/provider boundaries can preserve only the underlying Node error text. - return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) -} - -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 -} - async function mapDiscoveryConcurrently( items: readonly T[], mapper: (item: T) => Promise diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c33a0ae7701..f68ba32d6c1 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -185,6 +185,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 4da4bd3f4a6..77806f38be6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -21,6 +21,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 // Depth 0 denotes a direct child of rootDir. diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 4f9cbd73bb0..4ba3bd4d647 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -180,6 +180,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 c8e07929b61..0ad894214e1 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -7,12 +7,17 @@ import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { sessionSortTime } from './session-scanner-accumulator' -import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' -import { errorMessage } from './session-scanner-values' +import { + codexRolloutHardlinkIdentity, + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' +import { sessionSortTime } from './session-scanner-accumulator' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' +import { errorMessage } from './session-scanner-values' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -42,21 +47,30 @@ export async function scanRemoteAiVaultSessions(args: { } }) } - 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, + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) + } ) - .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({ @@ -66,9 +80,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() } @@ -97,6 +115,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 } 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..81c73232f4f --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts @@ -0,0 +1,264 @@ +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' +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'` + }) + }) + + 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 576438a8c49..d93d6f01fea 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 efc6660ab1a..79eaa7c2ec3 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 antigravityBrainDir?: string @@ -45,8 +48,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 ddb9ba7a861..fa9f8c74a2b 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -7,6 +7,11 @@ 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 { + codexRolloutHardlinkIdentity, + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { createAntigravityWorkspaceResolver, type AntigravityWorkspaceResolver @@ -63,24 +68,35 @@ export async function scanAiVaultSessions( const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) 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, - antigravityHistoryPath: - discovery.agent === 'antigravity' - ? antigravityHistoryPathForBrainDir(discovery.rootDir) - : undefined - }) + 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, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined + }) + ) ) - ) - .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, + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) + } + ) const parsedSessions = await parseSessionCandidates({ candidates, @@ -92,7 +108,7 @@ export async function scanAiVaultSessions( antigravityWorkspaceResolver }) - const cappedSessions = parsedSessions + const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) @@ -222,6 +238,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-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 501229444dc..c9f8d156ed9 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -982,15 +982,123 @@ 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) }) + 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.getHostCodexHomePathsForSessionDiscovery()).toEqual([ + getRuntimeCodexHomePath(), + getSystemCodexHomePath() + ]) + 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', + '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() + 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 () => { + 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..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 @@ -60,6 +61,10 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' 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 = { email: string | null @@ -91,6 +96,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 @@ -138,7 +157,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) @@ -147,6 +169,14 @@ export class CodexRuntimeHomeService { this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } + 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-> + // managed session bridge runs, so the real home stays the single source. + return null + } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -159,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 @@ -188,8 +231,54 @@ export class CodexRuntimeHomeService { }) } - getHostRuntimeHomePath(): string { - return this.getRuntimeHomePath() + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + 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()) + } + return homes.filter((home, index) => homes.indexOf(home) === index) + } + + // 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 + // 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(launchEnv?: NodeJS.ProcessEnv): boolean { + const settings = this.store.getSettings() + 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(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() } syncActiveWslSelectionsBeforeRestart(): void { @@ -255,6 +344,13 @@ export class CodexRuntimeHomeService { const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } + if (this.isHostSystemDefaultRealHome()) { + // 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() syncSystemConfigIntoManagedCodexHome() 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 02527c8ed23..8b78fdf5570 100644 --- a/src/main/codex-accounts/wsl-codex-command.ts +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -12,6 +12,32 @@ 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(), + '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.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-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.test.ts b/src/main/codex/codex-app-server-client.test.ts new file mode 100644 index 00000000000..7030f7a9979 --- /dev/null +++ b/src/main/codex/codex-app-server-client.test.ts @@ -0,0 +1,541 @@ +import { EventEmitter } from 'node:events' +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' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + runCodexHookTrustGrantSession, + type CodexHookTrustGrantRequest +} from './codex-app-server-client' +import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session' +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 +// 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) { + 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: [{ + 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(() => { + vi.restoreAllMocks() + 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; 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, + args: [stubPath], + env: { + STUB_CONFIG: JSON.stringify({ + scenario: options.scenario, + hooks: options.hooks, + cwd: root, + recordFile, + pidFile + }) + }, + 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('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('stops stdout before killing a server with an oversized response', async () => { + const child = new EventEmitter() as ChildProcessWithoutNullStreams + child.stdin = new PassThrough() + const stdout = new PassThrough() + child.stdout = stdout + 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 + ) + 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) + 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') + 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) + // 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 () => { + 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('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' + 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({ + 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, pidFile } = 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) + const childPid = Number(readFileSync(pidFile, 'utf8')) + 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: { + 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\)/ + ) + }) + + 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-client.ts b/src/main/codex/codex-app-server-client.ts new file mode 100644 index 00000000000..646f763851d --- /dev/null +++ b/src/main/codex/codex-app-server-client.ts @@ -0,0 +1,180 @@ +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 +// (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 { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexAppServerUnsupportedError, + type CodexAppServerInvocation +} from './codex-app-server-session' + +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 } + +type CodexHookListing = { + key: string + command: string | null + currentHash: string + trustStatus: string +} + +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. + */ +export async function runCodexHookTrustGrantSession( + request: CodexHookTrustGrantRequest, + spawnImpl: typeof spawn = spawn +): Promise { + 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) + const managedKeyCoverage = normalizedKeyCoverage(managedListings) + if ( + managedListings.length !== expectedKeys.size || + !setContainsEvery(managedKeyCoverage, expectedKeys) + ) { + return { + outcome: 'verify-failed', + reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} 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 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 || + !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} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries` + } + } + return { + outcome: 'granted', + wroteTrust: needingTrust.length > 0, + entries: verifiedListings.map((listing) => ({ + key: listing.key, + normalizedKey: normalizeHookTrustKeyForLookup(listing.key), + trustedHash: listing.currentHash + })) + } + }, + 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-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts new file mode 100644 index 00000000000..88f8d6ef80a --- /dev/null +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -0,0 +1,122 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + 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 +// 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. + +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 + +export function resolveCodexGrantEntryPath( + 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 + // 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 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)) { + return candidate + } + } + return null +} + +export type RunGrantSessionSyncOptions = { + entryPath?: string + nodeCommand?: string + /** Test-only override; production keeps enough margin for child cleanup. */ + timeoutMarginMs?: number +} + +/** + * 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 + (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 + } + 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..6fe201b30c3 --- /dev/null +++ b/src/main/codex/codex-app-server-grant-entry.ts @@ -0,0 +1,74 @@ +// 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-envelope' +import { writeSync } from 'node:fs' +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(() => { + // 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', + 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.exitCode = 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.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-app-server-session.ts b/src/main/codex/codex-app-server-session.ts new file mode 100644 index 00000000000..f9699ddbfaf --- /dev/null +++ b/src/main/codex/codex-app-server-session.ts @@ -0,0 +1,312 @@ +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 +// 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 + +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 ?? '') +} + +// 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) { + // 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 + } + 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() + } + + let rejectDeadline: (error: Error) => void = () => {} + const deadlinePromise = new Promise((_resolve, reject) => { + rejectDeadline = reject + }) + const deadline = setTimeout(() => { + timedOut = true + const error = new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + killCodexAppServerProcessTree(child) + failPending(error) + rejectDeadline(error) + }, 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 { + 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 && + !(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. + await waitForProcessExitUntil(exitPromise, 1500) + if (!exited) { + killCodexAppServerProcessTree(child) + 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-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-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts new file mode 100644 index 00000000000..5f184355b58 --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 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, + setCodexTrustGrantTelemetry, + 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(() => { + vi.useRealTimers() + _internals.setGrantSessionRunnerSync(null) + setCodexTrustGrantTelemetry(() => {}) + 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((): CodexHookTrustGrantSessionResult => { + 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('backs off transient failures without poisoning the capability', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const entries = [managedEntry('session_start')] + 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: '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) + }) + + 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('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) + 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')] + 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-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts new file mode 100644 index 00000000000..b66c63b65cb --- /dev/null +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -0,0 +1,328 @@ +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 { + 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' +import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' +import { + readCodexTrustGrantLedgerHomeMatchingStamp, + resolveCodexTrustGrantHost, + type CodexTrustGrantHost +} from './codex-trust-grant-host' + +// 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' + +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' + | 'retry-cached' + | '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 +} +const transientRetryAfterByHost = new Map() + +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 +} + +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 + +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 ?? '' + ) + emitTelemetry({ + 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 findLedgerGrant( + plan: CodexManagedTrustGrantPlan, + expected: ExpectedManagedEntry[], + currentStamp: CodexTrustGrantBinaryStamp | null +): CodexTrustEntry[] | null { + const home = readCodexTrustGrantLedgerHomeMatchingStamp(plan.runtimeHomePath, currentStamp) + if (!home) { + 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 +} + +/** + * 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 resolvedHost = resolveCodexTrustGrantHost(plan.host) + const currentStamp = resolvedHost.binaryStamp + 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 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( + resolvedHost.buildRequest({ + runtimeHomePath: plan.runtimeHomePath, + managedCommand: plan.managedCommand, + expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey) + }) + ) + } 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) + } + + 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) { + 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}`) + } + 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 (seenNormalizedKeys.size !== 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, + 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)` + ) + emitTelemetry({ 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 + transientRetryAfterByHost.clear() + } +} 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-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/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts new file mode 100644 index 00000000000..6c1ec8fa1eb --- /dev/null +++ b/src/main/codex/codex-real-home-flag.test.ts @@ -0,0 +1,63 @@ +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 + +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) + 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/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..02945104606 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -0,0 +1,421 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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' +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>(), + 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', () => ({ + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000, + 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 getRealConfigTomlPath(): string { + return join(fakeHomeDir, '.codex', 'config.toml') +} + +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('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` + 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('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( + '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') + + 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) + }) + + 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)', () => { + 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) + }) + + 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 new file mode 100644 index 00000000000..ae13635cf5f --- /dev/null +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -0,0 +1,352 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + unlinkSync +} from 'node:fs' +import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + MANAGED_HOOK_TIMEOUT_SECONDS, + readHooksJson, + removeManagedCommands, + writeHooksJson, + writeManagedScript, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' +import { getCodexManagedScriptFileName } from './codex-hook-identity' +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 { getSystemCodexHomePath } from './codex-home-paths' +import 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' +let installRetryAfterMs = 0 + +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') +} + +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') +} + +/** + * 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 { + // 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 +} + +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 + // 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' + } + + // 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 reconciled = reconcileManagedHookDefinition(current, isManagedCommand, material.command) + nextHooks[eventName] = reconciled.definitions + managedEntries.push({ + sourcePath: hooksJsonPath, + eventLabel: material.eventLabel[eventName], + groupIndex: reconciled.groupIndex, + handlerIndex: reconciled.handlerIndex, + 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 + 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(hooksWritePath, { ...config, hooks: nextHooks } as HooksConfig, { + preserveMode: true + }) + + 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(hooksWritePath, previousRaw, previousMode) + installRetryAfterMs = getInstallRetryAfterMs(grant.reason) + console.warn( + `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` + ) + 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 + : Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS +} + +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 } + let removedAny = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if ( + cleaned.length !== definitions.length || + cleaned.some((definition, index) => definition !== definitions[index]) + ) { + removedAny = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedAny) { + 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 + // groups must never make Orca delete a user's trust record at the same key. + try { + 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) + } + } + return 'removed' +} + +/** One-time pristine copy of the user's file, kept under Orca's userData. */ +function backupRealHomeHooksJsonOnce(userDataPath: string, previousRaw: string | null): void { + if (previousRaw === null) { + return + } + 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, + previousMode?: number +): void { + if (previousRaw === null) { + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath) + } + 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 = { + setLaneForTesting(lane: RealHomeCodexHookLane): void { + currentLane = lane + installRetryAfterMs = 0 + } +} 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 new file mode 100644 index 00000000000..6f6db65710a --- /dev/null +++ b/src/main/codex/codex-real-home-path.ts @@ -0,0 +1,24 @@ +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() + 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( + normalizedCodexHome && + normalizedCodexHome !== normalizedOrcaCodexHome && + normalizedCodexHome !== normalizePathForComparison(getSystemCodexHomePath()) + ) +} + +function normalizePathForComparison(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} 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..c4740b95572 --- /dev/null +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -0,0 +1,75 @@ +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' + +export type CodexSessionBackfillAuditWriter = (record: Record) => Promise + +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 => { + // 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, + // 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 + } 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 + } + } +} + +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-copy.ts b/src/main/codex/codex-session-backfill-copy.ts new file mode 100644 index 00000000000..8c033abf97d --- /dev/null +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto' +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 +): 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 + } + if (!isHardlinkUnsupportedError(installLinkError)) { + throw installLinkError + } + // 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 { + 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' +} + +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-marker.ts b/src/main/codex/codex-session-backfill-marker.ts new file mode 100644 index 00000000000..d3d5a07e5f7 --- /dev/null +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -0,0 +1,60 @@ +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 = 3 + +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` + ) +} + +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-types.ts b/src/main/codex/codex-session-backfill-types.ts new file mode 100644 index 00000000000..d3f25b37bbf --- /dev/null +++ b/src/main/codex/codex-session-backfill-types.ts @@ -0,0 +1,27 @@ +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 + failedHealAuditRecords: 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 new file mode 100644 index 00000000000..bd5e0bca680 --- /dev/null +++ b/src/main/codex/codex-session-backfill.test.ts @@ -0,0 +1,630 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + 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' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +const { fsMockState } = vi.hoisted(() => ({ + fsMockState: { + failLink: false, + failInstallLink: false, + failInstallLinkTransiently: false, + raceTargetIntoExistence: false, + failCopy: false, + failAuditMkdirOnce: false, + failAuditWrites: false, + failDirectoryPath: null as string | null, + failLstatPath: null as string | null + } +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + 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, + 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) + }, + 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 + error.code = 'EACCES' + throw error + } + return actual.lstat(...args) + }, + 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' + 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 + } + 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.link(...args) + }, + 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. + 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.copyFile(...args) + }, + 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 { + ...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) + .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 + 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 + 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('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') + 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(['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') + 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 }) + 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 () => { + 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('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( + 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('fails closed when the target filesystem cannot install without overwrite', async () => { + fsMockState.failLink = true + fsMockState.failInstallLink = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedUnsupportedFilesystemFiles: 1, + failedFiles: 0 + }) + const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') + expect(existsSync(targetPath)).toBe(false) + expect(readdirSync(dirname(targetPath))).toEqual([]) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) + }) + + it('keeps transient install failures retryable', async () => { + fsMockState.failLink = true + fsMockState.failInstallLinkTransiently = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ + skippedUnsupportedFilesystemFiles: 0, + failedFiles: 1 + }) + expect(readAuditActions()).toEqual(['failed', '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 }) + 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']) + }) + + 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('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('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: 3 }) + + // 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('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 + + 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 + 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 + 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) + 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) + }) + + 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') + + 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) + }) + + 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 new file mode 100644 index 00000000000..e0294a9a72a --- /dev/null +++ b/src/main/codex/codex-session-backfill.ts @@ -0,0 +1,346 @@ +import { link, lstat, mkdir } from 'node:fs/promises' +import { dirname, join, relative, sep } from 'node:path' +import { + getCodexSessionBackfillStateDirPath, + getOrcaManagedCodexHomePath, + getSystemCodexHomePath +} from './codex-home-paths' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter, + recordExistingCodexSessionForHeal, + type CodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import { + copySessionFileWithoutOverwrite, + 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, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' + +export type { + CodexSessionBackfillOptions, + CodexSessionBackfillPaths, + CodexSessionBackfillSummary +} from './codex-session-backfill-types' + +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: CodexSessionBackfillOptions = {}, + 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: CodexSessionBackfillOptions, + systemCodexHomePathOverride?: string +): Promise { + const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) + if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + return null + } + const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) + // Why: file or heal-queue failures leave the marker unset so the next + // startup retries; skip-existing keeps those retries cheap. + if ( + !summary.stopped && + options.shouldStop?.() !== true && + summary.failedFiles === 0 && + summary.failedDirectories === 0 && + summary.failedHealAuditRecords === 0 + ) { + writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, 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: CodexSessionBackfillOptions = {} +): Promise { + const summary: CodexSessionBackfillSummary = { + stopped: false, + scannedFiles: 0, + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, + skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, + failedDirectories: 0, + failedFiles: 0, + failedHealAuditRecords: 0 + } + 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, + 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 + await appendAuditRecord({ + action: 'scan-failed', + source: directoryPath, + error: describeError(error) + }) + } + )) { + 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 + continue + } + // 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 + ) + } + } + 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 +} + +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) { + 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) + ) +} + +async function backfillOneManagedSessionFile( + paths: CodexSessionBackfillPaths, + managedSessionFilePath: string, + 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 + return + } + const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) + const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) + if (await pathEntryExists(systemSessionFilePath)) { + await recordExistingCodexSessionForHeal( + appendAuditRecord, + summary, + managedSessionFilePath, + systemSessionFilePath + ) + return + } + + try { + 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 + await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { + action: 'hardlink', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (linkError) { + if (isExistsError(linkError)) { + // 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)) { + ensuredTargetDirectories.delete(dirname(systemSessionFilePath)) + } + try { + // Why: cross-volume copies are staged so failures cannot strand a + // truncated rollout, then installed without overwriting collisions. + await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) + summary.copiedFiles += 1 + await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { + action: 'copy', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + } catch (copyError) { + if (isExistsError(copyError)) { + await recordExistingCodexSessionForHeal( + appendAuditRecord, + summary, + managedSessionFilePath, + systemSessionFilePath + ) + return + } + if (isAtomicNoReplaceUnsupportedError(copyError)) { + summary.skippedUnsupportedFilesystemFiles += 1 + await appendAuditRecord({ + action: 'copy-unsupported', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + return + } + summary.failedFiles += 1 + await appendAuditRecord({ + action: 'failed', + source: managedSessionFilePath, + target: systemSessionFilePath, + error: describeError(copyError), + linkError: describeError(linkError) + }) + } + } +} + +async function isSymbolicLink(filePath: string): Promise { + try { + return (await lstat(filePath)).isSymbolicLink() + } catch { + return false + } +} + +/** 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' +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index c6eeb9b1f77..6532f6ee5c9 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 | Promise ): 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) { + await onDirectoryError?.(currentDirectory, error) console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } } 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..4efc124316e --- /dev/null +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -0,0 +1,272 @@ +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 +// (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 = 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 + +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 + /** Publication event identity; null only for audit records written before event ids. */ + auditRecordId: string | null +} + +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 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') { + continue + } + if (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 + } + const threadId = match[2].toLowerCase() + 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], auditRecordId }) + } + 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 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 ( + line.v === CODEX_SESSION_INDEX_HEAL_VERSION && + typeof line.threadId === 'string' && + typeof line.systemSessionsRoot === 'string' && + (line.outcome === 'healed' || line.outcome === 'missing') && + normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot + ) { + const threadId = line.threadId.toLowerCase() + if (line.outcome === 'healed') { + healedThreadIds.add(threadId) + } else if (typeof line.auditRecordId === 'string') { + missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) + } else { + legacyMissingThreadIds.add(threadId) + } + } + } + return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } +} + +export function appendHealLedgerRecord( + paths: CodexSessionIndexHealPaths, + threadId: string, + outcome: HealLedgerOutcome, + auditRecordId?: string | null +): boolean { + try { + mkdirSync(dirname(paths.healLedgerPath), { recursive: true }) + appendFileSync( + paths.healLedgerPath, + // Why: a killed process can leave a torn tail. Start on a fresh line so + // the durable outcome cannot be swallowed by that corrupt fragment. + `\n${JSON.stringify({ + v: CODEX_SESSION_INDEX_HEAL_VERSION, + systemSessionsRoot: paths.systemSessionsRoot, + threadId, + outcome, + ...(auditRecordId ? { auditRecordId } : {}), + at: new Date().toISOString() + })}\n` + ) + return true + } catch (error) { + // 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 + } +} + +function readJsonlLines(filePath: string, throwOnReadFailure = false): Record[] { + let contents: string + try { + contents = readFileSync(filePath, 'utf-8') + } 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[] = [] + 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 (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 +): 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 + retryableFailureAt?: 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 + } + if (typeof marker.retryableFailureAt === 'number') { + // Why: malformed or still-growing rollouts must be retried eventually, + // but a permanently bad file must not spawn an app-server every launch. + return ( + marker.auditBytes === auditBytes && + Date.now() - marker.retryableFailureAt < HEAL_FAILED_THREAD_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, + retry?: { unsupportedAt?: number; retryableFailureAt?: 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, + ...(retry?.unsupportedAt === undefined ? {} : { unsupportedAt: retry.unsupportedAt }), + ...(retry?.retryableFailureAt === undefined + ? {} + : { retryableFailureAt: retry.retryableFailureAt }), + 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..861c7f72b4e --- /dev/null +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -0,0 +1,751 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + appendFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { CodexAppServerInvocation } from './codex-app-server-session' +import { createCodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' +import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' +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.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) + } + 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[] + busyThreadIds?: 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 [index, audited] of (options.auditedThreads ?? []).entries()) { + 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), + recordId: `audit-record-${index}` + })}\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 ?? [], + busyThreadIds: options.busyThreadIds ?? [], + 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)) { + 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 +} + +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'), action: 'existing' } + ] + }) + + 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('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') }, + { 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') + + 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('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({ + 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 () => { + 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('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) => ({ + 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('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', + 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) + + 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 () => { + 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('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, { + 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) + }) + + 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]) + }) + + 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) + }) + + 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]) + }) + + 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 new file mode 100644 index 00000000000..49e575f4f12 --- /dev/null +++ b/src/main/codex/codex-session-index-heal.ts @@ -0,0 +1,276 @@ +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 HealLedgerOutcome, + 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 = 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) + + 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)) + 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 + 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: readConcurrency }, () => worker())) + } + ) + } 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, { unsupportedAt: 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, + summary.failedThreads > 0 ? { retryableFailureAt: Date.now() } : undefined + ) + 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 + recordHealOutcome(paths, thread, '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 + recordHealOutcome(paths, thread, '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 + recordHealOutcome(paths, thread, 'failed') + } +} + +function recordHealOutcome( + paths: CodexSessionIndexHealPaths, + thread: PendingHealThread, + outcome: HealLedgerOutcome +): void { + if (!appendHealLedgerRecord(paths, thread.threadId, outcome, thread.auditRecordId)) { + throw new Error(`Failed to persist Codex session index-heal outcome for ${thread.threadId}`) + } +} + +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( + 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/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts new file mode 100644 index 00000000000..136b5a22466 --- /dev/null +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +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' + +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('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') + 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) + } + }) + + 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', + () => { + 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 new file mode 100644 index 00000000000..0642b834483 --- /dev/null +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -0,0 +1,112 @@ +import { + chmodSync, + closeSync, + fstatSync, + 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; restorePath?: string } + | { existed: true; contents: Buffer; mode: number; restorePath: string } + +function resolveConfigRestorePath(tomlPath: string): string { + try { + 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 + } + throw error + } +} + +export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { + const restorePath = resolveConfigRestorePath(tomlPath) + let descriptor: number + try { + descriptor = openSync(restorePath, 'r') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return restorePath === tomlPath ? { existed: false } : { existed: false, restorePath } + } + throw error + } + 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, + restorePath + } + } finally { + closeSync(descriptor) + } +} + +export function restoreCodexTrustConfig( + tomlPath: string, + snapshot: CodexTrustConfigSnapshot +): void { + if (!snapshot.existed) { + try { + unlinkSync(snapshot.restorePath ?? tomlPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + return + } + const { restorePath } = snapshot + try { + 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(restorePath, snapshot.mode) + 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. + // 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, restorePath) + } catch (error) { + try { + unlinkSync(tempPath) + } catch { + // Best effort; preserve the rollback failure as the actionable error. + } + throw error + } +} 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..b238b722235 --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildWslCodexIdentityArgs } from '../codex-accounts/wsl-codex-command' + +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 +})) + +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) +}) + +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) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + 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', + 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 new file mode 100644 index 00000000000..e79b2becb1c --- /dev/null +++ b/src/main/codex/codex-trust-grant-host.ts @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process' +import { resolveCodexCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import { + buildWslCodexAppServerArgs, + buildWslCodexIdentityArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} 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: buildWslCodexBinaryStamp(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 + } + } + } +} + +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 +): CodexTrustGrantLedgerHome | null { + const home = readCodexTrustGrantLedgerHome(runtimeHomePath) + return home && binaryStampsMatch(home.binary, currentStamp) ? home : null +} + +export function readCurrentCodexTrustGrantLedgerHome( + runtimeHomePath: string, + host: CodexTrustGrantHost +): CodexTrustGrantLedgerHome | null { + try { + 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. + return null + } +} 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..cb4c5450c6d --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.test.ts @@ -0,0 +1,104 @@ +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', + 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' } + } + }) + + 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', + path: '/home/alice/.local/bin/codex', + version: 'codex-cli 1.2.3' + }) + + 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 } + 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(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 new file mode 100644 index 00000000000..b1d20421bce --- /dev/null +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -0,0 +1,147 @@ +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' + +// 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 } + | { kind: 'wsl'; distro: string; path: string; version: 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 + } +} + +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() +): 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 + persistLedgerFile(ledgerPath, file) +} + +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] + persistLedgerFile(ledgerPath, file) +} + +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 && + recorded.path === current.path && + recorded.version === current.version + ) + } + return ( + recorded.path === current.path && + recorded.size === current.size && + recorded.mtimeMs === current.mtimeMs + ) +} 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 6edc4aed660..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 = { @@ -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 = @@ -183,8 +188,10 @@ 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..583b8fe11f0 --- /dev/null +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -0,0 +1,321 @@ +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 { 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' +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' +import { getCodexHookTrustSignature } from './codex-hook-identity' + +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) + +import { CodexHookService, getCodexManagedHookInstallMaterial } from './hook-service' + +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 + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + resolveCodexCommandMock.mockReturnValue(process.execPath) + 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() + 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) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + 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'}` + 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) + } + }) + writeCodexLikeTrust(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) + // 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) + }) + + 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() + 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 logical entries', () => { + 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') + // 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', + 'pre_tool_use', + 'permission_request', + 'post_tool_use', + 'stop' + ]) { + 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', () => { + 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) + + 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', () => { + 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 d6b06f85189..28c230ff160 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,10 @@ import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { computeTrustKey, computeTrustedHash, + normalizeHookTrustKeyForLookup, + parseTrustKey, readHookTrustEntries, + upsertHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' import { @@ -16,6 +19,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 @@ -48,7 +54,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 +91,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 +112,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' }) }) @@ -197,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') @@ -206,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') @@ -215,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') }) @@ -408,3 +439,163 @@ 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 + trustGrantInternals.resetDiagnostics() + 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) + }) + }) + + 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') + 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 { + 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)) + ) + staleKeyExpectedRemoved = oldKey + + 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.ts b/src/main/codex/hook-service.ts index b09a94f5ede..fd72b9b3bcd 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -66,6 +66,15 @@ import { promoteCodexRuntimeHookApprovalsToSystem, snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' +import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' +import { + 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. @@ -136,6 +145,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 } @@ -187,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 } @@ -532,15 +576,35 @@ 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 + } const legacyConfigPath = getSystemConfigPath() const runtimeConfigPath = getConfigPath() if (legacyConfigPath === runtimeConfigPath) { return } + const systemHomePath = getSystemCodexHomePath() + const hasRecordedRealHomeGrant = + readCodexTrustGrantLedgerHomeForReconciliation(systemHomePath) !== null const config = readHooksJson(legacyConfigPath) if (!config?.hooks) { + if (hasRecordedRealHomeGrant) { + removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) + } return } @@ -579,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 { @@ -632,52 +703,14 @@ function cleanupLegacyManagedHookRepresentations(): void { function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { - const tomlPath = getCodexConfigTomlPath() - const existingEntries = readHookTrustEntries(tomlPath) - const scriptPath = getManagedScriptPath() - const command = getManagedCommand(scriptPath) - 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 }) - ]) - if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { - continue - } - ourKeys.push(key) - } - if (ourKeys.length > 0) { - removeHookTrustEntries(tomlPath, ourKeys) - } + 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. @@ -687,43 +720,14 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { try { - const existingEntries = readHookTrustEntries(plan.tomlPath) - const command = wrapReadablePosixHookCommand(plan.commandScriptPath) - 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 }) - ]) - if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { - ourKeys.push(key) - } - } - if (ourKeys.length > 0) { - removeHookTrustEntries(plan.tomlPath, ourKeys) - } + 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. @@ -733,48 +737,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 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 }) - ]) - 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 { @@ -923,10 +898,31 @@ 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 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) + // 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, + managedCommand: command, + managedEntries: trustEntries, + host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome } + }) + 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) + } } catch (error) { return { agent: 'codex', @@ -996,12 +992,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' || @@ -1038,6 +1041,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 @@ -1055,7 +1062,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 @@ -1080,6 +1088,7 @@ export class CodexHookService { return } installedTrustConfigPath = resolvedPlan.trustConfigPath + installSucceeded = status.state === 'installed' } const wslPlan = createCodexWslRuntimeHookInstallPlan( runtimeHomePath, @@ -1088,7 +1097,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( @@ -1101,6 +1112,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) @@ -1131,6 +1148,24 @@ 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. + // 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[] = [] @@ -1175,9 +1210,21 @@ 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 grantedHash = getCodexLedgerTrustedHash(ledgerHome, trustKey, trustInput) + 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) } else if (actualState?.enabled === false) { disabled.push(eventName) @@ -1277,7 +1324,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 +1340,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 +1349,8 @@ export class CodexHookService { timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS }) } + const trustEntries: CodexTrustEntry[] = [...mirroredTrustEntries, ...managedTrustEntries] + let recentGrantEntries: readonly CodexTrustEntry[] = [] config.hooks = nextHooks writeManagedScript(scriptPath, getManagedScript()) @@ -1309,13 +1361,35 @@ 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') { + recentGrantEntries = grant.entries + 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 { @@ -1333,7 +1407,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( diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b4e5fc8e26c..8ab662b8a7f 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) @@ -1911,6 +1916,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 8e1f7024891..5f7c03d9758 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -123,6 +123,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. */ @@ -585,9 +604,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 } @@ -761,9 +778,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 ababeb6e24e..bcb1dc1de65 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,7 +135,15 @@ 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 { 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' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -749,8 +757,29 @@ function startTerminalRuntimeStartupServices(): Promise { return firstWindowStartupServicesReady } -function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { - const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) +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 + // 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, 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 + } const hookTarget = target?.runtime === 'wsl' ? { @@ -1048,7 +1077,7 @@ function openMainWindow(): BrowserWindow { keybindings, { getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], + codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], onBeforeRelaunch: async () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() @@ -1802,6 +1831,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 @@ -1823,7 +1862,56 @@ 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) + // 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(() => { + // 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()) + 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( + { 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()) { + return + } + return startCodexSessionIndexHealInBackground( + { + shouldStop: shouldStopSessionMigration + }, + systemCodexHomePathOverride + ) + }) + }, 15_000) claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver((target) => @@ -1915,7 +2003,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() : {} }) @@ -2052,7 +2140,14 @@ app.whenReady().then(async () => { removeManagedAgentHooks() } } - + if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { + // 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 4f08581282b..44405a17d15 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -752,7 +752,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 @@ -1380,6 +1383,62 @@ 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('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', @@ -1732,6 +1791,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(['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 () => { + 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', { @@ -2332,7 +2424,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' }) ) @@ -2369,7 +2466,8 @@ describe('registerPtyHandlers', () => { undefined, (() => ({ httpProxyUrl: 'http://proxy.example:8080', - httpProxyBypassRules: 'localhost' + httpProxyBypassRules: 'localhost', + codexSystemDefaultRealHomeEnabled: true })) as never, undefined, store as never @@ -2408,6 +2506,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() @@ -5606,9 +5708,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(), @@ -5654,7 +5758,10 @@ describe('registerPtyHandlers', () => { mainWindow as never, runtime as never, undefined, - undefined, + (() => ({ + agentStatusHooksEnabled: false, + codexSystemDefaultRealHomeEnabled: true + })) as never, undefined, store as never ) @@ -5676,11 +5783,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 d04a84a506e..9cff971f2fa 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -144,6 +144,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' @@ -559,6 +560,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 @@ -630,8 +635,52 @@ 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 -type GetSelectedCodexHomePath = (target?: CodexAccountSelectionTarget) => string | null + +// 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 { + for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + delete baseEnv[key] + } +} + +// 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'] + if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { + keysToDelete.push('CODEX_HOME') + } + return keysToDelete +} + +type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv +) => string | null type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -1003,6 +1052,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. @@ -1585,13 +1636,20 @@ 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, { 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, @@ -3072,13 +3130,21 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, env) ?? null ) : null const skipCodexHomeEnv = isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && !selectedCodexHomePath + 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') @@ -3088,6 +3154,7 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, + stripInheritedOrcaCodexHome, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, @@ -3120,6 +3187,12 @@ export function registerPtyHandlers( spawnOptions.envToDelete, CODEX_HOME_ENV_KEYS ) + } else if (stripInheritedOrcaCodexHome) { + // 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) @@ -3961,13 +4034,21 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget) ?? null + getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv) ?? null ) : null const skipCodexHomeEnv = isDaemonHostSpawn && shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && !selectedCodexHomePath + 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 @@ -3992,6 +4073,7 @@ export function registerPtyHandlers( userDataPath: app.getPath('userData'), selectedCodexHomePath, skipCodexHomeEnv, + stripInheritedOrcaCodexHome, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, @@ -4030,12 +4112,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: 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/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 15a59321409..30645294b47 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10086,10 +10086,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', @@ -10102,6 +10105,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 e64143647ed..6bc22d1a35d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1043,6 +1043,7 @@ type TerminalCreateOptions = { claudeAgentTeamsSourceCommand?: string cwd?: string env?: Record + envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent @@ -1065,6 +1066,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 @@ -18465,7 +18474,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, @@ -18663,6 +18675,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18708,6 +18721,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18744,6 +18758,7 @@ export class OrcaRuntimeService { command: startupCommand.command, cwd, env: startupCommand.env, + envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, @@ -18793,6 +18808,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 } : {}), @@ -18857,6 +18873,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, @@ -18902,6 +18919,7 @@ export class OrcaRuntimeService { opts: { command?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig @@ -18910,6 +18928,7 @@ export class OrcaRuntimeService { ): Promise<{ command?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent @@ -18918,6 +18937,7 @@ export class OrcaRuntimeService { return { command: opts.command, env: opts.env, + envToDelete: opts.envToDelete, launchConfig: opts.launchConfig, launchAgent: opts.launchAgent, startupCommandDelivery: opts.startupCommandDelivery @@ -18981,6 +19001,7 @@ export class OrcaRuntimeService { command?: string cwd?: string env?: Record + envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent @@ -19000,6 +19021,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 6d5879d9cf8..ef636bc5443 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -923,6 +923,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(), @@ -1405,6 +1406,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/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' 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/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 21325c3e036..a0710b89fee 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -69,7 +69,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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a8793c5f8b1..baf67fb6de6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1253,6 +1253,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 d1a6dfb5c92..48c94008d47 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -832,6 +832,7 @@ const api = { cwd?: string cwdFallback?: 'worktree' env?: Record + envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 59ff443fd24..eed1782df95 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -20,7 +20,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { 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/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.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 18e99dac5a7..34a336e87a9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17488,6 +17488,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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 859a71bba1a..387b8a40f04 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3262,6 +3262,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 838cf50720e..ba33415d31c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1813,6 +1813,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..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 { @@ -33,6 +34,7 @@ type AiVaultResumeCommandSession = Pick< export type AiVaultResumeStartup = { command: string env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig } @@ -70,7 +72,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 +120,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...realHomeCodexResumeEnvDeletion(args.session), launchConfig: startupPlan.launchConfig } } @@ -135,7 +141,8 @@ 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) } } 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 { 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 a64a9a2c9e2..485b1b21732 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -508,6 +508,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string @@ -694,6 +695,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string @@ -711,6 +713,7 @@ export type TerminalSlice = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index c7ac1423e10..8ac4c7612fc 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -40,6 +40,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({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index dd780ef9ac4..eba2e8fef70 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -277,6 +277,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] } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0573c89ec93..5a3cdc098d5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -341,6 +341,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/runtime-types.ts b/src/shared/runtime-types.ts index 774114b6d75..11e9286d947 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 diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index ee34b53ec3c..608989630df 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -442,6 +442,28 @@ 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', + 'retry-cached', + 'error' + ]) + .optional() + }) + .strict() + const settingsChangedSchema = z .object({ setting_key: settingsChangedKeySchema, @@ -1428,6 +1450,8 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, + codex_trust_grant: codexTrustGrantSchema, + settings_changed: settingsChangedSchema, native_chat_toggled: nativeChatToggledSchema, diff --git a/src/shared/types.ts b/src/shared/types.ts index 91b7785f026..06a5bfbf926 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2854,6 +2854,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