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 }