feat(runner): qualify pinned Pi runtime and linked provider launchers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 13:47:34 -05:00
parent 368ebe76e7
commit 2962ebfdb7
37 changed files with 348 additions and 123 deletions

View File

@ -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.

View File

@ -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)"; \

View File

@ -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"
},

View File

@ -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();

View File

@ -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",
))
}
};

View File

@ -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",
))
}
};

View File

@ -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();
}

View File

@ -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:

View File

@ -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 }); }
}

View File

@ -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 }); }
});

View File

@ -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");
}

View File

@ -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 }); }
});

View File

@ -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 });
}

View File

@ -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,

View File

@ -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`,

View File

@ -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", () => {

View File

@ -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");
}

View File

@ -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,
},
});

View File

@ -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;
}

View File

@ -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",

View File

@ -66,7 +66,7 @@ export interface EvalSessionRequest {
provider?: EvalSessionProvider;
driver?: EvalSessionDriver;
opencodeVersion?: string;
acpxAgent?: Exclude<QualifiedAcpxAgent, "pi">;
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,

View File

@ -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",

View File

@ -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({

View File

@ -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);

View File

@ -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",
}),
),
),

View File

@ -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<VerifiedAcpxInstallation> {
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<string, unknown>)[
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<string, unknown>)[
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;",

View File

@ -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<QualifiedAcpxAgent, QualifiedAcpxProfile>
@ -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",

View File

@ -245,11 +245,6 @@ export class AcpxRuntimeHost {
dependencies: AcpxRuntimeHostDependencies,
): Promise<AcpxRuntimeHost> {
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,

View File

@ -861,9 +861,6 @@ export class CapabilityLiveSessionService {
}
async create(input: CreateCapabilityLiveSessionInput = {}): Promise<CapabilityLiveSession> {
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");
}

View File

@ -2039,9 +2039,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string }>();
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<never>((_resolve, reject) => {
this.#rejectFailureSignal = reject;
});

View File

@ -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" } },
},
{

View File

@ -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");
});
});

View File

@ -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:

View File

@ -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:

View File

@ -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];

View File

@ -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",

View File

@ -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" && (
<Field
label="ACP agent"
hint="Only the pinned Claude and Codex profiles are qualified; Pi is unavailable."
hint="Uses the pinned Claude, Codex, or Pi profile."
>
<select
className={inputClass}
value={acpxAgent}
onChange={(event) => {
const agent = event.target.value === "codex" ? "codex" : "claude";
const agent = event.target.value === "pi" ? "pi" : event.target.value === "codex" ? "codex" : "claude";
const model = acpxRunnerModels[agent];
if (isCreate) {
set!({
@ -414,6 +415,7 @@ export function CodexLocalConfigFields({
>
<option value="claude">Claude via ACPX</option>
<option value="codex">Codex via ACPX</option>
<option value="pi">Pi via ACPX</option>
</select>
</Field>
)}