mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
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:
1 parent
ade402c75f
commit
8362dbff4d
8 files changed
+265
-68
No files matched your search
@@ -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/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user