From 2962ebfdb71149c0f80c513cba9e54faa555b086 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 7 Sep 2026 13:47:34 -0500 Subject: [PATCH] feat(runner): qualify pinned Pi runtime and linked provider launchers Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 13 ++++ docker/daytona-runner/Dockerfile | 9 +-- packages/paperclip-runner/package.json | 4 +- .../runner-core/src/acpx_provider_backend.rs | 28 ++++++--- .../runner-core/src/acpx_provider_session.rs | 3 +- .../runner-core/src/acpx_sidecar_transport.rs | 3 +- .../tests/native_provider_backend.rs | 6 +- .../scripts/build-provider-pack.mjs | 31 ++++------ .../scripts/materialize-pi-binary.mjs | 45 ++++++++++++++ .../scripts/materialize-pi-binary.test.mjs | 32 ++++++++++ .../scripts/portable-provider-shim.mjs | 17 ++++++ .../scripts/portable-provider-shim.test.mjs | 27 +++++++++ .../scripts/verify-pi-provider-launch.mjs | 53 +++++++++++++++++ .../src/backends/codex-acpx-native-backend.ts | 5 -- .../src/backends/codex-native-backend.ts | 5 -- .../backends/native-backend-factory.test.ts | 6 +- .../src/backends/native-backend-factory.ts | 5 -- .../src/cli/acpx-runtime-sidecar.test.ts | 8 +-- .../src/cli/acpx-runtime-sidecar.ts | 4 +- .../src/cli/eval-session-contract.test.ts | 7 ++- .../src/cli/eval-session-contract.ts | 11 ++-- .../src/drivers/acpx/codex-acpx-driver.ts | 5 -- .../src/drivers/acpx/driver-profile.test.ts | 3 +- .../src/drivers/acpx/driver-profile.ts | 4 +- .../acpx/installation-integrity.test.ts | 21 ++++--- .../drivers/acpx/installation-integrity.ts | 59 +++++++++++++++---- .../src/drivers/acpx/qualified-profiles.ts | 8 +-- .../src/drivers/acpx/runtime-host.ts | 5 -- .../paperclip-runner/src/live/live-session.ts | 3 - .../src/live/runnerd-codex-transport.ts | 3 - server/src/adapters/registry.ts | 5 +- .../heartbeat-runner-provider-config.test.ts | 11 +++- .../native-session-executor.test.ts | 3 + .../native-runtime/native-session-executor.ts | 3 + .../native-runtime/provider-profile.ts | 5 +- tests/runner-e2e/daytona-image-content.ts | 3 + ui/src/adapters/codex-local/config-fields.tsx | 8 ++- 37 files changed, 348 insertions(+), 123 deletions(-) create mode 100644 packages/paperclip-runner/scripts/materialize-pi-binary.mjs create mode 100644 packages/paperclip-runner/scripts/materialize-pi-binary.test.mjs create mode 100644 packages/paperclip-runner/scripts/portable-provider-shim.mjs create mode 100644 packages/paperclip-runner/scripts/portable-provider-shim.test.mjs create mode 100644 packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 759af6a5c4..8a4a1bb3c4 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -12,6 +12,19 @@ Credentials are not recorded in Playwright reports. These API checks supplement the required browser walkthrough, two real 180-second intervals, and recovery scenarios; passing them alone is not staging acceptance. +The staging matrix covers legacy Codex and Claude with both CLI and ACP, +legacy OpenCode and Pi, and native Codex, OpenCode, and ACPX Claude/Codex/Pi. +Cursor, Gemini, Grok, and Kimi are excluded from this acceptance campaign by +explicit user instruction. Other required profiles must not be silently skipped. + +Native Pi uses `pi-acp@0.0.33` with the official Linux x64 Pi `0.84.2` +standalone executable. The image build verifies the archive and executable +SHA-256, then starts an ACP session through the runner's descriptor-based +launcher. It does not make a model request. Live staging must still verify the +qualified OpenRouter model, work folders, saves, and recovery. The same image +exposes this Pi executable to the legacy adapter. Provider-pack shims resolve +links before locating their runtime so task-local launch paths remain valid. + Sandbox runs use the operating-system user's home directory. Both legacy adapters and the native runner enter the same host-owned lifecycle before dispatch. Local execution keeps its existing workspace and home behavior. diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index 67e88e6fa0..4e093bf10f 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -28,7 +28,7 @@ COPY cli/package.json ./cli/package.json # The complete resolved lock (including transitive integrity hashes) is reviewed. # Reject registry-time drift BEFORE installing packages or running lifecycle code. # Refresh this digest together with source/provider dependency changes. -ARG PAPERCLIP_RUNNER_LOCK_SHA256=47a7c09302d47843054d0301f8f52f3da935b9c6ac771bace0409da752b6af7f +ARG PAPERCLIP_RUNNER_LOCK_SHA256=72faa8db55c9e0d3287c73e9f9bfff116d71ea435426db81df24ffed32794cfb RUN pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile \ && printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ && sha256sum -c /tmp/provider-lock.sha256 \ @@ -38,6 +38,7 @@ RUN test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}" RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \ && PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_RUNNER_SOURCE_REVISION}" \ node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \ + && node packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs /provider-pack \ && chmod -R a+rX /provider-pack # Fleet sandbox base image. Keep this section aligned with @@ -50,7 +51,7 @@ USER root ENV PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=/opt/paperclip-runner/provider-pack ENV PATH=${PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT}/node_modules/.bin:/usr/local/share/nvm/current/bin:/usr/local/python/current/bin:/usr/local/py-utils/bin:${PATH} -# Share the pack's latest stable Codex, Claude and OpenCode with every adapter. +# Share the pack's latest stable Codex, Claude, OpenCode and Pi with every adapter. # Never add a second CLI version to work around a runner compatibility pin. RUN npm uninstall -g @anthropic-ai/claude-code @openai/codex opencode-ai \ && npm install -g \ @@ -96,7 +97,7 @@ RUN set -eu; \ COPY --from=runnerd-build /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd /usr/local/bin/paperclip-runnerd COPY --from=provider-pack-build /provider-pack /opt/paperclip-runner/provider-pack -RUN set -eu; for cli in codex claude opencode; do \ +RUN set -eu; for cli in codex claude opencode pi; do \ printf '#!/bin/sh\nexec /opt/paperclip-runner/provider-pack/node_modules/.bin/%s "$@"\n' "$cli" > "/usr/local/bin/$cli"; \ chmod 755 "/usr/local/bin/$cli"; \ done @@ -113,7 +114,7 @@ RUN set -eu; \ printf '%s\n' 'export PATH=/opt/paperclip-runner/provider-pack/node_modules/.bin:$PATH' \ > /etc/profile.d/01-paperclip-runner-provider-pack.sh; \ chmod 0644 /etc/profile.d/01-paperclip-runner-provider-pack.sh; \ - for command_name in acpx claude-agent-acp codex-acp claude codex grok gemini kimi opencode cursor-agent agent hermes gh paperclip-runnerd; do \ + for command_name in acpx claude-agent-acp codex-acp pi-acp pi claude codex grok gemini kimi opencode cursor-agent agent hermes gh paperclip-runnerd; do \ command -v "$command_name" >/dev/null || { echo "FATAL: $command_name not on PATH after build" >&2; exit 1; }; \ done; \ metadata="$(paperclip-runnerd --build-metadata)"; \ diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 16941733a5..cc3ec2f5a3 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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 scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.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 scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-pi-binary.test.mjs scripts/portable-provider-shim.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::", @@ -158,11 +158,13 @@ "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.70.0", "@agentclientprotocol/codex-acp": "1.6.2", + "@earendil-works/pi-coding-agent": "0.84.2", "@openai/codex": "0.153.4", "acpx": "0.13.1", "ajv": "^8.20.0", "json-schema-to-ts": "^3.1.1", "opencode-ai": "1.18.29", + "pi-acp": "0.0.33", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index ca259d30d2..1b4017b424 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -157,12 +157,17 @@ impl AcpxProviderDescriptor { Some("0.153.4"), "sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400", ), - "pi" => return Err(DurableRunnerError::invalid( - "ACPX agent pi is not executable through the verified runnerd provider boundary", - )), + "pi" => ( + "openrouter/deepseek/deepseek-v4-flash-0731", + "pi-acp", + "0.0.33", + Some("@earendil-works/pi-coding-agent"), + Some("0.84.2"), + "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", + ), _ => { return Err(DurableRunnerError::invalid( - "ACPX agent must be a qualified claude or codex profile", + "ACPX agent must be a qualified claude, codex, or pi profile", )) } }; @@ -1604,6 +1609,15 @@ mod tests { json!("0.3.263"), "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", ) + } else if agent == "pi" { + ( + "openrouter/deepseek/deepseek-v4-flash-0731", + "pi-acp", + "0.0.33", + json!("@earendil-works/pi-coding-agent"), + json!("0.84.2"), + "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", + ) } else { ( "gpt-5.6-sol", @@ -1641,8 +1655,8 @@ mod tests { } #[test] - fn admits_only_exact_qualified_claude_and_codex_descriptors() { - for agent in ["claude", "codex"] { + fn admits_only_exact_qualified_claude_codex_and_pi_descriptors() { + for agent in ["claude", "codex", "pi"] { let descriptor: AcpxProviderDescriptor = serde_json::from_value(descriptor(agent)).unwrap(); descriptor.validate(&context()).unwrap(); @@ -1683,7 +1697,7 @@ mod tests { } #[test] - fn rejects_pi_before_process_launch() { + fn rejects_pi_with_codex_artifacts_before_process_launch() { let mut pi = descriptor("codex"); pi["agent"] = json!("pi"); let pi: AcpxProviderDescriptor = serde_json::from_value(pi).unwrap(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index a6dc391d47..5cd6746fbe 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -73,9 +73,10 @@ impl AcpxProviderSessionConfig { let qualified_model = match self.agent.as_str() { "claude" => "claude-sonnet-5", "codex" => "gpt-5.6-sol", + "pi" => "openrouter/deepseek/deepseek-v4-flash-0731", _ => { return Err(LocalRunnerError::invalid( - "ACPX agent must be claude or codex", + "ACPX agent must be claude, codex, or pi", )) } }; 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 908519d176..ddaf3adcf7 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 @@ -98,9 +98,10 @@ impl AcpxSidecarTransport { let credential_keys: &[&str] = match agent { "claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], "codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"], + "pi" => &["OPENROUTER_API_KEY"], _ => { return Err(LocalRunnerError::invalid( - "ACPX sidecar credentials require a qualified claude or codex agent", + "ACPX sidecar credentials require a qualified claude, codex, or pi agent", )) } }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 3c2f37c674..9eb1496eca 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -648,7 +648,7 @@ fn rejects_opencode_launch_profile_drift_across_fresh_recovery() { } #[test] -fn rejects_pi_before_starting_a_sidecar() { +fn rejects_pi_with_codex_artifacts_before_starting_a_sidecar() { let directory = temporary_directory("pi"); let config = config(&directory); let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); @@ -659,7 +659,9 @@ fn rejects_pi_before_starting_a_sidecar() { prepare_payload(&directory, "pi"), )) .unwrap_err(); - assert!(error.to_string().contains("agent pi is not executable")); + assert!(error + .to_string() + .contains("does not match a qualified immutable profile")); assert!(!directory.join("acpx-runtime").exists()); fs::remove_dir_all(directory).unwrap(); } diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index c9648a1ae5..4e06cd893a 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -1,3 +1,4 @@ +import { portableProviderShim } from "./portable-provider-shim.mjs"; import { createHash } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { @@ -18,6 +19,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import { materializePiBinary } from "./materialize-pi-binary.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -85,31 +87,13 @@ function sha256Tree(root) { function writePortableNodeShim(name, entrypoint) { const shimPath = join(temporaryRoot, "node_modules", ".bin", name); - writeFileSync( - shimPath, - [ - "#!/bin/sh", - "set -eu", - 'basedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)', - `exec "$basedir/../node/bin/node" "$basedir/../${entrypoint}" "$@"`, - "", - ].join("\n"), - ); + writeFileSync(shimPath, portableProviderShim(entrypoint, { node: true })); chmodSync(shimPath, 0o755); } function writePortableExecutableShim(name, executable) { const shimPath = join(temporaryRoot, "node_modules", ".bin", name); - writeFileSync( - shimPath, - [ - "#!/bin/sh", - "set -eu", - 'basedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)', - `exec "$basedir/../${executable}" "$@"`, - "", - ].join("\n"), - ); + writeFileSync(shimPath, portableProviderShim(executable)); chmodSync(shimPath, 0o755); } @@ -189,6 +173,10 @@ try { writePortableExecutableShim("node", "node/bin/node"); writePortableExecutableShim("opencode", "opencode-ai/bin/opencode.exe"); writePortableNodeShim("acpx", "acpx/dist/cli.js"); + const piPackageRoot = realpathSync(join(temporaryRoot, "node_modules", "@earendil-works", "pi-coding-agent")); + await materializePiBinary(piPackageRoot); + writePortableExecutableShim("pi", "@earendil-works/pi-coding-agent/vendor/standalone/pi"); + writePortableNodeShim("pi-acp", "pi-acp/dist/index.js"); writePortableNodeShim( "claude-agent-acp", "@agentclientprotocol/claude-agent-acp/dist/index.js", @@ -295,6 +283,8 @@ try { acpx: "0.13.1", claudeAcp: "0.70.0", codexAcp: "1.6.2", + pi: "0.84.2", + piAcp: "0.0.33", }, target: { platform: process.platform, architecture: process.arch }, runnerSourceRevision: `${revision}${dirty ? "-dirty" : ""}`, @@ -307,6 +297,7 @@ try { .update(distDigest) .digest("hex")}`, acpxProfileDigests: { + pi: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: diff --git a/packages/paperclip-runner/scripts/materialize-pi-binary.mjs b/packages/paperclip-runner/scripts/materialize-pi-binary.mjs new file mode 100644 index 0000000000..c765e4b61c --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-pi-binary.mjs @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { createReadStream, createWriteStream } from "node:fs"; +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +export const PI_BINARY = Object.freeze({ + version: "0.84.2", + url: "https://github.com/earendil-works/pi/releases/download/v0.84.2/pi-linux-x64.tar.gz", + archiveSha256: "906fbe787fd225c4ac624fe7ebd5b1d55a60e0f5c7ef51795d231564f9ee1c13", + executableSha256: "9a2d20fab3caacbe3517d91e59d495ccc49fd4b51a1a72dcec6e8c1f4b7d6ab2", +}); + +/** Install the official standalone runtime so ACPX never launches ambient `pi`. */ +export async function materializePiBinary(packageDirectory, options = {}) { + if ((options.platform ?? process.platform) !== "linux" || (options.arch ?? process.arch) !== "x64") { + throw new Error("The qualified Pi binary requires Linux x64"); + } + const temporary = await mkdtemp(join(tmpdir(), "paperclip-pi-binary-")); + try { + const response = await (options.fetch ?? fetch)(PI_BINARY.url); + if (!response.ok || !response.body) throw new Error("Pi runtime download failed"); + const archive = join(temporary, "pi.tar.gz"); + const digest = createHash("sha256"); + let bytes = 0; + await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, _encoding, done) { + bytes += chunk.length; + if (bytes > 192 * 1024 * 1024) return done(new Error("Pi runtime archive exceeds its size bound")); + digest.update(chunk); done(null, chunk); + } }), createWriteStream(archive, { flags: "wx", mode: 0o600 })); + if (digest.digest("hex") !== PI_BINARY.archiveSha256) throw new Error("Pi runtime archive integrity mismatch"); + const destination = join(packageDirectory, "vendor", "standalone"); + await mkdir(destination, { recursive: true }); + execFileSync("tar", ["-xzf", archive, "--strip-components=1", "-C", destination]); + const executable = join(destination, "pi"); + const executableDigest = createHash("sha256"); + for await (const chunk of createReadStream(executable)) executableDigest.update(chunk); + if (executableDigest.digest("hex") !== PI_BINARY.executableSha256) throw new Error("Pi runtime executable integrity mismatch"); + await chmod(executable, 0o755); + return executable; + } finally { await rm(temporary, { recursive: true, force: true }); } +} diff --git a/packages/paperclip-runner/scripts/materialize-pi-binary.test.mjs b/packages/paperclip-runner/scripts/materialize-pi-binary.test.mjs new file mode 100644 index 0000000000..68aff321cd --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-pi-binary.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { materializePiBinary, PI_BINARY } from "./materialize-pi-binary.mjs"; + +test("rejects unsupported platforms before fetching", async () => { + await assert.rejects(materializePiBinary("/unused", { platform: "darwin", arch: "arm64", fetch: () => { throw Error("unexpected download"); } }), /requires Linux x64/); +}); + +test("rejects a tampered archive before extracting anything", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-materialization-")); + try { + await assert.rejects(materializePiBinary(root, { platform: "linux", arch: "x64", fetch: async (url) => { + assert.equal(url, PI_BINARY.url); + return new Response("untrusted archive"); + } }), /archive integrity mismatch/); + await assert.rejects(access(join(root, "vendor"))); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("bounds the downloaded archive before extraction", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-materialization-")); + try { + const chunk = new Uint8Array(1024 * 1024); + await assert.rejects(materializePiBinary(root, { platform: "linux", arch: "x64", fetch: async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(chunk); }, + })) }), /size bound/); + await assert.rejects(access(join(root, "vendor"))); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/portable-provider-shim.mjs b/packages/paperclip-runner/scripts/portable-provider-shim.mjs new file mode 100644 index 0000000000..3165b357d0 --- /dev/null +++ b/packages/paperclip-runner/scripts/portable-provider-shim.mjs @@ -0,0 +1,17 @@ +/** Provider packs can move and their launchers can be invoked through task-local links. */ +export function portableProviderShim(entrypoint, { node = false } = {}) { + return [ + "#!/bin/sh", + "set -eu", + 'self=$0; links=0', + 'while [ -L "$self" ]; do', + ' links=$((links + 1)); [ "$links" -le 40 ] || exit 1', + ' parent=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd)', + ' self=$(readlink -- "$self")', + ' case "$self" in /*) ;; *) self=$parent/$self ;; esac', + 'done', + 'basedir=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd)', + `exec ${node ? '"$basedir/../node/bin/node" ' : ""}"$basedir/../${entrypoint}" "$@"`, + "", + ].join("\n"); +} diff --git a/packages/paperclip-runner/scripts/portable-provider-shim.test.mjs b/packages/paperclip-runner/scripts/portable-provider-shim.test.mjs new file mode 100644 index 0000000000..cc83bad081 --- /dev/null +++ b/packages/paperclip-runner/scripts/portable-provider-shim.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { portableProviderShim } from "./portable-provider-shim.mjs"; + +for (const node of [false, true]) test(`launcher preserves pack paths through absolute and relative links (node=${node})`, async () => { + const root = await mkdtemp(join(tmpdir(), "provider shim ")); + try { + const bin = join(root, "pack/node_modules/.bin"); + await mkdir(bin, { recursive: true }); + await mkdir(join(root, "pack/node_modules/node/bin"), { recursive: true }); + const executable = join(root, "pack/node_modules/provider"); + await writeFile(executable, node ? 'console.log(JSON.stringify(process.argv.slice(2)));' : '#!/bin/sh\nprintf "%s\\n" "$1"\n', { mode: 0o755 }); + await symlink(process.execPath, join(root, "pack/node_modules/node/bin/node")); + const shim = join(bin, "provider"); + await writeFile(shim, portableProviderShim("provider", { node }), { mode: 0o755 }); + await symlink(shim, join(root, "absolute")); + await symlink("absolute", join(root, "relative")); + for (const command of [shim, join(root, "absolute"), join(root, "relative")]) { + const output = execFileSync(command, ["argument with spaces"], { encoding: "utf8", cwd: "/" }).trim(); + assert.equal(output, node ? '["argument with spaces"]' : "argument with spaces"); + } + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs b/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs new file mode 100644 index 0000000000..88115619d1 --- /dev/null +++ b/packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +// Runs inside the Linux image build against the actual deployed provider pack. +// An ACP session starts the pinned Pi RPC child without making a model request. +const pack = resolve(process.argv[2]); +const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/installation-integrity.js"))); +const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); +const root = await mkdtemp(join(tmpdir(), "pi-qualified-launch-")); +const installation = await verifyQualifiedAcpxInstallation( + resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"), + createAcpxPackageJsonResolver(pack), +); +const lease = await installation.openCommand(); +const child = lease.spawn([], { cwd: root, detached: true, env: { + HOME: root, PATH: "/usr/bin:/bin", PI_CODING_AGENT_DIR: join(root, ".pi"), + // Makes the static OpenRouter model catalog selectable; never sends a prompt. + OPENROUTER_API_KEY: "qualification-no-model-requests", +} }); +let buffer = "", stderr = ""; +const pending = new Map(); +child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-4000); }); +child.stdout.on("data", (data) => { + buffer += data; + assert(buffer.length < 1024 * 1024, "Provider protocol output exceeded bound"); + let end; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + const response = JSON.parse(line); + pending.get(response.id)?.(response); + } +}); +const request = (id, method, params) => new Promise((resolveRequest, reject) => { + const timer = setTimeout(() => reject(new Error(`${method} timed out: ${stderr}`)), 30_000); + pending.set(id, (response) => { clearTimeout(timer); pending.delete(id); resolveRequest(response); }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); +}); +try { + const initialize = await request(1, "initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "paperclip-image-qualification", version: "1" } }); + assert.equal(initialize.error, undefined, JSON.stringify(initialize.error)); + const session = await request(2, "session/new", { cwd: root, mcpServers: [] }); + assert.equal(session.error, undefined, JSON.stringify(session.error)); + assert.equal(typeof session.result?.sessionId, "string"); + console.log("Verified Pi ACP and pinned RPC runtime started successfully"); +} finally { + try { process.kill(-child.pid, "SIGTERM"); } catch {} + await lease.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index ee3cd71e0e..c4c160e6d8 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -30,11 +30,6 @@ export function createAcpxNativeSessionBackend( if (input.provider.kind !== "acpx") { throw new Error("ACPX backend requires provider kind acpx"); } - if (input.provider.agent === "pi") { - throw new Error( - "Pi ACPX backend is unavailable until descriptor-confined verified launch is implemented", - ); - } const qualifiedProfile = resolveQualifiedAcpxProfile( input.provider.agent, input.provider.model, diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index fa06ca094e..2288d51561 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -81,11 +81,6 @@ function transportDriverIdentity(input: NativeExecutionInput): { version: input.provider.agentCoreProfile.qualificationRevision, }; case "acpx": - if (input.provider.agent === "pi") { - throw new Error( - "Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented", - ); - } return { kind: "acpx_runtime", displayName: `${input.provider.agent === "claude" ? "Claude" : "Codex"} via ACPX`, diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 3f9ffb18b6..66b4ae37ac 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -106,7 +106,7 @@ function acpxExecution( agent === "codex" ? "sha256:7a923b3829884d3cabcc9659d22cace3f86813e7bfffc90974b10140a45bc400" : agent === "pi" - ? "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f" + ? "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc" : "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", }, }, @@ -487,12 +487,12 @@ describe("native backend factory", () => { }, ); - it("rejects Pi before constructing an ACPX backend", () => { + it("constructs Pi through the verified ACPX backend", () => { expect(() => createNativeSessionBackend(acpxExecution("pi"), { acpxRuntimeDirectory: "/runtime", }), - ).toThrow("descriptor-confined verified launch"); + ).not.toThrow(); }); it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 6395c7a985..5c60285ccb 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -76,11 +76,6 @@ export function createNativeSessionBackend( }); } if (input.provider.kind === "acpx") { - if (input.provider.agent === "pi") { - throw new Error( - "Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented", - ); - } if (!options.acpxRuntimeDirectory?.trim()) { throw new Error("ACPX backend requires an instance runtime directory"); } diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index b783aaac03..d62c495590 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -443,7 +443,7 @@ describe("qualified ACPX runtime sidecar", () => { }); }); - it.each([["claude", "claude-sonnet-5"]])( + it.each([["claude", "claude-sonnet-5"], ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"]])( "reports the qualified %s profile", async (agent, model) => { const sidecar = startSidecar(); @@ -464,8 +464,8 @@ describe("qualified ACPX runtime sidecar", () => { sidecar.write( initializeRequest( 1, - "pi", - "openrouter/deepseek/deepseek-v4-flash-0731", + "unqualified", + "unqualified-model", ), ); @@ -476,7 +476,7 @@ describe("qualified ACPX runtime sidecar", () => { ok: false, error: { code: "acpx_sidecar_command_failed", - message: "ACPX agent must be claude or codex", + message: "ACPX agent must be claude, codex, or pi", retryable: false, }, }); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index aa7440b32e..df5f9d5683 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1148,8 +1148,8 @@ function requireHost( } function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent { - if (value !== "codex" && value !== "claude") { - throw new Error("ACPX agent must be claude or codex"); + if (value !== "codex" && value !== "claude" && value !== "pi") { + throw new Error("ACPX agent must be claude, codex, or pi"); } return value; } diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 590d575e3e..485469872d 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -145,11 +145,12 @@ describe("eval-session request contract", () => { })))).toBe("17"); }); - it("rejects Pi and accepts both qualified remote provider profiles", () => { - expect(() => parseEvalSessionRequest(request({ + it("accepts Pi and both qualified remote provider profiles", () => { + expect(parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "pi", - }))).toThrow("Pi ACPX profile is not available"); + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }))).toMatchObject({ provider: "acpx", acpxAgent: "pi" }); expect(parseEvalSessionRequest(request({ provider: "aws_agentcore", driver: "aws_agentcore_harness_api", diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.ts b/packages/paperclip-runner/src/cli/eval-session-contract.ts index 9c48f987b9..c933ff791e 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.ts @@ -66,7 +66,7 @@ export interface EvalSessionRequest { provider?: EvalSessionProvider; driver?: EvalSessionDriver; opencodeVersion?: string; - acpxAgent?: Exclude; + acpxAgent?: QualifiedAcpxAgent; managedProfile?: EvalSessionManagedProfile; agentCoreProfile?: EvalSessionAgentCoreProfile; runnerd: { path: string; sha256: string }; @@ -237,13 +237,13 @@ export function parseEvalSessionRequest(value: unknown): EvalSessionRequest { // options as JSON null. Preserve compatibility with those immutable request // artifacts while continuing to reject non-null values for the wrong lane. const acpxAgent = input.acpxAgent === null ? undefined : input.acpxAgent; - if (acpxAgent === "pi") throw new Error("The Pi ACPX profile is not available"); if ( acpxAgent !== undefined && acpxAgent !== "codex" && - acpxAgent !== "claude" + acpxAgent !== "claude" && + acpxAgent !== "pi" ) { - throw new Error("eval-session acpxAgent must be codex or claude"); + throw new Error("eval-session acpxAgent must be codex, claude, or pi"); } if (provider !== "acpx" && acpxAgent !== undefined) { throw new Error("eval-session acpxAgent requires provider acpx"); @@ -306,9 +306,6 @@ export function parseEvalSessionRequest(value: unknown): EvalSessionRequest { ) { throw new Error("request.session.requestedModel must match request.model"); } - if (session.acpxAgent === "pi") { - throw new Error("The Pi ACPX profile is not available"); - } return { schema: EVAL_SESSION_REQUEST_SCHEMA, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 62343cfeb6..ea4a01d778 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -256,11 +256,6 @@ export class CodexAcpxDriver implements HarnessDriver { options: CodexAcpxDriverOptions, dependencies: CodexAcpxDriverDependencies = {}, ) { - if (options.agent === "pi") { - throw new Error( - "Pi ACPX driver is unavailable until descriptor-confined verified launch is implemented", - ); - } this.#options = { ...options, agent: options.agent ?? "codex", diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index acf5efce76..7d0bd74e87 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -83,8 +83,7 @@ describe("ACPX driver profile", () => { model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).toMatchObject({ - ok: false, - issues: [{ path: "agent", code: "invalid_agent" }], + ok: true, }); expect( validateAcpxDriverConfig({ diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index 97b1880550..b3a11ddaf8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -12,7 +12,7 @@ import { type QualifiedAcpxAgent, } from "./qualified-profiles.js"; -const ACPX_AGENTS = ["claude", "codex"] as const; +const ACPX_AGENTS = ["claude", "codex", "pi"] as const; const ACPX_PERMISSION_MODES = [ "approve-all", "approve-reads", @@ -95,7 +95,7 @@ export function validateAcpxDriverConfig( return invalid( "agent", "invalid_agent", - "ACPX agent must be claude or codex.", + "ACPX agent must be claude, codex, or pi.", ); } const model = text(config.model); 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 cb605c8949..2e6512c590 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -32,11 +32,18 @@ import { snapshotDescriptorAncestorIndex, snapshotDescriptorResolution, verifiedExecutableOpenFlags, - verifyQualifiedAcpxInstallation, + verifyQualifiedAcpxInstallation as verifyProductionInstallation, type VerifiedAcpxProviderLifetime, } from "./installation-integrity.js"; import { stageManagedCodexCredential } from "./codex-credentials.js"; +// Synthetic provider commands exercise the generic descriptor/module confinement +// independently of the production Pi ELF. Real profiles always use production checks. +const verifyQualifiedAcpxInstallation: typeof verifyProductionInstallation = (profile, resolver) => + verifyProductionInstallation(profile, resolver, profile.agent === "pi" && + profile.commandDigest !== resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731").commandDigest + ? { runtimeExecutable: async () => null, dependencies: [] } : {}); + const temporaryDirectories: string[] = []; const descriptorCommandPath = "/proc/self/fd/4/server.js"; @@ -67,7 +74,7 @@ describe("ACPX installation integrity", () => { ]); expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe( - providerPackageJson, + await realpath(providerPackageJson), ); const nestedDependencyDirectory = join( @@ -85,7 +92,7 @@ describe("ACPX installation integrity", () => { JSON.stringify({ name: "qualified-dependency", version: "1.0.0", - exports: "./index.js", + exports: { ".": { import: "./index.js" } }, }), ); await writeFile(join(nestedDependencyDirectory, "index.js"), "export {};"); @@ -94,7 +101,7 @@ describe("ACPX installation integrity", () => { "qualified-dependency", providerPackageJson, ), - ).toBe(nestedDependencyPackageJson); + ).toBe(await realpath(nestedDependencyPackageJson)); expect(() => createAcpxPackageJsonResolver("relative/provider-pack"), ).toThrow("explicit normalized absolute path"); @@ -129,7 +136,7 @@ describe("ACPX installation integrity", () => { ); expect( createAcpxPackageJsonResolver(root, runnerManifest)("pnpm-provider"), - ).toBe(join(pnpmProviderDirectory, "package.json")); + ).toBe(await realpath(join(pnpmProviderDirectory, "package.json"))); const outsideManifest = join(parent, "outside-package.json"); await writeFile(outsideManifest, JSON.stringify({ private: true })); @@ -414,7 +421,7 @@ describe("ACPX installation integrity", () => { }); }); - it("pins Claude ACP direct dependencies outside its package root", async () => { + it.each(["exports", "main"])("pins ACP direct dependencies with %s metadata outside its package root", async (entryField) => { const fixture = await installationFixture(); const command = [ 'import { qualifiedValue } from "@anthropic-ai/claude-agent-sdk";', @@ -469,7 +476,7 @@ describe("ACPX installation integrity", () => { name: dependency.name, version: dependency.version, type: "module", - exports: "./index.js", + [entryField]: "./index.js", }), ), ), diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index ef2f72a926..02eea23fb2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -4,7 +4,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { constants, realpathSync } from "node:fs"; +import { constants, existsSync, realpathSync } from "node:fs"; import { lstat, open, @@ -43,6 +43,22 @@ const PROVIDER_GUARDIAN_HANDSHAKE_TIMEOUT_MS = 5_000; const VERIFIED_PROVIDER_RUNTIME_TARGET_ENV = "PAPERCLIP_ACPX_VERIFIED_PROVIDER_RUNTIME_TARGET"; +const QUALIFIED_PI_LINUX_X64_RUNTIME = Object.freeze({ + runtimePackageName: "@earendil-works/pi-coding-agent", + runtimePackageVersion: "0.84.2", + packageName: "@earendil-works/pi-coding-agent", + packageVersion: "0.84.2", + dependencyDeclaration: "0.84.2", + relativeExecutable: "vendor/standalone/pi", + executableDigest: "sha256:9a2d20fab3caacbe3517d91e59d495ccc49fd4b51a1a72dcec6e8c1f4b7d6ab2", + environmentVariable: "PI_ACP_PI_COMMAND", +}); + +const QUALIFIED_PI_PROVIDER_DEPENDENCIES = Object.freeze([ + { packageName: "@agentclientprotocol/sdk", packageVersion: "0.26.0", dependencyDeclaration: "^0.26.0" }, + { packageName: "zod", packageVersion: "3.25.76", dependencyDeclaration: "^3.25.0" }, +]); + const QUALIFIED_CLAUDE_LINUX_X64_RUNTIME = Object.freeze({ runtimePackageName: "@anthropic-ai/claude-agent-sdk", runtimePackageVersion: "0.3.263", @@ -355,6 +371,13 @@ function resolvePackageJsonFromIssuer( ) { throw new Error(`ACPX provider package name is invalid: ${packageName}`); } + // Import-only packages need not expose either package.json or a require + // entrypoint. Locate metadata without executing their code; the caller still + // confines its real path to the selected provider pack. + for (const searchRoot of issuerRequire.resolve.paths(packageName) ?? []) { + const candidate = resolve(searchRoot, ...packageSegments, "package.json"); + if (existsSync(candidate)) return candidate; + } let directory = dirname(realpathSync(issuerRequire.resolve(packageName))); for (let count = 0; count < MAX_DEPENDENCY_ANCESTORS; count += 1) { const matchesPackage = @@ -472,7 +495,7 @@ interface VerifiedAcpxRuntimeExecutable { path: string; digest: string; identity: VerifiedAcpxCommandIdentity; - environmentVariable: "CLAUDE_CODE_EXECUTABLE" | "CODEX_PATH"; + environmentVariable: "CLAUDE_CODE_EXECUTABLE" | "CODEX_PATH" | "PI_ACP_PI_COMMAND"; } interface AcpxPackageMetadata { @@ -504,6 +527,10 @@ const GUARDED_MODULE_SNAPSHOT_BOOTSTRAP = snapshotBootstrap("module", true); export async function verifyQualifiedAcpxInstallation( profile: QualifiedAcpxProfile, resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver, + verification: { + runtimeExecutable?: typeof verifyQualifiedRuntimeExecutable; + dependencies?: readonly { packageName: string; packageVersion: string; dependencyDeclaration: string }[]; + } = {}, ): Promise { const serverPackageJsonPath = await realpath( resolvePackageJson(profile.agentServerPackage), @@ -570,7 +597,7 @@ export async function verifyQualifiedAcpxInstallation( ); } runtimePackageFormat = packageModuleFormat(runtimePackage.type); - runtimeExecutable = await verifyQualifiedRuntimeExecutable({ + runtimeExecutable = await (verification.runtimeExecutable ?? verifyQualifiedRuntimeExecutable)({ profile, runtimePackage, runtimePackageJsonPath, @@ -584,23 +611,25 @@ export async function verifyQualifiedAcpxInstallation( directory: string; format: AcpxCommandFormat; }> = []; - if (profile.agent === "claude") { + const qualifiedDependencies = verification.dependencies ?? (profile.agent === "claude" + ? QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES : profile.agent === "pi" ? QUALIFIED_PI_PROVIDER_DEPENDENCIES : []); + if (qualifiedDependencies.length) { const declaredDependencies = serverPackage.dependencies; if ( typeof declaredDependencies !== "object" || declaredDependencies === null || Array.isArray(declaredDependencies) ) { - throw new Error("ACPX claude package omitted its qualified dependencies"); + throw new Error(`ACPX ${profile.agent} package omitted its qualified dependencies`); } - for (const expected of QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES) { + for (const expected of qualifiedDependencies) { if ( (declaredDependencies as Record)[ expected.packageName ] !== expected.dependencyDeclaration ) { throw new Error( - `ACPX claude package dependency mismatch for ${expected.packageName}`, + `ACPX ${profile.agent} package dependency mismatch for ${expected.packageName}`, ); } const dependencyPackageJsonPath = await realpath( @@ -612,7 +641,7 @@ export async function verifyQualifiedAcpxInstallation( ); if (dependencyPackage.version !== expected.packageVersion) { throw new Error( - `ACPX claude dependency package version mismatch for ${expected.packageName}: expected ${expected.packageVersion}, received ${dependencyPackage.version ?? "unknown"}`, + `ACPX ${profile.agent} dependency package version mismatch for ${expected.packageName}: expected ${expected.packageVersion}, received ${dependencyPackage.version ?? "unknown"}`, ); } supplementalPackages.push({ @@ -803,7 +832,7 @@ async function verifyQualifiedRuntimeExecutable(input: { ? QUALIFIED_CLAUDE_LINUX_X64_RUNTIME : input.profile.agent === "codex" ? QUALIFIED_CODEX_LINUX_X64_RUNTIME - : null; + : input.profile.agent === "pi" ? QUALIFIED_PI_LINUX_X64_RUNTIME : null; if (qualification === null) return null; if ( input.profile.agentRuntimePackage !== qualification.runtimePackageName || @@ -820,20 +849,20 @@ async function verifyQualifiedRuntimeExecutable(input: { } const optionalDependencies = input.runtimePackage.optionalDependencies; - if ( + if (input.profile.agent !== "pi" && ( typeof optionalDependencies !== "object" || optionalDependencies === null || Array.isArray(optionalDependencies) || (optionalDependencies as Record)[ qualification.packageName ] !== qualification.dependencyDeclaration - ) { + )) { throw new Error( `ACPX ${input.profile.agent} runtime omitted its qualified Linux executable package`, ); } - const executablePackageJsonPath = await realpath( + const executablePackageJsonPath = input.profile.agent === "pi" ? input.runtimePackageJsonPath : await realpath( input.resolvePackageJson( qualification.packageName, input.runtimePackageJsonPath, @@ -1634,7 +1663,8 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { 'if ((serverPackageFormat !== "module" && serverPackageFormat !== "commonjs") || !Array.isArray(dependencyAncestorFormats) || dependencyAncestorFormats.length !== dependencyAncestorCount || dependencyAncestorFormats.some((value) => value !== "module" && value !== "commonjs")) throw new Error("ACPX provider package formats are invalid");', 'if (providerRuntimeExecutableCount !== 0 && providerRuntimeExecutableCount !== 1) throw new Error("ACPX provider runtime executable count is invalid");', `const providerRuntimeExecutableFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount;`, - 'if (providerRuntimeExecutableCount === 1) { if (providerRuntimeEnvironmentVariable !== "CODEX_PATH" && providerRuntimeEnvironmentVariable !== "CLAUDE_CODE_EXECUTABLE") throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', + 'if (providerRuntimeExecutableCount === 1) { if (!["CODEX_PATH", "CLAUDE_CODE_EXECUTABLE", "PI_ACP_PI_COMMAND"].includes(providerRuntimeEnvironmentVariable)) throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', + `if (providerRuntimeEnvironmentVariable === "PI_ACP_PI_COMMAND") { if (serverDependencyAncestorCount >= dependencyAncestorCount) throw new Error("Pi runtime package descriptor is missing"); process.env.PI_PACKAGE_DIR = "/proc/" + process.pid + "/fd/" + (${DEPENDENCY_ANCESTOR_FD_START} + serverDependencyAncestorCount) + "/vendor/standalone"; }`, ...(guarded ? [ `const guardianFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount + providerRuntimeExecutableCount;`, @@ -1709,6 +1739,9 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "let dependencyError = error;", "for (let dependencyIndex = Math.max(0, parentDependencyAncestorIndex); dependencyIndex < dependencyDirectoryUrls.length; dependencyIndex += 1) {", "const dependencyDirectoryUrl = dependencyDirectoryUrls[dependencyIndex];", + // Node's package self-reference requires exports. Older ACP SDKs expose + // only main; resolve that exact named root through its retained descriptor. + 'if (dependencyIndex >= serverDependencyAncestorCount) { const metadata = JSON.parse(fs.readFileSync(new URL("package.json", dependencyDirectoryUrl), "utf8")); if (metadata.name === specifier && metadata.exports === undefined) { const main = typeof metadata.main === "string" ? metadata.main : "index.js"; const mainUrl = new URL(main, dependencyDirectoryUrl); if (!mainUrl.href.startsWith(dependencyDirectoryUrl)) throw new Error("ACPX dependency main escapes its verified package"); return rememberDependencyAncestor(specifier, nextResolve(mainUrl.href, context)); } }', 'try { const candidateResolution = context.conditions?.includes("require") ? nextResolve(resolveBareFromDescriptor(specifier, dependencyDirectoryUrl), context) : nextResolve(specifier, { ...context, parentURL: new URL("package.json", dependencyDirectoryUrl).href }); return rememberDependencyAncestor(specifier, candidateResolution); } catch (candidateError) {', "if (!isMissingModuleError(candidateError)) throw candidateError;", "dependencyError = candidateError;", diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index d851031398..185e3b9df6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -32,9 +32,9 @@ export interface QualifiedAcpxProfile { } /** - * Digests bind the closed profile declaration (package, version, runtime and - * model), not a caller-controlled executable. The environment probe separately - * verifies the resolved package files before a billable prompt is admitted. + * Command digests bind the exact installed adapter entrypoint bytes. The closed + * profile also pins its package, runtime and model; launch verifies these + * artifacts before a billable prompt is admitted. */ export const QUALIFIED_ACPX_PROFILES: Readonly< Record @@ -50,7 +50,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 0836cbcf40..11b61ad4c0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -245,11 +245,6 @@ export class AcpxRuntimeHost { dependencies: AcpxRuntimeHostDependencies, ): Promise { options.signal?.throwIfAborted(); - if (options.agent === "pi") { - throw new Error( - "ACPX pi is unavailable until its runtime has descriptor-confined verified launch", - ); - } const profile = resolveQualifiedAcpxProfile(options.agent, options.model); const binding = await runAbortableAdmissionStage( options.signal, diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 8cc9c15c2a..c29035cdbb 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -861,9 +861,6 @@ export class CapabilityLiveSessionService { } async create(input: CreateCapabilityLiveSessionInput = {}): Promise { - if (input.provider === "acpx" && input.acpxAgent === "pi") { - throw new Error("The Pi ACPX profile is not available"); - } if (input.provider === "claude_managed" && !input.managedProfile) { throw new Error("Claude Managed live sessions require a qualified managed profile"); } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 139fe1fb83..4feabcd02e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -2039,9 +2039,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { - if (options.provider === "acpx" && options.acpxAgent === "pi") { - throw new Error("The Pi ACPX profile is not available"); - } this.#failureSignal = new Promise((_resolve, reject) => { this.#rejectFailureSignal = reject; }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 86791b6933..9657c8527b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -488,7 +488,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.153.4"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Claude/Codex ACP agent through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Claude/Codex/Pi ACP agent through the Rust Paperclip runner and authenticated PRP transport. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { @@ -546,8 +546,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { options: [ { value: "claude", label: "Claude via ACPX" }, { value: "codex", label: "Codex via ACPX" }, + { value: "pi", label: "Pi via ACPX" }, ], - hint: "Only the pinned Claude and Codex profiles are qualified; Pi is unavailable.", + hint: "Uses the pinned Claude, Codex, or Pi profile.", meta: { visibleWhen: { key: "provider", value: "acpx" } }, }, { diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 72b194cac7..49ea5e706d 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -370,12 +370,19 @@ describe("Paperclip Runner native provider configuration", () => { ).toThrow("provider changed after this run selected its native backend"); }); - it("rejects Pi before a native descriptor is persisted", () => { + it("accepts the exact Pi profile for native execution", () => { + expect(resolvePaperclipRunnerNativeProviderInput({ + backend: "acpx_runtime", + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + })).toMatchObject({ provider: "acpx", acpxAgent: "pi" }); + }); + + it("rejects an unqualified Pi model before a native descriptor is persisted", () => { expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "pi-model" }, }), - ).toThrow("Pi is not available"); + ).toThrow("requires exact model openrouter/deepseek/deepseek-v4-flash-0731"); }); }); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 8a60439a30..6729b76e28 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -630,12 +630,15 @@ describe("remote provider pack manifest", () => { acpx: "0.13.1", claudeAcp: "0.70.0", codexAcp: "1.6.2", + pi: "0.84.2", + piAcp: "0.0.33", }, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "1".repeat(40), distDigest: sha256DirectoryTree(join(root, "dist")), bridgeDigest: "", acpxProfileDigests: { + pi: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 4222d68c91..fe291517bb 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5043,8 +5043,11 @@ const REMOTE_PROVIDER_PACK_PINS = { acpx: "0.13.1", claudeAcp: "0.70.0", codexAcp: "1.6.2", + pi: "0.84.2", + piAcp: "0.0.33", } as const; const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = { + pi: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 197f82f1f8..021d573819 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -17,6 +17,7 @@ export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const; export const QUALIFIED_ACPX_RUNNER_MODELS = { claude: "claude-sonnet-5", codex: "gpt-5.6-sol", + pi: "openrouter/deepseek/deepseek-v4-flash-0731", } as const; export type QualifiedPaperclipRunnerAcpxAgent = @@ -404,10 +405,10 @@ export function resolvePaperclipRunnerProviderProfile( } const acpxAgent = config.acpxAgent; - if (acpxAgent !== "claude" && acpxAgent !== "codex") { + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "pi") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", - "Paperclip Runner ACPX requires the qualified Claude or Codex agent profile; Pi is not available.", + "Paperclip Runner ACPX requires the qualified Claude, Codex, or Pi agent profile.", ); } const qualifiedModel = QUALIFIED_ACPX_RUNNER_MODELS[acpxAgent]; diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 23a9a5dca8..2efe26f640 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -34,6 +34,9 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/build-provider-pack.mjs", + "packages/paperclip-runner/scripts/materialize-pi-binary.mjs", + "packages/paperclip-runner/scripts/portable-provider-shim.mjs", + "packages/paperclip-runner/scripts/verify-pi-provider-launch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", "packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs", diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 706e0df118..23a1593fab 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -33,6 +33,7 @@ const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const acpxRunnerModels = { claude: "claude-sonnet-5", codex: "gpt-5.6-sol", + pi: "openrouter/deepseek/deepseek-v4-flash-0731", } as const; const defaultClaudeManagedModel = "claude-sonnet-5"; const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6"; @@ -119,7 +120,7 @@ export function CodexLocalConfigFields({ ? values!.adapterSchemaValues?.acpxAgent : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude") : "claude"; - const acpxAgent = configuredAcpxAgent === "codex" ? "codex" : "claude"; + const acpxAgent = configuredAcpxAgent === "pi" ? "pi" : configuredAcpxAgent === "codex" ? "codex" : "claude"; const runnerLifecycleMode = runnerManaged ? isCreate ? (values!.paperclipRunnerLifecycleMode ?? "per_turn") @@ -390,13 +391,13 @@ export function CodexLocalConfigFields({ {runnerManaged && runnerProvider === "acpx" && ( )}