mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +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>
91 lines
4.1 KiB
JavaScript
91 lines
4.1 KiB
JavaScript
import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, unlinkSync } from "node:fs";
|
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
|
export function removeControllerArtifactsFromProviderPack(packRoot) {
|
|
for (const directory of ["bin", "remote-provider-packs"]) {
|
|
rmSync(join(packRoot, "dist", directory), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** libc family of the Node copied into the pack; undefined where npm has no libc field. */
|
|
export function providerPackLibc(platform = process.platform, report = () => process.report.getReport()) {
|
|
if (platform !== "linux") return undefined;
|
|
return report().header.glibcVersionRuntime ? "glibc" : "musl";
|
|
}
|
|
|
|
function ownPackageManifests(entryRoot) {
|
|
// In pnpm's virtual store an entry's own package is the only real directory
|
|
// under its node_modules; every dependency beside it is a symlink.
|
|
const modules = join(entryRoot, "node_modules");
|
|
if (!existsSync(modules)) return [];
|
|
const manifests = [];
|
|
for (const child of readdirSync(modules, { withFileTypes: true })) {
|
|
const children = child.isDirectory() && child.name.startsWith("@")
|
|
? readdirSync(join(modules, child.name), { withFileTypes: true }).map(scoped => ({ entry: scoped, path: join(modules, child.name, scoped.name) }))
|
|
: [{ entry: child, path: join(modules, child.name) }];
|
|
for (const { entry, path } of children) {
|
|
if (entry.isDirectory() && !entry.name.startsWith(".") && existsSync(join(path, "package.json"))) {
|
|
manifests.push(JSON.parse(readFileSync(join(path, "package.json"), "utf8")));
|
|
}
|
|
}
|
|
}
|
|
return manifests;
|
|
}
|
|
|
|
/**
|
|
* Remove whole pnpm virtual-store entries and every link that resolves into
|
|
* them, so no dangling dependency slot survives into the immutable pack.
|
|
*/
|
|
export function removePnpmStoreEntries(packRoot, entryNames) {
|
|
const store = join(packRoot, "node_modules", ".pnpm");
|
|
const removed = entryNames.map(name => {
|
|
const path = resolve(store, name);
|
|
if (dirname(path) !== store || name === "node_modules") throw new Error(`Refusing to remove pnpm store path ${name}`);
|
|
return path;
|
|
});
|
|
if (!removed.length) return;
|
|
for (const path of removed) rmSync(path, { recursive: true, force: true });
|
|
const inside = target => removed.some(path => target === path || target.startsWith(`${path}${sep}`));
|
|
const visit = directory => {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isSymbolicLink()) {
|
|
if (inside(resolve(directory, readlinkSync(path)))) unlinkSync(path);
|
|
} else if (entry.isDirectory()) {
|
|
visit(path);
|
|
if (entry.name.startsWith("@") && readdirSync(path).length === 0) rmdirSync(path);
|
|
}
|
|
}
|
|
};
|
|
visit(join(packRoot, "node_modules"));
|
|
}
|
|
|
|
/**
|
|
* pnpm keeps optional platform packages for every libc on the target OS/CPU.
|
|
* The pack only runs its own copied Node, so packages built for the other libc
|
|
* can never load; the Claude and OpenCode musl builds add 400-600 MB per pack.
|
|
*/
|
|
export function removeForeignLibcPackagesFromProviderPack(packRoot, libc) {
|
|
if (!libc) return [];
|
|
const store = join(packRoot, "node_modules", ".pnpm");
|
|
const foreign = readdirSync(store, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory() && entry.name !== "node_modules")
|
|
.map(entry => entry.name)
|
|
.filter(name => {
|
|
const manifests = ownPackageManifests(join(store, name));
|
|
return manifests.length > 0 && manifests.every(manifest => Array.isArray(manifest.libc) && !manifest.libc.includes(libc));
|
|
})
|
|
.sort();
|
|
removePnpmStoreEntries(packRoot, foreign);
|
|
return foreign;
|
|
}
|
|
|
|
/** The pnpm virtual-store entry that owns a resolved package path inside the pack. */
|
|
export function pnpmStoreEntryOf(packRoot, path) {
|
|
const parts = relative(realpathSync(join(packRoot, "node_modules", ".pnpm")), realpathSync(path)).split(sep);
|
|
if (parts.length < 2 || parts[0] === ".." || parts[0] === "node_modules" || basename(parts[0]) !== parts[0]) {
|
|
throw new Error(`${path} is not inside the provider pack's pnpm store`);
|
|
}
|
|
return parts[0];
|
|
}
|