mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
Validate retained packages against the active provider contract
This commit is contained in:
1 parent
38610a4f23
commit
193c2baeee
2 files changed
+71
-11
No files matched your search
@@ -1476,6 +1476,40 @@ describe("remote provider pack manifest", () => {
|
||||
const changedPins = structuredClone(retainedManifest);
|
||||
changedPins.payload.pins = { ...changedPins.payload.pins, nodeMinimum: "0.0.0" as typeof changedPins.payload.pins.nodeMinimum };
|
||||
expect(() => validateRemoteProviderPackIdentity(changedPins)).toThrow("pins or source revision");
|
||||
const rehash = (manifest: typeof retainedManifest) => {
|
||||
manifest.digest = digest(canonical(manifest.payload));
|
||||
return manifest;
|
||||
};
|
||||
const oldPack = structuredClone(retainedManifest);
|
||||
Object.assign(oldPack.payload.pins, { opencode: "1.18.33", nodeMinimum: "24.10.0", codex: "0.159.0" });
|
||||
Object.assign(oldPack.payload.acpxProfileDigests, { codex: digest("old-codex-profile") });
|
||||
rehash(oldPack);
|
||||
const retainedAuthority = { digest: oldPack.digest, agent: "claude" };
|
||||
// A controller-bound Claude session survives unrelated bundled-provider
|
||||
// drift, while fresh admissions continue to require the current release.
|
||||
expect(() => validateRemoteProviderPackIdentity(oldPack)).toThrow("pins or source revision");
|
||||
expect(validateRemoteProviderPackIdentity(oldPack, root, true, retainedAuthority).digest).toBe(oldPack.digest);
|
||||
expect(() => validateRemoteProviderPackIdentity(oldPack, root, true,
|
||||
{ ...retainedAuthority, digest: retainedManifest.digest })).toThrow("manifest digest mismatch");
|
||||
for (const key of ["acpx", "claudeAcp"] as const) {
|
||||
const incompatible = structuredClone(oldPack);
|
||||
Object.assign(incompatible.payload.pins, { [key]: "0.0.1" });
|
||||
rehash(incompatible);
|
||||
expect(() => validateRemoteProviderPackIdentity(incompatible, root, true,
|
||||
{ ...retainedAuthority, digest: incompatible.digest })).toThrow("pins or source revision");
|
||||
}
|
||||
const incompatibleProfile = structuredClone(oldPack);
|
||||
Object.assign(incompatibleProfile.payload.acpxProfileDigests, { claude: digest("old-claude-profile") });
|
||||
rehash(incompatibleProfile);
|
||||
expect(() => validateRemoteProviderPackIdentity(incompatibleProfile, root, true,
|
||||
{ ...retainedAuthority, digest: incompatibleProfile.digest })).toThrow("pins or source revision");
|
||||
const tamperedManifest = structuredClone(oldPack);
|
||||
tamperedManifest.payload.artifacts.nodeCommand.sha256 = digest("substitute-node");
|
||||
rehash(tamperedManifest);
|
||||
expect(() => validateRemoteProviderPackIdentity(tamperedManifest, root, true, retainedAuthority)).toThrow("manifest digest mismatch");
|
||||
await writeFile(join(root, "node_modules", "node", "bin", "node"), "substitute-node");
|
||||
expect(() => validateRemoteProviderPackIdentity(oldPack, root, true, retainedAuthority)).toThrow("provider Node digest mismatch");
|
||||
await writeFile(join(root, "node_modules", "node", "bin", "node"), node);
|
||||
expect(readRemoteProviderPackManifest(root).payload.pins.opencode).toBe(
|
||||
"1.18.34",
|
||||
);
|
||||
@@ -1499,6 +1533,11 @@ describe("remote provider pack manifest", () => {
|
||||
await writeManifest(); await cp(join(root, "provider-pack.json"), manifestPath);
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("Cursor profile or closure");
|
||||
expect(() => readBundledRemoteProviderPackManifest(manifestPath)).toThrow("Cursor profile or closure");
|
||||
const oldCursorPack = JSON.parse(await readFile(manifestPath, "utf8")) as typeof retainedManifest;
|
||||
expect(validateRemoteProviderPackIdentity(oldCursorPack, root, true,
|
||||
{ digest: oldCursorPack.digest, agent: "claude" }).digest).toBe(oldCursorPack.digest);
|
||||
expect(() => validateRemoteProviderPackIdentity(oldCursorPack, root, true,
|
||||
{ digest: oldCursorPack.digest, agent: "cursor" })).toThrow("Cursor profile or closure");
|
||||
Object.assign(cursor, { [field]: previous });
|
||||
}
|
||||
await writeManifest(); await cp(join(root, "provider-pack.json"), manifestPath);
|
||||
|
||||
@@ -10233,14 +10233,34 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
return validateRemoteProviderPackIdentity(manifest, packRoot, verifyControllerFiles);
|
||||
}
|
||||
|
||||
export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackManifest, packRoot = "", verifyControllerFiles = false): RemoteProviderPackManifest {
|
||||
export function validateRemoteProviderPackIdentity(
|
||||
manifest: RemoteProviderPackManifest,
|
||||
packRoot = "",
|
||||
verifyControllerFiles = false,
|
||||
retainedAuthority?: { digest: string; agent: string },
|
||||
): RemoteProviderPackManifest {
|
||||
const payload = manifest?.payload;
|
||||
// A retained session may outlive unrelated bundled-provider upgrades. Its
|
||||
// active provider must still satisfy this release's qualified ACPX contract.
|
||||
const retainedAgent = retainedAuthority?.agent;
|
||||
const retainedProfile = retainedAgent === "claude" || retainedAgent === "codex" || retainedAgent === "grok"
|
||||
? retainedAgent : null;
|
||||
const activePins = retainedProfile === "claude" ? ["acpx", "claudeAcp"] as const
|
||||
: retainedProfile === "codex" ? ["acpx", "codex", "codexAcp"] as const
|
||||
: ["acpx", "grok"] as const;
|
||||
const pinsMatch = retainedProfile
|
||||
? canonicalJson(Object.keys(payload?.pins ?? {}).sort()) === canonicalJson(Object.keys(REMOTE_PROVIDER_PACK_PINS).sort())
|
||||
&& Object.values(payload?.pins ?? {}).every(value => typeof value === "string" && value.length > 0 && value.length <= 120)
|
||||
&& activePins.every(key => payload?.pins?.[key] === REMOTE_PROVIDER_PACK_PINS[key])
|
||||
&& canonicalJson(Object.keys(payload?.acpxProfileDigests ?? {}).sort()) === canonicalJson(Object.keys(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS).sort())
|
||||
&& Object.values(payload?.acpxProfileDigests ?? {}).every(value => /^sha256:[0-9a-f]{64}$/.test(value))
|
||||
&& payload?.acpxProfileDigests?.[retainedProfile] === REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[retainedProfile]
|
||||
: canonicalJson(payload?.pins) === canonicalJson(REMOTE_PROVIDER_PACK_PINS)
|
||||
&& canonicalJson(payload?.acpxProfileDigests) === canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS);
|
||||
if (
|
||||
manifest?.schema !== REMOTE_PROVIDER_PACK_SCHEMA ||
|
||||
!payload ||
|
||||
canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) ||
|
||||
canonicalJson(payload.acpxProfileDigests) !==
|
||||
canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS) ||
|
||||
!pinsMatch ||
|
||||
typeof payload.target?.platform !== "string" ||
|
||||
typeof payload.target?.architecture !== "string" ||
|
||||
!/^[0-9a-f]{40}(?:-dirty)?$/.test(payload.runnerSourceRevision) ||
|
||||
@@ -10253,7 +10273,7 @@ export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackM
|
||||
const digest = `sha256:${createHash("sha256")
|
||||
.update(canonicalJson(payload))
|
||||
.digest("hex")}`;
|
||||
if (manifest.digest !== digest) {
|
||||
if (manifest.digest !== digest || (retainedAuthority && digest !== retainedAuthority.digest)) {
|
||||
throw new Error(
|
||||
"runner_remote_provider_artifact_incompatible: provider pack manifest digest mismatch",
|
||||
);
|
||||
@@ -10333,7 +10353,7 @@ export function validateRemoteProviderPackIdentity(manifest: RemoteProviderPackM
|
||||
throw new Error("runner_remote_provider_artifact_incompatible: invalid candidate identity");
|
||||
}
|
||||
const candidatePath = providerPackRelativePath(candidate.path, "candidate assets");
|
||||
if (provider === "cursor") {
|
||||
if (provider === "cursor" && !retainedProfile) {
|
||||
const target = `${payload.target.platform}-${payload.target.architecture}`;
|
||||
const distribution = Object.hasOwn(CURSOR_DISTRIBUTION_PINS, target)
|
||||
? CURSOR_DISTRIBUTION_PINS[target as keyof typeof CURSOR_DISTRIBUTION_PINS] : undefined;
|
||||
@@ -12044,12 +12064,13 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
control: JSON.parse(readBoundedNativeFile(resolve(root, "control-plane", "control-plane-state.json"),
|
||||
NATIVE_CONTROL_PLANE_STATE_MAX_BYTES, "runner_durable_identity_too_large").toString("utf8")),
|
||||
}) : null;
|
||||
if (retainedComputerPack && remoteCommandRunner && remoteStorage) {
|
||||
if (retainedComputerPack && remoteCommandRunner && remoteStorage && input.execution.provider.kind === "acpx") {
|
||||
// The local attachment template chooses the digest. Remote content cannot
|
||||
// authorize a newer pack or rewrite the existing provider's launch identity.
|
||||
const [manifest, state] = await readPinnedComputerProviderMetadata({ runner: remoteCommandRunner,
|
||||
packRoot: retainedComputerPack.root, stateDirectory: posix.join(remoteStorage.sessionRoot, "runner") });
|
||||
expectedProviderPackManifest = validateRemoteProviderPackIdentity(manifest as RemoteProviderPackManifest);
|
||||
expectedProviderPackManifest = validateRemoteProviderPackIdentity(manifest as RemoteProviderPackManifest, "", false,
|
||||
{ digest: retainedComputerPack.digest, agent: input.execution.provider.agent });
|
||||
if (expectedProviderPackManifest.digest !== retainedComputerPack.digest ||
|
||||
expectedProviderPackManifest.payload.target.platform !== "linux" ||
|
||||
expectedProviderPackManifest.payload.target.architecture !== "x64")
|
||||
@@ -12282,8 +12303,8 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
|
||||
"for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}",
|
||||
"const version=process.versions.node.split('.').map(Number)",
|
||||
"const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
|
||||
"if(version[0]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
|
||||
`const minimums=[manifest.payload.pins.nodeMinimum,${JSON.stringify(REMOTE_PROVIDER_PACK_PINS.nodeMinimum)}]`,
|
||||
"for(const value of minimums){const minimum=value.split('.').map(Number);if(version[0]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')}",
|
||||
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
|
||||
"const packageVersion=(pkg)=>JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
|
||||
@@ -12315,7 +12336,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
if (
|
||||
opencodeVersion.exitCode !== 0 ||
|
||||
opencodeVersion.timedOut ||
|
||||
opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
|
||||
opencodeVersion.stdout.trim() !== expectedProviderPackManifest.payload.pins.opencode
|
||||
) {
|
||||
throw new Error(
|
||||
"runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
|
||||
|
||||
Reference in new issue
Block a user