From 8362dbff4d3c7e04b29e87f444b9006c6b46f82d Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 30 Oct 2026 11:25:06 -0700 Subject: [PATCH] feat(runner): make every ACPX provider declare its release attestation (#18342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .greptile/files.json | 16 ++++ AGENTS.md | 4 + .../paperclip-runner/docs/durable-recovery.md | 35 +++++--- .../scripts/acpx-release-attestations.mjs | 89 +++++++++++++------ .../acpx-release-attestations.test.mjs | 56 ++++++++++-- .../scripts/generate-acpx-profiles.mjs | 44 +++++---- .../acpx/profile-compatibility.test.ts | 17 ++++ rules/acpx-profile-identity.mdc | 72 +++++++++++++++ 8 files changed, 265 insertions(+), 68 deletions(-) create mode 100644 rules/acpx-profile-identity.mdc diff --git a/.greptile/files.json b/.greptile/files.json index 1595fa1bf81..09e7b412637 100644 --- a/.greptile/files.json +++ b/.greptile/files.json @@ -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/**" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index e274da631ff..8a7d54e19f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index 8b8f6a7a368..0fbef29f508 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -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 diff --git a/packages/paperclip-runner/scripts/acpx-release-attestations.mjs b/packages/paperclip-runner/scripts/acpx-release-attestations.mjs index fc55dd9ff0a..dc58149e0e7 100644 --- a/packages/paperclip-runner/scripts/acpx-release-attestations.mjs +++ b/packages/paperclip-runner/scripts/acpx-release-attestations.mjs @@ -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 }; } diff --git a/packages/paperclip-runner/scripts/acpx-release-attestations.test.mjs b/packages/paperclip-runner/scripts/acpx-release-attestations.test.mjs index c79aa0fb540..c95cabe00de 100644 --- a/packages/paperclip-runner/scripts/acpx-release-attestations.test.mjs +++ b/packages/paperclip-runner/scripts/acpx-release-attestations.test.mjs @@ -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")); diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 4ec70598b78..7b8d5fb59ad 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -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; diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index eb2848c2c6e..d122058e4de 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -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> = { + 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)) { diff --git a/rules/acpx-profile-identity.mdc b/rules/acpx-profile-identity.mdc new file mode 100644 index 00000000000..b27c138a771 --- /dev/null +++ b/rules/acpx-profile-identity.mdc @@ -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).