mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The runner release builds a Linux "provider pack". The pack is a self-contained runtime with Node, Claude Code, Codex, and OpenCode. The Daytona runner image and SSH hosts use it. > - The release artifacts for this pack are large: 1,182 MB for linux-x64 and 543 MB for linux-arm64. > - Most of that size is necessary. But about 20–30% of it is code that the pack can never run. > - pnpm 9 installs all libc variants of optional platform packages. Thus the pack includes the musl (Alpine) builds of Claude Code and OpenCode. The pack runs only its copied glibc Node, so these builds are never used. > - On linux-x64, the npm Copilot package is also a byte-identical copy of the verified Copilot provider asset (162 MB). > - This pull request removes these unused bytes from the pack at build time. > - The benefit is smaller release artifacts and Docker images, and faster uploads and downloads. ## Linked Issues or Issue Description **What happened?** The runner release workflow uploads `runner-provider-packs-linux-x64` (1,182 MB) and `runner-provider-packs-linux-arm64` (543 MB). The darwin daemon artifacts are about 10 MB. The darwin artifacts are a different artifact type, so the comparison is not direct. But inspection of the extracted Linux packs found real waste: - `@anthropic-ai/claude-agent-sdk-linux-*-musl` (about 225 MB uncompressed) - `opencode-linux-*-musl` and `opencode-linux-x64-baseline-musl` (about 180 MB each) - on linux-x64, `node_modules/.pnpm/@github+copilot-linux-x64@1.0.88/.../copilot`, which has the same SHA-256 (`0059754c…`) as `provider-assets/copilot/linux-x64/copilot` **Expected behavior** The pack contains only code that its copied Node and its launch shims can run. **Paperclip version or commit** `master` at `e0d9033`. The inspected release run built commit `837add0`. **Steps to reproduce** 1. Install `@agentclientprotocol/claude-agent-acp@0.73.0` and `opencode-ai@1.18.34` with pnpm 9.15.4 on a glibc Linux host. 2. List `node_modules/.pnpm`. 3. See the `*-musl` entries. Their `package.json` declares `"libc": ["musl"]`. ## What Changed - `provider-pack-runtime-tree.mjs`: add `providerPackLibc()`. It reports the libc of the Node that the pack copies (`glibc` or `musl` on Linux, none on other platforms). - `provider-pack-runtime-tree.mjs`: add `removeForeignLibcPackagesFromProviderPack()`. It removes each pnpm virtual-store entry whose own `package.json` declares a `libc` list without the pack libc. Packages without a `libc` field stay (for example `@openai/codex-linux-*`, which holds Codex's static musl binary). - `provider-pack-runtime-tree.mjs`: add `removePnpmStoreEntries()`. It removes store entries and every symlink that resolves into them, so no dangling link stays in the pack. It refuses names outside `node_modules/.pnpm`. - `provider-pack-executable-shims.mjs`: when `provider-assets/copilot/<platform>/copilot` has the same bytes as the npm Copilot executable, the `copilot-<platform>` command now points at the asset and the npm copy is removed. If the bytes are different, the old behavior stays. - `build-provider-pack.mjs`: run the libc prune immediately after `pnpm deploy`. Log the removed entries. - No file bound by `provider-pack.json` changes. Thus the manifest digest and the qualification and digest pins do not change. ## Verification - `node --test packages/paperclip-runner/scripts/provider-pack-runtime-tree.test.mjs packages/paperclip-runner/scripts/provider-pack-executable-shims.test.mjs packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs packages/paperclip-runner/scripts/release-provider-pack.test.mjs`: 40/40 pass. - The new Copilot test fails with the old `provider-pack-executable-shims.mjs` and passes with the new file. - A real pnpm 9.15.4 tree with the pack's provider dependencies, on linux-x64 (glibc): - The prune removed `@anthropic-ai+claude-agent-sdk-linux-x64-musl`, `opencode-linux-x64-musl`, and `opencode-linux-x64-baseline-musl`. The Copilot dedupe removed the npm Copilot entry. - `node_modules` changed from 1.9 GB to 1.1 GB. - `find node_modules -xtype l` found no dangling links. - The claude-agent-acp SDK still resolves `@anthropic-ai/claude-agent-sdk-linux-x64/claude`. - `opencode --version` prints `1.18.34`. - The rewritten `copilot-linux-x64` prints `GitHub Copilot CLI 1.0.88.` - Earlier measurement: we pruned the same paths from the extracted pack artifacts and compressed them again with `tar -czf`. linux-x64 went from 1131 MB to 876 MB. linux-arm64 went from 519 MB to 360 MB. - Not run locally: a full `build-provider-pack.mjs` build. This sandbox has 10 GB of disk, which is not sufficient for a full workspace install and a 3 GB pack. The `release-verify.yml` provider-pack step and the Daytona image `RUN claude/codex/acpx --version` checks run the full build. - `test/acpx-codex-package-contract.test.mjs` has 1 failure in this sandbox, and the failure is the same on `master`. The cause is that this checkout has no `node_modules`. ## Risks - Low risk. All pack builds run on glibc hosts (Debian bookworm images and Ubuntu CI). On a musl build host, the prune removes the glibc packages, and the existing Claude executable resolution fails at build time. Before this change, that build gave a pack with a Claude binary that could not run. - The `copilot-linux-x64` command now runs the binary from `provider-assets/copilot/linux-x64/`. The bytes are identical. Runtime admission already uses this asset, through the descriptor-pinned lease. - The pack content changes but the manifest digest does not. The remote verifier checks only the bound artifacts, `dist`, and provider asset trees. None of these change. - Not in this pull request: Pi's bundled Node is byte-identical to the pack Node (121 MB, about 43 MB compressed). A fix for it changes the qualified Pi closure digest. Also, the `release.yml` publish jobs download the full packs, but they read only `provider-pack.json`. These are possible follow-ups. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through Claude Code with tool use (shell, file edit) and code execution in a sandbox. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no doc change is necessary: build behavior and commands do not change) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
6.7 KiB
JavaScript
127 lines
6.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, relative } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
providerPackLibc,
|
|
removeControllerArtifactsFromProviderPack,
|
|
removeForeignLibcPackagesFromProviderPack,
|
|
removePnpmStoreEntries,
|
|
} from "./provider-pack-runtime-tree.mjs";
|
|
|
|
test("image and native producer packs retain identical provider outputs despite different controller artifacts", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "provider-pack-producers-"));
|
|
try {
|
|
const packs = [join(root, "image"), join(root, "native")];
|
|
for (const pack of packs) {
|
|
mkdirSync(join(pack, "dist", "cli"), { recursive: true });
|
|
writeFileSync(join(pack, "dist", "cli", "acpx-runtime-sidecar.cjs"), "identical provider bridge\n");
|
|
}
|
|
mkdirSync(join(packs[1], "dist", "bin"));
|
|
writeFileSync(join(packs[1], "dist", "bin", "paperclip-runnerd"), "host-specific daemon");
|
|
mkdirSync(join(packs[1], "dist", "remote-provider-packs"));
|
|
writeFileSync(join(packs[1], "dist", "remote-provider-packs", "prior-pack.json"), "earlier release identity");
|
|
for (const pack of packs) {
|
|
removeControllerArtifactsFromProviderPack(pack);
|
|
assert.deepEqual(readdirSync(join(pack, "dist")), ["cli"]);
|
|
assert.equal(readFileSync(join(pack, "dist", "cli", "acpx-runtime-sidecar.cjs"), "utf8"), "identical provider bridge\n");
|
|
}
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("removing an owned controller-artifact link preserves its external target", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "provider-pack-controller-link-"));
|
|
try {
|
|
const pack = join(root, "pack"), outside = join(root, "outside");
|
|
mkdirSync(join(pack, "dist"), { recursive: true }); mkdirSync(outside);
|
|
writeFileSync(join(outside, "preserved"), "original");
|
|
symlinkSync(outside, join(pack, "dist", "bin"));
|
|
removeControllerArtifactsFromProviderPack(pack);
|
|
assert.equal(existsSync(join(pack, "dist", "bin")), false);
|
|
assert.equal(readFileSync(join(outside, "preserved"), "utf8"), "original");
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
function pnpmPackage(pack, entry, name, manifest = {}) {
|
|
const path = join(pack, "node_modules", ".pnpm", entry, "node_modules", name);
|
|
mkdirSync(path, { recursive: true });
|
|
writeFileSync(join(path, "package.json"), JSON.stringify({ name, version: "1.0.0", ...manifest }));
|
|
writeFileSync(join(path, "binary"), `${name}\n`);
|
|
return path;
|
|
}
|
|
|
|
function link(pack, from, entry, name) {
|
|
const path = join(pack, "node_modules", from, name);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
symlinkSync(relative(dirname(path), join(pack, "node_modules", ".pnpm", entry, "node_modules", name)), path);
|
|
return path;
|
|
}
|
|
|
|
test("detects the libc of the Node copied into Linux packs only", () => {
|
|
assert.equal(providerPackLibc("linux", () => ({ header: { glibcVersionRuntime: "2.39" } })), "glibc");
|
|
assert.equal(providerPackLibc("linux", () => ({ header: {} })), "musl");
|
|
assert.equal(providerPackLibc("darwin", () => { throw new Error("unused"); }), undefined);
|
|
});
|
|
|
|
test("removes packages built only for another libc and every link into them", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "provider-pack-libc-"));
|
|
try {
|
|
const pack = join(root, "pack");
|
|
pnpmPackage(pack, "@scope+native-linux-x64@1.0.0", "@scope/native-linux-x64", { libc: ["glibc"] });
|
|
pnpmPackage(pack, "@scope+native-linux-x64-musl@1.0.0", "@scope/native-linux-x64-musl", { libc: ["musl"] });
|
|
pnpmPackage(pack, "tool-linux-x64-musl@1.0.0", "tool-linux-x64-musl", { libc: ["musl"] });
|
|
pnpmPackage(pack, "tool-linux-x64@1.0.0", "tool-linux-x64");
|
|
pnpmPackage(pack, "consumer@1.0.0", "consumer", { optionalDependencies: { "tool-linux-x64": "1.0.0", "tool-linux-x64-musl": "1.0.0" } });
|
|
const kept = [
|
|
link(pack, ".pnpm/consumer@1.0.0/node_modules", "tool-linux-x64@1.0.0", "tool-linux-x64"),
|
|
link(pack, ".pnpm/node_modules", "@scope+native-linux-x64@1.0.0", "@scope/native-linux-x64"),
|
|
];
|
|
const pruned = [
|
|
link(pack, ".pnpm/consumer@1.0.0/node_modules", "tool-linux-x64-musl@1.0.0", "tool-linux-x64-musl"),
|
|
link(pack, ".pnpm/node_modules", "@scope+native-linux-x64-musl@1.0.0", "@scope/native-linux-x64-musl"),
|
|
link(pack, "", "@scope+native-linux-x64-musl@1.0.0", "@scope/native-linux-x64-musl"),
|
|
];
|
|
|
|
assert.deepEqual(removeForeignLibcPackagesFromProviderPack(pack, "glibc"), [
|
|
"@scope+native-linux-x64-musl@1.0.0", "tool-linux-x64-musl@1.0.0",
|
|
]);
|
|
assert.deepEqual(readdirSync(join(pack, "node_modules", ".pnpm")).sort(), [
|
|
"@scope+native-linux-x64@1.0.0", "consumer@1.0.0", "node_modules", "tool-linux-x64@1.0.0",
|
|
]);
|
|
for (const path of kept) assert.equal(existsSync(join(path, "package.json")), true);
|
|
for (const path of pruned) assert.equal(lstatExists(path), false);
|
|
// The emptied top-level scope goes too; the shared hoisted scope keeps its glibc link.
|
|
assert.equal(existsSync(join(pack, "node_modules", "@scope")), false);
|
|
assert.deepEqual(readdirSync(join(pack, "node_modules", ".pnpm", "node_modules", "@scope")), ["native-linux-x64"]);
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("keeps every package when the pack has no libc or the libc matches", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "provider-pack-libc-noop-"));
|
|
try {
|
|
const pack = join(root, "pack");
|
|
pnpmPackage(pack, "tool-linux-x64-musl@1.0.0", "tool-linux-x64-musl", { libc: ["musl"] });
|
|
assert.deepEqual(removeForeignLibcPackagesFromProviderPack(pack, undefined), []);
|
|
assert.deepEqual(removeForeignLibcPackagesFromProviderPack(pack, "musl"), []);
|
|
assert.equal(existsSync(join(pack, "node_modules", ".pnpm", "tool-linux-x64-musl@1.0.0")), true);
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("refuses store entry names that escape the pnpm virtual store", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "provider-pack-store-escape-"));
|
|
try {
|
|
const pack = join(root, "pack");
|
|
mkdirSync(join(pack, "node_modules", ".pnpm", "node_modules"), { recursive: true });
|
|
writeFileSync(join(pack, "node_modules", "outside"), "kept");
|
|
for (const name of ["../outside", "node_modules", "a/b"]) {
|
|
assert.throws(() => removePnpmStoreEntries(pack, [name]), /Refusing to remove/);
|
|
}
|
|
assert.equal(readFileSync(join(pack, "node_modules", "outside"), "utf8"), "kept");
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
function lstatExists(path) {
|
|
try { lstatSync(path); return true; } catch { return false; }
|
|
}
|