Files
PaperClipAI/packages/paperclip-runner/scripts/build-provider-pack.mjs
T
cc127f5516 fix(runner): drop unusable musl builds and duplicate Copilot from provider packs (#17387)
## 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>
2026-10-23 16:41:14 -07:00

389 lines
15 KiB
JavaScript

import profiles from "../acpx-profiles.json" with { type: "json" };
import { createHash } from "node:crypto";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
import { providerPackLibc, removeControllerArtifactsFromProviderPack, removeForeignLibcPackagesFromProviderPack } from "./provider-pack-runtime-tree.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspaceRoot = resolve(packageRoot, "../..");
const { output: outputArgument, candidates } = parseProviderPackArguments(process.argv.slice(2));
const outputRoot = resolve(
process.cwd(),
outputArgument ?? join(packageRoot, "provider-pack"),
);
if (
outputRoot === workspaceRoot ||
outputRoot === packageRoot ||
outputRoot === "/"
) {
throw new Error(`Refusing unsafe provider-pack output path: ${outputRoot}`);
}
const temporaryParent = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-provider-pack-")));
const temporaryRoot = join(temporaryParent, "pack");
function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function sha256File(path) {
return `sha256:${createHash("sha256")
.update(readFileSync(path))
.digest("hex")}`;
}
function sha256Tree(root) {
const hash = createHash("sha256");
const visit = (directory, prefix = "") => {
const entries = readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
);
for (const entry of entries) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
hash.update(`directory\0${relativePath}\n`);
visit(absolutePath, relativePath);
} else if (entry.isFile()) {
hash.update(`file\0${relativePath}\0${sha256File(absolutePath)}\n`);
} else if (entry.isSymbolicLink()) {
hash.update(
`symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`,
);
} else {
throw new Error(
`Provider pack tree contains unsupported entry ${relativePath}`,
);
}
}
};
visit(root);
return `sha256:${hash.digest("hex")}`;
}
function writePortableNodeShim(name, entrypoint) {
const shimPath = join(temporaryRoot, "node_modules", ".bin", name);
writeFileSync(
shimPath,
[
"#!/bin/sh",
"set -eu",
'basedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
`exec "$basedir/../node/bin/node" "$basedir/../${entrypoint}" "$@"`,
"",
].join("\n"),
);
chmodSync(shimPath, 0o755);
}
try {
const deployed = spawnSync(
"pnpm",
[
"--filter",
"@paperclipai/paperclip-runner",
"deploy",
"--prod",
temporaryRoot,
],
{ cwd: workspaceRoot, encoding: "utf8", stdio: "inherit" },
);
if (deployed.status !== 0) {
throw new Error(`pnpm deploy failed with exit code ${deployed.status}`);
}
removeControllerArtifactsFromProviderPack(temporaryRoot);
const foreignLibcPackages = removeForeignLibcPackagesFromProviderPack(temporaryRoot, providerPackLibc());
if (foreignLibcPackages.length) {
process.stderr.write(`Removed ${foreignLibcPackages.length} provider-pack packages built for another libc: ${foreignLibcPackages.join(", ")}\n`);
}
// Fail the image build if a bridge silently brings back an older/private
// provider CLI. A direct dependency alone does not deduplicate pnpm's graph.
const packRequire = createRequire(join(temporaryRoot, "package.json"));
const codexAcpRequire = createRequire(packRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
if (realpathSync(codexAcpRequire.resolve("@openai/codex/package.json")) !==
realpathSync(packRequire.resolve("@openai/codex/package.json"))) {
throw new Error("Codex ACP must share the image's Codex installation");
}
// Reuse the already-qualified build interpreter instead of introducing a
// package-manager lifecycle hook or a second binary supply chain. The pack
// manifest binds the copied bytes, platform, architecture, and minimum
// version before any provider is launched.
const minimumNodeVersion = [24, 11, 0];
const actualNodeVersion = process.versions.node.split(".").map(Number);
if (
actualNodeVersion[0] < minimumNodeVersion[0] ||
(actualNodeVersion[0] === minimumNodeVersion[0] &&
(actualNodeVersion[1] < minimumNodeVersion[1] ||
(actualNodeVersion[1] === minimumNodeVersion[1] &&
actualNodeVersion[2] < minimumNodeVersion[2])))
) {
throw new Error("Provider pack build Node is older than 24.11.0");
}
const stableNodeRoot = join(temporaryRoot, "node_modules", "node");
if (existsSync(stableNodeRoot)) {
throw new Error(
"Provider pack deployment unexpectedly claimed the stable Node path",
);
}
const stableNodeCommand = join(stableNodeRoot, "bin", "node");
mkdirSync(dirname(stableNodeCommand), { recursive: true, mode: 0o755 });
copyFileSync(process.execPath, stableNodeCommand);
chmodSync(stableNodeCommand, 0o755);
// A freshly copied x64 Mach-O can stall when launched directly from a
// translated Node process. Select Rosetta explicitly for this build probe;
// native Intel and every other platform still execute the copied bytes directly.
const translated = process.platform === "darwin" && process.arch === "x64"
&& spawnSync("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"], {
encoding: "utf8", timeout: 1_000,
}).stdout?.trim() === "1";
const relocatedNode = spawnSync(translated ? "/usr/bin/arch" : stableNodeCommand,
translated ? ["-x86_64", stableNodeCommand, "--version"] : ["--version"], {
cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(),
});
if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) {
throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution");
}
const candidateProviders = {};
for (const provider of providerPackProviders(process.platform, process.arch, candidates)) {
const assetPath = `provider-assets/${provider}/${process.platform}-${process.arch}`;
const metadata = await materializeCandidateProviderPack({ provider, outputRoot: join(temporaryRoot, assetPath) });
if (typeof metadata?.version !== "string" || !metadata.version || metadata.version.length > 120
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) ? "qualified" : "pending", path: assetPath,
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
}
// pnpm's generated .bin shims embed the temporary deployment directory in
// NODE_PATH. That makes an otherwise identical provider pack hash differ on
// every build and leaks a nonexistent host path after relocation. Replace
// every provider-facing shim with a pack-relative launcher that always uses
// the pinned Node executable owned by this pack.
// The image exposes these same installations to every adapter. Never add a
// separate global/runner-only CLI version; refresh these packages and their
// qualification digests together to the latest stable releases.
writePortableNodeShim("codex", "@openai/codex/bin/codex.js");
const claudeAcpRequire = createRequire(
packRequire.resolve("@agentclientprotocol/claude-agent-acp/package.json"),
);
// Use the ACP bridge's SDK dependency directly, avoiding a second peer-
// resolved SDK installation just to expose its CLI on the global PATH.
const sdkRequire = createRequire(claudeAcpRequire.resolve("@anthropic-ai/claude-agent-sdk"));
const claudeExecutable = sdkRequire.resolve(
`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude`,
);
writePortableExecutableShim(
temporaryRoot,
"claude",
relative(realpathSync(join(temporaryRoot, "node_modules")), realpathSync(claudeExecutable)),
);
writePortableExecutableShim(temporaryRoot, "node", "node/bin/node");
writePortableExecutableShim(temporaryRoot, "opencode", "opencode-ai/bin/opencode.exe");
writePortableCopilotShims(temporaryRoot);
writePortableNodeShim("acpx", "acpx/dist/cli.js");
writePortableNodeShim("paperclip-runner-copilot-metadata-probe", "../dist/cli/copilot-metadata-probe.js");
writePortableNodeShim(
"claude-agent-acp",
"@agentclientprotocol/claude-agent-acp/dist/index.js",
);
writePortableNodeShim(
"codex-acp",
"@agentclientprotocol/codex-acp/dist/index.js",
);
// pnpm deploy may retain a workspace self-link under its virtual store. It
// points outside the immutable pack and is not needed by the deployed
// package, so it must not be staged or captured.
rmSync(
join(
temporaryRoot,
"node_modules",
".pnpm",
"node_modules",
"@paperclipai",
"paperclip-runner",
),
{ recursive: true, force: true },
);
for (const shimName of readdirSync(
join(temporaryRoot, "node_modules", ".bin"),
)) {
const shimPath = join(temporaryRoot, "node_modules", ".bin", shimName);
const contents = readFileSync(shimPath, "utf8");
if (contents.includes(temporaryParent)) {
throw new Error(
`Provider pack shim ${shimName} retains its temporary build path`,
);
}
}
// Exercise a clean import from the deployed dependency graph. This catches
// malformed patch hunks that can look correct in an existing pnpm store but
// land inside the wrong function when applied to a fresh acpx tarball.
const acpxImport = spawnSync(
join(temporaryRoot, "node_modules", "node", "bin", "node"),
["--input-type=module", "-e", 'await import("acpx/runtime")'],
{
cwd: temporaryRoot,
encoding: "utf8",
timeout: 30_000,
},
);
if (acpxImport.status !== 0) {
throw new Error(
`Provider pack ACPX import failed: ${String(acpxImport.stderr ?? "")
.trim()
.slice(-2_000)}`,
);
}
const opencodeProxyPath = "dist/cli/opencode-app-server-proxy.cjs";
const acpxSidecarPath = "dist/cli/acpx-runtime-sidecar.cjs";
const opencodeCommand = "node_modules/.bin/opencode";
const opencodeExecutable = "node_modules/opencode-ai/bin/opencode.exe";
const nodeCommand = "node_modules/node/bin/node";
const productionLock = "pnpm-lock.yaml";
copyFileSync(
join(workspaceRoot, "pnpm-lock.yaml"),
join(temporaryRoot, productionLock),
);
for (const relativePath of [
nodeCommand,
productionLock,
opencodeProxyPath,
acpxSidecarPath,
opencodeCommand,
opencodeExecutable,
]) {
if (!existsSync(join(temporaryRoot, relativePath))) {
throw new Error(`Provider pack is missing ${relativePath}`);
}
}
const opencodeProxySha = sha256File(join(temporaryRoot, opencodeProxyPath));
const acpxSidecarSha = sha256File(join(temporaryRoot, acpxSidecarPath));
const distDigest = sha256Tree(join(temporaryRoot, "dist"));
const configuredRevision =
process.env.PAPERCLIP_RUNNER_SOURCE_REVISION?.trim();
const revision =
configuredRevision ??
execFileSync("git", ["rev-parse", "HEAD"], {
cwd: workspaceRoot,
encoding: "utf8",
}).trim();
if (!/^[0-9a-f]{40}$/.test(revision)) {
throw new Error("PAPERCLIP_RUNNER_SOURCE_REVISION must be a full Git SHA");
}
const dirty = configuredRevision
? false
: spawnSync("git", ["diff", "--quiet", "--", "packages/paperclip-runner"], {
cwd: workspaceRoot,
}).status !== 0;
const payload = {
pins: {
nodeMinimum: minimumNodeVersion.join("."),
codex: profiles.profiles.codex.agentRuntimeVersion,
opencode: "1.18.34",
acpx: profiles.acpxVersion,
grok: profiles.profiles.grok.agentRuntimeVersion,
claudeAcp: profiles.profiles.claude.agentServerVersion,
codexAcp: profiles.profiles.codex.agentServerVersion,
},
target: { platform: process.platform, architecture: process.arch },
runnerSourceRevision: `${revision}${dirty ? "-dirty" : ""}`,
distDigest,
bridgeDigest: `sha256:${createHash("sha256")
.update(opencodeProxySha)
.update("\n")
.update(acpxSidecarSha)
.update("\n")
.update(distDigest)
.digest("hex")}`,
acpxProfileDigests: {
grok: profiles.profiles.grok.commandDigest,
claude:
profiles.profiles.claude.commandDigest,
codex:
profiles.profiles.codex.commandDigest,
},
...providerPackManifestFields(candidateProviders, candidates),
artifacts: {
grokLauncher: {
path: "dist/providers/grok/launcher.cjs",
sha256: sha256File(join(temporaryRoot, "dist/providers/grok/launcher.cjs")),
},
nodeCommand: {
path: nodeCommand,
sha256: sha256File(join(temporaryRoot, nodeCommand)),
},
productionLock: {
path: productionLock,
sha256: sha256File(join(temporaryRoot, productionLock)),
},
opencodeCommand: {
path: opencodeCommand,
sha256: sha256File(join(temporaryRoot, opencodeCommand)),
},
opencodeExecutable: {
path: opencodeExecutable,
sha256: sha256File(join(temporaryRoot, opencodeExecutable)),
},
opencodeProxy: {
path: opencodeProxyPath,
sha256: opencodeProxySha,
},
acpxSidecar: { path: acpxSidecarPath, sha256: acpxSidecarSha },
},
};
const manifest = {
schema: "paperclip-runner/remote-provider-pack/v1",
digest: `sha256:${createHash("sha256")
.update(canonicalJson(payload))
.digest("hex")}`,
payload,
};
writeFileSync(
join(temporaryRoot, "provider-pack.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
{ mode: 0o600 },
);
rmSync(outputRoot, { recursive: true, force: true });
renameSync(temporaryRoot, outputRoot);
process.stdout.write(`${outputRoot}\n`);
} finally {
rmSync(temporaryParent, { recursive: true, force: true });
}