From 1bd30651d65fa33f5feda0e2c65b6b95d39c1368 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:43:36 -0700 Subject: [PATCH] fix(codex): a real-home resume starts at once, trusting Orca's entries for that process A resume that must run in ~/.codex waited for Codex's background approval of Orca's newly written hook entry: up to 30-40 s on a cold app-server. That made the user's resume wait on bookkeeping, and the alternatives (start at 10 s with Codex's hook review showing, or withdraw the entry and race Codex's own write) were worse. Codex reads hook trust from its session-flag config layer as well as the user's config.toml, merged per key, and has since hook trust shipped. So the resume no longer waits. When Orca's own frozen entries in ~/.codex are untrusted (or hold a stale hash), the resume command carries `-c hooks.state={''={trusted_hash=''},...}` for exactly those entries: the key under both the logical and the real path of ~/.codex (Codex keys an explicit CODEX_HOME by its real path), and the hash of that entry's content, so it can trust nothing else at that slot. The user's hooks are never included, nothing is written, and the background approval still runs for later plain `codex` launches. An approved entry adds nothing; a Codex known to lack hook trust gets nothing. One inline table, because Codex splits a `-c` key on every `.` and the key holds `.codex/hooks.json`. TOML literal strings keep `"` out of Windows native-argument quoting. The flag goes before `resume `, quoted for the pane's shell (portable Unix, PowerShell or cmd), in the launch command and in the setup-sequenced copy of it; a cmd line whose path cmd would expand, or a key with an apostrophe, is left unchanged. SSH and WSL resumes get no preparation, so no local path reaches them. --- .../codex/codex-real-home-hook-install.ts | 10 -- .../codex-real-home-session-hook-trust.ts | 98 +++++++++++ .../codex-real-home-slow-app-server.test.ts | 64 ++++--- src/main/codex/codex-session-resume-home.ts | 11 +- .../codex-resume-session-hook-trust.test.ts | 131 +++++++++++++++ src/main/ipc/pty/host-env/codex-resume.ts | 102 ++++++++++-- src/main/ipc/pty/ipc/spawn-env-codex.ts | 5 +- src/main/ipc/pty/ipc/spawn-types.ts | 2 +- src/main/ipc/pty/register-handlers.ts | 6 +- src/main/ipc/pty/runtime/controller-deps.ts | 2 +- .../spawn-preflight-requested-shell.test.ts | 5 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 5 +- .../pty-spawn-commit-dependencies-fixture.ts | 2 +- ...odex-launch-during-real-home-grant.test.ts | 157 ++++++++++-------- ...odex-launch-per-agent-hook-opt-out.test.ts | 16 +- .../startup/codex-session-resume-launch.ts | 17 +- src/shared/agent-resume-argv-drop.ts | 44 +++-- 17 files changed, 517 insertions(+), 160 deletions(-) create mode 100644 src/main/codex/codex-real-home-session-hook-trust.ts create mode 100644 src/main/ipc/pty/host-env/codex-resume-session-hook-trust.test.ts diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 2f3de75f265..d968fe7cf86 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -116,16 +116,6 @@ export function ensureRealHomeCodexHookState(args: { return ensureInFlight } -/** - * For a resume that must run in the real home, with no managed home to fall - * back to: waits until a background grant settles, which its deadline bounds. - * Settled means Codex approved the entry or the grant withdrew it. - */ -export async function awaitRealHomeCodexHookTrust(): Promise { - await backgroundGrant - return currentLane -} - async function runRealHomeCodexHookEnsure(args: { hooksEnabled: boolean userDataPath: string diff --git a/src/main/codex/codex-real-home-session-hook-trust.ts b/src/main/codex/codex-real-home-session-hook-trust.ts new file mode 100644 index 00000000000..039fb13411c --- /dev/null +++ b/src/main/codex/codex-real-home-session-hook-trust.ts @@ -0,0 +1,98 @@ +import { readHooksJsonWithRaw } from '../agent-hooks/installer-utils' +import { + codexAppServerCapabilityCache, + getCodexAppServerHostKey +} from './codex-app-server-capability-cache' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { readOrcaEntryTrust } from './codex-real-home-entry-trust' +import { getRealHomeConfigTomlPath, getRealHomeHooksJsonPath } from './codex-real-home-hooks-json' +import { + computeTrustedHash, + computeTrustKey, + getCodexExplicitHomeHookSourcePath, + readHookTrustEntries +} from './config-toml-trust' + +/** One `[hooks.state.""]` trust record, held by a single Codex process only. */ +export type CodexSessionHookTrust = { key: string; trustedHash: string } + +/** + * Orca's own entries in the real ~/.codex that Codex would list for review, each + * with the hash of exactly that entry's content. A resume passes them as `-c` + * overrides, so it need not wait for the background approval; nothing is written. + */ +export function readRealHomeCodexSessionHookTrust(): CodexSessionHookTrust[] { + try { + // Why: a Codex known to lack hook trust has nothing to approve. + if (!codexAppServerCapabilityCache.shouldTry(getCodexAppServerHostKey({ kind: 'native' }))) { + return [] + } + const hooksJsonPath = getRealHomeHooksJsonPath() + const hooks = readHooksJsonWithRaw(hooksJsonPath).config?.hooks + if (!hooks) { + return [] + } + const command = getCodexManagedHookInstallMaterial().command + const trustStates = readHookTrustEntries(getRealHomeConfigTomlPath()) + // Why both: Codex keys a default home by its logical path, an explicit CODEX_HOME by its real path. + const sourcePaths = new Set([hooksJsonPath, getCodexExplicitHomeHookSourcePath(hooksJsonPath)]) + const trust = new Map() + for (const [eventName, definitions] of Object.entries(hooks)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => + definition.hooks?.forEach((hook, handlerIndex) => { + const entry = + hook.command === command + ? createCodexHookTrustEntry( + hooksJsonPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + : null + const state = entry ? readOrcaEntryTrust(entry, trustStates) : null + if (!entry || (state !== 'untrusted' && state !== 'stale')) { + return + } + const trustedHash = computeTrustedHash(entry) + for (const sourcePath of sourcePaths) { + trust.set(computeTrustKey({ ...entry, sourcePath }), trustedHash) + } + }) + ) + } + return [...trust].map(([key, trustedHash]) => ({ key, trustedHash })) + } catch (error) { + // Why: without overrides the resume still runs; Codex only lists the entry for review. + console.warn('[codex-real-home-hooks] could not read Orca entry trust for a resume:', error) + return [] + } +} + +// Why literal strings: a TOML literal string needs no escapes, so no `"` reaches a +// Windows shell's native-argument quoting; a key it cannot hold gets no override. +function fitsTomlLiteralString(value: string): boolean { + return [...value].every((char) => char !== "'" && char >= ' ' && char !== '\u007f') +} + +/** + * The `-c` value for these records: one inline table, since Codex splits a `-c` + * key on every `.` and a hooks.json path contains one. Null when one cannot be spelled. + */ +export function formatCodexSessionHookTrustOverride( + trust: readonly CodexSessionHookTrust[] +): string | null { + if ( + trust.length === 0 || + !trust.every(({ key, trustedHash }) => fitsTomlLiteralString(key + trustedHash)) + ) { + return null + } + const records = trust.map(({ key, trustedHash }) => `'${key}'={trusted_hash='${trustedHash}'}`) + return `hooks.state={${records.join(',')}}` +} diff --git a/src/main/codex/codex-real-home-slow-app-server.test.ts b/src/main/codex/codex-real-home-slow-app-server.test.ts index c6f7319c128..175542f7b05 100644 --- a/src/main/codex/codex-real-home-slow-app-server.test.ts +++ b/src/main/codex/codex-real-home-slow-app-server.test.ts @@ -4,6 +4,7 @@ import type * as Os from 'node:os' import { dirname, join } from 'node:path' import type { HookDefinition } from '../agent-hooks/installer-utils' import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import type { CodexTrustEntry } from './config-toml-trust' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS, @@ -12,6 +13,7 @@ import { import { computeTrustedHash, computeTrustKey, + getCodexExplicitHomeHookSourcePath, normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, @@ -34,7 +36,6 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexComman import { _internals as realHomeInternals, - awaitRealHomeCodexHookTrust, ensureRealHomeCodexHookState, isRealHomeCodexHookLaneUsable } from './codex-real-home-hook-install' @@ -43,6 +44,7 @@ import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' import { createCodexHookTrustEntry } from './codex-hook-identity' import { getOrcaManagedCodexHomePath } from './codex-home-paths' import { readOrcaEntryTrust } from './codex-real-home-entry-trust' +import { readRealHomeCodexSessionHookTrust } from './codex-real-home-session-hook-trust' // Why this file (QA case 4): a cold `codex app-server` on a loaded Mac took over // 10 s. A launch must never wait on that approval, the approval must still land, @@ -73,28 +75,31 @@ function orcaHandlerCount(): number { .filter((hook) => isCodexManagedCommand(hook.command)).length } -function orcaEntryTrust(): string[] { - const trust = readHookTrustEntries(configPath()) +function orcaEntries(): CodexTrustEntry[] { return Object.entries(readHooks()).flatMap(([eventName, definitions]) => definitions.flatMap((definition, groupIndex) => (definition.hooks ?? []).flatMap((hook, handlerIndex) => { - if (!isCodexManagedCommand(hook.command)) { - return [] - } - const entry = createCodexHookTrustEntry( - hooksPath(), - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted'] + const entry = isCodexManagedCommand(hook.command) + ? createCodexHookTrustEntry( + hooksPath(), + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + : null + return entry ? [entry] : [] }) ) ) } +function orcaEntryTrust(): string[] { + const trust = readHookTrustEntries(configPath()) + return orcaEntries().map((entry) => readOrcaEntryTrust(entry, trust)) +} + type AppServer = { sessions: number; start: () => void } /** @@ -182,20 +187,31 @@ describe('a slow codex app-server start', () => { expect(server.sessions).toBe(1) }) - it('lets a resume into the real home wait until the grant settles', async () => { + it("lets a resume trust exactly Orca's entries, with the hashes the grant then writes", async () => { const server = installAppServer(15_000) expect(await launch()).toBe('granting') - let settled = false - const resumed = awaitRealHomeCodexHookTrust().then((lane) => { - settled = true - return lane - }) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(settled).toBe(false) + const trust = readRealHomeCodexSessionHookTrust() + const explicitHooksPath = getCodexExplicitHomeHookSourcePath(hooksPath()) + const expectedKeys = orcaEntries().flatMap((entry) => [ + computeTrustKey(entry), + computeTrustKey({ ...entry, sourcePath: explicitHooksPath }) + ]) + expect(trust.map(({ key }) => key).sort()).toEqual([...new Set(expectedKeys)].sort()) + // Why: the user's own Stop hook is untrusted too, and must stay for the user to review. + expect(trust.some(({ key }) => key.endsWith(':stop:0:0'))).toBe(false) server.start() - expect(await resumed).toBe('installed') + expect(await realHomeInternals.settledLaneForTesting()).toBe('installed') + const stored = readHookTrustEntries(configPath()) + for (const entry of orcaEntries()) { + const key = computeTrustKey(entry) + expect(trust.find((record) => record.key === key)?.trustedHash).toBe( + stored.get(key)?.trustedHash + ) + } + // Why: once approved, a resume passes nothing extra. + expect(readRealHomeCodexSessionHookTrust()).toEqual([]) }) it('starts no cooldown after a timeout: the next launch tries again at once', async () => { diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index 86186333347..78c73efdab7 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -7,6 +7,7 @@ import { } from '../../shared/cross-platform-path' import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import type { CodexSessionHookTrust } from './codex-real-home-session-hook-trust' // Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. const CLAIMED_CODEX_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?` @@ -20,9 +21,15 @@ const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${CLAIMED_CODEX_RO * unverifiable rollout must never resume under whichever account is selected now. * `reconcileSharedRuntimeAuth` revalidates mutable shared-home auth before spawn. * `claimedCodexProvenance` gates the user-facing notice: a path that claimed real - * Codex layout is worth reporting, stale cross-agent metadata is not. */ + * Codex layout is worth reporting, stale cross-agent metadata is not. + * `sessionHookTrust` trusts Orca's own not-yet-approved entries for this one process. */ export type CodexSessionResumePreparation = - | { outcome: 'resume'; codexHomePath: string; reconcileSharedRuntimeAuth?: boolean } + | { + outcome: 'resume' + codexHomePath: string + reconcileSharedRuntimeAuth?: boolean + sessionHookTrust?: readonly CodexSessionHookTrust[] + } | { outcome: 'fresh'; claimedCodexProvenance: boolean } // Why: fold only Win32's extended drive spelling; \\.\ device namespaces and every other \\?\ form diff --git a/src/main/ipc/pty/host-env/codex-resume-session-hook-trust.test.ts b/src/main/ipc/pty/host-env/codex-resume-session-hook-trust.test.ts new file mode 100644 index 00000000000..4a453746199 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-resume-session-hook-trust.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildAgentResumeLaunchCommand } from '../../../../shared/agent-resume-launch-command' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' +import type { CodexSessionHookTrust } from '../../../codex/codex-real-home-session-hook-trust' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } })) + +const { formatCodexSessionHookTrustOverride } = + await import('../../../codex/codex-real-home-session-hook-trust') +const { + resolveCodexResumeLaunch, + resolveCodexResumeStartupShell, + rewriteSequencedStartupResumeArgv +} = await import('./codex-resume') + +// Why this file: a resume into the real ~/.codex carries Orca's not-yet-approved +// hook trust as a `-c` flag typed into the pane's shell; each shell must hand +// Codex the exact bytes. + +const SESSION: AgentProviderSessionMetadata = { key: 'session_id', id: '019abc' } +const POSIX_TRUST: CodexSessionHookTrust[] = [ + { key: '/Users/me/.codex/hooks.json:stop:1:0', trustedHash: 'sha256:ab12' } +] +const WINDOWS_TRUST: CodexSessionHookTrust[] = [ + { key: 'C:\\Users\\me\\.codex\\hooks.json:stop:1:0', trustedHash: 'sha256:ab12' } +] + +function resumeCommand(shell: AgentStartupShell): string { + return buildAgentResumeLaunchCommand('codex', 'codex', ['codex', 'resume', SESSION.id], shell) +} + +function launchWith( + shell: AgentStartupShell, + prepared: CodexSessionResumePreparation | null, + command = resumeCommand(shell) +): ReturnType { + return resolveCodexResumeLaunch(command, { + providerSession: SESSION, + preparation: Promise.resolve(prepared), + startupShell: shell + }) +} + +function resumeHome(sessionHookTrust?: CodexSessionHookTrust[]): CodexSessionResumePreparation { + return { outcome: 'resume', codexHomePath: '/Users/me/.codex', sessionHookTrust } +} + +describe('the -c value that trusts Orca entries for one Codex process', () => { + it('is one inline table of TOML literal strings, since Codex splits a -c key on every dot', () => { + expect( + formatCodexSessionHookTrustOverride([ + ...POSIX_TRUST, + { key: '/Users/me/.codex/hooks.json:session_start:2:0', trustedHash: 'sha256:cd34' } + ]) + ).toBe( + "hooks.state={'/Users/me/.codex/hooks.json:stop:1:0'={trusted_hash='sha256:ab12'}," + + "'/Users/me/.codex/hooks.json:session_start:2:0'={trusted_hash='sha256:cd34'}}" + ) + }) + + it('is withheld when a key cannot be a TOML literal string', () => { + expect( + formatCodexSessionHookTrustOverride([ + { key: "/Users/o'brien/.codex/hooks.json:stop:1:0", trustedHash: 'sha256:ab12' } + ]) + ).toBeNull() + expect(formatCodexSessionHookTrustOverride([])).toBeNull() + }) +}) + +describe('a resume command carrying session hook trust', () => { + it('quotes it for every Unix shell, before the resume argv', async () => { + const launch = await launchWith('posix', resumeHome(POSIX_TRUST)) + expect(launch.command).toBe( + "codex '-c' 'hooks.state={'\"'\"'/Users/me/.codex/hooks.json:stop:1:0'\"'\"'" + + "={trusted_hash='\"'\"'sha256:ab12'\"'\"'}}' 'resume' '019abc'" + ) + }) + + it('quotes it for PowerShell without a double quote in the argument', async () => { + const launch = await launchWith('powershell', resumeHome(WINDOWS_TRUST)) + expect(launch.command).toBe( + "codex '-c' 'hooks.state={''C:\\Users\\me\\.codex\\hooks.json:stop:1:0''" + + "={trusted_hash=''sha256:ab12''}}' 'resume' '019abc'" + ) + }) + + it('quotes it for cmd', async () => { + const launch = await launchWith('cmd', resumeHome(WINDOWS_TRUST)) + expect(launch.command).toBe( + 'codex "-c" "hooks.state={\'C:\\Users\\me\\.codex\\hooks.json:stop:1:0\'' + + '={trusted_hash=\'sha256:ab12\'}}" "resume" "019abc"' + ) + }) + + it('leaves a cmd line unchanged when the path holds a character cmd would expand', async () => { + const trust = [{ ...WINDOWS_TRUST[0]!, key: 'C:\\Users\\me%x%\\.codex\\hooks.json:stop:1:0' }] + const launch = await launchWith('cmd', resumeHome(trust)) + expect(launch.command).toBe(resumeCommand('cmd')) + expect(launch.sessionHookTrustArgs).toBeNull() + }) + + it('leaves the command unchanged with no trust to pass, or no resume argv to find', async () => { + expect((await launchWith('posix', resumeHome())).command).toBe(resumeCommand('posix')) + expect((await launchWith('posix', resumeHome(POSIX_TRUST), 'my-codex')).command).toBe( + 'my-codex' + ) + }) + + it('reaches the sequenced startup command too, which the pane runs instead', async () => { + const launch = await launchWith('posix', resumeHome(POSIX_TRUST)) + const env = rewriteSequencedStartupResumeArgv( + { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: resumeCommand('posix') }, + launch + ) + expect(env[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(launch.command) + }) +}) + +describe("the pane shell's command dialect", () => { + it('follows the Windows shell, and is portable Unix quoting everywhere else', () => { + expect(resolveCodexResumeStartupShell('win32', undefined)).toBe('powershell') + expect(resolveCodexResumeStartupShell('win32', 'C:\\Windows\\system32\\cmd.exe')).toBe('cmd') + expect(resolveCodexResumeStartupShell('win32', 'git-bash')).toBe('posix') + expect(resolveCodexResumeStartupShell('darwin', '/opt/homebrew/bin/fish')).toBe('posix') + expect(resolveCodexResumeStartupShell('linux', undefined)).toBe('posix') + }) +}) diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 62e199e09c3..a06f2f8390a 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -4,10 +4,19 @@ import { type AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' -import { dropAgentResumeArgvFromCommand } from '../../../../shared/agent-resume-argv-drop' +import { + dropAgentResumeArgvFromCommand, + findAgentResumeArgvSuffix +} from '../../../../shared/agent-resume-argv-drop' +import { quoteStartupArg, type AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' +import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import { dropUnverifiedCodexResumeArgv } from '../../../codex/codex-unverified-resume-launch' import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import { + formatCodexSessionHookTrustOverride, + type CodexSessionHookTrust +} from '../../../codex/codex-real-home-session-hook-trust' import { CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE, codexHomePathsEqual } from './codex-home' import type { PrepareCodexSessionResume } from './types' @@ -17,11 +26,14 @@ export type CodexResumeLaunch = { notifyResumeUnavailable: boolean droppedResumeArgv: boolean providerSession: AgentProviderSessionMetadata | null + /** The quoted `-c` override spliced in front of the resume argv, if any. */ + sessionHookTrustArgs: string | null } export type PreparedCodexResumeHome = { providerSession: AgentProviderSessionMetadata preparation: Promise + startupShell: AgentStartupShell } export type PrepareCodexResumeHomeArgs = { @@ -31,6 +43,16 @@ export type PrepareCodexResumeHomeArgs = { target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv workspacePath?: string + /** The pane's resolved shell; on Windows it decides how the command line is quoted. */ + shellOverride?: string +} + +/** The dialect the pane's shell parses the launch command in. */ +export function resolveCodexResumeStartupShell( + platform: NodeJS.Platform, + shellOverride: string | undefined +): AgentStartupShell { + return platform === 'win32' ? resolveWindowsShellStartupFamily(shellOverride) : 'posix' } export function prepareCodexResumeHome( @@ -51,7 +73,8 @@ export function prepareCodexResumeHome( target: args.target, launchEnv: args.launchEnv, workspacePath: args.workspacePath - }) + }), + startupShell: resolveCodexResumeStartupShell(process.platform, args.shellOverride) } } @@ -63,10 +86,35 @@ export function noCodexResumeLaunch(command: string | undefined): CodexResumeLau command, notifyResumeUnavailable: false, droppedResumeArgv: false, - providerSession: null + providerSession: null, + sessionHookTrustArgs: null } } +// Why: cmd expands `%` and `!` even inside double quotes, and a caret there is literal. +const CMD_UNQUOTABLE = /[\^&|<>()%!"]/ + +function quoteSessionHookTrustArgs( + trust: readonly CodexSessionHookTrust[] | undefined, + shell: AgentStartupShell +): string | null { + const override = trust ? formatCodexSessionHookTrustOverride(trust) : null + if (!override || (shell === 'cmd' && CMD_UNQUOTABLE.test(override))) { + return null + } + return `${quoteStartupArg('-c', shell)} ${quoteStartupArg(override, shell)}` +} + +/** Puts the override before `resume `, where Codex reads it as a root flag. */ +function insertBeforeCodexResumeArgv( + command: string, + providerSession: AgentProviderSessionMetadata, + args: string +): string | null { + const found = findAgentResumeArgvSuffix({ command, agent: 'codex', providerSession }) + return found.status === 'found' ? `${found.base} ${args} ${found.suffix}` : null +} + /** The command a Codex launch actually runs: unchanged when provenance is verified, * stripped of `resume ` when it is not. */ export function resolveCodexResumeLaunch( @@ -76,12 +124,21 @@ export function resolveCodexResumeLaunch( return preparation.preparation.then((prepared) => { const providerSession = preparation.providerSession if (prepared?.outcome !== 'fresh') { + const trustArgs = quoteSessionHookTrustArgs( + prepared?.sessionHookTrust, + preparation.startupShell + ) + const trustedCommand = + trustArgs && command + ? insertBeforeCodexResumeArgv(command, providerSession, trustArgs) + : null return { codexResumeHome: prepared ?? null, - command, + command: trustedCommand ?? command, notifyResumeUnavailable: false, droppedResumeArgv: false, - providerSession + providerSession, + sessionHookTrustArgs: trustedCommand ? trustArgs : null } } const dropped = dropUnverifiedCodexResumeArgv({ @@ -98,7 +155,8 @@ export function resolveCodexResumeLaunch( dropped.droppedResumeArgv && (prepared.claimedCodexProvenance || !providerSession.transcriptPath), droppedResumeArgv: dropped.droppedResumeArgv, - providerSession + providerSession, + sessionHookTrustArgs: null } }) } @@ -118,21 +176,31 @@ export async function reconcileSharedRuntimeResumeHome( } /** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command - * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ -export function stripSequencedStartupResumeArgv | undefined>( + * and the sequenced wrapper `eval`s it, so a resume argv rewrite has to go there too. */ +export function rewriteSequencedStartupResumeArgv | undefined>( env: T, launch: CodexResumeLaunch ): T { const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] - if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { + if (!env || !sequenced || !launch.providerSession) { return env } - const drop = dropAgentResumeArgvFromCommand({ - command: sequenced, - agent: 'codex', - providerSession: launch.providerSession - }) - return drop.status === 'dropped' - ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } - : env + let rewritten: string | null = null + if (launch.droppedResumeArgv) { + const drop = dropAgentResumeArgvFromCommand({ + command: sequenced, + agent: 'codex', + providerSession: launch.providerSession + }) + rewritten = drop.status === 'dropped' ? drop.command : null + } else if (launch.sessionHookTrustArgs) { + rewritten = insertBeforeCodexResumeArgv( + sequenced, + launch.providerSession, + launch.sessionHookTrustArgs + ) + } + return rewritten === null + ? env + : { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: rewritten } } diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index cee47100b5f..c8e49639ad8 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -40,7 +40,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, launchEnv: ctx.baseEnv, - workspacePath: ctx.cwd + workspacePath: ctx.cwd, + shellOverride: ctx.effectiveShellOverride }) ctx.codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -51,7 +52,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.spawnTiming.mark('codex_resume') const codexResumeHome = ctx.codexResumeLaunch.codexResumeHome ctx.launchCommand = ctx.codexResumeLaunch.command - ctx.baseEnv = ctx.deps.stripSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch) + ctx.baseEnv = ctx.deps.rewriteSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch) // Why: declared after the strip so a local-provider spawn cannot capture the // pre-strip env — only the daemon branch below re-derives this from baseEnv. ctx.env = ctx.baseEnv diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index e14b469d1fb..c377ee914db 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -115,7 +115,7 @@ export type PtySpawnIpcDeps = { resumeHome: Extract, resolveCurrent: () => string | null | Promise ) => Promise - stripSequencedStartupResumeArgv: | undefined>( + rewriteSequencedStartupResumeArgv: | undefined>( env: T, launch: CodexResumeLaunch ) => T diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 5c7c897474a..fb56c7d6130 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -60,7 +60,7 @@ import { prepareCodexResumeHome, reconcileSharedRuntimeResumeHome, resolveCodexResumeLaunch, - stripSequencedStartupResumeArgv + rewriteSequencedStartupResumeArgv } from './host-env/codex-resume' import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' @@ -220,7 +220,7 @@ export function registerPtyHandlers( resolveCodexResumeLaunch, noCodexResumeLaunch, reconcileSharedRuntimeResumeHome, - stripSequencedStartupResumeArgv, + rewriteSequencedStartupResumeArgv, assertFolderWorkspacePtyPathUsable, resolvePtySpawnStartupCwd, requestSerializedBuffer: session.requestSerializedBuffer, @@ -267,7 +267,7 @@ export function registerPtyHandlers( noCodexResumeLaunch, resolveCodexResumeLaunch, reconcileSharedRuntimeResumeHome, - stripSequencedStartupResumeArgv, + rewriteSequencedStartupResumeArgv, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index e1bfd5d7f10..3b9333ad67f 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -42,7 +42,7 @@ export type PtyRuntimeControllerDeps = { resumeHome: Extract, resolveCurrent: () => string | null | Promise ) => Promise - stripSequencedStartupResumeArgv: | undefined>( + rewriteSequencedStartupResumeArgv: | undefined>( env: T, launch: CodexResumeLaunch ) => T diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index a299327fc3f..f1954730ac3 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -16,7 +16,8 @@ function makeDeps(): PtyRuntimeControllerDeps { command, notifyResumeUnavailable: false, droppedResumeArgv: false, - providerSession: null + providerSession: null, + sessionHookTrustArgs: null }) return { store: undefined, @@ -31,7 +32,7 @@ function makeDeps(): PtyRuntimeControllerDeps { resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command), noCodexResumeLaunch, reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath, - stripSequencedStartupResumeArgv: (env) => env, + rewriteSequencedStartupResumeArgv: (env) => env, assertFolderWorkspacePtyPathUsable: () => undefined, resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd, requestSerializedBuffer: async () => null, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 452f6355086..195372ec701 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -137,7 +137,8 @@ export async function prepareRuntimePtySpawn( providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, launchEnv: args.env, - workspacePath: ctx.cwd + workspacePath: ctx.cwd, + shellOverride: ctx.terminalRuntimeOptions.shellOverride }) const codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -182,7 +183,7 @@ export async function prepareRuntimePtySpawn( ctx.requestedAgentTeamsPath = ctx.env?.ORCA_AGENT_TEAMS_TEAM_ID ? ctx.env[resolvePathEnvKey(ctx.env, process.platform)] : undefined - ctx.env = ctx.deps.stripSequencedStartupResumeArgv(ctx.env, codexResumeLaunch) + ctx.env = ctx.deps.rewriteSequencedStartupResumeArgv(ctx.env, codexResumeLaunch) if (args.preAllocatedHandle) { ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index 2777fdfde4b..1c79406b698 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -25,7 +25,7 @@ export function createPtySpawnCommitDependencies( noCodexResumeLaunch: unexpectedPreflight, resolveCodexResumeLaunch: unexpectedPreflight, reconcileSharedRuntimeResumeHome: unexpectedPreflight, - stripSequencedStartupResumeArgv: unexpectedPreflight, + rewriteSequencedStartupResumeArgv: unexpectedPreflight, transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, trustedTerminalHandleEnv: new Set(), syncPtyBackgroundedDelivery: unexpectedPreflight, diff --git a/src/main/startup/codex-launch-during-real-home-grant.test.ts b/src/main/startup/codex-launch-during-real-home-grant.test.ts index c7b974b912b..651e14ae510 100644 --- a/src/main/startup/codex-launch-during-real-home-grant.test.ts +++ b/src/main/startup/codex-launch-during-real-home-grant.test.ts @@ -1,14 +1,16 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import type * as Os from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CodexHookTrustGrantRequest } from '../codex/codex-app-server-client' +import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import type { CodexTrustEntry } from '../codex/config-toml-trust' import { isCodexManagedCommand, setupCodexHookHomes } from '../codex/hook-service-test-harness' // Why this file (QA case 4, full launch path): Codex's approval of the real-home // entry runs in the background. A launch during it must settle on the managed // home at once, with that home's hook install and the project trust write done. -// A resume has no other home, so it must not spawn beside an unapproved entry. +// A resume has no other home, so it spawns at once and trusts Orca's entries for itself. const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -73,14 +75,17 @@ vi.mock('./main-process-state', async () => { } }) -const { CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS, _internals: grantInternals } = - await import('../codex/codex-hook-trust-grant') +const { _internals: grantInternals } = await import('../codex/codex-hook-trust-grant') +const { codexAppServerCapabilityCache, getCodexAppServerHostKey } = + await import('../codex/codex-app-server-capability-cache') const { _internals: realHomeInternals } = await import('../codex/codex-real-home-hook-install') const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') const { prepareCodexRuntimeHomeForLaunch } = await import('./codex-launch-preparation') const { prepareCodexSessionResumeForLaunch } = await import('./codex-session-resume-launch') const { computeTrustedHash, + computeTrustKey, + getCodexExplicitHomeHookSourcePath, normalizeHookTrustKeyForLookup, parseTrustKey, readHookTrustEntries, @@ -105,36 +110,44 @@ type HookDefinition = { hooks?: { command?: string }[] } -/** How Codex will treat each Orca entry in the real ~/.codex/hooks.json. */ -function realHomeOrcaEntryTrust(): string[] { +/** Orca's entries in the real ~/.codex/hooks.json. */ +function realHomeOrcaEntries(): CodexTrustEntry[] { const hooksPath = join(homes.tmpHome, '.codex', 'hooks.json') const hooks: Record = JSON.parse(readFileSync(hooksPath, 'utf-8')).hooks - const trust = readHookTrustEntries(join(homes.tmpHome, '.codex', 'config.toml')) return Object.entries(hooks).flatMap(([eventName, definitions]) => definitions.flatMap((definition, groupIndex) => (definition.hooks ?? []).flatMap((hook, handlerIndex) => { - if (!isCodexManagedCommand(hook.command)) { - return [] - } - const entry = createCodexHookTrustEntry( - hooksPath, - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted'] + const entry = isCodexManagedCommand(hook.command) + ? createCodexHookTrustEntry( + hooksPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + : null + return entry ? [entry] : [] }) ) ) } -function resume(): Promise { - return prepareCodexSessionResumeForLaunch({ +/** How Codex will treat each Orca entry in the real ~/.codex/hooks.json. */ +function realHomeOrcaEntryTrust(): string[] { + const trust = readHookTrustEntries(join(homes.tmpHome, '.codex', 'config.toml')) + return realHomeOrcaEntries().map((entry) => readOrcaEntryTrust(entry, trust)) +} + +async function resume(): Promise> { + const prepared = await prepareCodexSessionResumeForLaunch({ providerSession: { key: 'session_id', id: 'abc' }, target: { runtime: 'host' } }) + if (prepared?.outcome !== 'resume') { + throw new Error('expected the session to resume') + } + return prepared } function launch(workspacePath: string): Promise { @@ -144,12 +157,9 @@ function launch(workspacePath: string): Promise { }) } -afterEach(() => { - vi.useRealTimers() -}) - beforeEach(() => { realHomeInternals.setLaneForTesting('pending') + codexAppServerCapabilityCache.clear() resolveCodexCommandMock.mockReturnValue(process.execPath) mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) writeFileSync(join(homes.tmpHome, '.codex', 'hooks.json'), '{"hooks":{}}\n') @@ -200,54 +210,65 @@ describe('a Codex launch while the real-home approval hangs', () => { }) }) +/** Codex approving every requested entry once `approval` resolves, as the grant session does. */ +function installApprovingRunner(approval: Promise): void { + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + await approval + const entries = request.expectedTrustKeys.map((key) => { + const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } + return { key, entry, trustedHash: computeTrustedHash(entry) } + }) + upsertHookTrustEntries( + join(homes.tmpHome, '.codex', 'config.toml'), + entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) + ) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: entries.map(({ key, trustedHash }) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash + })) + } + }) +} + describe('a Codex resume into the real ~/.codex while its approval runs', () => { - it('spawns once Codex approves the entry, never beside an unapproved one', async () => { - let approve: () => void = () => {} - const approval = new Promise((resolve) => { - approve = resolve - }) - grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { - await approval - const entries = request.expectedTrustKeys.map((key) => { - const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } - return { key, entry, trustedHash: computeTrustedHash(entry) } - }) - upsertHookTrustEntries( - join(homes.tmpHome, '.codex', 'config.toml'), - entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) - ) - return { - outcome: 'granted' as const, - wroteTrust: true, - entries: entries.map(({ key, trustedHash }) => ({ - key, - normalizedKey: normalizeHookTrustKeyForLookup(key), - trustedHash - })) - } - }) + it("spawns at once, trusting exactly Orca's entries for that one process", async () => { + writeFileSync( + join(homes.tmpHome, '.codex', 'hooks.json'), + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user.sh' }] }] } })}\n` + ) + grantInternals.setGrantSessionRunner(() => new Promise(() => {})) const resumed = resume() - expect(await settlesWithin(resumed, 200)).toBe(false) - approve() - await resumed - const trust = realHomeOrcaEntryTrust() - expect(trust.length).toBeGreaterThan(0) - expect(trust.every((state) => state === 'trusted')).toBe(true) + expect(await settlesWithin(resumed, 2_000)).toBe(true) + const trust = (await resumed).sessionHookTrust ?? [] + expect(realHomeOrcaEntryTrust().every((state) => state === 'untrusted')).toBe(true) + const hooksPath = join(homes.tmpHome, '.codex', 'hooks.json') + const orcaKeys = realHomeOrcaEntries().flatMap((entry) => [ + computeTrustKey(entry), + computeTrustKey({ ...entry, sourcePath: getCodexExplicitHomeHookSourcePath(hooksPath) }) + ]) + expect(orcaKeys.length).toBeGreaterThan(0) + expect(trust.map(({ key }) => key).sort()).toEqual([...new Set(orcaKeys)].sort()) + expect(trust.some(({ key }) => key.endsWith(':stop:0:0'))).toBe(false) }) - it('spawns at the approval deadline with the unapproved entries withdrawn', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - grantInternals.setGrantSessionRunner(() => new Promise(() => {})) - let spawned = false - const resumed = resume().then(() => { - spawned = true - }) + it("passes nothing extra once Codex has approved Orca's entries", async () => { + installApprovingRunner(Promise.resolve()) + await resume() + await realHomeInternals.settledLaneForTesting() + expect(realHomeOrcaEntryTrust().every((state) => state === 'trusted')).toBe(true) - await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS - 1) - expect(spawned).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await resumed - expect(realHomeOrcaEntryTrust()).toEqual([]) + expect((await resume()).sessionHookTrust).toBeUndefined() + }) + + it('passes nothing to a Codex known to lack hook trust', async () => { + codexAppServerCapabilityCache.rememberUnsupported(getCodexAppServerHostKey({ kind: 'native' })) + grantInternals.setGrantSessionRunner(() => new Promise(() => {})) + + expect((await resume()).sessionHookTrust).toBeUndefined() }) }) diff --git a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts index 721965918e2..bc1240f89c8 100644 --- a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts +++ b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => { installForLaunchPrep: vi.fn(async () => {}), refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), ensureRealHomeCodexHookState: vi.fn(async () => 'installed' as const), - awaitRealHomeCodexHookTrust: vi.fn(async () => 'installed' as const), + readRealHomeCodexSessionHookTrust: vi.fn(() => [{ key: 'k', trustedHash: 'sha256:h' }]), prepareCodexSessionResume: vi.fn() } }) @@ -35,9 +35,11 @@ vi.mock('../codex/hook-service', () => ({ } })) vi.mock('../codex/codex-real-home-hook-install', () => ({ - awaitRealHomeCodexHookTrust: mocks.awaitRealHomeCodexHookTrust, ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState })) +vi.mock('../codex/codex-real-home-session-hook-trust', () => ({ + readRealHomeCodexSessionHookTrust: mocks.readRealHomeCodexSessionHookTrust +})) // Why: the real predicate, without loading every agent's hook service. vi.mock( '../agent-hooks/managed-agent-hook-controls', @@ -97,7 +99,7 @@ const HOOK_SETTINGS: readonly { } ] -function resumeFrom(homePath: string): Promise { +function resumeFrom(homePath: string): ReturnType { mocks.prepareCodexSessionResume.mockImplementation( async (args: { resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise @@ -160,14 +162,16 @@ describe('Codex launch prep honours the per-agent hook opt-out', () => { async ({ settings, codexHooksOn }) => { mocks.settings = settings - await resumeFrom(SYSTEM_HOME) + const prepared = await resumeFrom(SYSTEM_HOME) expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( expect.objectContaining({ hooksEnabled: codexHooksOn, writePolicy: 'add-missing-only' }) ) - // Why: a resume has no managed home to fall back to, so it waits for the grant to settle. - expect(mocks.awaitRealHomeCodexHookTrust).toHaveBeenCalledOnce() + // Why: with hooks off this Orca's entries are not its business to trust. + expect(prepared?.outcome === 'resume' ? prepared.sessionHookTrust : null).toEqual( + codexHooksOn ? [{ key: 'k', trustedHash: 'sha256:h' }] : undefined + ) expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() } diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index c554e184310..77f7abe4f02 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -6,10 +6,11 @@ import { prepareCodexSessionResume } from '../codex/codex-session-resume-prepara import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' import { - awaitRealHomeCodexHookTrust, - ensureRealHomeCodexHookState -} from '../codex/codex-real-home-hook-install' + readRealHomeCodexSessionHookTrust, + type CodexSessionHookTrust +} from '../codex/codex-real-home-session-hook-trust' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' @@ -39,6 +40,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { // readable alias wins. A throw here refuses the whole resume instead // (#STA-4422). const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + let sessionHookTrust: CodexSessionHookTrust[] = [] // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ @@ -106,9 +108,9 @@ export async function prepareCodexSessionResumeForLaunch(args: { userDataPath: app.getPath('userData'), writePolicy: 'add-missing-only' }) - // Why wait: an unapproved entry would show hook review in this pane, and - // the grant's own settle is the only one that cannot race Codex's write. - await awaitRealHomeCodexHookTrust() + // Why no wait: the session lives here, so instead of waiting on Codex's + // approval this process alone trusts Orca's entries while it runs. + sessionHookTrust = hooksEnabled ? readRealHomeCodexSessionHookTrust() : [] } else if (hooksEnabled) { await codexHookService.installForLaunchPrep(resumeHome) } else { @@ -126,7 +128,8 @@ export async function prepareCodexSessionResumeForLaunch(args: { ...preparation, reconcileSharedRuntimeAuth: normalizeRuntimePathForComparison(preparation.codexHomePath) === - normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) + normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()), + ...(sessionHookTrust.length > 0 ? { sessionHookTrust } : {}) } : preparation } diff --git a/src/shared/agent-resume-argv-drop.ts b/src/shared/agent-resume-argv-drop.ts index df8c16aae4f..d554a95ce33 100644 --- a/src/shared/agent-resume-argv-drop.ts +++ b/src/shared/agent-resume-argv-drop.ts @@ -33,6 +33,34 @@ function stripSuffix(command: string, suffix: string): string | null { return trimmed.length > 0 ? trimmed : null } +/** Where `buildAgentResumeStartupPlan` appended the resume argv: `base` is the + * plain agent launch and `suffix` the argv exactly as it was quoted. */ +export type AgentResumeArgvSuffix = + | { status: 'found'; base: string; suffix: string } + | { status: 'absent' } + | { status: 'unrecognized' } + +export function findAgentResumeArgvSuffix(args: { + command: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata +}): AgentResumeArgvSuffix { + const argv = getAgentResumeArgv(args.agent, args.providerSession) + const resumeArgs = argv?.slice(1) ?? [] + const locator = resumeArgs.at(-1) + const command = args.command.trimEnd() + if (!locator || !command.includes(locator)) { + return { status: 'absent' } + } + for (const suffix of resumeArgvSuffixCandidates(resumeArgs)) { + const base = stripSuffix(command, suffix) + if (base !== null && !base.includes(locator)) { + return { status: 'found', base, suffix } + } + } + return { status: 'unrecognized' } +} + /** * Remove the resume argv `buildAgentResumeStartupPlan` appended, leaving the plain * agent launch. Main uses this when it cannot verify which account owns the session, @@ -46,18 +74,6 @@ export function dropAgentResumeArgvFromCommand(args: { agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata }): AgentResumeArgvDrop { - const argv = getAgentResumeArgv(args.agent, args.providerSession) - const resumeArgs = argv?.slice(1) ?? [] - const locator = resumeArgs.at(-1) - const command = args.command.trimEnd() - if (!locator || !command.includes(locator)) { - return { status: 'absent' } - } - for (const suffix of resumeArgvSuffixCandidates(resumeArgs)) { - const base = stripSuffix(command, suffix) - if (base !== null && !base.includes(locator)) { - return { status: 'dropped', command: base } - } - } - return { status: 'unrecognized' } + const found = findAgentResumeArgvSuffix(args) + return found.status === 'found' ? { status: 'dropped', command: found.base } : found }