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>
120 lines
6.9 KiB
JavaScript
120 lines
6.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
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 pkg = await readJson("package.json");
|
|
assert.equal(manifest.schema, "paperclip.acpx-profiles.v1");
|
|
assert.equal(manifest.acpxVersion, pkg.dependencies.acpx);
|
|
// 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");
|
|
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 });
|
|
}
|
|
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]+$/);
|
|
assert.ok(Number.isSafeInteger(profile.agentProfileVersion) && profile.agentProfileVersion > 0);
|
|
for (const key of ["agentServerPackage", "agentServerVersion"]) assert.ok(typeof profile[key] === "string" && profile[key].length > 0);
|
|
assert.equal(profile.agentRuntimePackage === null, profile.agentRuntimeVersion === null);
|
|
assert.match(profile.commandDigest, /^sha256:[a-f0-9]{64}$/);
|
|
assert.equal(typeof profile.requiresProviderPolicy, "boolean");
|
|
assert.ok([undefined, "pending"].includes(profile.qualificationStatus));
|
|
for (const [name, version] of [[profile.agentServerPackage, profile.agentServerVersion], [profile.agentRuntimePackage, profile.agentRuntimeVersion]]) {
|
|
if (name && Object.hasOwn(pkg.dependencies, name)) assert.equal(version, pkg.dependencies[name], `${agent}: installed dependency drift`);
|
|
}
|
|
}
|
|
// 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(contract.commandDigest, manifest.profiles.cursor.commandDigest, "cursor-contract.json carries a stale commandDigest");
|
|
assert.equal(distributions.patchVersion, contract.nativePatch);
|
|
assert.equal(manifest.profiles.antigravity.agentServerVersion, antigravityDistributions.version);
|
|
for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
|
|
|
|
const quote = JSON.stringify;
|
|
const profiles = Object.fromEntries(entries.map(([agent, declaration]) => {
|
|
const { requiresProviderPolicy, ...runtime } = declaration;
|
|
return [agent, {
|
|
driverKind: manifest.driverKind, protocolVersion: manifest.protocolVersion,
|
|
acpxVersion: manifest.acpxVersion, agent, ...runtime,
|
|
permissionPolicy: "interactive",
|
|
}];
|
|
}));
|
|
const header = "Generated from acpx-profiles.json and cursor-distributions.json. Do not edit.";
|
|
const typescript = `// ${header}
|
|
export const QUALIFIED_ACPX_VERSION = ${quote(manifest.acpxVersion)} as const;
|
|
export const ACPX_DRIVER_KIND = ${quote(manifest.driverKind)} as const;
|
|
export const ACPX_DRIVER_PROTOCOL_VERSION = ${manifest.protocolVersion} as const;
|
|
|
|
export const QUALIFIED_ACPX_PROFILE_DATA = ${JSON.stringify(profiles, null, 2)} as const;
|
|
|
|
export const CURSOR_DISTRIBUTION_PINS = ${JSON.stringify(Object.fromEntries(Object.entries(distributions.platforms).map(([platform, { closureSha256, executable, entrypoint }]) => [platform, { closureSha256, executable, entrypoint }])), null, 2)} as const;
|
|
|
|
export const ANTIGRAVITY_DISTRIBUTION_PINS = ${JSON.stringify(Object.fromEntries(Object.entries(antigravityDistributions.platforms).filter(([, pin]) => pin.provisioning === "verified-closure").map(([platform, { closureSha256, executable, args }]) => [platform, { closureSha256, executable, args }])), null, 2)} as const;
|
|
`;
|
|
const option = value => value === null ? "None" : `Some(${quote(value)})`;
|
|
const rust = `// ${header}
|
|
pub(crate) const QUALIFIED_ACPX_VERSION: &str = ${quote(manifest.acpxVersion)};
|
|
pub(crate) const ACPX_DRIVER_KIND: &str = ${quote(manifest.driverKind)};
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct AcpxReleaseProfile {
|
|
pub agent_server_package: &'static str,
|
|
pub agent_server_version: &'static str,
|
|
pub agent_runtime_package: Option<&'static str>,
|
|
pub agent_runtime_version: Option<&'static str>,
|
|
pub command_digest: &'static str,
|
|
pub requires_provider_policy: bool,
|
|
pub qualification_pending: bool,
|
|
}
|
|
|
|
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
|
Some(match agent {
|
|
${entries.map(([agent, p]) => ` ${quote(agent)} => AcpxReleaseProfile {
|
|
agent_server_package: ${quote(p.agentServerPackage)},
|
|
agent_server_version: ${quote(p.agentServerVersion)},
|
|
agent_runtime_package: ${option(p.agentRuntimePackage)},
|
|
agent_runtime_version: ${option(p.agentRuntimeVersion)},
|
|
command_digest:
|
|
${quote(p.commandDigest)},
|
|
requires_provider_policy: ${p.requiresProviderPolicy},
|
|
qualification_pending: ${p.qualificationStatus === "pending"},
|
|
},`).join("\n")}
|
|
_ => return None,
|
|
})
|
|
}
|
|
`;
|
|
|
|
for (const [path, content] of [
|
|
["src/drivers/acpx/generated-profiles.ts", typescript],
|
|
["runner/crates/runner-core/src/generated_acpx_profiles.rs", rust],
|
|
]) {
|
|
const url = new URL(path, root);
|
|
if (check) {
|
|
assert.equal(await readFile(url, "utf8"), content, `Stale ACPX profiles: ${fileURLToPath(url)}; run generate:acpx-profiles`);
|
|
} else await writeFile(url, content);
|
|
}
|
|
console.log("TypeScript/Rust ACPX profiles match the release manifest.");
|