mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
fix(runner): pin patched Undici and Node in Pi profile v5
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6a4785d32f
commit
aec26ad83f
12 files changed
+216
-43
No files matched your search
@@ -1,9 +1,10 @@
|
||||
# Pi rich ACP runtime
|
||||
|
||||
Status: implementation candidate, 2026-09-28. Profile version 4 repairs native
|
||||
tool ID reuse across model iterations and warm prompts. Its deterministic bridge,
|
||||
SDK ordering, lifecycle and history regressions pass; authenticated v4
|
||||
qualification is pending. Historical version 3 passes cover local Product file
|
||||
Status: implementation candidate, 2026-09-28. Profile version 5 repairs the
|
||||
Undici dependency and bundled Node runtime affected by GHSA-3wwx-pv8p-q78v.
|
||||
Authenticated v5 qualification is pending. Historical version 4 repairs native
|
||||
tool ID reuse across model iterations and warm prompts; its native steering,
|
||||
queued follow-up and denied-write probe passes. Historical version 3 passes cover local Product file
|
||||
delivery, typed question continuation and native steering, queued follow-up and
|
||||
denied-write controls. Its semantic plan journey exposed two defects: the shared
|
||||
MCP display projection, now repaired, followed by a resumed-turn ID collision,
|
||||
@@ -81,10 +82,11 @@ cached validation errors, concurrent exact retries, changed-payload rejection,
|
||||
and a corrected invocation in the next iteration. Installed-wrapper tests verify
|
||||
lifecycle correlation and stable, disjoint historical replay identities.
|
||||
|
||||
The v4 command digest is
|
||||
`sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`.
|
||||
Versions 1, 2 and 3 cannot reopen under this identity. Older paid and image proofs
|
||||
below remain evidence of their recorded versions, not v4 qualification.
|
||||
The current v5 command digest is
|
||||
`sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803`.
|
||||
Versions 1–4 cannot reopen under this identity. Paid and image proofs below remain
|
||||
evidence of their recorded versions, not v5 qualification. The historical v4
|
||||
digest is `sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`.
|
||||
|
||||
## Lifetime and recovery
|
||||
|
||||
@@ -221,7 +223,7 @@ Primary references reviewed:
|
||||
Run `node packages/paperclip-runner/scripts/materialize-pi-distribution.mjs
|
||||
/absolute/new-output` on the target build host. Supported targets are macOS arm64,
|
||||
macOS x64 and Linux x64; no cross-platform qualification is inferred. The builder
|
||||
uses exact official Node 24.19.0, pinned archive SHA-256 and executable SHA-256
|
||||
uses exact official Node 24.21.0, pinned archive SHA-256 and executable SHA-256
|
||||
for each target. `--node=/absolute/portable-node` reuses a matching binary; without
|
||||
it the builder downloads the pinned official archive. It rejects non-system
|
||||
dynamic-library dependencies and executes the copied interpreter after relocation.
|
||||
@@ -234,10 +236,34 @@ preserves Pi's upstream nested shrinkwrap, and adds npm registry SHA-512 integri
|
||||
for the six exact 0.84.2 Pi family packages whose published shrinkwrap omitted it.
|
||||
Installation uses `npm ci --ignore-scripts`, public registry access, private npm
|
||||
configuration and an environment without npm or provider credentials. The builder
|
||||
checks every locked installed version and upstream shrinkwrap entry, applies the
|
||||
checks every locked installed version and upstream shrinkwrap entry (with the
|
||||
exact security exception below), applies the
|
||||
owned ACP patch, verifies the helper matches its TypeScript source, and compiles
|
||||
the owned extension to `runtime/extensions/paperclip.js`.
|
||||
|
||||
Profile v5 replaces only the nested `undici@8.9.0` with `8.10.2`. Both an
|
||||
existing-lock resolution and a fresh resolution ignore npm's scoped override
|
||||
inside Pi's published shrinkwrap; `npm ci` also installs 8.9.0 despite the fixed
|
||||
outer lock. The materializer therefore checks the exact original nested path,
|
||||
version, registry URL and SHA-512, plus the installed vulnerable package identity,
|
||||
before fetching the exact fixed tarball. It verifies the pinned SHA-512 before
|
||||
extraction, bounds both gzip and inflated tar bytes to 4 MiB, rejects traversal,
|
||||
links and unexpected entry types, and requires the 214 regular-file payload.
|
||||
The published Pi package metadata and shrinkwrap remain unchanged; only this exact
|
||||
old-to-new tuple is an exception. Every other package retains its previous lock
|
||||
entry. Installed version checks and the complete source-pinned closure then make
|
||||
it impossible for npm's old copy to survive admission.
|
||||
|
||||
Node 24.21.0 also replaces bundled Undici 7.29.0 with 7.29.1, covering the global
|
||||
WebSocket implementation as well as the nested npm copy. The materializer checks
|
||||
`process.versions.undici` explicitly. Sources: [official advisory](https://github.com/advisories/GHSA-3wwx-pv8p-q78v),
|
||||
[fixed Undici release](https://github.com/nodejs/undici/releases/tag/v8.10.2),
|
||||
[Node 24.21.0 release](https://nodejs.org/en/blog/release/v24.21.0) and
|
||||
[official archive checksums](https://nodejs.org/dist/v24.21.0/SHASUMS256.txt).
|
||||
Only Pi's declarations and three closure pins change; sibling provider pins
|
||||
remain unchanged. The containing provider pack must be rebuilt with the patched
|
||||
portable interpreter and retains its own manifest identity.
|
||||
|
||||
The result contains `runtime/` and a sibling `pi-distribution.json`. Keeping the
|
||||
manifest outside its inventoried root avoids a self-referential file digest. The
|
||||
returned environment bindings point into `runtime/`; callers must retain a verified
|
||||
@@ -295,7 +321,7 @@ The first authenticated local Runner snapshot verified the exact configured
|
||||
model ID. It did not settle or produce a terminal usage receipt; complete local
|
||||
and Linux/Daytona receipt qualification remains outstanding.
|
||||
|
||||
Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. The current v4 digest above hashes its versioned
|
||||
Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. The current v5 digest above hashes its versioned
|
||||
profile domain, patched wrapper source and platform closure pins. Every native
|
||||
closure remains independently checked at launch. Version 1, 2 and 3 warm sessions cannot
|
||||
be reused with this integration.
|
||||
@@ -418,9 +444,10 @@ attempt at $0.000546502; Pi's separate catalog estimate is $0.000803936. Initial
|
||||
unchanged billing reads are retained and were not treated as free inference.
|
||||
|
||||
A newly indexed [Undici advisory](https://github.com/advisories/GHSA-3wwx-pv8p-q78v)
|
||||
also affects the current isolated npm Undici 8.9.0 and Node 24.19.0's bundled
|
||||
Undici 7.29.0. Further paid work is held while the provider-local v5 dependency
|
||||
repair is prepared. This paid v4 proof remains bound to its original bytes.
|
||||
affects the historical v4 isolated npm Undici 8.9.0 and Node 24.19.0's bundled
|
||||
Undici 7.29.0. Profile v5 fixes both copies as described above. Further paid work
|
||||
remains held until fresh pack proofs and a new bounded authorization. This paid
|
||||
v4 proof remains bound to its original bytes.
|
||||
|
||||
The first [v4 plan attempt](../../packages/paperclip-runner/test-fixtures/pi-acp/product-plan-infrastructure-failure.v4.darwin-arm64.json)
|
||||
failed before provider startup: embedded PostgreSQL could not initialize the
|
||||
|
||||
@@ -170,7 +170,7 @@ impl AcpxProviderDescriptor {
|
||||
"0.0.33",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991",
|
||||
"sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803",
|
||||
),
|
||||
"cursor" => (
|
||||
self.model.as_str(),
|
||||
@@ -2461,7 +2461,7 @@ mod tests {
|
||||
"pi",
|
||||
"pi-acp",
|
||||
"0.0.33",
|
||||
"sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991",
|
||||
"sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991";
|
||||
"sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { stripTypeScriptTypes } from "node:module";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts";
|
||||
import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts";
|
||||
@@ -17,13 +18,79 @@ const lockDirectory = join(packageRoot, "scripts/pi-distribution");
|
||||
const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch");
|
||||
const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
export const PI_DISTRIBUTION_PINS = Object.freeze({
|
||||
wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION,
|
||||
wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1",
|
||||
wrapperSha256: "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac",
|
||||
helperSha256: "9e50b60644d0d00b3fb2660eb78ad616dd01395775bcb05974d2bdeadc7f7e57",
|
||||
});
|
||||
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** npm ci honors the committed graph and Pi's published nested shrinkwrap. */
|
||||
/** The sole reviewed departure from Pi 0.84.2's published shrinkwrap.
|
||||
* npm ci reifies that upstream shrinkwrap even when the outer lock and override
|
||||
* request the fixed version. Keep the published metadata intact, replace this
|
||||
* exact package explicitly, and bind the resulting files in all closure pins.
|
||||
* https://github.com/advisories/GHSA-3wwx-pv8p-q78v
|
||||
*/
|
||||
export const PI_UNDICI_SECURITY_OVERRIDE = Object.freeze({
|
||||
path: "node_modules/@earendil-works/pi-coding-agent/node_modules/undici",
|
||||
upstreamPath: "node_modules/undici",
|
||||
previous: Object.freeze({ version: "8.9.0", resolved: "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", integrity: "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==" }),
|
||||
fixed: Object.freeze({ version: "8.10.2", resolved: "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", integrity: "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==" }),
|
||||
});
|
||||
const samePackagePin = (entry, pin) => entry?.version === pin.version && entry?.resolved === pin.resolved && entry?.integrity === pin.integrity;
|
||||
export function assertPiUndiciSecurityOverride(lock, shrinkwrap) {
|
||||
const pin = PI_UNDICI_SECURITY_OVERRIDE;
|
||||
if (shrinkwrap.version !== PI_DISTRIBUTION_PINS.runtime || shrinkwrap.lockfileVersion !== 3 ||
|
||||
!samePackagePin(shrinkwrap.packages?.[pin.upstreamPath], pin.previous) ||
|
||||
!samePackagePin(lock.packages?.[pin.path], pin.fixed)) throw new Error("Pi Undici security override differs from its exact reviewed old/new pins");
|
||||
}
|
||||
|
||||
export function assertPiUndiciArchive(bytes) {
|
||||
if (bytes.length > 4 * 1024 * 1024 || `sha512-${createHash("sha512").update(bytes).digest("base64")}` !== PI_UNDICI_SECURITY_OVERRIDE.fixed.integrity) throw new Error("Pi fixed Undici archive differs from its integrity pin");
|
||||
// Bound the complete tar stream (headers and file payloads), not just gzip.
|
||||
gunzipSync(bytes, { maxOutputLength: 4 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
export function assertPiUndiciArchiveEntries(paths, listing) {
|
||||
const entries = paths.trim().split("\n");
|
||||
if (entries.length < 2 || entries.length > 512 || entries.some(path => !path.startsWith("package/") || path.includes("\\") || path.split("/").some(part => part === ".." || part === ".") || /[\x00-\x1f\x7f]/.test(path)) ||
|
||||
listing.trim().split("\n").length !== entries.length || listing.trim().split("\n").some(line => !/^[d-]/.test(line))) throw new Error("Pi fixed Undici archive has an unsafe entry");
|
||||
}
|
||||
|
||||
export async function applyPiUndiciSecurityOverride(root, lock) {
|
||||
root = await realpath(root);
|
||||
const pin = PI_UNDICI_SECURITY_OVERRIDE;
|
||||
const piRoot = join(root, "node_modules/@earendil-works/pi-coding-agent");
|
||||
const shrinkwrap = JSON.parse(await readFile(join(piRoot, "npm-shrinkwrap.json"), "utf8"));
|
||||
assertPiUndiciSecurityOverride(lock, shrinkwrap);
|
||||
const installed = join(root, pin.path);
|
||||
const metadata = JSON.parse(await readFile(join(installed, "package.json"), "utf8"));
|
||||
if ((await lstat(installed)).isSymbolicLink() || await realpath(installed) !== installed || metadata.name !== "undici" || metadata.version !== pin.previous.version) throw new Error("Pi npm install did not produce the expected upstream Undici package");
|
||||
const temporary = await mkdtemp(join(dirname(installed), ".paperclip-undici-fix-"));
|
||||
try {
|
||||
const response = await fetch(pin.fixed.resolved, { redirect: "error", signal: AbortSignal.timeout(60_000) });
|
||||
if (!response.ok || !response.body) throw new Error("Pi fixed Undici download failed");
|
||||
const chunks = []; let length = 0;
|
||||
for await (const chunk of response.body) {
|
||||
length += chunk.length; if (length > 4 * 1024 * 1024) throw new Error("Pi fixed Undici archive exceeds its bound");
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const bytes = Buffer.concat(chunks); assertPiUndiciArchive(bytes);
|
||||
const archive = join(temporary, "fixed.tgz"); await writeFile(archive, bytes);
|
||||
const options = { env: { PATH: "/usr/bin:/bin" }, timeout: 30_000, maxBuffer: 1024 * 1024 };
|
||||
const [paths, listing] = await Promise.all([run("tar", ["-tzf", archive], options), run("tar", ["-tvzf", archive], options)]);
|
||||
assertPiUndiciArchiveEntries(paths.stdout, listing.stdout);
|
||||
const extracted = join(temporary, "package"); await mkdir(extracted);
|
||||
await run("tar", ["-xzf", archive, "-C", extracted, "--strip-components=1", "--no-same-owner"], options);
|
||||
const fixed = JSON.parse(await readFile(join(extracted, "package.json"), "utf8"));
|
||||
if (fixed.name !== "undici" || fixed.version !== pin.fixed.version) throw new Error("Pi fixed Undici package identity is invalid");
|
||||
const files = await inventoryPiRuntimeFiles(extracted);
|
||||
if (files.length !== 214 || files.some(file => file.kind !== "file")) throw new Error("Pi fixed Undici package payload is invalid");
|
||||
await rm(installed, { recursive: true });
|
||||
await rename(extracted, installed);
|
||||
} finally { await rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/** npm ci honors Pi's published nested shrinkwrap; the exact security fix follows. */
|
||||
export function piDistributionInstallCommand() {
|
||||
return ["ci", "--ignore-scripts", "--include=optional", "--omit=dev", "--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", "--userconfig=.npmrc", "--globalconfig=.npmrc-global"];
|
||||
}
|
||||
@@ -55,6 +122,7 @@ export async function verifyLockedPiPackageGraph(root, lock, target = { platform
|
||||
for (const [path, entry] of Object.entries(shrinkwrap.packages)) {
|
||||
if (!path) continue;
|
||||
const pinned = lock.packages[`${piPath}/${path}`];
|
||||
if (path === PI_UNDICI_SECURITY_OVERRIDE.upstreamPath) { assertPiUndiciSecurityOverride(lock, shrinkwrap); continue; }
|
||||
if (!pinned || pinned.version !== entry.version || (entry.integrity !== undefined && pinned.integrity !== entry.integrity) || pinned.resolved !== entry.resolved) throw new Error(`Pi distribution dropped or changed shrinkwrapped package ${path}`);
|
||||
}
|
||||
return verified;
|
||||
@@ -142,6 +210,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np
|
||||
if (hash(await readFile(node)) !== nodePin.executableSha256 || (await lstat(node)).size !== nodePin.executableSize) throw new Error("Pi Node executable does not match its target release pin");
|
||||
const version = (await run(node, ["--version"], { env: {}, timeout: 10_000 })).stdout.trim();
|
||||
if (version !== `v${PI_NODE_VERSION}`) throw new Error("Pi distribution requires exact pinned Node version");
|
||||
if ((await run(node, ["-p", "process.versions.undici"], { env: {}, timeout: 10_000 })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin");
|
||||
await mkdir(runtimeRoot);
|
||||
await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(lockDirectory, name), join(runtimeRoot, name))));
|
||||
await writeFile(join(runtimeRoot, ".npmrc"), "registry=https://registry.npmjs.org/\nignore-scripts=true\naudit=false\nfund=false\n");
|
||||
@@ -155,6 +224,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np
|
||||
const installedLockBytes = await readFile(join(runtimeRoot, "package-lock.json"));
|
||||
if (!installedLockBytes.equals(await readFile(join(lockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock");
|
||||
const lock = JSON.parse(installedLockBytes.toString("utf8"));
|
||||
await applyPiUndiciSecurityOverride(runtimeRoot, lock);
|
||||
const packageCount = await verifyLockedPiPackageGraph(runtimeRoot, lock);
|
||||
const wrapper = join(runtimeRoot, "node_modules/pi-acp");
|
||||
await run("git", ["apply", "--check", patchPath], { cwd: wrapper, env: environment, timeout: 10_000 });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { assertPiNodeSystemDependencies, PI_DISTRIBUTION_PINS, materializePiDistribution, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs";
|
||||
import { assertPiNodeSystemDependencies, applyPiUndiciSecurityOverride, assertPiUndiciArchive, assertPiUndiciArchiveEntries, assertPiUndiciSecurityOverride, PI_UNDICI_SECURITY_OVERRIDE, PI_DISTRIBUTION_PINS, materializePiDistribution, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs";
|
||||
import { verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts";
|
||||
|
||||
async function fixture(t) {
|
||||
@@ -17,6 +17,10 @@ test("distribution lock closes exact wrapper, SDK and upstream Pi graph with int
|
||||
const pkg = JSON.parse(await readFile(new URL("./pi-distribution/package.json", import.meta.url), "utf8"));
|
||||
const lock = JSON.parse(await readFile(new URL("./pi-distribution/package-lock.json", import.meta.url), "utf8"));
|
||||
assert.deepEqual(lock.packages[""].dependencies, pkg.dependencies);
|
||||
assert.deepEqual(pkg.overrides, { "@earendil-works/pi-coding-agent@0.84.2": { undici: "8.10.2" } });
|
||||
assert.equal(lock.packages[PI_UNDICI_SECURITY_OVERRIDE.path].version, "8.10.2");
|
||||
assert.equal(PI_DISTRIBUTION_PINS.nodeVersion, "24.21.0");
|
||||
assert.equal(PI_DISTRIBUTION_PINS.nodeBundledUndici, "7.29.1");
|
||||
assert.deepEqual(pkg.dependencies, { "@agentclientprotocol/sdk": "0.26.0", "@earendil-works/pi-coding-agent": "0.84.2", "pi-acp": "0.0.33", zod: "3.25.76" });
|
||||
for (const [path, entry] of Object.entries(lock.packages)) {
|
||||
if (!path) continue;
|
||||
@@ -82,3 +86,68 @@ test("Node dependency inspection rejects Homebrew and non-system Linux libraries
|
||||
assert.throws(() => assertPiNodeSystemDependencies("libnode.so => /opt/lib/libnode.so (0x000)\n", "linux"), /unbundled/);
|
||||
assert.doesNotThrow(() => assertPiNodeSystemDependencies("linux-vdso.so.1 (0x000)\nlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000)\n/lib64/ld-linux-x86-64.so.2 (0x000)\n", "linux"));
|
||||
});
|
||||
|
||||
|
||||
test("security exception admits only the exact upstream-to-fixed Undici tuple", async (t) => {
|
||||
const { root, write } = await fixture(t);
|
||||
const pi = "node_modules/@earendil-works/pi-coding-agent";
|
||||
const pin = PI_UNDICI_SECURITY_OVERRIDE;
|
||||
const lock = { lockfileVersion: 3, packages: {
|
||||
[pi]: { version: "0.84.2", resolved: "https://registry.npmjs.org/pi.tgz", integrity: "sha512-YWJjZA==" },
|
||||
[pin.path]: { ...pin.fixed },
|
||||
} };
|
||||
const shrinkwrap = { version: "0.84.2", lockfileVersion: 3, packages: { [pin.upstreamPath]: { ...pin.previous } } };
|
||||
await write(`${pi}/package.json`, JSON.stringify({ version: "0.84.2" }));
|
||||
await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify(shrinkwrap));
|
||||
// npm ci's vulnerable copy cannot pass the post-replacement installed check.
|
||||
await write(`${pin.path}/package.json`, JSON.stringify({ name: "undici", version: "8.9.0" }));
|
||||
await assert.rejects(verifyLockedPiPackageGraph(root, lock), /differs from its lock/);
|
||||
await write(`${pin.path}/package.json`, JSON.stringify({ name: "undici", version: "8.10.2" }));
|
||||
assert.equal(await verifyLockedPiPackageGraph(root, lock), 2);
|
||||
for (const field of ["version", "resolved", "integrity"]) {
|
||||
const badOld = structuredClone(shrinkwrap); badOld.packages[pin.upstreamPath][field] = "tampered";
|
||||
assert.throws(() => assertPiUndiciSecurityOverride(lock, badOld), /exact reviewed old\/new pins/);
|
||||
const badNew = structuredClone(lock); badNew.packages[pin.path][field] = "tampered";
|
||||
assert.throws(() => assertPiUndiciSecurityOverride(badNew, shrinkwrap), /exact reviewed old\/new pins/);
|
||||
}
|
||||
const moved = structuredClone(lock); moved.packages["node_modules/undici"] = moved.packages[pin.path]; delete moved.packages[pin.path];
|
||||
assert.throws(() => assertPiUndiciSecurityOverride(moved, shrinkwrap), /exact reviewed/);
|
||||
const altered = structuredClone(shrinkwrap); altered.packages[pin.upstreamPath].integrity = "sha512-YWJjZA==";
|
||||
await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify(altered));
|
||||
await assert.rejects(verifyLockedPiPackageGraph(root, lock), /exact reviewed/);
|
||||
});
|
||||
|
||||
test("security tarball rejects tampering, traversal, links and unbounded entry lists", () => {
|
||||
assert.throws(() => assertPiUndiciArchive(Buffer.from("not the pinned tarball")), /integrity pin/);
|
||||
assert.throws(() => assertPiUndiciArchive(Buffer.alloc(4 * 1024 * 1024 + 1)), /integrity pin/);
|
||||
const goodPaths = "package/package.json\npackage/index.js\n";
|
||||
const goodListing = "-rw-r--r-- package/package.json\n-rw-r--r-- package/index.js\n";
|
||||
assert.doesNotThrow(() => assertPiUndiciArchiveEntries(goodPaths, goodListing));
|
||||
for (const path of ["/absolute", "package/../escape", "other/index.js", "package/./index.js", "package/evil\\name"]) {
|
||||
assert.throws(() => assertPiUndiciArchiveEntries(`package/package.json\n${path}\n`, goodListing), /unsafe entry/);
|
||||
}
|
||||
assert.throws(() => assertPiUndiciArchiveEntries(goodPaths, goodListing.replace("-rw", "lrw")), /unsafe entry/);
|
||||
assert.throws(() => assertPiUndiciArchiveEntries(goodPaths, goodListing.replace("-rw", "hrw")), /unsafe entry/);
|
||||
assert.throws(() => assertPiUndiciArchiveEntries(goodPaths.repeat(257), goodListing.repeat(257)), /unsafe entry/);
|
||||
});
|
||||
|
||||
|
||||
test("tampered replacement leaves the original dependency intact and cleans staging", async (t) => {
|
||||
const { root, write } = await fixture(t);
|
||||
const pin = PI_UNDICI_SECURITY_OVERRIDE;
|
||||
const pi = "node_modules/@earendil-works/pi-coding-agent";
|
||||
const lock = { packages: { [pin.path]: { ...pin.fixed } } };
|
||||
await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify({ version: "0.84.2", lockfileVersion: 3, packages: { [pin.upstreamPath]: { ...pin.previous } } }));
|
||||
const original = JSON.stringify({ name: "undici", version: "8.9.0" });
|
||||
await write(`${pin.path}/package.json`, original);
|
||||
const savedFetch = globalThis.fetch;
|
||||
t.after(() => { globalThis.fetch = savedFetch; });
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.equal(url, pin.fixed.resolved);
|
||||
assert.equal(options.redirect, "error");
|
||||
return new Response("tampered archive");
|
||||
};
|
||||
await assert.rejects(applyPiUndiciSecurityOverride(root, lock), /integrity pin/);
|
||||
assert.equal(await readFile(join(root, pin.path, "package.json"), "utf8"), original);
|
||||
assert.deepEqual(await readdir(join(root, pi, "node_modules")), ["undici"]);
|
||||
});
|
||||
@@ -1755,9 +1755,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@earendil-works/pi-coding-agent@0.84.2": {
|
||||
"undici": "8.10.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Candidate closure pins from the isolated npm lock and official Node 24.19.0.
|
||||
* Candidate closure pins from the isolated npm lock and official Node 24.21.0.
|
||||
* The non-Node graph is identical across targets, including platform resources.
|
||||
* macOS arm64 executed admission tests; x64 target execution remains pending.
|
||||
* Changing any package, helper, extension or bootstrap requires regenerating all
|
||||
* three pins. Never accept a digest supplied only by an installed manifest.
|
||||
*/
|
||||
export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({
|
||||
"darwin-arm64": "b0e02ed16d27fd1d90a5e91d6309f1c47d6cf55f8937a8f680baeee19646d320",
|
||||
"darwin-x64": "a79b55d7773f4f67d1c6d87fe67346852df5a23e36e8c55d1e84f80d4b668f22",
|
||||
"linux-x64": "fc7464cb5ea5af2a2d2519f3a67f7471ca3185110f33eaaea3785043709ad3b2",
|
||||
"darwin-arm64": "0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff",
|
||||
"darwin-x64": "9dd49bd1341a6e3438db56cc6b3dc772964ebcdd0552bcfbf2e5e621b2970ab6",
|
||||
"linux-x64": "91169667544764764e2e402868fbb76a9a429575cfdb1b312b8ed8ae669ebc89",
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 4 });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 5 });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
|
||||
@@ -24,7 +24,7 @@ async function fixture() {
|
||||
|
||||
describe("Pi installation factory", () => {
|
||||
it("binds the profile declaration to the reviewed patch and platform closure pins", async () => {
|
||||
const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v4\0")
|
||||
const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v5\0")
|
||||
.update(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))
|
||||
.update("\0").update(await readFile(new URL("./pi-closure-pins.ts", import.meta.url))).digest("hex");
|
||||
expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(`sha256:${digest}`);
|
||||
@@ -32,9 +32,10 @@ describe("Pi installation factory", () => {
|
||||
|
||||
it("rejects legacy profiles and caller-selected identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 4");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 4");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 4");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 5");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 5");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 5");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 5");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
@@ -43,8 +44,8 @@ describe("Pi installation factory", () => {
|
||||
const { assets } = await fixture();
|
||||
await writeFile(join(assets, "pi-distribution.json"), JSON.stringify({
|
||||
schema: "paperclip.pi-distribution.v1", runtimeRoot: "runtime", nativeClosureSha256: "0".repeat(64),
|
||||
target: { platform: process.platform, architecture: process.arch, nodeVersion: "24.19.0" },
|
||||
pins: { nodeVersion: "24.19.0", wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76" },
|
||||
target: { platform: process.platform, architecture: process.arch, nodeVersion: "24.21.0" },
|
||||
pins: { nodeVersion: "24.21.0", wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76" },
|
||||
}));
|
||||
await expect(verifyPiInstallation(candidate())).rejects.toThrow("trusted target pin");
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function record(value: unknown): Record<string, unknown> {
|
||||
/** Candidate identity is build-owned; model selection cannot substitute a CLI. */
|
||||
export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void {
|
||||
const trusted = QUALIFIED_ACPX_PROFILES.pi;
|
||||
if (profile.agentProfileVersion !== 4) throw new Error("Pi rich ACP requires profile version 4; reopen the previous session");
|
||||
if (profile.agentProfileVersion !== 5) throw new Error("Pi rich ACP requires profile version 5; reopen the previous session");
|
||||
if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "0.84.2" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Official Node release SHASUMS256.txt, version 24.19.0, reviewed 2026-09-28. */
|
||||
export const PI_NODE_VERSION = "24.19.0" as const;
|
||||
/** Official Node release SHASUMS256.txt, version 24.21.0, reviewed 2026-09-28.
|
||||
* https://nodejs.org/dist/v24.21.0/SHASUMS256.txt — bundled Undici 7.29.1. */
|
||||
export const PI_NODE_VERSION = "24.21.0" as const;
|
||||
export const PI_NODE_DISTRIBUTIONS = Object.freeze({
|
||||
"darwin-arm64": Object.freeze({ archiveSha256: "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d", executableSha256: "27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", executableSize: 121306800 }),
|
||||
"darwin-x64": Object.freeze({ archiveSha256: "d1b5e999db158c62fe8f7267a4476b035d8bd93b1a605bac24a3f0dd166e3316", executableSha256: "1052eb9c7d6c60a79b968e09f75af55a73462b0f6dff0964336d63b5e13eb63c", executableSize: 123666640 }),
|
||||
"linux-x64": Object.freeze({ archiveSha256: "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4", executableSha256: "bc17c508ffeed0ec622934f9b7fa72f8e78da65350e63c3eceb56fa688aa5e12", executableSize: 125989464 }),
|
||||
"darwin-arm64": Object.freeze({ archiveSha256: "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", executableSha256: "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", executableSize: 122129232 }),
|
||||
"darwin-x64": Object.freeze({ archiveSha256: "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", executableSha256: "7abcf39bd37ab251015337ff75304d7555f0d8e88c6e0fbf04bce8ce34636f49", executableSize: 125270960 }),
|
||||
"linux-x64": Object.freeze({ archiveSha256: "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", executableSha256: "7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c", executableSize: 126595440 }),
|
||||
});
|
||||
@@ -47,14 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "pi",
|
||||
agentProfileVersion: 4,
|
||||
agentProfileVersion: 5,
|
||||
qualificationStatus: "pending",
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest:
|
||||
"sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991",
|
||||
"sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803",
|
||||
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
|
||||
Reference in new issue
Block a user