mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## 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>
146 lines
8.6 KiB
JavaScript
146 lines
8.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
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",
|
|
"src/providers/grok/launcher.cjs", "src/drivers/acpx/copilot-closure-pins.ts",
|
|
"src/drivers/acpx/pi-closure-pins.ts", "src/drivers/acpx/pi-node-pins.ts",
|
|
"src/drivers/acpx/generated-profiles.ts", "runner/crates/runner-core/src/generated_acpx_profiles.rs",
|
|
];
|
|
|
|
/** A relocated runner package containing only the attestation and generator
|
|
* inputs. It has no repository patches: the shared ACPX layer is bound by the
|
|
* profile's acpxVersion and the pack contract digest, never by one provider. */
|
|
function relocatedPackage(t) {
|
|
const repository = mkdtempSync(join(tmpdir(), "acpx-attestation-relocated-"));
|
|
t.after(() => rmSync(repository, { recursive: true, force: true }));
|
|
const relocated = join(repository, "nested/elsewhere/packages/paperclip-runner");
|
|
for (const path of packageFiles) {
|
|
mkdirSync(dirname(join(relocated, path)), { recursive: true });
|
|
copyFileSync(join(packageRoot, path), join(relocated, path));
|
|
}
|
|
return relocated;
|
|
}
|
|
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("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 COMPUTED) {
|
|
const here = await acpxReleaseAttestation(agent, packageRoot);
|
|
const there = await acpxReleaseAttestation(agent, relocated);
|
|
assert.equal(there.commandDigest, here.commandDigest, agent);
|
|
assert.doesNotMatch(JSON.stringify(there), new RegExp(`${packageRoot}|${relocated}|tmp`), `${agent} must not bind a location`);
|
|
}
|
|
const checked = generate(relocated, "--check");
|
|
assert.equal(checked.status, 0, checked.stderr);
|
|
});
|
|
|
|
test("the attestations bind no Paperclip source beyond the Grok launcher", async () => {
|
|
for (const agent of ["copilot", "pi"]) {
|
|
const { declaration } = await acpxReleaseAttestation(agent, packageRoot);
|
|
assert.deepEqual(Object.keys(declaration).filter(key => /source/iu.test(key)), [], agent);
|
|
assert.deepEqual(Object.keys(declaration.closure).sort(), ["darwin-arm64", "darwin-x64", "linux-x64"]);
|
|
}
|
|
});
|
|
|
|
for (const [name, agents, edit] of [
|
|
["an upstream server version", ["copilot"], root => {
|
|
const path = join(root, "acpx-profiles.json"); const value = JSON.parse(readFileSync(path, "utf8"));
|
|
value.profiles.copilot.agentServerVersion = "9.9.9"; writeFileSync(path, JSON.stringify(value));
|
|
}],
|
|
["a Copilot platform closure", ["copilot"], root => {
|
|
const path = join(root, "src/drivers/acpx/copilot-closure-pins.ts");
|
|
writeFileSync(path, readFileSync(path, "utf8").replace(/"linux-x64": "[a-f0-9]{64}"/u, `"linux-x64": "${"0".repeat(64)}"`));
|
|
}],
|
|
["a Pi platform closure", ["pi"], root => {
|
|
const path = join(root, "src/drivers/acpx/pi-closure-pins.ts");
|
|
writeFileSync(path, readFileSync(path, "utf8").replace(/"linux-x64": "[a-f0-9]{64}"/u, `"linux-x64": "${"0".repeat(64)}"`));
|
|
}],
|
|
["the Grok launcher bytes", ["grok"], root => {
|
|
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(COMPUTED.map(async agent => [agent, (await acpxReleaseAttestation(agent, relocated)).commandDigest])));
|
|
edit(relocated);
|
|
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 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"));
|
|
assert.equal(written.profiles.grok.commandDigest, (await acpxReleaseAttestation("grok", relocated)).commandDigest);
|
|
assert.equal(written.profiles.grok.agentProfileVersion, manifest.profiles.grok.agentProfileVersion);
|
|
assert.equal(generate(relocated, "--check").status, 0);
|
|
});
|