Files
PaperClipAI/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs
T
Nicky LeachandClaude Sonnet 5 c1b55537ba fix(paperclip-runner): bump claude-agent-acp pin to 0.73.0 (#13162)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Claude local adapter can run agent turns through an ACP (Agent
Client Protocol) server, `claude-agent-acp`, instead of the plain CLI
> - Two separate packages each pin their own copy of that dependency:
`packages/adapters/claude-local` (the server-side adapter) and
`packages/paperclip-runner` (which builds the provider pack baked into
every managed sandbox image)
> - `claude-local` moved to `^0.73.0` in #12730, but `paperclip-runner`
was never bumped past `0.70.0` — nothing keeps the two in sync when only
one changes
> - That split means a sandbox image built from `paperclip-runner`'s
provider pack ships a `claude-agent-acp` the server-side adapter was
never actually compatible with
> - This pull request bumps `paperclip-runner`'s pin to `0.73.0`, the
only version that satisfies both packages' declared ranges at once, and
fixes the matching hardcoded version assertion in
`docker/daytona-runner/Dockerfile`
> - The benefit is one consistent, compatible `claude-agent-acp` version
across both the server host and every sandbox image built from this
source, instead of a silent split that only surfaces as a runtime
failure

## Linked Issues or Issue Description

No public issue exists for this specific split; opening directly per
CONTRIBUTING.md path B, following the bug report template fields.

