feat(runner): qualify pinned Pi runtime and linked provider launchers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
368ebe76e7
commit
2962ebfdb7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)"; \
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
}
|
||||
|
|
@ -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 }); }
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 }); }
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue