feat(runner): make every ACPX provider declare its release attestation (#18342)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native ACPX runs admit a provider only when its saved profile
version and `commandDigest` match the current profile exactly.
> - #18325 made every provider digest attest its upstream release, and
bound the shared ACPX layer and Runner source through the pack
`contractDigest`.
> - The model is documented, but it lived in prose. A new provider, such
as one in an open PR, could still choose another digest model without
any check or guidance.
> - This pull request makes the model the default path: each provider
declares its attestation in one registry, and the generator, a test and
a scoped rule explain the pattern where contributors meet it.
> - The benefit is that new providers and upstream bumps follow one
model, without a growing list of blocked legacy patterns.

## Linked Issues or Issue Description

Refs #18325, #18237.

**What happened?**

After #18325, only Copilot, Grok and Pi went through
`acpx-release-attestations.mjs`. Cursor and Antigravity had separate
inline checks in the generator, and Claude and Codex had none. A new
provider could add a profile with any digest. Nothing told the author
how to compute it, and nothing pinned the profile versions that now
change only for deliberate resets.

**Expected behavior**

Every ACPX provider declares how its `commandDigest` attests the
upstream release. Tools and review guidance describe the pattern when a
contributor adds a provider, bumps an upstream release or changes a
version.

**Steps to reproduce**

1. Add a new provider profile to
`packages/paperclip-runner/acpx-profiles.json` with an arbitrary
`commandDigest`.
2. Run `node
packages/paperclip-runner/scripts/generate-acpx-profiles.mjs --check`.
3. Before this change, the check passes.

## What Changed

- Add `ACPX_RELEASE_ATTESTATIONS` to
`scripts/acpx-release-attestations.mjs`. Every profile declares one
kind:
- `installed-command` (Claude, Codex): installation hashes the upstream
launch command.
  - `launcher` (Grok): the SHA-256 of the launcher shipped verbatim.
- `closure` (Copilot, Pi): upstream versions and complete platform
closure pins.
- `declaration` (Cursor, Antigravity): a checked-in release declaration,
as canonical JSON.
- `generate-acpx-profiles.mjs` checks every profile through the
registry. The separate Cursor and Antigravity digest checks are now part
of the same path. No digest changes.
- A profile without a declared attestation fails generation. The message
says to declare one and points to the rule.
- A stale digest message now says that a digest changes only with its
upstream release, and that an upstream change keeps the profile version.
- Add a test that pins the profile versions. It explains that a bump
resets every saved session for that provider, and that a new provider
starts at 1.
- Add `rules/acpx-profile-identity.mdc`. It describes the identity
model, how to add a provider, when to bump a version, and review
criteria. `AGENTS.md` and `.greptile/files.json` route the profile,
attestation, contract-registry, ACPX driver and provider paths to it.
- Update the profile identity section in
`packages/paperclip-runner/docs/durable-recovery.md` with the
attestation kinds and the version policy.

## Verification

- In `packages/paperclip-runner`:
- `node scripts/generate-acpx-profiles.mjs --check` passes. Running it
without `--check` changes no digest.
- `node --test scripts/acpx-release-attestations.test.mjs
scripts/contracts/contract-fingerprints.test.mjs`: 22 tests pass. New
cases: every profile declares an attestation, a new provider without one
fails with guidance, and Cursor and Antigravity declaration changes
affect only their own digests.
- `vitest run src/drivers/acpx/profile-compatibility.test.ts`: 15 tests
pass, including the version table.
  - `tsc -p tsconfig.json --noEmit` passes.

## Risks

- Low risk. No digest, version or runtime admission behavior changes.
- A new provider PR must add one registry entry. The generator error
states what to add.
- The version table needs an edit for each deliberate reset. Upstream
bumps do not touch it.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local test runs).

## 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
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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 Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-10-30 11:25:06 -07:00
1 parent ade402c75f
commit 8362dbff4d
8 files changed
+265 -68

No files matched your search

+16
View File
@@ -71,6 +71,22 @@
"packages/paperclip-runner/src/**",
"packages/paperclip-runner/runner/**"
]
},
{
"path": "rules/acpx-profile-identity.mdc",
"description": "ACPX provider identity model: provider digests attest upstream releases, the pack contract digest covers Runner source and the shared ACPX layer, and profile versions change only for deliberate resets.",
"scope": [
"packages/paperclip-runner/acpx-profiles.json",
"packages/paperclip-runner/cursor-contract.json",
"packages/paperclip-runner/cursor-distributions.json",
"packages/paperclip-runner/antigravity-distributions.json",
"packages/paperclip-runner/scripts/acpx-release-attestations.mjs",
"packages/paperclip-runner/scripts/generate-acpx-profiles.mjs",
"packages/paperclip-runner/scripts/contracts/**",
"packages/paperclip-runner/src/drivers/acpx/**",
"packages/paperclip-runner/src/providers/**",
"packages/paperclip-runner/protocol/**"
]
}
]
}
+4
View File
@@ -41,6 +41,10 @@ For `server/src/services/chat-channels.ts` and everything under
For `server/src/services/heartbeat.ts`, `server/src/services/heartbeat/`,
heartbeat-prefixed sibling services, and heartbeat regression tests, read
[`rules/heartbeat-execution-boundaries.mdc`](rules/heartbeat-execution-boundaries.mdc).
For ACPX provider profiles, their release attestations and pins, the provider-pack
contract registry, and ACPX driver and provider code under
`packages/paperclip-runner/`, read
[`rules/acpx-profile-identity.mdc`](rules/acpx-profile-identity.mdc).
See [`rules/README.md`](rules/README.md) for rule authoring and Greptile setup.
## 3. Repo Map
@@ -73,17 +73,30 @@ real transport; it does not qualify a hosted OpenCode or ACPX deployment.
## Profile identity
Every ACPX provider's `commandDigest` attests only its own upstream release.
For Copilot (profile 92), Grok (20) and Pi (37) it binds the upstream package
versions, the platform closure pins and, for Grok, the launcher bytes.
`scripts/acpx-release-attestations.mjs` computes these digests and
`generate:acpx-profiles` writes them. Runner source changes do not change them.
Every ACPX harness binds the shared ACPX layer the same way: saved inputs must
match the current ACPX version, and the provider pack's `contractDigest` binds
the Runner's ACPX patch with the other Runner contracts.
Saved inputs with any profile version from 1 through the current one decode, so
they reach recovery. Launch and recovery admission require the exact current
profile and digest, and refuse the rest. A profile identity does not establish
hosted harness qualification.
Each provider declares how in `ACPX_RELEASE_ATTESTATIONS`
(`scripts/acpx-release-attestations.mjs`):
| Kind | Providers | The digest is |
| --- | --- | --- |
| `installed-command` | Claude, Codex | the upstream package's launch command, hashed at installation |
| `launcher` | Grok | the SHA-256 of the launcher shipped verbatim |
| `closure` | Copilot, Pi | upstream versions and the complete platform closure pins |
| `declaration` | Cursor, Antigravity | a checked-in release declaration, as canonical JSON |
`generate:acpx-profiles` writes the computed digests and `check:acpx-profiles`
refuses a stale one, so a digest changes only with its upstream release. Every
ACPX harness binds the shared ACPX layer the same way: saved inputs must match
the current ACPX version, and the provider pack's `contractDigest` binds the
Runner's ACPX patch with the other Runner contracts. Runner source changes
change no provider digest.
A profile version changes only to reset every saved session for that provider
on purpose; an upstream release keeps it, and a new provider starts at 1. Saved
inputs with any version from 1 through the current one decode, so they reach
recovery. Launch and recovery admission require the exact current profile and
digest, and refuse the rest. A profile identity does not establish hosted
harness qualification. [`rules/acpx-profile-identity.mdc`](../../../rules/acpx-profile-identity.mdc)
summarizes these practices for contributors.
## External controller checkpoints
@@ -3,13 +3,34 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
// Copilot, Grok and Pi profile identities attest upstream releases, like Claude,
// Codex, Cursor and Antigravity. They bind upstream versions and the bytes a
// repackager copies verbatim: complete platform closures and Grok's launcher.
// Never bind Paperclip source, the shared ACPX layer, absolute paths, the
// provider-pack manifest digest or install layout. The profile's acpxVersion and
// the pack contract digest bind ACPX and Runner source for every harness alike.
export const ATTESTED_ACPX_AGENTS = Object.freeze(["copilot", "grok", "pi"]);
/**
* How each ACPX provider's `commandDigest` attests its upstream release. Every
* profile in acpx-profiles.json declares one kind here:
*
* - `installed-command`: installation hashes the upstream package's launch
* command and compares it with the digest (Claude, Codex).
* - `launcher`: the SHA-256 of a launcher shipped verbatim (Grok).
* - `closure`: upstream versions and the complete platform closure pins.
* - `declaration`: a checked-in release declaration, hashed as canonical JSON.
*
* A digest changes only when the upstream release changes. It never binds
* Paperclip source, the shared ACPX layer, absolute paths, the provider-pack
* manifest digest or install layout: the profile's acpxVersion and the pack
* contract digest bind ACPX and Runner source for every provider alike.
* See rules/acpx-profile-identity.mdc.
*/
export const ACPX_RELEASE_ATTESTATIONS = Object.freeze({
claude: { kind: "installed-command" },
codex: { kind: "installed-command" },
grok: { kind: "launcher", path: "src/providers/grok/launcher.cjs" },
copilot: { kind: "closure", pins: { closure: ["copilot-closure-pins.ts", "COPILOT_CLOSURE_SHA256"] } },
pi: { kind: "closure", pins: {
nodeVersion: ["pi-node-pins.ts", "PI_NODE_VERSION"],
closure: ["pi-closure-pins.ts", "PI_DISTRIBUTION_CLOSURE_SHA256"],
} },
cursor: { kind: "declaration", path: "cursor-contract.json", digestField: "commandDigest" },
antigravity: { kind: "declaration", path: "antigravity-distributions.json" },
});
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
const canonicalJson = value => value && typeof value === "object"
@@ -17,28 +38,42 @@ const canonicalJson = value => value && typeof value === "object"
: `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`
: JSON.stringify(value);
/** The release attestation for one provider, computed from a runner package root. */
/**
* The release attestation for one provider, computed from a runner package
* root, or null when installation verifies the digest instead.
*/
export async function acpxReleaseAttestation(agent, packageRoot) {
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "acpx-profiles.json"), "utf8"));
const profile = manifest.profiles[agent];
if (!ATTESTED_ACPX_AGENTS.includes(agent) || !profile) throw new Error(`No upstream release attestation for ${agent}`);
// Grok keeps its physical byte-digest contract: admission hashes the launcher.
if (agent === "grok") {
return { commandDigest: `sha256:${sha256(readFileSync(resolve(packageRoot, "src/providers/grok/launcher.cjs")))}` };
const attestation = Object.hasOwn(ACPX_RELEASE_ATTESTATIONS, agent) ? ACPX_RELEASE_ATTESTATIONS[agent] : undefined;
if (!profile || !attestation) {
throw new Error(`${agent} needs a release attestation: declare how its commandDigest attests the upstream release `
+ "in ACPX_RELEASE_ATTESTATIONS (scripts/acpx-release-attestations.mjs). See rules/acpx-profile-identity.mdc.");
}
switch (attestation.kind) {
case "installed-command":
return null;
case "launcher":
return { commandDigest: `sha256:${sha256(readFileSync(resolve(packageRoot, attestation.path)))}` };
case "declaration": {
const { [attestation.digestField]: _digest, ...declaration } = JSON.parse(readFileSync(resolve(packageRoot, attestation.path), "utf8"));
return { commandDigest: `sha256:${sha256(canonicalJson(declaration))}`, declaration };
}
case "closure": {
const declaration = {
schema: "paperclip.acpx-release-attestation.v1", agent,
agentProfileVersion: profile.agentProfileVersion,
agentServerPackage: profile.agentServerPackage, agentServerVersion: profile.agentServerVersion,
agentRuntimePackage: profile.agentRuntimePackage, agentRuntimeVersion: profile.agentRuntimeVersion,
};
for (const [field, [file, name]] of Object.entries(attestation.pins)) {
// Key each import on content so a process never reuses pins from edited bytes.
const path = resolve(packageRoot, "src/drivers/acpx", file);
declaration[field] = (await import(`${pathToFileURL(path).href}?sha256=${sha256(readFileSync(path))}`))[name];
}
return { commandDigest: `sha256:${sha256(canonicalJson(declaration))}`, declaration };
}
default:
throw new Error(`${agent} declares an unknown release attestation kind: ${attestation.kind}`);
}
// Key each import on content so a process never reuses pins from edited bytes.
const pins = async file => {
const path = resolve(packageRoot, "src/drivers/acpx", file);
return import(`${pathToFileURL(path).href}?sha256=${sha256(readFileSync(path))}`);
};
const declaration = {
schema: "paperclip.acpx-release-attestation.v1", agent,
agentProfileVersion: profile.agentProfileVersion,
agentServerPackage: profile.agentServerPackage, agentServerVersion: profile.agentServerVersion,
agentRuntimePackage: profile.agentRuntimePackage, agentRuntimeVersion: profile.agentRuntimeVersion,
...agent === "copilot"
? { closure: (await pins("copilot-closure-pins.ts")).COPILOT_CLOSURE_SHA256 }
: { nodeVersion: (await pins("pi-node-pins.ts")).PI_NODE_VERSION, closure: (await pins("pi-closure-pins.ts")).PI_DISTRIBUTION_CLOSURE_SHA256 },
};
return { commandDigest: `sha256:${sha256(canonicalJson(declaration))}`, declaration };
}
@@ -5,10 +5,12 @@ import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { acpxReleaseAttestation, ATTESTED_ACPX_AGENTS } from "./acpx-release-attestations.mjs";
import { ACPX_RELEASE_ATTESTATIONS, acpxReleaseAttestation } from "./acpx-release-attestations.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(readFileSync(join(packageRoot, "acpx-profiles.json"), "utf8"));
// Providers whose digest the generator computes; installation verifies the rest.
const COMPUTED = Object.keys(manifest.profiles).filter(agent => ACPX_RELEASE_ATTESTATIONS[agent]?.kind !== "installed-command");
const packageFiles = [
"acpx-profiles.json", "package.json", "cursor-distributions.json", "cursor-contract.json", "antigravity-distributions.json",
"scripts/acpx-release-attestations.mjs", "scripts/generate-acpx-profiles.mjs",
@@ -33,15 +35,27 @@ function relocatedPackage(t) {
const generate = (root, ...args) => spawnSync(process.execPath, [join(root, "scripts/generate-acpx-profiles.mjs"), ...args],
{ cwd: root, env: { PATH: process.env.PATH }, encoding: "utf8", timeout: 20_000 });
test("the manifest carries each provider's current release attestation", async () => {
for (const agent of ATTESTED_ACPX_AGENTS) {
test("every profile declares how its digest attests the upstream release", async () => {
assert.deepEqual(Object.keys(ACPX_RELEASE_ATTESTATIONS).sort(), Object.keys(manifest.profiles).sort());
assert.deepEqual(COMPUTED.sort(), ["antigravity", "copilot", "cursor", "grok", "pi"]);
for (const agent of COMPUTED) {
assert.equal(manifest.profiles[agent].commandDigest, (await acpxReleaseAttestation(agent, packageRoot)).commandDigest, agent);
}
});
test("a new provider is told to declare its release attestation", async t => {
const relocated = relocatedPackage(t);
const path = join(relocated, "acpx-profiles.json"); const value = JSON.parse(readFileSync(path, "utf8"));
value.profiles.newagent = { ...value.profiles.codex };
writeFileSync(path, JSON.stringify(value));
const result = generate(relocated, "--check");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /newagent needs a release attestation: declare how its commandDigest attests the upstream release/u);
});
test("a relocated copy of the provider inputs computes the same attestations", async t => {
const relocated = relocatedPackage(t);
for (const agent of ATTESTED_ACPX_AGENTS) {
for (const agent of COMPUTED) {
const here = await acpxReleaseAttestation(agent, packageRoot);
const there = await acpxReleaseAttestation(agent, relocated);
assert.equal(there.commandDigest, here.commandDigest, agent);
@@ -76,24 +90,52 @@ for (const [name, agents, edit] of [
const path = join(root, "src/providers/grok/launcher.cjs");
writeFileSync(path, `${readFileSync(path, "utf8")}\n// changed\n`);
}],
["the Cursor release declaration", ["cursor"], root => {
const path = join(root, "cursor-contract.json"); const value = JSON.parse(readFileSync(path, "utf8"));
value.nativePatch = "changed"; writeFileSync(path, JSON.stringify(value));
}],
["the Antigravity distribution declaration", ["antigravity"], root => {
const path = join(root, "antigravity-distributions.json"); const value = JSON.parse(readFileSync(path, "utf8"));
value.version = "9.9.9"; writeFileSync(path, JSON.stringify(value));
}],
]) test(`changing ${name} changes only the affected attestations`, async t => {
const relocated = relocatedPackage(t);
const before = Object.fromEntries(await Promise.all(ATTESTED_ACPX_AGENTS.map(async agent => [agent, (await acpxReleaseAttestation(agent, relocated)).commandDigest])));
const before = Object.fromEntries(await Promise.all(COMPUTED.map(async agent => [agent, (await acpxReleaseAttestation(agent, relocated)).commandDigest])));
edit(relocated);
for (const agent of ATTESTED_ACPX_AGENTS) {
for (const agent of COMPUTED) {
const after = (await acpxReleaseAttestation(agent, relocated)).commandDigest;
if (agents.includes(agent)) assert.notEqual(after, before[agent], agent);
else assert.equal(after, before[agent], agent);
}
});
test("the generator refreshes a release declaration's own digest with the profile", async t => {
const relocated = relocatedPackage(t);
const path = join(relocated, "cursor-contract.json"); const contract = JSON.parse(readFileSync(path, "utf8"));
contract.modes = [...contract.modes, "review"];
writeFileSync(path, `${JSON.stringify(contract, null, 2)}\n`);
const stale = generate(relocated, "--check");
assert.notEqual(stale.status, 0);
assert.match(stale.stderr, /cursor commandDigest no longer matches its upstream release attestation/u);
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), contract, "check mode writes nothing");
const refreshed = generate(relocated);
assert.equal(refreshed.status, 0, refreshed.stderr);
const { commandDigest } = await acpxReleaseAttestation("cursor", relocated);
assert.notEqual(commandDigest, manifest.profiles.cursor.commandDigest);
assert.equal(JSON.parse(readFileSync(path, "utf8")).commandDigest, commandDigest);
assert.equal(JSON.parse(readFileSync(join(relocated, "acpx-profiles.json"), "utf8")).profiles.cursor.commandDigest, commandDigest);
assert.match(readFileSync(join(relocated, "src/drivers/acpx/generated-profiles.ts"), "utf8"), new RegExp(commandDigest));
assert.match(readFileSync(join(relocated, "runner/crates/runner-core/src/generated_acpx_profiles.rs"), "utf8"), new RegExp(commandDigest));
assert.equal(generate(relocated, "--check").status, 0);
});
test("the generator refuses a stale attestation in check mode and refreshes it otherwise", async t => {
const relocated = relocatedPackage(t);
const launcher = join(relocated, "src/providers/grok/launcher.cjs");
writeFileSync(launcher, `${readFileSync(launcher, "utf8")}\n// changed\n`);
const stale = generate(relocated, "--check");
assert.notEqual(stale.status, 0);
assert.match(stale.stderr, /grok release attestation is stale/u);
assert.match(stale.stderr, /grok commandDigest no longer matches its upstream release attestation/u);
const refreshed = generate(relocated);
assert.equal(refreshed.status, 0, refreshed.stderr);
const written = JSON.parse(readFileSync(join(relocated, "acpx-profiles.json"), "utf8"));
@@ -1,30 +1,36 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { acpxReleaseAttestation, ATTESTED_ACPX_AGENTS } from "./acpx-release-attestations.mjs";
import { ACPX_RELEASE_ATTESTATIONS, acpxReleaseAttestation } from "./acpx-release-attestations.mjs";
const root = new URL("../", import.meta.url);
const readJson = async path => JSON.parse(await readFile(new URL(path, root), "utf8"));
const manifest = await readJson("acpx-profiles.json");
const distributions = await readJson("cursor-distributions.json");
const antigravityDistributions = await readJson("antigravity-distributions.json");
const contract = await readJson("cursor-contract.json");
const pkg = await readJson("package.json");
assert.equal(manifest.schema, "paperclip.acpx-profiles.v1");
assert.equal(manifest.acpxVersion, pkg.dependencies.acpx);
// Copilot, Grok and Pi digests attest upstream releases; they change only when
// an upstream version, a platform closure or the Grok launcher changes.
// Every profile's commandDigest attests its upstream release, so it changes only
// when that release changes. Installation verifies installed-command digests.
const check = process.argv.includes("--check");
let attestationsChanged = false;
for (const agent of ATTESTED_ACPX_AGENTS) {
const { commandDigest } = await acpxReleaseAttestation(agent, fileURLToPath(root));
if (manifest.profiles[agent].commandDigest === commandDigest) continue;
assert.ok(!check, `${agent} release attestation is stale in acpx-profiles.json; run generate:acpx-profiles`);
manifest.profiles[agent].commandDigest = commandDigest;
attestationsChanged = true;
const writes = new Map();
for (const agent of Object.keys(manifest.profiles)) {
const attestation = await acpxReleaseAttestation(agent, fileURLToPath(root));
if (!attestation) continue;
// A release declaration may carry its own copy of the digest; keep it in step.
const { path, digestField } = ACPX_RELEASE_ATTESTATIONS[agent];
const declaration = digestField ? await readJson(path) : null;
if (manifest.profiles[agent].commandDigest === attestation.commandDigest
&& (!declaration || declaration[digestField] === attestation.commandDigest)) continue;
assert.ok(!check, `${agent} commandDigest no longer matches its upstream release attestation. `
+ "Run `pnpm --filter @paperclipai/paperclip-runner generate:acpx-profiles`; an upstream change keeps the profile version.");
manifest.profiles[agent].commandDigest = attestation.commandDigest;
writes.set("acpx-profiles.json", manifest);
if (declaration) writes.set(path, { ...declaration, [digestField]: attestation.commandDigest });
}
if (attestationsChanged) await writeFile(new URL("acpx-profiles.json", root), `${JSON.stringify(manifest, null, 2)}\n`);
for (const [path, value] of writes) await writeFile(new URL(path, root), `${JSON.stringify(value, null, 2)}\n`);
const contract = await readJson("cursor-contract.json");
const entries = Object.entries(manifest.profiles);
for (const [agent, profile] of entries) {
assert.match(agent, /^[a-z]+$/);
@@ -38,21 +44,13 @@ for (const [agent, profile] of entries) {
if (name && Object.hasOwn(pkg.dependencies, name)) assert.equal(version, pkg.dependencies[name], `${agent}: installed dependency drift`);
}
}
// The Cursor contract is an immutable release attestation, not another runtime
// lookup table. Refuse generation if a release declaration disagrees with it.
// Release declarations must agree with the profiles they attest.
assert.equal(manifest.profiles.cursor.agentServerVersion, distributions.version);
assert.equal(manifest.profiles.cursor.agentServerVersion, contract.vendor);
assert.equal(manifest.profiles.cursor.agentProfileVersion, contract.profileVersion);
assert.equal(manifest.profiles.cursor.commandDigest, contract.commandDigest);
assert.equal(contract.commandDigest, manifest.profiles.cursor.commandDigest, "cursor-contract.json carries a stale commandDigest");
assert.equal(distributions.patchVersion, contract.nativePatch);
const canonicalJson = value => value && typeof value === "object"
? Array.isArray(value) ? `[${value.map(canonicalJson).join(",")}]`
: `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`
: JSON.stringify(value);
assert.equal(manifest.profiles.antigravity.agentServerVersion, antigravityDistributions.version);
assert.equal(manifest.profiles.antigravity.commandDigest, `sha256:${createHash("sha256").update(canonicalJson(antigravityDistributions)).digest("hex")}`, "Antigravity distribution identity is stale");
const { commandDigest, ...attestation } = contract;
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
const quote = JSON.stringify;
@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js";
// A profile version changes only to reset saved sessions on purpose. An upstream
// release changes the commandDigest and keeps the version; a new provider starts
// at 1. Update this table, with the reason, only for a deliberate reset.
const PROFILE_VERSIONS: Readonly<Record<string, number>> = {
antigravity: 1, claude: 1, codex: 1, copilot: 92, cursor: 17, grok: 20, pi: 37,
};
describe("ACPX profile versions", () => {
it("change only for a deliberate session reset", () => {
for (const [agent, profile] of Object.entries(QUALIFIED_ACPX_PROFILE_DATA)) {
expect(profile.agentProfileVersion, `${agent}: a profile version bump resets every saved ${agent} session. `
+ "An upstream release changes only the commandDigest. See rules/acpx-profile-identity.mdc.")
.toBe(PROFILE_VERSIONS[agent] ?? 1);
}
});
});
describe("historical ACPX profile decoding", () => {
it("decodes every revision from 1 through the current one so saved runs reach recovery", () => {
for (const [agent, current] of Object.entries(QUALIFIED_ACPX_PROFILE_DATA)) {
+72
View File
@@ -0,0 +1,72 @@
---
description: Give every ACPX provider the same identity model - its commandDigest attests the upstream release, the pack contract digest covers Runner source and the shared ACPX layer, and profile versions change only for deliberate resets.
globs: "packages/paperclip-runner/acpx-profiles.json,packages/paperclip-runner/cursor-contract.json,packages/paperclip-runner/cursor-distributions.json,packages/paperclip-runner/antigravity-distributions.json,packages/paperclip-runner/scripts/acpx-release-attestations.mjs,packages/paperclip-runner/scripts/generate-acpx-profiles.mjs,packages/paperclip-runner/scripts/contracts/**,packages/paperclip-runner/src/drivers/acpx/**,packages/paperclip-runner/src/providers/**,packages/paperclip-runner/protocol/**"
alwaysApply: false
---
# ACPX profile identity
## Scope and purpose
This rule covers ACPX provider profiles, their release declarations and pins,
the profile generator, the provider-pack contract registry, and the ACPX
driver and provider code. Every ACPX harness (Claude, Codex, Copilot, Cursor,
Grok, Pi, Antigravity and any new one) uses the same identity model, so a
change behaves the same way for all of them.
## The identity model
A provider profile in `acpx-profiles.json` has a version and a `commandDigest`.
Three things decide whether saved work and installed packs stay compatible:
| What changed | What carries it | Effect |
| --- | --- | --- |
| The provider's upstream release: package versions, platform closures, a launcher shipped verbatim | The profile's `commandDigest` | Saved sessions for that provider start fresh |
| The shared ACPX layer | The profile's `acpxVersion` and the Runner's ACPX patch in the pack `contractDigest` | The same for every provider |
| A runner/pack contract: schemas, generated catalogs, Rust shapes and env keys | The provider pack's `contractDigest` | Older image packs are not reused |
Ordinary Runner source changes, refactors and comments change none of these.
## Required behavior
- **Attest the upstream release.** Declare each provider's attestation in
`ACPX_RELEASE_ATTESTATIONS` (`scripts/acpx-release-attestations.mjs`), using
the kind that fits: `installed-command`, `launcher`, `closure` or
`declaration`. The digest binds only what a repackager copies verbatim from
the upstream release, so the same release gives the same digest wherever the
pack is built or installed.
- **Keep Paperclip source and the ACPX layer out of provider digests.**
Runner/pack compatibility belongs to the `contractDigest`. When Runner code
and a pack must agree on a new field or behavior, declare it in a schema
under `protocol/` and generate the TypeScript, so the contract registry
covers it.
- **Regenerate instead of hand-editing.** After an upstream bump or a pin
change, run `pnpm --filter @paperclipai/paperclip-runner generate:acpx-profiles`.
`check:acpx-profiles` runs in the build and refuses a stale digest.
- **Keep the profile version.** An upstream release changes the digest, not the
version. A new provider starts at version 1. Bump a version only to reset
every saved session for that provider on purpose, such as a change in saved
session semantics that exact admission cannot detect. Record the reason in
the PR and in the version table in `profile-compatibility.test.ts`.
- **Keep earlier versions decodable.** Saved inputs with any version from 1
through the current one decode, so recovery can refuse them. Launch and
recovery admission require the exact current profile and digest.
## Adding a provider
1. Add its profile at version 1 to `acpx-profiles.json`.
2. Add its attestation to `ACPX_RELEASE_ATTESTATIONS`, with its pins in
erasable TypeScript or JSON that the generator can read.
3. Run `generate:acpx-profiles`, then `node --test scripts/acpx-release-attestations.test.mjs`.
## Review criteria
- Does every provider digest change only with its upstream release?
- Is a new runner/pack contract declared where the `contractDigest` reads it?
- Does a version bump state which saved sessions it resets, and why?
- Does the change treat every ACPX harness the same way?
The canonical reference is
[`packages/paperclip-runner/docs/durable-recovery.md`](../packages/paperclip-runner/docs/durable-recovery.md#profile-identity).
The pack contract digest is described in
[`docker/daytona-runner/README.md`](../docker/daytona-runner/README.md).