From aec26ad83f1d082f0d0a5eaffbd615a9e2e26155 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 17:33:50 -0500 Subject: [PATCH] fix(runner): pin patched Undici and Node in Pi profile v5 Co-Authored-By: Paperclip --- doc/architecture/runner-pi-capabilities.md | 55 ++++++++++---- .../runner-core/src/acpx_provider_backend.rs | 4 +- .../tests/native_provider_backend.rs | 2 +- .../scripts/materialize-pi-distribution.mjs | 74 ++++++++++++++++++- .../materialize-pi-distribution.test.mjs | 73 +++++++++++++++++- .../scripts/pi-distribution/package-lock.json | 6 +- .../scripts/pi-distribution/package.json | 5 ++ .../src/drivers/acpx/pi-closure-pins.ts | 8 +- .../src/drivers/acpx/pi-installation.test.ts | 15 ++-- .../src/drivers/acpx/pi-installation.ts | 2 +- .../src/drivers/acpx/pi-node-pins.ts | 11 +-- .../src/drivers/acpx/qualified-profiles.ts | 4 +- 12 files changed, 216 insertions(+), 43 deletions(-) diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index de2af32ed9f..f73d0d85937 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -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 diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 7925dbbbde1..7db41a1c426 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -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", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 75297484396..ad91f7af31a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -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() diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index a6d5114fcdd..82873e8b13b 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -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 }); diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs index 5af938bdf8c..39e39e7c889 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs @@ -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"]); +}); diff --git a/packages/paperclip-runner/scripts/pi-distribution/package-lock.json b/packages/paperclip-runner/scripts/pi-distribution/package-lock.json index d93c77cf0d1..3fb8482b1ad 100644 --- a/packages/paperclip-runner/scripts/pi-distribution/package-lock.json +++ b/packages/paperclip-runner/scripts/pi-distribution/package-lock.json @@ -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" diff --git a/packages/paperclip-runner/scripts/pi-distribution/package.json b/packages/paperclip-runner/scripts/pi-distribution/package.json index 8f019e049ff..c126d198bab 100644 --- a/packages/paperclip-runner/scripts/pi-distribution/package.json +++ b/packages/paperclip-runner/scripts/pi-distribution/package.json @@ -11,5 +11,10 @@ }, "engines": { "node": ">=24.11.0" + }, + "overrides": { + "@earendil-works/pi-coding-agent@0.84.2": { + "undici": "8.10.2" + } } } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index 101e1c4396b..2e40234e53a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -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", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 7af733d642e..12849bf512d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -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"); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index 1dcd8cd099e..a9baabd8c7d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -24,7 +24,7 @@ function record(value: unknown): Record { /** 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"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts index 7132c2521bb..93d1b1a4e95 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts @@ -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 }), }); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 5932bd6e38b..3324b453b84 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -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",