diff --git a/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md b/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md index 14554af461..eebd41fb15 100644 --- a/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md +++ b/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md @@ -113,6 +113,21 @@ separately staged runnerd artifact digest. The consumer uses no workspace protocol, source-relative import, or deep package path. This is the packaging gate; workspace tests alone are not proof. +For installed dependencies without bundled dependencies, the gate packs a private +copy outside pnpm's dependency tree, excluding only the package-root +`node_modules` directory. Manifest bytes, file modes, symlinks, and npm file +selection rules remain unchanged. Packages declaring bundled dependencies keep +the original pack path so their dependency payload is preserved. Temporary pack +inputs are removed after success or failure; installed sources are not modified. + +Run the focused staging regression from the repository root: + +```sh +node --test packages/paperclip-runner/scripts/installed-package-pack.test.mjs +``` + +This regression also runs in the Runner's `test:typescript:prep` gate. + ## Consequences - Existing tests importing mock/conformance values from the package root must diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 67283bbc2f..619bc38338 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -74,7 +74,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "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 test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.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", + "test:typescript:prep": "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 test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.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/installed-package-pack.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "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", diff --git a/packages/paperclip-runner/scripts/installed-package-pack.test.mjs b/packages/paperclip-runner/scripts/installed-package-pack.test.mjs index b091d4d362..8835d66646 100644 --- a/packages/paperclip-runner/scripts/installed-package-pack.test.mjs +++ b/packages/paperclip-runner/scripts/installed-package-pack.test.mjs @@ -15,7 +15,28 @@ async function fixture(t, extra = {}) { return { root, source }; } -for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] }]) { +// Capture bytes, modes, and link text without following package symlinks. +async function inventory(root) { + const entries = []; + async function visit(relative) { + const path = join(root, relative); + const stat = await lstat(path); + const entry = { path: relative, mode: stat.mode & 0o7777 }; + if (stat.isSymbolicLink()) entries.push({ ...entry, type: "link", target: await readlink(path) }); + else if (stat.isDirectory()) { + entries.push({ ...entry, type: "directory" }); + for (const name of (await readdir(path)).sort()) await visit(join(relative, name)); + } else { + assert.ok(stat.isFile()); + entries.push({ ...entry, type: "file", bytes: await readFile(path) }); + } + } + await visit(""); + return entries; +} + +for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] }, + { bundledDependencies: false }, { bundledDependencies: [] }]) { test(`stages unchanged non-bundled package ${JSON.stringify(extra)}`, async t => { const { root, source } = await fixture(t, extra); await mkdir(join(source, "node_modules")); @@ -24,6 +45,7 @@ for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] await chmod(join(source, "bin.js"), 0o755); await writeFile(join(source, ".npmignore"), "excluded.txt\n"); await symlink("bin.js", join(source, "link")); + const sourceBefore = await inventory(source); let staged; const result = await withInstalledPackagePackInput(source, root, async (input, external) => { staged = input; @@ -37,31 +59,35 @@ for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] return "tarball"; }); assert.equal(result, "tarball"); + assert.deepEqual(await inventory(source), sourceBefore); await assert.rejects(lstat(staged), { code: "ENOENT" }); assert.equal(await readFile(join(source, "node_modules/foreign"), "utf8"), "must not copy"); }); } -for (const extra of [{ bundleDependencies: true }, { bundleDependencies: ["dependency"] }, { bundledDependencies: ["dependency"] }]) { +for (const extra of [{ bundleDependencies: true }, { bundleDependencies: ["dependency"] }, { bundledDependencies: true }, { bundledDependencies: ["dependency"] }]) { test(`preserves original bundled pack path ${JSON.stringify(extra)}`, async t => { const { root, source } = await fixture(t, extra); const before = await readdir(root); + const sourceBefore = await inventory(source); assert.equal(await withInstalledPackagePackInput(source, root, async (input, external) => { assert.equal(input, source); assert.equal(external, false); return "unchanged"; }), "unchanged"); assert.deepEqual(await readdir(root), before); + assert.deepEqual(await inventory(source), sourceBefore); }); } test("removes only its staging input when packing fails", async t => { const { root, source } = await fixture(t); + const sourceBefore = await inventory(source); let staged; const error = new Error("pack failed"); await assert.rejects(withInstalledPackagePackInput(source, root, async input => { staged = input; throw error; }), value => value === error); await assert.rejects(lstat(staged), { code: "ENOENT" }); - assert.ok((await lstat(join(source, "package.json"))).isFile()); + assert.deepEqual(await inventory(source), sourceBefore); }); for (const useFiles of [true, false]) { @@ -93,11 +119,13 @@ for (const useFiles of [true, false]) { }; // Check actual archive bytes against the installed input; the failing npm // directory traversal itself is reproduced separately on Linux CI. + const sourceBefore = await inventory(source); let input; const staged = await withInstalledPackagePackInput(source, root, async path => { input = path; return pack(path, "first"); }); + assert.deepEqual(await inventory(source), sourceBefore); for (const file of staged.files) { const bytes = execFileSync("tar", ["-xzOf", "-", `package/${file.path}`], { input: staged.archive, timeout: 5_000, maxBuffer: 1024 * 1024 });