From fb25904052fbf9f02c7558b3c7127a7ed81a0f71 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 11:46:36 -0500 Subject: [PATCH] refactor(runner): generate shared ACPX release declarations Consolidate runtime pins and explicit model admission policy across Rust, TypeScript, and packaging while preserving recovery identity. Co-Authored-By: Paperclip --- .../2026-10-03-cursor-production-readiness.md | 23 ++ packages/paperclip-runner/README.md | 18 ++ packages/paperclip-runner/acpx-profiles.json | 101 ++++++++ packages/paperclip-runner/package.json | 10 +- .../runner-core/src/acpx_provider_backend.rs | 224 ++++++------------ .../runner-core/src/acpx_provider_session.rs | 30 +-- .../src/generated_acpx_profiles.rs | 95 ++++++++ .../runner/crates/runner-core/src/lib.rs | 1 + .../scripts/build-provider-pack.mjs | 17 +- .../scripts/candidate-provider-pack.mjs | 3 +- .../scripts/generate-acpx-profiles.mjs | 120 ++++++++++ .../src/contracts/native-execution.ts | 7 +- .../src/drivers/acpx/cursor-installation.ts | 19 +- .../src/drivers/acpx/cursor-launch-policy.ts | 4 +- .../src/drivers/acpx/generated-profiles.ts | 139 +++++++++++ .../drivers/acpx/qualified-profiles.test.ts | 15 +- .../src/drivers/acpx/qualified-profiles.ts | 95 +------- .../drivers/acpx/recovery-identity.test.ts | 13 + .../test/acpx-codex-package-contract.test.mjs | 34 +-- .../native-runtime/native-session-executor.ts | 17 +- 20 files changed, 665 insertions(+), 320 deletions(-) create mode 100644 packages/paperclip-runner/acpx-profiles.json create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs create mode 100644 packages/paperclip-runner/scripts/generate-acpx-profiles.mjs create mode 100644 packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index 57654860d38..48fb8860968 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -25,6 +25,29 @@ spend or enforceable per-run dollar accounting. Native AskQuestion is implemente defensively but is not advertised or certified. See the [Cursor capability contract](../architecture/runner-cursor-capabilities.md). +## Bounded declaration cleanup — 2026-10-06 + +The PR audit found duplicated release tuples in Rust descriptor validation, +duplicated model rules in Rust session validation, and independent pins in the +TypeScript profile registry, provider-pack builder, server pack verifier, and +Cursor installation code. `packages/paperclip-runner/acpx-profiles.json` now owns +the release/profile declarations; generated TypeScript and named Rust structs +share them. Model admission is explicit and separate from qualification examples. +Cursor closure pins are generated from the existing distribution manifest. +Build and typecheck reject declaration drift, including mismatches with the +Cursor release attestation and installed dependencies. + +All provider versions, digests, model acceptance rules, and recovery identity +fields retain their previous meaning. A regression check pins the pre-cleanup +Cursor recovery digest. Historical rejection fixtures and evidence keep their +original identities. Cursor launch flags, credential names, and native extension +translation remain provider-adapter responsibilities; generic mode, lifecycle, +permission, and transport contracts retain the earlier cleanup. + +This is a source cleanup after the live qualification recorded below. Its checks +do not relabel the earlier package/image as a build of this source, and no further +paid qualification or release is implied. + ## Final installed-release qualification — 2026-10-06 This qualification follows the generic mode and provider lifecycle cleanup. Its diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 7e001a2a9d9..3b61d848f57 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -41,6 +41,24 @@ Legacy fresh attempts receive the same bounded handoff, including resume-failure fallbacks. Provider authentication repairs retain their existing fresh-session recovery behavior. +## ACPX release declarations + +`acpx-profiles.json` owns the package versions, command/profile digests, model +admission policies, and required execution policies used by TypeScript and Rust. +After changing it, run `pnpm --filter @paperclipai/paperclip-runner generate:acpx-profiles`. +Normal build and typecheck reject stale generated declarations. Generation also +checks installed dependency pins and agreement with Cursor's distribution manifest +and immutable release attestation (`cursor-contract.json`). Cursor's per-platform +closure pins are generated from `cursor-distributions.json`. + +Model admission is separate from qualification examples. `provider-verified` +requires an explicit caller-selected model and native verification; `exact` +retains the existing restriction for the Codex ACP and Pi profiles. Qualification +examples are neither a model catalog nor defaults. The legacy snapshot field +`qualificationModel` contains the selected model after resolution; its serialized +name is retained to preserve recovery identities. Historical profile fixtures +remain immutable evidence, not release declarations. + ## Public package surfaces - `@paperclipai/paperclip-runner` — production contracts, clients/backends, diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json new file mode 100644 index 00000000000..b007f19658a --- /dev/null +++ b/packages/paperclip-runner/acpx-profiles.json @@ -0,0 +1,101 @@ +{ + "schema": "paperclip.acpx-profiles.v1", + "acpxVersion": "0.13.1", + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "profiles": { + "grok": { + "agentProfileVersion": 1, + "agentServerPackage": "builtin:grok-acp", + "agentServerVersion": "1", + "agentRuntimePackage": "native:grok", + "agentRuntimeVersion": "1.0.13", + "commandDigest": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + "modelAdmission": { + "kind": "provider-verified" + }, + "requiresProviderPolicy": false, + "qualification": { + "model": "grok-4.7" + } + }, + "pi": { + "agentProfileVersion": 1, + "agentServerPackage": "pi-acp", + "agentServerVersion": "0.0.33", + "agentRuntimePackage": "@earendil-works/pi-coding-agent", + "agentRuntimeVersion": "0.84.2", + "commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "modelAdmission": { + "kind": "exact", + "model": "openrouter/deepseek/deepseek-v4-flash-0731" + }, + "requiresProviderPolicy": true, + "qualification": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731" + } + }, + "cursor": { + "agentProfileVersion": 11, + "agentServerPackage": "cursor-agent", + "agentServerVersion": "2026.09.26-dd393fe", + "agentRuntimePackage": null, + "agentRuntimeVersion": null, + "commandDigest": "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + "modelAdmission": { + "kind": "provider-verified" + }, + "requiresProviderPolicy": true, + "qualification": { + "model": "" + } + }, + "copilot": { + "agentProfileVersion": 2, + "agentServerPackage": "@github/copilot", + "agentServerVersion": "1.0.88", + "agentRuntimePackage": null, + "agentRuntimeVersion": null, + "commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "modelAdmission": { + "kind": "provider-verified" + }, + "requiresProviderPolicy": true, + "qualification": { + "model": "", + "status": "pending" + } + }, + "claude": { + "agentProfileVersion": 1, + "agentServerPackage": "@agentclientprotocol/claude-agent-acp", + "agentServerVersion": "0.73.0", + "agentRuntimePackage": "@anthropic-ai/claude-agent-sdk", + "agentRuntimeVersion": "0.3.286", + "commandDigest": "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + "modelAdmission": { + "kind": "provider-verified" + }, + "requiresProviderPolicy": false, + "qualification": { + "model": "claude-sonnet-5" + } + }, + "codex": { + "agentProfileVersion": 1, + "agentServerPackage": "@agentclientprotocol/codex-acp", + "agentServerVersion": "1.6.2", + "agentRuntimePackage": "@openai/codex", + "agentRuntimeVersion": "0.160.0", + "commandDigest": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", + "modelAdmission": { + "kind": "exact", + "model": "gpt-5.6-sol" + }, + "requiresProviderPolicy": false, + "qualification": { + "model": "gpt-5.6-sol" + } + } + } +} diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 3e4e9d88209..1bad816f6b4 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -58,7 +58,7 @@ "sideEffects": false, "scripts": { "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check", - "build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", + "build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", "build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs", "stage:release-binaries": "node scripts/stage-release-runner-binaries.mjs", @@ -70,8 +70,8 @@ "build:scenarios": "vite build --config vite.scenarios.config.ts && node scripts/check-capability-csp-bundle.mjs", "build:issue-thread": "vite build --config vite.issue-thread.config.ts", "typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust", - "typecheck:typescript": "pnpm run ensure:eval-build-deps && node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json --noEmit", - "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", + "typecheck:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-acpx-sidecar-contract.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json --noEmit", + "typecheck:rust": "pnpm run check:acpx-profiles && cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", @@ -161,7 +161,9 @@ "browser:preview": "vite preview --config vite.config.ts", "verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet", "verify:rootless": "bash scripts/verify-rootless-linux.sh", - "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs" + "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs", + "generate:acpx-profiles": "node scripts/generate-acpx-profiles.mjs", + "check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.73.0", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 70f20f92f58..6131c3d8669 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -1,3 +1,6 @@ +use crate::generated_acpx_profiles::{ + acpx_release_profile, ACPX_DRIVER_KIND, QUALIFIED_ACPX_VERSION, +}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fs::{self, DirBuilder, File}; use std::io::{Read, Write}; @@ -185,67 +188,15 @@ impl AcpxProviderDescriptor { &self, context: &AcpxEventProjectionContext, ) -> Result<(), DurableRunnerError> { - let expected = match self.agent.as_str() { - "claude" => ( - "claude-sonnet-5", - "@agentclientprotocol/claude-agent-acp", - "0.73.0", - Some("@anthropic-ai/claude-agent-sdk"), - Some("0.3.286"), - "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", - ), - "codex" => ( - "gpt-5.6-sol", - "@agentclientprotocol/codex-acp", - "1.6.2", - Some("@openai/codex"), - Some("0.160.0"), - "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", - ), - "pi" => ( - "openrouter/deepseek/deepseek-v4-flash-0731", - "pi-acp", - "0.0.33", - Some("@earendil-works/pi-coding-agent"), - Some("0.84.2"), - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", - ), - "cursor" => ( - self.model.as_str(), - "cursor-agent", - "2026.09.26-dd393fe", - None, - None, - "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", - ), - "copilot" => ( - self.model.as_str(), - "@github/copilot", - "1.0.88", - None, - None, - "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", - ), - "grok" => ( - "grok-4.7", - "builtin:grok-acp", - "1", - Some("native:grok"), - Some("1.0.13"), - "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", - ), - _ => { - return Err(DurableRunnerError::invalid( - "ACPX agent must name a known immutable profile", - )) - } - }; + let expected = acpx_release_profile(&self.agent).ok_or_else(|| { + DurableRunnerError::invalid("ACPX agent must name a known immutable profile") + })?; if self.kind != "acpx" || self.provider != "acpx" - || self.driver != "acpx_runtime" - || self.provider_version != "0.13.1" - || self.acpx_version != "0.13.1" - || (self.agent != "claude" && self.agent != "grok" && self.model != expected.0) + || self.driver != ACPX_DRIVER_KIND + || self.provider_version != QUALIFIED_ACPX_VERSION + || self.acpx_version != QUALIFIED_ACPX_VERSION + || !expected.model_admission.accepts(&self.model) || self.model.trim().is_empty() || self.model.len() > 240 || self.model.contains('\0') @@ -254,13 +205,12 @@ impl AcpxProviderDescriptor { || mode.chars().count() > 240 || mode.chars().any(char::is_control) }) - || (matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") - && self.provider_policy.is_none()) - || self.agent_server_package != expected.1 - || self.agent_server_version != expected.2 - || self.agent_runtime_package.as_deref() != expected.3 - || self.agent_runtime_version.as_deref() != expected.4 - || self.command_digest != expected.5 + || (expected.requires_provider_policy && self.provider_policy.is_none()) + || self.agent_server_package != expected.agent_server_package + || self.agent_server_version != expected.agent_server_version + || self.agent_runtime_package.as_deref() != expected.agent_runtime_package + || self.agent_runtime_version.as_deref() != expected.agent_runtime_version + || self.command_digest != expected.command_digest { return Err(DurableRunnerError::invalid( "ACPX provider descriptor does not match a qualified immutable profile", @@ -2328,48 +2278,28 @@ mod tests { } fn descriptor(agent: &str) -> Value { - let (model, package, version, runtime_package, runtime_version, digest) = - if agent == "claude" { - ( - "claude-sonnet-5", - "@agentclientprotocol/claude-agent-acp", - "0.73.0", - json!("@anthropic-ai/claude-agent-sdk"), - json!("0.3.286"), - "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", - ) - } else if agent == "cursor" { - ( - "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", - "cursor-agent", - "2026.09.26-dd393fe", - Value::Null, - Value::Null, - "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", - ) - } else { - ( - "gpt-5.6-sol", - "@agentclientprotocol/codex-acp", - "1.6.2", - json!("@openai/codex"), - json!("0.160.0"), - "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", - ) - }; + let profile = acpx_release_profile(agent).unwrap(); + let model = match agent { + "claude" => "claude-sonnet-5", + "cursor" => "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "pi" => "openrouter/deepseek/deepseek-v4-flash-0731", + "grok" => "grok-4.7", + "copilot" => "explicit-test-model", + _ => "gpt-5.6-sol", + }; let mut value = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", + "providerVersion": QUALIFIED_ACPX_VERSION, "agent": agent, "model": model, - "acpxVersion": "0.13.1", - "agentServerPackage": package, - "agentServerVersion": version, - "agentRuntimePackage": runtime_package, - "agentRuntimeVersion": runtime_version, - "commandDigest": digest, + "acpxVersion": QUALIFIED_ACPX_VERSION, + "agentServerPackage": profile.agent_server_package, + "agentServerVersion": profile.agent_server_version, + "agentRuntimePackage": profile.agent_runtime_package, + "agentRuntimeVersion": profile.agent_runtime_version, + "commandDigest": profile.command_digest, "sidecarCommand": "/qualified/node", "sidecarArgs": ["/qualified/acpx-sidecar.js"], "runtimeDirectory": "/runtime/acpx", @@ -2381,9 +2311,11 @@ mod tests { "permissionModePinned": true, "runtimeContext": null, }); + if profile.requires_provider_policy { + value["providerPolicy"] = json!({"readOnly": true}); + } if agent == "cursor" { value["mode"] = json!("agent"); - value["providerPolicy"] = json!({"readOnly": true}); } value } @@ -2822,50 +2754,50 @@ mod tests { } #[test] - fn admits_candidate_descriptors_only_with_explicit_policy_and_exact_distribution() { - for (agent, package, version, digest, runtime_package, runtime_version, model) in [ - ( - "cursor", - "cursor-agent", - "2026.09.26-dd393fe", - "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", - None, - None, - "explicit-model", - ), - ( - "copilot", - "@github/copilot", - "1.0.88", - "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", - None, - None, - "explicit-model", - ), - ( - "pi", - "pi-acp", - "0.0.33", - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", - Some("@earendil-works/pi-coding-agent"), - Some("0.84.2"), - "openrouter/deepseek/deepseek-v4-flash-0731", - ), - ] { - let mut value = descriptor("codex"); - value["agent"] = json!(agent); - value["model"] = json!(model); - value["agentServerPackage"] = json!(package); - value["agentServerVersion"] = json!(version); - value["agentRuntimePackage"] = json!(runtime_package); - value["agentRuntimeVersion"] = json!(runtime_version); - value["commandDigest"] = json!(digest); - let missing: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); - assert!(missing.validate(&context()).is_err()); - value["providerPolicy"] = json!({"readOnly":true}); - if agent == "cursor" { - value["mode"] = json!("agent"); + fn model_admission_is_independent_of_qualification_examples() { + for agent in ["claude", "grok", "cursor", "copilot", "codex", "pi"] { + let original: AcpxProviderDescriptor = + serde_json::from_value(descriptor(agent)).unwrap(); + original.validate_session(&context()).unwrap(); + let mut alternative = descriptor(agent); + alternative["model"] = json!("explicit-model-not-in-a-catalog"); + let alternative: AcpxProviderDescriptor = serde_json::from_value(alternative).unwrap(); + assert_eq!( + alternative.validate_session(&context()).is_ok(), + !matches!(agent, "codex" | "pi") + ); + for field in [ + "agentServerPackage", + "agentServerVersion", + "agentRuntimePackage", + "agentRuntimeVersion", + "commandDigest", + "acpxVersion", + "providerVersion", + ] { + let mut tampered = descriptor(agent); + tampered[field] = json!("untrusted"); + let tampered: AcpxProviderDescriptor = serde_json::from_value(tampered).unwrap(); + assert!( + tampered.validate_session(&context()).is_err(), + "{agent}: {field}" + ); } + let mut empty = descriptor(agent); + empty["model"] = json!(" "); + let empty: AcpxProviderDescriptor = serde_json::from_value(empty).unwrap(); + assert!(empty.validate_session(&context()).is_err()); + } + } + + #[test] + fn admits_candidate_descriptors_only_with_explicit_policy_and_exact_distribution() { + for agent in ["cursor", "copilot", "pi"] { + let mut value = descriptor(agent); + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove("providerPolicy"); + let missing: AcpxProviderDescriptor = serde_json::from_value(missing).unwrap(); + assert!(missing.validate(&context()).is_err()); let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); valid.validate(&context()).unwrap(); if matches!(agent, "cursor" | "copilot") { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index f4b82443f02..1ee8fd83c06 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -1,3 +1,4 @@ +use crate::generated_acpx_profiles::{acpx_release_profile, ModelAdmission}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -83,31 +84,22 @@ pub struct AcpxProviderSessionConfig { impl AcpxProviderSessionConfig { pub fn validate(&self) -> Result<(), LocalRunnerError> { self.transport.validate()?; - let qualified_model = match self.agent.as_str() { - "claude" => "claude-sonnet-5", - "grok" => "grok-4.7", - "codex" => "gpt-5.6-sol", - "pi" => "openrouter/deepseek/deepseek-v4-flash-0731", - "cursor" | "copilot" => self.model.as_str(), - _ => { - return Err(LocalRunnerError::invalid( - "ACPX agent must name a known immutable profile", - )) + let profile = acpx_release_profile(&self.agent).ok_or_else(|| { + LocalRunnerError::invalid("ACPX agent must name a known immutable profile") + })?; + if let ModelAdmission::Exact(qualified_model) = profile.model_admission { + if self.model != qualified_model { + return Err(LocalRunnerError::invalid(format!( + "ACPX {} profile requires exact model {qualified_model}", + self.agent + ))); } - }; - if self.agent != "claude" && self.agent != "grok" && self.model != qualified_model { - return Err(LocalRunnerError::invalid(format!( - "ACPX {} profile requires exact model {qualified_model}", - self.agent - ))); } validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; if let Some(mode) = self.mode.as_deref() { validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; } - if matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") - && self.provider_policy.is_none() - { + if profile.requires_provider_policy && self.provider_policy.is_none() { return Err(LocalRunnerError::invalid( "ACPX candidate requires explicit provider read-only policy", )); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs new file mode 100644 index 00000000000..7aa51b5ad2e --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -0,0 +1,95 @@ +// Generated from acpx-profiles.json and cursor-distributions.json. Do not edit. +pub(crate) const QUALIFIED_ACPX_VERSION: &str = "0.13.1"; +pub(crate) const ACPX_DRIVER_KIND: &str = "acpx_runtime"; + +#[derive(Debug)] +pub(crate) enum ModelAdmission { + Exact(&'static str), + ProviderVerified, +} + +impl ModelAdmission { + pub(crate) fn accepts(&self, model: &str) -> bool { + match self { + Self::Exact(expected) => model == *expected, + Self::ProviderVerified => true, + } + } +} + +#[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 model_admission: ModelAdmission, + pub requires_provider_policy: bool, +} + +pub(crate) fn acpx_release_profile(agent: &str) -> Option { + Some(match agent { + "grok" => AcpxReleaseProfile { + agent_server_package: "builtin:grok-acp", + agent_server_version: "1", + agent_runtime_package: Some("native:grok"), + agent_runtime_version: Some("1.0.13"), + command_digest: + "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + model_admission: ModelAdmission::ProviderVerified, + requires_provider_policy: false, + }, + "pi" => AcpxReleaseProfile { + agent_server_package: "pi-acp", + agent_server_version: "0.0.33", + agent_runtime_package: Some("@earendil-works/pi-coding-agent"), + agent_runtime_version: Some("0.84.2"), + command_digest: + "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + model_admission: ModelAdmission::Exact("openrouter/deepseek/deepseek-v4-flash-0731"), + requires_provider_policy: true, + }, + "cursor" => AcpxReleaseProfile { + agent_server_package: "cursor-agent", + agent_server_version: "2026.09.26-dd393fe", + agent_runtime_package: None, + agent_runtime_version: None, + command_digest: + "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + model_admission: ModelAdmission::ProviderVerified, + requires_provider_policy: true, + }, + "copilot" => AcpxReleaseProfile { + agent_server_package: "@github/copilot", + agent_server_version: "1.0.88", + agent_runtime_package: None, + agent_runtime_version: None, + command_digest: + "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + model_admission: ModelAdmission::ProviderVerified, + requires_provider_policy: true, + }, + "claude" => AcpxReleaseProfile { + agent_server_package: "@agentclientprotocol/claude-agent-acp", + agent_server_version: "0.73.0", + agent_runtime_package: Some("@anthropic-ai/claude-agent-sdk"), + agent_runtime_version: Some("0.3.286"), + command_digest: + "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + model_admission: ModelAdmission::ProviderVerified, + requires_provider_policy: false, + }, + "codex" => AcpxReleaseProfile { + agent_server_package: "@agentclientprotocol/codex-acp", + agent_server_version: "1.6.2", + agent_runtime_package: Some("@openai/codex"), + agent_runtime_version: Some("0.160.0"), + command_digest: + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", + model_admission: ModelAdmission::Exact("gpt-5.6-sol"), + requires_provider_policy: false, + }, + _ => return None, + }) +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 1015939ce2c..ab2e2774dfa 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod codex_provider; mod codex_startup_trust; pub mod durable; pub mod fake_harness; +mod generated_acpx_profiles; pub mod generated_acpx_sidecar_contract; pub mod local_runner; pub mod managed_provider; diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index eb8d379c75d..d6a09822484 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -1,3 +1,4 @@ +import profiles from "../acpx-profiles.json" with { type: "json" }; import { createHash } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { @@ -310,12 +311,12 @@ try { const payload = { pins: { nodeMinimum: minimumNodeVersion.join("."), - codex: "0.160.0", + codex: profiles.profiles.codex.agentRuntimeVersion, opencode: "1.18.34", - acpx: "0.13.1", - grok: "1.0.13", - claudeAcp: "0.73.0", - codexAcp: "1.6.2", + acpx: profiles.acpxVersion, + grok: profiles.profiles.grok.agentRuntimeVersion, + claudeAcp: profiles.profiles.claude.agentServerVersion, + codexAcp: profiles.profiles.codex.agentServerVersion, }, target: { platform: process.platform, architecture: process.arch }, runnerSourceRevision: `${revision}${dirty ? "-dirty" : ""}`, @@ -328,11 +329,11 @@ try { .update(distDigest) .digest("hex")}`, acpxProfileDigests: { - grok: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + grok: profiles.profiles.grok.commandDigest, claude: - "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + profiles.profiles.claude.commandDigest, codex: - "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", + profiles.profiles.codex.commandDigest, }, ...providerPackManifestFields(candidateProviders, candidates), artifacts: { diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index f1389685149..70aee7f19f6 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -1,3 +1,4 @@ +import profiles from "../acpx-profiles.json" with { type: "json" }; import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs"; const CANDIDATES = new Set(["cursor", "copilot", "pi"]); @@ -35,7 +36,7 @@ export async function materializeCandidateProviderPack({ provider, outputRoot }) if (provider === "cursor") { const result = await materializePinnedCursorDistribution({ destination: outputRoot }); return { version: result.version, - profileDigest: "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + profileDigest: profiles.profiles.cursor.commandDigest, closureDigest: `sha256:${result.closureSha256}` }; } throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`); diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs new file mode 100644 index 00000000000..c57a1ebcad9 --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +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 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); +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.equal(typeof profile.qualification.model, "string"); + assert.ok([undefined, "pending"].includes(profile.qualification.status)); + assert.ok(["exact", "provider-verified"].includes(profile.modelAdmission.kind)); + if (profile.modelAdmission.kind === "exact") assert.ok(profile.modelAdmission.model?.trim()); + else assert.equal(profile.modelAdmission.model, undefined); + 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`); + } +} +// The Cursor contract is an immutable release attestation, not another runtime +// lookup table. Refuse generation if a release declaration disagrees with it. +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(distributions.patchVersion, contract.nativePatch); +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 { modelAdmission, requiresProviderPolicy, qualification, ...runtime } = declaration; + return [agent, { + driverKind: manifest.driverKind, protocolVersion: manifest.protocolVersion, + acpxVersion: manifest.acpxVersion, agent, ...runtime, + ...(qualification.status ? { qualificationStatus: qualification.status } : {}), + qualificationModel: qualification.model, reportedModelId: qualification.model, + permissionPolicy: "interactive", + }]; +})); +const policies = Object.fromEntries(entries.map(([agent, profile]) => [agent, profile.modelAdmission])); +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 ACPX_MODEL_ADMISSION = ${JSON.stringify(policies, 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; +`; +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) enum ModelAdmission { + Exact(&'static str), + ProviderVerified, +} + +impl ModelAdmission { + pub(crate) fn accepts(&self, model: &str) -> bool { + match self { + Self::Exact(expected) => model == *expected, + Self::ProviderVerified => true, + } + } +} + +#[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 model_admission: ModelAdmission, + pub requires_provider_policy: bool, +} + +pub(crate) fn acpx_release_profile(agent: &str) -> Option { + 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)}, + model_admission: ${p.modelAdmission.kind === "exact" ? `ModelAdmission::Exact(${quote(p.modelAdmission.model)})` : "ModelAdmission::ProviderVerified"}, + requires_provider_policy: ${p.requiresProviderPolicy}, + },`).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 (process.argv.includes("--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."); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index fc1f1e4b0a5..826ff043c69 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -1,3 +1,4 @@ +import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js"; import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js"; import { isProviderMode } from "./provider-mode.js"; import { createHash } from "node:crypto"; @@ -90,7 +91,7 @@ export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "ap export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; protocolVersion: 1; - acpxVersion: "0.13.1"; + acpxVersion: typeof QUALIFIED_ACPX_VERSION; agent: NativeAcpxAgent; agentProfileVersion: AcpxProfileVersion; agentServerPackage: string; @@ -606,7 +607,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if ( profile.driverKind !== "acpx_runtime" || profile.protocolVersion !== 1 - || profile.acpxVersion !== "0.13.1" + || profile.acpxVersion !== QUALIFIED_ACPX_VERSION || profile.agent !== provider.agent || !isSupportedAcpxProfileVersion(provider.agent, profile.agentProfileVersion) ) { @@ -628,7 +629,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: QUALIFIED_ACPX_VERSION, agent: provider.agent, agentProfileVersion: profile.agentProfileVersion, agentServerPackage: text(profile.agentServerPackage, "input.provider.profile.agentServerPackage"), diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts index 0f3aa3d68af..e5b98293b72 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts @@ -2,16 +2,12 @@ import { join } from "node:path"; import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; import type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; -import { CURSOR_FIXED_ARGUMENTS } from "./cursor-launch-policy.js"; +import { CURSOR_PINNED_VERSION, CURSOR_FIXED_ARGUMENTS } from "./cursor-launch-policy.js"; import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; -export const CURSOR_PINNED_VERSION = "2026.09.26-dd393fe"; -const CLOSURE_PINS = Object.freeze({ - "darwin-arm64": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30", - "darwin-x64": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2", - "linux-x64": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74", -}); +import { CURSOR_DISTRIBUTION_PINS } from "./generated-profiles.js"; +export { CURSOR_PINNED_VERSION } from "./cursor-launch-policy.js"; /** Trusted package assets only: no workspace, PATH, executable override or env root. */ export function cursorNativeDistributionSpec( @@ -19,14 +15,15 @@ export function cursorNativeDistributionSpec( architecture: string = process.arch, ): NativeAcpxDistributionInput { const key = `${platform}-${architecture}`; - if (!Object.hasOwn(CLOSURE_PINS, key)) throw new Error(`Cursor ${CURSOR_PINNED_VERSION} has no pinned distribution for ${key}`); + if (!Object.hasOwn(CURSOR_DISTRIBUTION_PINS, key)) throw new Error(`Cursor ${CURSOR_PINNED_VERSION} has no pinned distribution for ${key}`); + const distribution = CURSOR_DISTRIBUTION_PINS[key as keyof typeof CURSOR_DISTRIBUTION_PINS]; const distributionRoot = join(resolveRunnerProviderAssetsRoot(import.meta.url, "cursor"), key); return { distributionRoot, manifestPath: join(distributionRoot, ".paperclip-cursor-closure.json"), - expectedClosureSha256: CLOSURE_PINS[key as keyof typeof CLOSURE_PINS], - executable: "node", - entrypoint: "index.js", + expectedClosureSha256: distribution.closureSha256, + executable: distribution.executable, + entrypoint: distribution.entrypoint, fixedArguments: [...CURSOR_FIXED_ARGUMENTS], }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts index 68b9866b9f4..e7b748a6b3c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts @@ -1,7 +1,9 @@ import { lstat, readFile, realpath } from "node:fs/promises"; import { dirname, join } from "node:path"; -export const CURSOR_PINNED_VERSION = "2026.09.26-dd393fe"; +import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; + +export const CURSOR_PINNED_VERSION = QUALIFIED_ACPX_PROFILES.cursor.agentServerVersion; export const CURSOR_FIXED_ARGUMENTS = ["--disable-project-configs", "--disable-auto-update", "acp"] as const; export const CURSOR_CREDENTIAL_ENVIRONMENT_NAMES = ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] as const; diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts new file mode 100644 index 00000000000..68dce8c7841 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -0,0 +1,139 @@ +// Generated from acpx-profiles.json and cursor-distributions.json. Do not edit. +export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; +export const ACPX_DRIVER_KIND = "acpx_runtime" as const; +export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; + +export const QUALIFIED_ACPX_PROFILE_DATA = { + "grok": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "grok", + "agentProfileVersion": 1, + "agentServerPackage": "builtin:grok-acp", + "agentServerVersion": "1", + "agentRuntimePackage": "native:grok", + "agentRuntimeVersion": "1.0.13", + "commandDigest": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + "qualificationModel": "grok-4.7", + "reportedModelId": "grok-4.7", + "permissionPolicy": "interactive" + }, + "pi": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "pi", + "agentProfileVersion": 1, + "agentServerPackage": "pi-acp", + "agentServerVersion": "0.0.33", + "agentRuntimePackage": "@earendil-works/pi-coding-agent", + "agentRuntimeVersion": "0.84.2", + "commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "qualificationModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": "openrouter/deepseek/deepseek-v4-flash-0731", + "permissionPolicy": "interactive" + }, + "cursor": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "cursor", + "agentProfileVersion": 11, + "agentServerPackage": "cursor-agent", + "agentServerVersion": "2026.09.26-dd393fe", + "agentRuntimePackage": null, + "agentRuntimeVersion": null, + "commandDigest": "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + "qualificationModel": "", + "reportedModelId": "", + "permissionPolicy": "interactive" + }, + "copilot": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "copilot", + "agentProfileVersion": 2, + "agentServerPackage": "@github/copilot", + "agentServerVersion": "1.0.88", + "agentRuntimePackage": null, + "agentRuntimeVersion": null, + "commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "qualificationStatus": "pending", + "qualificationModel": "", + "reportedModelId": "", + "permissionPolicy": "interactive" + }, + "claude": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "claude", + "agentProfileVersion": 1, + "agentServerPackage": "@agentclientprotocol/claude-agent-acp", + "agentServerVersion": "0.73.0", + "agentRuntimePackage": "@anthropic-ai/claude-agent-sdk", + "agentRuntimeVersion": "0.3.286", + "commandDigest": "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + "qualificationModel": "claude-sonnet-5", + "reportedModelId": "claude-sonnet-5", + "permissionPolicy": "interactive" + }, + "codex": { + "driverKind": "acpx_runtime", + "protocolVersion": 1, + "acpxVersion": "0.13.1", + "agent": "codex", + "agentProfileVersion": 1, + "agentServerPackage": "@agentclientprotocol/codex-acp", + "agentServerVersion": "1.6.2", + "agentRuntimePackage": "@openai/codex", + "agentRuntimeVersion": "0.160.0", + "commandDigest": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", + "qualificationModel": "gpt-5.6-sol", + "reportedModelId": "gpt-5.6-sol", + "permissionPolicy": "interactive" + } +} as const; + +export const ACPX_MODEL_ADMISSION = { + "grok": { + "kind": "provider-verified" + }, + "pi": { + "kind": "exact", + "model": "openrouter/deepseek/deepseek-v4-flash-0731" + }, + "cursor": { + "kind": "provider-verified" + }, + "copilot": { + "kind": "provider-verified" + }, + "claude": { + "kind": "provider-verified" + }, + "codex": { + "kind": "exact", + "model": "gpt-5.6-sol" + } +} as const; + +export const CURSOR_DISTRIBUTION_PINS = { + "darwin-arm64": { + "closureSha256": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30", + "executable": "node", + "entrypoint": "index.js" + }, + "darwin-x64": { + "closureSha256": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2", + "executable": "node", + "entrypoint": "index.js" + }, + "linux-x64": { + "closureSha256": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74", + "executable": "node", + "entrypoint": "index.js" + } +} as const; diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 4cecab32a8b..3939e935131 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -18,19 +18,24 @@ describe("qualified ACPX profiles", () => { } }); - it.each(["claude-opus-5-5", "claude-fable-5-1", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => { - expect(resolveQualifiedAcpxProfile("claude", model)).toMatchObject({ + it.each(["claude", "grok", "cursor", "copilot"] as const)("lets %s verify an explicit model absent from the catalog", (agent) => { + const model = "custom-model-not-in-catalog"; + expect(resolveQualifiedAcpxProfile(agent, model)).toMatchObject({ qualificationModel: model, reportedModelId: model, - commandDigest: QUALIFIED_ACPX_PROFILES.claude.commandDigest, + commandDigest: QUALIFIED_ACPX_PROFILES[agent].commandDigest, }); }); - it("rejects unqualified model substitutions", () => { + it.each(["codex", "pi"] as const)("preserves the existing exact-model restriction for %s", (agent) => { expect(() => - resolveQualifiedAcpxProfile("codex", "some-other-model"), + resolveQualifiedAcpxProfile(agent, "some-other-model"), ).toThrow("requires exact model"); }); + it.each(["claude", "codex", "grok", "cursor", "copilot", "pi"] as const)("never selects a qualification model by default for %s", (agent) => { + expect(() => resolveQualifiedAcpxProfile(agent, " ")).toThrow("must not be empty"); + }); + it("binds Codex ACP to the CLI runtime it launches", () => { expect(QUALIFIED_ACPX_PROFILES.codex).toMatchObject({ agentRuntimePackage: "@openai/codex", diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 3150e4079a1..383238480b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -1,6 +1,5 @@ -export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; -export const ACPX_DRIVER_KIND = "acpx_runtime" as const; -export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; +import { ACPX_DRIVER_KIND, ACPX_DRIVER_PROTOCOL_VERSION, QUALIFIED_ACPX_VERSION, QUALIFIED_ACPX_PROFILE_DATA, ACPX_MODEL_ADMISSION } from "./generated-profiles.js"; +export { ACPX_DRIVER_KIND, ACPX_DRIVER_PROTOCOL_VERSION, QUALIFIED_ACPX_VERSION } from "./generated-profiles.js"; import type { NativeAcpxAgent, NativeAcpxProfileSnapshot } from "../../contracts/native-execution.js"; @@ -13,13 +12,15 @@ export interface QualifiedAcpxProfile { readonly agent: QualifiedAcpxAgent; readonly agentProfileVersion: NativeAcpxProfileSnapshot["agentProfileVersion"]; readonly qualificationStatus?: "pending"; - readonly modelPolicy?: "explicit-provider-verified"; /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ readonly agentServerPackage: string; readonly agentServerVersion: string; readonly agentRuntimePackage: string | null; readonly agentRuntimeVersion: string | null; readonly commandDigest: string; + /** Legacy snapshot field: after resolution this is the caller's selected model. + * Keep its serialized name for recovery identity compatibility. The unresolved + * value is qualification metadata, never a product default or admission rule. */ readonly qualificationModel: string; /** Exact model ID sent to ACP; catalogs are suggestions, not an allowlist. */ readonly reportedModelId: string; @@ -33,86 +34,7 @@ export interface QualifiedAcpxProfile { */ export const QUALIFIED_ACPX_PROFILES: Readonly< Record -> = deepFreeze({ - grok: { - driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "grok", agentProfileVersion: 1, - agentServerPackage: "builtin:grok-acp", agentServerVersion: "1", - agentRuntimePackage: "native:grok", agentRuntimeVersion: "1.0.13", - commandDigest: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", - qualificationModel: "grok-4.7", reportedModelId: "grok-4.7", permissionPolicy: "interactive", - }, - pi: { - driverKind: ACPX_DRIVER_KIND, - protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, - agent: "pi", - agentProfileVersion: 1, - agentServerPackage: "pi-acp", - agentServerVersion: "0.0.33", - agentRuntimePackage: "@earendil-works/pi-coding-agent", - agentRuntimeVersion: "0.84.2", - commandDigest: - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", - qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", - reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", - permissionPolicy: "interactive", - }, - cursor: { - driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 11, - agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe", - agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", - // Every caller selects a model explicitly; the native provider must verify - // that exact ID. Qualification never turns Luna into a default model. - qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", - modelPolicy: "explicit-provider-verified", - }, - copilot: { - driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 2, - agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", - agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", - // Authenticated discovery has not established a qualification model. Never - // turn this empty declaration into a default; callers must select an ID. - qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", - modelPolicy: "explicit-provider-verified", qualificationStatus: "pending", - }, - claude: { - driverKind: ACPX_DRIVER_KIND, - protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, - agent: "claude", - agentProfileVersion: 1, - agentServerPackage: "@agentclientprotocol/claude-agent-acp", - agentServerVersion: "0.73.0", - agentRuntimePackage: "@anthropic-ai/claude-agent-sdk", - agentRuntimeVersion: "0.3.286", - commandDigest: - "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", - qualificationModel: "claude-sonnet-5", - reportedModelId: "claude-sonnet-5", - permissionPolicy: "interactive", - }, - codex: { - driverKind: ACPX_DRIVER_KIND, - protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, - agent: "codex", - agentProfileVersion: 1, - agentServerPackage: "@agentclientprotocol/codex-acp", - agentServerVersion: "1.6.2", - agentRuntimePackage: "@openai/codex", - agentRuntimeVersion: "0.160.0", - commandDigest: - "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", - qualificationModel: "gpt-5.6-sol", - reportedModelId: "gpt-5.6-sol", - permissionPolicy: "interactive", - }, -}); +> = deepFreeze(QUALIFIED_ACPX_PROFILE_DATA); export function resolveQualifiedAcpxProfile( agent: QualifiedAcpxAgent, @@ -120,9 +42,10 @@ export function resolveQualifiedAcpxProfile( ): QualifiedAcpxProfile { const profile = QUALIFIED_ACPX_PROFILES[agent]; if (!requestedModel.trim()) throw new Error("ACPX model must not be empty"); - if (agent !== "claude" && agent !== "grok" && profile.modelPolicy !== "explicit-provider-verified" && requestedModel !== profile.qualificationModel) { + const admission = ACPX_MODEL_ADMISSION[agent]; + if (admission.kind === "exact" && requestedModel !== admission.model) { throw new Error( - `ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`, + `ACPX ${agent} profile requires exact model ${admission.model}; received ${requestedModel}`, ); } return { ...structuredClone(profile), qualificationModel: requestedModel, reportedModelId: requestedModel }; diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index def42e6c4f5..146c417e7a7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -26,6 +26,19 @@ afterEach(async () => { }); describe("ACPX recovery identity", () => { + it("preserves the pre-manifest Cursor recovery profile identity", async () => { + const fixture = await recoveryFixture(); + const requestedModel = "explicit-test-model"; + const binding = await createAcpxRecoveryBinding({ + ...fixture.input, + requestedModel, + profile: resolveQualifiedAcpxProfile("cursor", requestedModel), + }); + // Captured from e75fde6098b0ddd8cec765bfb6ecaeecb88a26a6 before + // consolidating release declarations; this is historical evidence. + expect(binding.profileDigest).toBe("sha256:801bf9f450a2776ec879f2d85c05d8950172565a87f8f79177e0fc1211ae0bab"); + }); + it("derives one stable, filesystem-safe runtime directory name", () => { expect(acpxRuntimeSessionDirectoryName("session/1")).toMatch( /^session_1-[0-9a-f]{16}$/, diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index c17aa4243d0..6cc83159c5e 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -33,17 +33,8 @@ const claudePatch = await readFile( ), "utf8", ); -const qualifiedProfiles = await readFile( - new URL("../src/drivers/acpx/qualified-profiles.ts", import.meta.url), - "utf8", -); -const runnerdAcpxBackend = await readFile( - new URL( - "../runner/crates/runner-core/src/acpx_provider_backend.rs", - import.meta.url, - ), - "utf8", -); +const profiles = JSON.parse(await readFile(new URL("../acpx-profiles.json", import.meta.url), "utf8")); +const runnerdProfiles = await readFile(new URL("../runner/crates/runner-core/src/generated_acpx_profiles.rs", import.meta.url), "utf8"); const providerPackBuilder = await readFile( new URL("../scripts/build-provider-pack.mjs", import.meta.url), "utf8", @@ -75,29 +66,16 @@ test("the runner pins every qualified ACPX production dependency", () => { }); test("the patched Codex ACP executable digest stays aligned across launch boundaries", async () => { - const profileMatch = - /agent: "codex"[\s\S]*?commandDigest:\s*"(sha256:[a-f0-9]{64})"/.exec( - qualifiedProfiles, - ); - assert.ok(profileMatch, "qualified Codex ACPX profile digest"); - const digest = profileMatch[1]; + const digest = profiles.profiles.codex.commandDigest; const packagePath = createRequire(import.meta.url).resolve("@agentclientprotocol/codex-acp/package.json"); const installed = JSON.parse(await readFile(packagePath, "utf8")); const executable = await readFile(resolve(dirname(packagePath), installed.bin["codex-acp"])); assert.equal(digest, `sha256:${createHash("sha256").update(executable).digest("hex")}`, "the identity binds installed executable bytes, not the patch file"); - assert.match(runnerdAcpxBackend, new RegExp(`"codex"[\\s\\S]*?${digest}`)); - assert.match( - providerPackBuilder, - new RegExp(`acpxProfileDigests:[\\s\\S]*?codex:[\\s\\S]*?${digest}`), - ); - assert.match( - nativeSessionExecutor, - new RegExp( - `REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[\\s\\S]*?codex:[\\s\\S]*?${digest}`, - ), - ); + assert.match(runnerdProfiles, new RegExp(`"codex"[\\s\\S]*?${digest}`)); + assert.match(providerPackBuilder, /codex:\s*profiles\.profiles\.codex\.commandDigest/); + assert.match(nativeSessionExecutor, /codex:\s*QUALIFIED_ACPX_PROFILES\.codex\.commandDigest/); }); test("the package exposes only the reviewed runner CLI binaries", () => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 5c65e638c51..9ead51aafd5 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,4 @@ +import { QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js"; import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; @@ -9483,19 +9484,19 @@ const RUNNERD_BINARY_CONTRACT_VERSION = 2; const REMOTE_PROVIDER_PACK_SCHEMA = "paperclip-runner/remote-provider-pack/v1"; const REMOTE_PROVIDER_PACK_PINS = { nodeMinimum: "24.11.0", - codex: "0.160.0", + codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", - acpx: "0.13.1", - claudeAcp: "0.73.0", - codexAcp: "1.6.2", - grok: "1.0.13", + acpx: QUALIFIED_ACPX_VERSION, + claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, + codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion, + grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, } as const; const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = { - grok: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + grok: QUALIFIED_ACPX_PROFILES.grok.commandDigest, claude: - "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + QUALIFIED_ACPX_PROFILES.claude.commandDigest, codex: - "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", + QUALIFIED_ACPX_PROFILES.codex.commandDigest, } as const; const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = { grokLauncher: "dist/providers/grok/launcher.cjs",