diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index cc8619f568..38478ea72d 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -58,7 +58,7 @@ "sideEffects": false, "scripts": { "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check", - "build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json", + "build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", "build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs", "build:provider-pack": "pnpm run build:typescript && node scripts/build-provider-pack.mjs", @@ -73,7 +73,7 @@ "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", - "test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs && vitest run", + "test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs && vitest run", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", "test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 8d4a0ea456..b54ac1ebb3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -119,6 +119,7 @@ impl AcpxSidecarTransport { "RUST_BACKTRACE", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", + "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", ]; keys.extend_from_slice(credential_keys); Self::start_with_environment_keys(config, &keys) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index bd59e77132..d690b3fabb 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -492,7 +492,30 @@ fn process_command( // in the effect window, recovery returns an indeterminate result and never // executes the same logical command twice. store.save(state)?; - let execution = executor.execute(command)?; + let execution = match executor.execute(command) { + Ok(execution) => execution, + Err(error) => { + // An executor-returned error is a terminal observation, not crash + // ambiguity. Commit it before replying so recovery can replay the + // original provider/bootstrap failure without executing the + // command twice. A process death inside execute still leaves the + // pre-effect marker pending and remains indeterminate on recovery. + let message = error.to_string(); + state.record_diagnostic(format!( + "{} command failed: {message}", + command.command_type + )); + let result = state.fail_command( + command, + json!({ + "code": "command_execution_failed", + "message": message, + }), + )?; + store.save(state)?; + return Ok((result, CommandLifecycle::Continue)); + } + }; for (event_type, priority, payload) in execution.events { state.enqueue_event(config, event_type, priority, payload)?; } @@ -566,6 +589,10 @@ mod tests { calls: usize, } + struct FailingExecutor { + calls: usize, + } + struct RetainingEventExecutor { events: VecDeque, fail_acknowledgement: bool, @@ -578,6 +605,15 @@ mod tests { } } + impl CommandExecutor for FailingExecutor { + fn execute(&mut self, _command: &Command) -> Result { + self.calls += 1; + Err(DurableRunnerError::invalid( + "provider bootstrap rejected authorization=Bearer test-secret", + )) + } + } + impl CommandExecutor for RetainingEventExecutor { fn execute(&mut self, _command: &Command) -> Result { Ok(CommandExecution::result(json!({"status": "completed"}))) @@ -758,6 +794,78 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn executor_failure_is_durable_and_does_not_become_indeterminate() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-command-failure-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = FailingExecutor { calls: 0 }; + let command = command("session.open"); + + let failed = process_command(&mut state, &store, &config, &mut executor, &command) + .unwrap() + .0; + let (mut recovered, existed) = store.load_or_create(&config).unwrap(); + let replay = process_command(&mut recovered, &store, &config, &mut executor, &command) + .unwrap() + .0; + + assert!(existed); + assert_eq!(executor.calls, 1); + assert_eq!(failed, replay); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.result["code"], "command_execution_failed"); + assert_eq!( + failed.result["message"], + "provider bootstrap rejected authorization=Bearer [REDACTED]" + ); + assert!(recovered.diagnostics.iter().any(|diagnostic| { + diagnostic + == "session.open command failed: provider bootstrap rejected authorization=Bearer [REDACTED]" + })); + assert!(recovered + .diagnostics + .iter() + .all(|diagnostic| !diagnostic.contains("test-secret"))); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn process_death_after_journaling_remains_indeterminate_without_reexecution() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-command-indeterminate-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let command = command("session.open"); + + assert_eq!( + state.begin_command(&command).unwrap(), + CommandDisposition::Execute + ); + store.save(&state).unwrap(); + + let (mut recovered, existed) = store.load_or_create(&config).unwrap(); + let mut executor = CountingExecutor { calls: 0 }; + let replay = process_command(&mut recovered, &store, &config, &mut executor, &command) + .unwrap() + .0; + + assert!(existed); + assert_eq!(executor.calls, 0); + assert_eq!(replay.status, "indeterminate"); + assert_eq!(replay.result["code"], "execution_indeterminate"); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn completed_shutdown_replay_still_stops_after_delivery() { let directory = std::env::temp_dir().join(format!( diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 187a8b8348..73840a7ad7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -537,6 +537,28 @@ impl DurableState { command: &Command, result: Value, ) -> Result { + self.finish_command(command, "completed", result) + } + + pub fn fail_command( + &mut self, + command: &Command, + result: Value, + ) -> Result { + self.finish_command(command, "failed", result) + } + + fn finish_command( + &mut self, + command: &Command, + status: &str, + result: Value, + ) -> Result { + if status != "completed" && status != "failed" { + return Err(DurableRunnerError::invalid( + "durable command terminal status is unsupported", + )); + } { let stored = self .processed_commands @@ -568,7 +590,7 @@ impl DurableState { .processed_commands .get_mut(&command.command_id) .expect("pending command was checked above"); - stored.status = "completed".to_owned(); + stored.status = status.to_owned(); stored.result = sanitized_result; Ok(stored.clone()) } @@ -903,7 +925,7 @@ fn validate_binding( || command.controller_seq > state.last_controller_command_seq || !matches!( command.status.as_str(), - "pending" | "completed" | "indeterminate" + "pending" | "completed" | "failed" | "indeterminate" ) { return Err(DurableRunnerError::invalid( diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs new file mode 100644 index 0000000000..44c9ea60b6 --- /dev/null +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -0,0 +1,76 @@ +import { builtinModules } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export const verifiedProviderEntrypoints = Object.freeze([ + Object.freeze({ + name: "acpx-runtime-sidecar", + source: resolve(packageRoot, "src/cli/acpx-runtime-sidecar.ts"), + output: resolve(packageRoot, "dist/cli/acpx-runtime-sidecar.js"), + }), + Object.freeze({ + name: "opencode-app-server-proxy", + source: resolve(packageRoot, "src/cli/opencode-app-server-proxy.ts"), + output: resolve(packageRoot, "dist/cli/opencode-app-server-proxy.js"), + }), +]); + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +]); + +function assertSelfContainedBundle(entrypoint, result) { + const outputs = Object.entries(result.metafile.outputs).filter( + ([, output]) => output.entryPoint !== undefined, + ); + if (outputs.length !== 1) { + throw new Error( + `${entrypoint.name} bundle emitted ${outputs.length} entrypoint outputs instead of one`, + ); + } + const imports = outputs[0][1].imports; + for (const dependency of imports) { + if (!dependency.external || !nodeBuiltins.has(dependency.path)) { + throw new Error( + `${entrypoint.name} bundle retained a non-builtin import: ${dependency.path}`, + ); + } + } +} + +export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { + const results = []; + for (const entrypoint of verifiedProviderEntrypoints) { + const result = await build({ + entryPoints: [entrypoint.source], + outfile: entrypoint.output, + bundle: true, + platform: "node", + format: "esm", + target: "node24", + packages: "bundle", + splitting: false, + sourcemap: false, + legalComments: "none", + metafile: true, + treeShaking: true, + write, + logLevel: "silent", + }); + assertSelfContainedBundle(entrypoint, result); + results.push({ entrypoint, result }); + } + return results; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : null; +if (invokedPath === import.meta.url) { + await bundleVerifiedProviderEntrypoints(); +} diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs new file mode 100644 index 0000000000..26c9950b0e --- /dev/null +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bundleVerifiedProviderEntrypoints, + verifiedProviderEntrypoints, +} from "./build-verified-provider-entrypoints.mjs"; + +test("verified JS provider entrypoints bundle into one descriptor-safe file", async () => { + const bundles = await bundleVerifiedProviderEntrypoints({ write: false }); + assert.equal(bundles.length, verifiedProviderEntrypoints.length); + for (const { entrypoint, result } of bundles) { + assert.equal(result.outputFiles?.length, 1, entrypoint.name); + const source = result.outputFiles[0].text; + assert.match(source, /^#!\/usr\/bin\/env node\n/); + } +}); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 155e93b4ef..c6d8025adb 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -192,6 +192,48 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" expect(launches[0]!.environment.PAPERCLIP_OPENCODE_COMMAND).toBeUndefined(); }); +it("preserves the controller-selected ACPX provider package root", () => { + const launches: RunnerProcessLaunchSpec[] = []; + spawnRunner({ + connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, + stateDirectory: "/tmp/paperclip-runner-test", + identity, + ticket: "bootstrap-ticket", + maxOutboxBytes: 256 * 1024, + p0ReserveBytes: 64 * 1024, + runnerVersion: expectedRunnerVersion, + runnerDigest: expectedRunnerDigest, + environment: { + PATH: "/bin", + PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack", + NODE_PATH: "/untrusted/modules", + }, + processLauncher: (spec) => { + launches.push(spec); + return { + child: { + pid: 42, + exitCode: null, + signalCode: null, + kill: () => true, + }, + completion: Promise.resolve({ + code: 0, + signal: null, + stdout: "", + stderr: "", + }), + }; + }, + }); + + expect(launches).toHaveLength(1); + expect( + launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, + ).toBe("/verified/provider-pack"); + expect(launches[0]!.environment.NODE_PATH).toBeUndefined(); +}); + it("preserves file-backed AWS workload identity at the runner spawn boundary", () => { const launches: RunnerProcessLaunchSpec[] = []; spawnRunner({ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 62e66596fe..5a7e8ee74b 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -1892,6 +1892,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH", + "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY", "PAPERCLIP_PROVIDER_TRACE_PATH", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 1dab74e06a..85ef93e40c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -24,6 +24,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, + createAcpxPackageJsonResolver, guardSnapshotModuleLookup, guardSnapshotModuleResolution, reapCurrentProviderProcessGroup, @@ -48,6 +49,31 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + it("anchors dynamic provider package resolution at an explicit root", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-package-root-")); + temporaryDirectories.push(root); + const providerDirectory = join(root, "node_modules", "qualified-provider"); + const providerPackageJson = join(providerDirectory, "package.json"); + await mkdir(providerDirectory, { recursive: true }); + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ private: true })), + writeFile( + providerPackageJson, + JSON.stringify({ name: "qualified-provider", version: "1.0.0" }), + ), + ]); + + expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe( + providerPackageJson, + ); + expect(() => + createAcpxPackageJsonResolver("relative/provider-pack"), + ).toThrow("explicit normalized absolute path"); + expect(() => createAcpxPackageJsonResolver(undefined)).toThrow( + "explicit normalized absolute path", + ); + }); + it("rejects an unregistered provider exit proof", async () => { await expect( awaitVerifiedAcpxProviderExit({} as ChildProcess), diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index e628af617d..9d4d255c7f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -198,6 +198,25 @@ const providerExitProof = new WeakMap>(); export type AcpxPackageJsonResolver = (packageName: string) => string; +export function createAcpxPackageJsonResolver( + providerPackageRoot: string | undefined, +): AcpxPackageJsonResolver { + const root = providerPackageRoot?.trim(); + if ( + !root || + !isAbsolute(root) || + root.includes("\0") || + resolve(root) !== root + ) { + throw new Error( + "ACPX provider package root must be an explicit normalized absolute path", + ); + } + const providerRequire = createRequire(resolve(root, "package.json")); + return (packageName) => + providerRequire.resolve(`${packageName}/package.json`); +} + export interface VerifiedAcpxInstallation { readonly commandDigest: string; readonly agentServerPackageJsonPath: string; @@ -469,6 +488,12 @@ export async function verifyQualifiedAcpxInstallation( } function defaultPackageJsonResolver(packageName: string): string { + const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; + if (providerPackageRoot !== undefined) { + return createAcpxPackageJsonResolver(providerPackageRoot)(packageName); + } + // Source-mode and direct runtimes still have a stable module URL. The + // descriptor-backed runner sidecar always receives the explicit root above. return createRequire(import.meta.url).resolve(`${packageName}/package.json`); } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index ca5bc841d9..73546d082e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -117,6 +117,19 @@ it.each(["acpx-runtime-sidecar.js", "opencode-app-server-proxy.js"] as const)( }, ); +it("derives the ACPX package root only from the verified dist/cli layout", () => { + expect( + runnerdLaunchProfileInternals.acpxProviderPackageRoot( + "/provider-pack/dist/cli/acpx-runtime-sidecar.js", + ), + ).toBe("/provider-pack"); + expect(() => + runnerdLaunchProfileInternals.acpxProviderPackageRoot( + "/unverified/acpx-runtime-sidecar.js", + ), + ).toThrow("ACPX sidecar must use the provider package dist/cli layout"); +}); + it("requires a provider-pack authority for remote ACPX artifact hashes", () => { expect(() => runnerdLaunchProfileInternals.acpxRunnerLaunchProfile( @@ -409,6 +422,7 @@ it.each([ environment: { PATH: "/bin", ...credentialEnvironment, + PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root", PAPERCLIP_API_KEY: "must-not-reach-provider", DATABASE_URL: "must-not-reach-provider", }, @@ -424,6 +438,8 @@ it.each([ codexHome: "/isolated/codex-home", runtimeContextPath: "/isolated/runtime-context.json", hasRuntimeContext: true, + acpxSidecarPath: + "/verified/provider-pack/dist/cli/acpx-runtime-sidecar.js", }); expect(environment).toMatchObject({ @@ -432,6 +448,7 @@ it.each([ PAPERCLIP_RUN_ID: "run-1", PAPERCLIP_NORMALIZED_SESSION_ID: "session-1", PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json", + PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack", }); for (const key of allowed) expect(environment[key]).toBe(credentialEnvironment[key]); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 8cc5ac63a0..52312c7b73 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1111,6 +1111,20 @@ function resolveBuildOwnedCliArtifact( ); } +function acpxProviderPackageRoot(sidecarScript: string): string { + const cliDirectory = dirname(sidecarScript); + if ( + basename(sidecarScript) !== "acpx-runtime-sidecar.js" || + basename(cliDirectory) !== "cli" || + basename(dirname(cliDirectory)) !== "dist" + ) { + throw new Error( + "runner_provider_package_root_incompatible: ACPX sidecar must use the provider package dist/cli layout", + ); + } + return resolve(cliDirectory, "../.."); +} + function acpxRunnerLaunchProfile( options: CapabilityRunnerdCodexTransportOptions, command: string, @@ -1279,6 +1293,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: { codexHome: string; runtimeContextPath: string; hasRuntimeContext: boolean; + acpxSidecarPath?: string; }): NodeJS.ProcessEnv { const commonIdentity = { PAPERCLIP_RUNNER_INSTANCE_ID: input.identity.runnerInstanceId, @@ -1300,12 +1315,21 @@ export function createCapabilityRunnerdProviderEnvironment(input: { }; } if (input.provider === "acpx") { + const sidecarPath = + input.acpxSidecarPath ?? + input.options.acpxSidecarPath ?? + resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.js"); return { ...createSanitizedAcpxSpawnInput( input.options.environment, input.options.acpxAgent ?? "codex", ).env, ...commonIdentity, + // The verified sidecar bundle cannot use import.meta.url while Node + // executes it through /proc/self/fd. Anchor its closed provider package + // lookups at the package that owns the already-authenticated bundle. + PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: + acpxProviderPackageRoot(sidecarPath), ...(input.options.providerRecoveryPolicy === "allow_replacement_after_governed_wait" ? { @@ -2354,6 +2378,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, runtimeContextPath, hasRuntimeContext: runtimeContext !== null, + acpxSidecarPath, }), this.options.environment, ), @@ -2657,6 +2682,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, runtimeContextPath, hasRuntimeContext: runtimeContext !== null, + acpxSidecarPath, }), this.options.environment, ), @@ -3361,6 +3387,7 @@ export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; export const runnerdLaunchProfileInternals = Object.freeze({ + acpxProviderPackageRoot, acpxRunnerLaunchProfile, resolveBuildOwnedCliArtifact, }); diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index f75281cdbf..fb20579bff 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -93,6 +93,12 @@ describe("runner E2E catalog", () => { expectedRunCount: 2, }); expect(localQuestion?.buildPrompt("nonce")).toContain("ask_user_questions"); + expect(localQuestion?.buildPrompt("nonce")).toContain( + "do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_nonce", + ); + expect(localQuestion?.buildPrompt("nonce")).toContain( + "refer to it only as “the terminal marker.”", + ); expect(restartQuestion).toMatchObject({ flow: "question_resume_completion", expectedRunCount: 2, diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 5fd997ea5f..11b126ddee 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -572,6 +572,7 @@ const structuredQuestionResumeTask = { [ "Ask the user one structured question before completing this task.", "The question must be required, single-select, and offer Cobalt and Amber. Do not publish a final answer or mark the task Done while it is pending.", + `Before the answer arrives, do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_${nonce} in any visible response, comment, or tool payload; refer to it only as “the terminal marker.”`, `In a native runner, call request_human_input exactly once with idempotencyKey \`question-${nonce}\`, interactionKind \`questions\`, title \`Verification word\`, prompt \`Choose the verification word\`, continuationPolicy \`wake_assignee\`, and payload {version:1,questions:[{id:\`verification-word\`,prompt:\`Choose the verification word.\`,selectionMode:\`single\`,required:true,options:[{id:\`cobalt\`,label:\`Cobalt\`},{id:\`amber\`,label:\`Amber\`}]}]}.`, "In a legacy runner, POST /api/issues/$PAPERCLIP_TASK_ID/interactions with {kind:`ask_user_questions`,continuationPolicy:`wake_assignee`,payload:{version:1,questions:[{id:`verification-word`,prompt:`Choose the verification word.`,selectionMode:`single`,required:true,options:[{id:`cobalt`,label:`Cobalt`},{id:`amber`,label:`Amber`}]}]}} using Authorization and X-Paperclip-Run-Id, then move the issue to `in_review`.", `After the answer arrives, if it is Cobalt, publish exactly PAPERCLIP_E2E_QUESTION_DONE_${nonce} once as the complete visible response and mark the task Done.`, diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index fa366f84c7..22551f4ef1 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -178,6 +178,85 @@ describe("nativeRunEventsToTranscript", () => { ]); }); + it("coalesces sparse Codex tool lifecycle events at the named write boundary", () => { + const transcript = nativeRunEventsToTranscript([ + event(1, "tool.execution.started", { + schema: "paperclip.tool.execution.v1", + executionId: "exec-write-plan", + transport: "dynamic", + operation: "unknown", + name: null, + status: "running", + output: null, + }), + itemEvent(2, "item.started", "exec-write-plan", { + kind: "dynamicToolCall", + item: { id: "exec-write-plan" }, + }), + itemEvent(3, "item.started", "exec-write-plan", { + kind: "dynamicToolCall", + item: { + type: "tool_use", + id: "exec-write-plan", + name: "write_document", + input: { + key: "plan", + title: "Plan", + body: "Ship the durable runner.", + }, + }, + }), + itemEvent(4, "item.completed", "exec-write-plan", { + kind: "dynamicToolCall", + item: { + type: "tool_result", + id: "exec-write-plan", + tool_use_id: "exec-write-plan", + result: { + commandKind: "write_document", + revisionId: "revision-1", + }, + }, + }), + event(5, "tool.execution.completed", { + schema: "paperclip.tool.execution.v1", + executionId: "exec-write-plan", + transport: "dynamic", + operation: "unknown", + name: null, + status: "completed", + output: null, + }), + itemEvent(6, "item.completed", "exec-write-plan", { + kind: "dynamicToolCall", + item: { id: "exec-write-plan", status: "completed" }, + }), + ]); + + expect(transcript).toEqual([ + expect.objectContaining({ + kind: "tool_call", + name: "write_document", + toolUseId: "exec-write-plan", + input: { + key: "plan", + title: "Plan", + body: "Ship the durable runner.", + }, + }), + expect.objectContaining({ + kind: "tool_result", + toolUseId: "exec-write-plan", + toolName: "write_document", + content: JSON.stringify({ + commandKind: "write_document", + revisionId: "revision-1", + }), + isError: false, + }), + ]); + }); + it("streams deltas until a loss-resistant completed item is available", () => { expect(nativeRunEventsToTranscript([ event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }), diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 67d3056fa9..a57fa9c6d8 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -503,10 +503,44 @@ function timestamp(event: HeartbeatRunEvent, envelope: Record): return Number.isNaN(Date.parse(createdAt)) ? new Date(0).toISOString() : createdAt; } -function toolPresentation(payload: Record): { name: string; input: unknown } { +interface NativeToolItemDetails { + name: string | null; + input?: unknown; + result?: unknown; + isError: boolean; +} + +function nativeToolItemDetails( + payload: Record, +): { id: string | null; details: NativeToolItemDetails } | null { + const item = normalizedItem(payload); + const kind = (text(item.type) ?? "").replaceAll("_", "").toLowerCase(); + if (kind !== "tooluse" && kind !== "toolresult") return null; + const id = kind === "toolresult" + ? text(item.tool_use_id) ?? text(item.id) + : text(item.id); + return { + id, + details: { + name: text(item.name), + ...(Object.prototype.hasOwnProperty.call(item, "input") + ? { input: item.input } + : {}), + ...(Object.prototype.hasOwnProperty.call(item, "result") + ? { result: item.result } + : {}), + isError: item.isError === true || item.is_error === true, + }, + }; +} + +function toolPresentation( + payload: Record, + item?: NativeToolItemDetails, +): { name: string; input: unknown } { const transport = text(payload.transport); const operation = text(payload.operation); - const reportedName = text(payload.name); + const reportedName = text(payload.name) ?? item?.name ?? null; if (transport === "process") { return { name: "Bash", @@ -515,7 +549,7 @@ function toolPresentation(payload: Record): { name: string; inp } return { name: reportedName ?? operation ?? "Tool", - input: { + input: item?.input ?? { ...(operation ? { operation } : {}), ...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}), ...(text(payload.target) ? { target: text(payload.target) } : {}), @@ -531,6 +565,7 @@ function toolPresentation(payload: Record): { name: string; inp export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] { const entries: TranscriptEntry[] = []; const startedToolIds = new Set(); + const completedToolIds = new Set(); let hasFinalAssistantMessage = false; let usageSummary: { ts: string; @@ -569,6 +604,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] const completedAgentMessageIds = new Set(); const completedReasoningIds = new Set(); const completionItemIdentityById = new Map(); + const nativeToolItemsById = new Map(); for (const event of orderedEvents) { if (!isItemIdentityEvent(event.eventType)) continue; const envelope = record(event.payload?.prpEvent); @@ -583,6 +619,25 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] if (!payload) continue; const itemId = normalizedItemId(envelope, payload); if (!itemId) continue; + const toolItem = nativeToolItemDetails(payload); + if (toolItem) { + const toolId = toolItem.id ?? itemId; + const previous = nativeToolItemsById.get(toolId); + nativeToolItemsById.set(toolId, { + name: toolItem.details.name ?? previous?.name ?? null, + ...(toolItem.details.input !== undefined + ? { input: toolItem.details.input } + : previous?.input !== undefined + ? { input: previous.input } + : {}), + ...(toolItem.details.result !== undefined + ? { result: toolItem.details.result } + : previous?.result !== undefined + ? { result: previous.result } + : {}), + isError: toolItem.details.isError || previous?.isError === true, + }); + } const identity = resolveItemIdentity( payload, completionItemIdentityById.get(itemId), @@ -700,7 +755,8 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue; const executionId = text(payload.executionId); if (!executionId) continue; - const presentation = toolPresentation(payload); + const nativeToolItem = nativeToolItemsById.get(executionId); + const presentation = toolPresentation(payload, nativeToolItem); if (!startedToolIds.has(executionId)) { startedToolIds.add(executionId); entries.push({ @@ -711,14 +767,24 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] toolUseId: executionId, }); } - if (event.eventType === "tool.execution.completed") { + if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) { + completedToolIds.add(executionId); + const output = text(payload.output); + let content = output ?? ""; + if (!output && nativeToolItem?.result !== undefined) { + try { + content = JSON.stringify(nativeToolItem.result) ?? ""; + } catch { + content = "Tool result could not be serialized"; + } + } entries.push({ kind: "tool_result", ts, toolUseId: executionId, toolName: presentation.name, - content: text(payload.output) ?? "", - isError: payload.status === "failed", + content, + isError: payload.status === "failed" || nativeToolItem?.isError === true, }); } continue;