diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index 2e4dd8f10f..1a7689e992 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -109,6 +109,19 @@ export function piDistributionBootstrapSource() { ].join("\n"); } +/** The already hash-verified Node archive also pins setup's package manager. */ +export async function resolvePiBundledNpm(nodeRoot) { + const npmRoot = join(nodeRoot, "lib/node_modules/npm"); + const manifest = join(npmRoot, "package.json"); + const entry = join(npmRoot, "bin/npm-cli.js"); + for (const path of [manifest, entry]) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || await realpath(path) !== path) throw new Error("Pinned Pi npm has an invalid archive entry"); + } + if (JSON.parse(await readFile(manifest, "utf8")).version !== "11.19.0") throw new Error("Pinned Pi Node archive has an unexpected npm version"); + return entry; +} + /** Build on the target platform. No lifecycle scripts, model requests, or auth. */ export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm", inputs, checkCancelled = () => {} }) { // Cancellation is observed between bounded subprocesses. A setup signal must @@ -131,6 +144,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np const target = `${process.platform}-${process.arch}`; const nodePin = PI_NODE_DISTRIBUTIONS[target]; let node; + let bundledNpm; if (nodeExecutable) node = await realpath(nodeExecutable); else { const archiveName = `node-v${PI_NODE_VERSION}-${target}.tar.gz`; @@ -145,8 +159,9 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np if (hash(bytes) !== nodePin.archiveSha256) throw new Error("Pi Node archive does not match its release pin"); const archivePath = join(staging, archiveName); await writeFile(archivePath, bytes); const nodeRoot = join(staging, "node-extract"); await mkdir(nodeRoot); - await runOwned("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=2", `node-v${PI_NODE_VERSION}-${target}/bin/node`], { timeout: 30_000 }); - node = join(nodeRoot, "node"); + await runOwned("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=1", `node-v${PI_NODE_VERSION}-${target}/bin/node`, `node-v${PI_NODE_VERSION}-${target}/lib/node_modules/npm`], { timeout: 30_000 }); + node = join(nodeRoot, "bin/node"); + bundledNpm = await resolvePiBundledNpm(nodeRoot); } 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 runOwned(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); @@ -161,7 +176,10 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np // .npmrc. Public registry downloads need no private application credential. const environment = Object.fromEntries(["PATH", "LANG", "LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"].flatMap((key) => typeof process.env[key] === "string" ? [[key, process.env[key]]] : [])); environment.HOME = buildHome; - await runOwned(npmExecutable, piDistributionInstallCommand(), { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); + // npm 10 prunes non-host packages bundled by upstream Pi, unlike the npm + // 11.19.0 used to qualify this complete closure. Public setup must use the + // npm pinned by the verified Node archive, never whichever npm is on PATH. + await runOwned(bundledNpm ? node : npmExecutable, [...(bundledNpm ? [bundledNpm] : []), ...piDistributionInstallCommand()], { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); const installedLockBytes = await readFile(join(runtimeRoot, "package-lock.json")); if (!installedLockBytes.equals(await readFile(join(inputLockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); const lock = JSON.parse(installedLockBytes.toString("utf8")); diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs index 54e7780cbb..b92c79ca5b 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, realpath, 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, PI_DISTRIBUTION_PINS, materializePiDistribution, resolvePiBundledNpm, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs"; import { verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; async function fixture(t) { @@ -98,3 +98,19 @@ 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("setup selects the exact archive npm and rejects wrong versions or linked entrypoints", async (t) => { + const fixtureRoot = await fixture(t); + const root = await realpath(fixtureRoot.root), write = fixtureRoot.write; + const prefix = "lib/node_modules/npm/"; + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" })); + await write(prefix + "bin/npm-cli.js", "// pinned archive fixture"); + assert.equal(await resolvePiBundledNpm(root), join(root, prefix, "bin/npm-cli.js")); + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "10.9.7" })); + await assert.rejects(resolvePiBundledNpm(root), /unexpected npm version/); + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" })); + await rm(join(root, prefix, "bin/npm-cli.js")); + await symlink(join(root, prefix, "package.json"), join(root, prefix, "bin/npm-cli.js")); + await assert.rejects(resolvePiBundledNpm(root), /invalid archive entry/); +});