**What happened?**
`packages/paperclip-runner/package.json` pins
`@agentclientprotocol/claude-agent-acp` at an exact `0.70.0`.
`packages/adapters/claude-local/package.json` requires `^0.73.0` (added
in #12730, 2026-09-02). Nobody re-synced `paperclip-runner`'s pin after
that change — the two packages' dependency graphs are independent, so a
bump in one doesn't propagate to the other. `paperclip-runner`'s copy is
what the fleet sandbox image's provider pack actually ships, so every
managed sandbox built from current source carries a `claude-agent-acp`
version the server-side adapter's own declared compatibility range
excludes.

**Expected behavior**
The two packages' `claude-agent-acp` pins should stay within a mutually
compatible range, so a sandbox image built from this source always ships
a version the server-side adapter actually supports.

**Steps to reproduce**
1. Check `packages/adapters/claude-local/package.json`'s
`@agentclientprotocol/claude-agent-acp` range (`^0.73.0`).
2. Check `packages/paperclip-runner/package.json`'s pin for the same
package (`0.70.0` before this PR).
3. Note that `^0.73.0` on a `0.x` version only admits patch releases
(`>=0.73.0 <0.74.0` per semver caret rules), so `0.70.0` falls outside
it.

**Paperclip version or commit**
`master` as of this PR (paperclip-runner still at `0.70.0` prior to this
change; claude-local's `^0.73.0` requirement landed in #12730).

**Deployment mode**
Any deployment that runs `claude_local` agents through the ACP engine
against a sandbox image built from `packages/paperclip-runner`'s
provider pack (managed cloud sandboxes in particular).

Related PRs for context (not duplicates — none of these touch
`paperclip-runner`'s pin):
- #12730 — introduced the `^0.73.0` requirement in `claude-local`
- #11873 — the last time `paperclip-runner`'s pin moved (`0.69.0` →
`0.70.0`)
- #13105 — separately made an unavailable ACP engine a hard failure
instead of a silent CLI fallback, which is what turned this version
split into a visible, run-blocking error rather than a quiet downgrade

## What Changed

- Bump `@agentclientprotocol/claude-agent-acp` from `0.70.0` to `0.73.0`
(exact pin, matching this package's existing pin style for its other
agent-CLI dependencies) in `packages/paperclip-runner/package.json`.
- Update the corresponding hardcoded version assertion (`test
"$(claude-agent-acp --version)" = "0.70.0"`) in
`docker/daytona-runner/Dockerfile` to `0.73.0`, so its own build-time
check stays accurate instead of failing on the next build for an
unrelated reason.
- `pnpm-lock.yaml` is intentionally **not** included —
`pr-trusted.yml`'s `Validate dependency resolution and regenerate stale
lockfile` step already regenerates it for the merge tree and hands it to
downstream `--frozen-lockfile` jobs as an artifact, so a manual lockfile
commit here would just be stale the moment CI runs.

## Verification

- `0.73.0` is a real published version on npm (confirmed via `npm view
@agentclientprotocol/claude-agent-acp versions`), and it's the *only*
version satisfying claude-local's `^0.73.0` range, so this isn't a guess
at compatibility — it's the unique intersection of both packages'
declared ranges.
- `grep -rn "0\.70\.0" docker/ packages/paperclip-runner/package.json`
after this change shows no remaining stale references to the old pin.
- I did not run a full local install/test pass against a hand-updated
lockfile, since regenerating one locally would conflict with leaving
`pnpm-lock.yaml` untouched per the note above; CI's own
lockfile-regeneration step is the intended verification path for a
manifest-only dependency bump like this one.
- Downstream/full verification (does a sandbox image actually built with
this pin work end-to-end) is tracked separately in `paperclip-cloud` —
an unrelated internal-only repo, so not linked here — where a sibling
fix restores the ACP servers to the runtime `PATH` in the fleet sandbox
image itself; both fixes are needed together for a working sandbox, but
this PR is scoped to the version pin alone.

## Risks

- Low risk: single-line dependency version bump plus a matching
test-assertion update, no code changes. `0.73.0` is a patch release
within claude-local's own already-declared-safe range, so there's no
reason to expect it changes behavior tenants depend on.
- The main risk is unknown breaking changes between `claude-agent-acp`
0.70.0 and 0.73.0 that aren't caught by the version-string assertion
alone (that check only confirms the binary reports the right version,
not that its behavior is unchanged). I have not audited that package's
own changelog between those versions.
- `docker/daytona-runner/Dockerfile` is a parallel/reference image (per
its own header comment, meant to stay aligned with the private
`paperclip-cloud/fleet-sandbox-image/Dockerfile`, which is out of scope
here) — this PR does not touch that other Dockerfile.

## Model Used

Claude Sonnet 5 (`claude-sonnet-5`), via Claude Code, with tool use
(file edits, shell/git, `gh` CLI, `npm view` for version verification).
No extended-thinking mode. Standard Claude Code context window.

## 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 — see Verification: a
manifest-only bump with the lockfile intentionally left to CI's own
regeneration step; no local test run applicable
- [x] I have added or updated tests where applicable — version-pin bump
only, no new behavior to test
- [x] I have updated relevant documentation to reflect my changes — none
applicable
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green — pending CI run on this PR
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
pending review
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:35:33 -07:00

215 lines
7.0 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import test from "node:test";
const runnerPackage = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
);
const rootPackage = JSON.parse(
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
);
const workspace = await readFile(
new URL("../../../pnpm-workspace.yaml", import.meta.url),
"utf8",
);
const acpxPatch = await readFile(
new URL("../../../patches/acpx@0.13.1.patch", import.meta.url),
"utf8",
);
const codexPatch = await readFile(
new URL(
"../../../patches/@agentclientprotocol__codex-acp@1.6.2.patch",
import.meta.url,
),
"utf8",
);
const claudePatch = await readFile(
new URL(
"../../../patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
import.meta.url,
),
"utf8",
);
const qualifiedProfiles = await readFile(
new URL("../src/drivers/acpx/qualified-profiles.ts", import.meta.url),
"utf8",
);
const runnerdAcpxBackend = await readFile(
new URL(
"../runner/crates/runner-core/src/acpx_provider_backend.rs",
import.meta.url,
),
"utf8",
);
const providerPackBuilder = await readFile(
new URL("../scripts/build-provider-pack.mjs", import.meta.url),
"utf8",
);
const nativeSessionExecutor = await readFile(
new URL(
"../../../server/src/services/native-runtime/native-session-executor.ts",
import.meta.url,
),
"utf8",
);
test("the runner pins every qualified ACPX production dependency", () => {
assert.equal(runnerPackage.dependencies["@openai/codex"], "0.153.4");
assert.equal(runnerPackage.dependencies["@anthropic-ai/claude-agent-sdk"], undefined);
assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/codex-acp@1.6.2>@openai/codex"], runnerPackage.dependencies["@openai/codex"]);
assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk"], "0.3.263");
assert.equal(runnerPackage.optionalDependencies, undefined);
assert.equal(runnerPackage.dependencies.node, undefined);
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
assert.equal(
runnerPackage.dependencies["@agentclientprotocol/codex-acp"],
"1.6.2",
);
assert.equal(
runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"],
"0.73.0",
);
});
test("the patched Codex ACP executable digest stays aligned across launch boundaries", async () => {
const profileMatch =
/agent: "codex"[\s\S]*?commandDigest:\s*"(sha256:[a-f0-9]{64})"/.exec(
qualifiedProfiles,
);
assert.ok(profileMatch, "qualified Codex ACPX profile digest");
const digest = profileMatch[1];
const packagePath = createRequire(import.meta.url).resolve("@agentclientprotocol/codex-acp/package.json");
const installed = JSON.parse(await readFile(packagePath, "utf8"));
const executable = await readFile(resolve(dirname(packagePath), installed.bin["codex-acp"]));
assert.equal(digest, `sha256:${createHash("sha256").update(executable).digest("hex")}`,
"the identity binds installed executable bytes, not the patch file");
assert.match(runnerdAcpxBackend, new RegExp(`"codex"[\\s\\S]*?${digest}`));
assert.match(
providerPackBuilder,
new RegExp(`acpxProfileDigests:[\\s\\S]*?codex:[\\s\\S]*?${digest}`),
);
assert.match(
nativeSessionExecutor,
new RegExp(
`REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[\\s\\S]*?codex:[\\s\\S]*?${digest}`,
),
);
});
test("the package exposes only the reviewed runner CLI binaries", () => {
assert.deepEqual(runnerPackage.bin, {
"paperclip-runner-eval-session": "./dist/cli/eval-session.js",
"paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js",
"paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js",
"paperclip-runner-opencode-proxy":
"./dist/cli/opencode-app-server-proxy.js",
});
});
test("old and new pnpm configuration both apply the exact runtime patches", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["acpx@0.13.1"],
"patches/acpx@0.13.1.patch",
);
assert.equal(
rootPackage.pnpm.patchedDependencies[
"@agentclientprotocol/claude-agent-acp@0.73.0"
],
"patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
);
assert.equal(
rootPackage.pnpm.patchedDependencies[
"@agentclientprotocol/codex-acp@1.6.2"
],
"patches/@agentclientprotocol__codex-acp@1.6.2.patch",
);
assert.match(workspace, /acpx@0\.13\.1: patches\/acpx@0\.13\.1\.patch/);
assert.match(
workspace,
/codex-acp@1\.6\.2["']: patches\/@agentclientprotocol__codex-acp@1\.6\.2\.patch/,
);
assert.match(
workspace,
/claude-agent-acp@0\.73\.0["']: patches\/@agentclientprotocol__claude-agent-acp@0\.73\.0\.patch/,
);
assert.equal(rootPackage.pnpm.patchedDependencies["node@24.11.0"], undefined);
assert.doesNotMatch(workspace, /node@24\.11\.0:/);
assert.match(
providerPackBuilder,
/copyFileSync\(process\.execPath, stableNodeCommand\)/,
);
assert.match(codexPatch, /\+ "@openai\/codex": "0\.153\.4"/);
});
test("the ACPX patch preserves launch-only state and verified spawning", () => {
for (const token of [
"spawnEnvironment",
"spawnCwd",
"spawnAgent",
"SpawnOptionsWithoutStdio",
"this.options.spawnAgent",
]) {
assert.match(acpxPatch, new RegExp(token));
}
});
test("the ACPX patch fails closed on an invalid spawn environment", () => {
for (const token of [
"isPlainStringEnvironment",
"Object.getPrototypeOf(value)",
'Object.values(value).every((entry) => typeof entry === "string")',
"spawnEnvironment !== void 0",
"sourceEnvironment = spawnEnvironment()",
"ACPX spawn environment must be a plain record of string values",
]) {
assert.match(
acpxPatch,
new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
assert.doesNotMatch(acpxPatch, /spawnEnvironment\?\.\(\)/);
assert.doesNotMatch(
acpxPatch,
/spawnEnvironment \? \{ \.\.\.spawnEnvironment \} : \{ \.\.\.process\.env \}/,
);
});
test("the Codex patch enforces isolated instructions, tools, and skills", () => {
for (const token of [
"PAPERCLIP_ACPX_ISOLATED_CONTEXT",
"baseInstructions",
"rawInput: { serverName: params.serverName }",
'"features.apps": false',
"process.env.CODEX_HOME",
]) {
assert.match(
codexPatch,
new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
});
test("the Codex patch keeps MCP tool approvals on the governed permission channel", () => {
assert.match(
codexPatch,
/!context\.isToolApproval && this\.shouldUseAcpElicitation\(params\)/,
);
});
test("the Claude patch removes ambient project and local configuration", () => {
for (const token of [
"PAPERCLIP_ACPX_ISOLATED_CONTEXT",
'settingSources: ["user"]',
"userProvidedOptions?.mcpServers",
]) {
assert.match(
claudePatch,
new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
});