diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9568e183e33..f903ed949bb 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2457,7 +2457,12 @@ directory staging; invalid sidecars fail closed. Keep transport files outside the pack so they cannot include themselves. Compatible protected image packs remain preferred and never transfer archives. The Runner's `src/live/provider-pack-contract.json` declares interface revisions; -bump only the affected interface when its runner/pack contract breaks. Qualify +bump only the affected interface when its runner/pack contract breaks. Image +packs must also match the bundled pack's computed `contractDigest` +(`scripts/contracts/contract-fingerprints.mjs`, over the `protocol/` and +`generated/` JSON contracts and Rust shapes), so an unbumped contract change +still retires older image packs. Declare new runner/pack contracts in a schema +and generate their TypeScript, so the digest covers them. Qualify and seal packs at image publication. The fast path requires root-owned, non-writable ancestors and manifest, and rejects writable aliases. The selected pack's own artifact identities feed launch and the recovery receipt. Reattachment diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index 70e1ca7ea19..a98caa5cf52 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -104,6 +104,18 @@ fall back to host-local processes. `src/live/provider-pack-contract.json` in the Runner package declares the OpenCode and ACPX interface revisions. Change a revision only for an incompatible runner/pack boundary change, and qualify the new pack before publishing it. +Bumping a revision also retires every older image pack. +The pack build also records a `contractDigest`, computed by +`scripts/contracts/contract-fingerprints.mjs` from the JSON contracts under +`protocol/` and `generated/` and from Rust serde shapes, payload keys, match +literals and env keys, rather than from source bytes. An image pack is reused +only when its digest equals the controller's bundled pack; comments and +refactors leave it unchanged, so a mismatch means a contract changed and the +controller uploads its bundled pack. Packs without a digest are not reused. +Define a new runner/pack contract in a schema under `protocol/` and generate the +TypeScript from it, as the sidecar's `session.open` parameter allowlist is +generated from `acpx-sidecar.schema.json`; TypeScript-only definitions are not +part of the digest. Dependency versions and Git SHAs remain build provenance. ACPX retains its selected agent's qualified profile checks. diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 4759711c201..586d494328e 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -15,12 +15,12 @@ "qualificationStatus": "pending" }, "grok": { - "agentProfileVersion": 14, + "agentProfileVersion": 15, "agentServerPackage": "builtin:grok-acp", "agentServerVersion": "1", "agentRuntimePackage": "native:grok", "agentRuntimeVersion": "1.0.13", - "commandDigest": "sha256:ca59edc7f416cc1e86ac7d7d1e288d3d91065b7ab59e79bcb62bae5f0cc5cf49", + "commandDigest": "sha256:658e6d41ce6fc7080aa6655d7a97121b3cae6214f2d4c074284d958b95bbe769", "requiresProviderPolicy": false }, "pi": { @@ -42,12 +42,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 87, + "agentProfileVersion": 88, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:ce2f4322cbc545932d6c725c57005eea1f44f2c743d5384c6ea90909563d958d", + "commandDigest": "sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 013021e7246..eae3b86450f 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -85,7 +85,7 @@ "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", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/grok-profile-identity.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/grok-profile-identity.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/contracts/contract-fingerprints.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", @@ -99,8 +99,8 @@ "record:sdk:codex": "pnpm run build:typescript && playwright test --config examples/playwright.real.config.ts", "generate:protocol-manifest": "node scripts/generate-protocol-manifest.mjs", "check:protocol-manifest": "node scripts/generate-protocol-manifest.mjs --check", - "generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs && node scripts/generate-acpx-sidecar-contract.mjs", - "check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check && node scripts/generate-acpx-sidecar-contract.mjs --check", + "generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs && node scripts/generate-acpx-sidecar-contract.mjs && node scripts/generate-runtime-context-contract.mjs", + "check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check && node scripts/generate-acpx-sidecar-contract.mjs --check && node scripts/generate-runtime-context-contract.mjs --check", "generate:capability-contract": "node scripts/generate-capability-contract.mjs", "check:capability-contract": "node scripts/generate-capability-contract.mjs --check", "generate:semantic-contracts": "pnpm run build:typescript && node scripts/generate-semantic-contracts.mjs", diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index 2287515faec..46ad48aab61 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -69,7 +69,7 @@ }, { "if": { "properties": { "command": { "const": "session.open" } } }, - "then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } } + "then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenParams" }, { "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } } } ] }, @@ -93,6 +93,40 @@ "additionalProperties": false }, "piThinkingLevel": { "enum": ["off", "low", "high", "max"] }, + "sessionOpenParams": { + "description": "The closed session.open parameter set. The sidecar's open-parameter allowlist is generated from these property names; field-specific rules live in the sibling definitions.", + "type": "object", + "properties": { + "runtimeDirectory": true, + "normalizedSessionId": true, + "workingDirectory": true, + "agent": true, + "model": true, + "permissionMode": true, + "mode": true, + "piThinkingLevel": true, + "claudeEffort": true, + "grokEffort": true, + "grokDisableWebSearch": true, + "permissionModePinned": true, + "providerPolicy": { "anyOf": [{ "type": "null" }, { "$ref": "#/$defs/providerPolicy" }] }, + "systemInstructions": true, + "runtimeContext": { "description": "Validated against runtime-context.schema.json." }, + "tools": true, + "providerSessionKey": true, + "expectedIdentity": true, + "candidateAdmission": true + }, + "additionalProperties": false + }, + "providerPolicy": { + "type": "object", + "required": ["readOnly"], + "properties": { + "readOnly": { "type": "boolean" } + }, + "additionalProperties": false + }, "sessionOpenThinkingLevel": { "type": "object", "properties": { diff --git a/packages/paperclip-runner/protocol/provider-schemas/runtime-context.schema.json b/packages/paperclip-runner/protocol/provider-schemas/runtime-context.schema.json new file mode 100644 index 00000000000..0d097479219 --- /dev/null +++ b/packages/paperclip-runner/protocol/provider-schemas/runtime-context.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/runner/v1/runtime-context.schema.json", + "title": "Paperclip native runtime context", + "description": "The runtime context that the controller sends in native execution input and in sidecar session.open. Every object is closed; parseNativeRuntimeContext rejects unknown fields with field lists generated from this schema.", + "$ref": "#/$defs/runtimeContext", + "$defs": { + "sha256Hex": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "nonEmptyText": { "type": "string", "minLength": 1 }, + "runtimeContext": { + "type": "object", + "required": ["prompt", "instructions", "skills", "mcp", "aggregateDigest"], + "properties": { + "prompt": { "$ref": "#/$defs/prompt" }, + "instructions": { "$ref": "#/$defs/instructions" }, + "skills": { "type": "array", "items": { "$ref": "#/$defs/skill" } }, + "mcp": { "$ref": "#/$defs/mcp" }, + "connectionInstructions": { "$ref": "#/$defs/connectionInstructions" }, + "aggregateDigest": { "$ref": "#/$defs/sha256Hex" } + }, + "additionalProperties": false + }, + "prompt": { + "type": "object", + "required": ["revision", "text", "digest"], + "properties": { + "revision": { "$ref": "#/$defs/nonEmptyText" }, + "text": { "$ref": "#/$defs/nonEmptyText" }, + "digest": { "$ref": "#/$defs/sha256Hex" } + }, + "additionalProperties": false + }, + "instructions": { + "type": "object", + "required": ["entryPath", "bundle"], + "properties": { + "entryPath": { "$ref": "#/$defs/nonEmptyText" }, + "bundle": { "$ref": "#/$defs/asset" }, + "workingCopy": { "$ref": "#/$defs/workingCopy" } + }, + "additionalProperties": false + }, + "workingCopy": { + "type": "object", + "required": ["rootPath", "entryPath"], + "properties": { + "rootPath": { "$ref": "#/$defs/nonEmptyText" }, + "entryPath": { "$ref": "#/$defs/nonEmptyText" }, + "kind": { "const": "agent_files" } + }, + "additionalProperties": false + }, + "skill": { + "type": "object", + "required": ["key", "runtimeName", "versionId", "bundle"], + "properties": { + "key": { "$ref": "#/$defs/nonEmptyText" }, + "runtimeName": { "$ref": "#/$defs/nonEmptyText" }, + "versionId": { "anyOf": [{ "$ref": "#/$defs/nonEmptyText" }, { "type": "null" }] }, + "bundle": { "$ref": "#/$defs/asset" } + }, + "additionalProperties": false + }, + "mcp": { + "type": "object", + "required": ["assignmentSetId", "digest", "bindingId"], + "properties": { + "assignmentSetId": { "$ref": "#/$defs/nonEmptyText" }, + "digest": { "$ref": "#/$defs/sha256Hex" }, + "bindingId": { "anyOf": [{ "$ref": "#/$defs/nonEmptyText" }, { "type": "null" }] } + }, + "additionalProperties": false + }, + "connectionInstructions": { + "type": "object", + "required": ["text", "digest"], + "properties": { + "text": { "$ref": "#/$defs/nonEmptyText" }, + "digest": { "$ref": "#/$defs/sha256Hex" } + }, + "additionalProperties": false + }, + "asset": { + "type": "object", + "required": ["schema", "digest", "manifestDigest", "rootPath", "fileCount", "totalBytes"], + "properties": { + "schema": { "const": "paperclip.runtime-asset.v1" }, + "digest": { "$ref": "#/$defs/sha256Hex" }, + "manifestDigest": { "$ref": "#/$defs/sha256Hex" }, + "rootPath": { "$ref": "#/$defs/nonEmptyText" }, + "fileCount": { "type": "integer", "minimum": 0 }, + "totalBytes": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + } +} 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 index bbfac6b48eb..1fed7fa297c 100644 --- 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 @@ -31,7 +31,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("native:grok"), agent_runtime_version: Some("1.0.13"), command_digest: - "sha256:ca59edc7f416cc1e86ac7d7d1e288d3d91065b7ab59e79bcb62bae5f0cc5cf49", + "sha256:658e6d41ce6fc7080aa6655d7a97121b3cae6214f2d4c074284d958b95bbe769", requires_provider_policy: false, qualification_pending: false, }, @@ -61,7 +61,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:ce2f4322cbc545932d6c725c57005eea1f44f2c743d5384c6ea90909563d958d", + "sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d", requires_provider_policy: true, qualification_pending: false, }, diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index bc421593276..580c3d1fd5d 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -24,6 +24,7 @@ import { parseProviderPackArguments, materializeCandidateProviderPack, providerP import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs"; import { providerPackLibc, removeControllerArtifactsFromProviderPack, removeForeignLibcPackagesFromProviderPack } from "./provider-pack-runtime-tree.mjs"; +import { packContractDigest } from "./contracts/contract-fingerprints.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -314,6 +315,9 @@ try { }).status !== 0; const payload = { compatibility, + // Semantic digest of the Runner/pack contracts. Image packs are reused only + // by a controller whose bundled pack records the same value. + contractDigest: packContractDigest(workspaceRoot), pins: { nodeMinimum: minimumNodeVersion.join("."), codex: profiles.profiles.codex.agentRuntimeVersion, diff --git a/packages/paperclip-runner/scripts/contracts/contract-fingerprints.mjs b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.mjs new file mode 100644 index 00000000000..130c2c65951 --- /dev/null +++ b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.mjs @@ -0,0 +1,471 @@ +// Fingerprints the contracts that cross the Runner/provider-pack boundary. +// Contracts are declared as JSON (PRP and sidecar schemas, generated catalogs) +// and read structurally, with description text stripped; Rust shapes are read +// lexically (serde structs, payload keys, match literals, env keys). Comments, +// formatting and refactors do not change a fingerprint; a new field, accepted +// value or env key does. build-provider-pack.mjs records the combined digest so +// an image pack is reused only by a controller built from the same contracts. +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const R = "packages/paperclip-runner"; +const RUST = `${R}/runner/crates/runner-core/src`; +const DOC_KEYS = new Set(["description", "title", "$comment", "examples", "markdownDescription"]); + +/** Contract registry. Each contract binds probes; a probe reads one artifact. + * Only trees present in every pack build context are read (Docker contexts + * exclude tests, fixtures and docs), so the digest is the same wherever a pack + * is built. A TypeScript contract joins the digest by moving its definition + * into a schema under protocol/ and generating the TypeScript from it. */ +export const CONTRACTS = [ + { id: "prp.schemas", expand: { kind: "json-dir", dirs: [`${R}/protocol/schemas`, `${R}/protocol/provider-schemas`] } }, + { id: "generated", expand: { dirs: [`${R}/generated`], suffix: ".json", idPrefix: "generated.", probes: [{ kind: "json", stripDocs: true }] } }, + // Rust file and module names are private, so the crate is one contract whose + // values are merged across files; moving code between files changes nothing. + { id: "rust", expand: { dirs: [RUST], suffix: ".rs", aggregate: true, probes: [ + { kind: "rust-serde" }, { kind: "rust-json-keys" }, { kind: "rust-match-literals" }, { kind: "rust-env-keys" }, + ] } }, + // Reserved and forwarded runtime env keys are const lists in Rust. + { id: "configured-environment", probes: [{ kind: "rust-consts", path: `${RUST}/configured_environment.rs` }] }, +]; + +/** Every hand-registered file a probe reads. */ +export function probePaths() { + return CONTRACTS.flatMap(c => c.probes ?? []).map(p => p.path); +} +export function expandDirs() { + return CONTRACTS.flatMap(c => c.expand?.dirs ?? []); +} + +/** + * @param {(path: string) => string | null} readFile repository-relative path + * @param {(dir: string) => string[]} listDir repository-relative file paths under dir + * @returns {Record} + */ +export function fingerprintContracts(readFile, listDir) { + const out = {}; + for (const contract of CONTRACTS) { + if (contract.expand?.kind === "json-dir") { + for (const path of contract.expand.dirs.flatMap(listDir).filter(p => p.endsWith(".json")).sort()) { + const value = probe({ kind: "json", path, stripDocs: true }, readFile); + out[`prp.${path.split("/").pop().replace(/\.schema\.json$|\.json$/, "")}`] = { fingerprint: digest(value), value }; + } + continue; + } + if (contract.expand) { + const { dirs, suffix, exclude, idPrefix, aggregate, probes } = contract.expand; + const candidates = dirs.flatMap(listDir).filter(p => p.endsWith(suffix) && !exclude?.test(p)); + const testOnly = suffix === ".rs" ? rustTestOnlyFiles(candidates, readFile) : new Set(); + const sources = candidates.filter(p => !testOnly.has(p)).sort(); + // Env keys are often passed through `const NAME: &str = "KEY"` defined in + // another file, so resolve constant arguments crate-wide. + const constants = suffix === ".rs" ? rustStringConstants(sources, readFile) : {}; + // Serde type names never reach JSON; a reference to one is replaced by the + // referenced type's shape, which may be defined in another file. + const typeShapes = suffix === ".rs" ? rustSerdeTypeShapes(sources, readFile) : {}; + const crateWide = { "rust-env-keys": { constants }, "rust-serde": { typeShapes } }; + const values = []; + for (const path of sources) { + const value = probes.map(p => probe({ ...p, path, ...crateWide[p.kind] }, readFile)); + if (isEmpty(value)) continue; + if (aggregate) { values.push(value); continue; } + const id = idPrefix + path.slice(dirs.find(d => path.startsWith(`${d}/`)).length + 1).replace(/\.[a-z]+$/, ""); + out[id] = { fingerprint: digest(value), value }; + } + if (aggregate) { + const value = probes.map((_, i) => union(values.map(v => v[i]))); + out[contract.id] = { fingerprint: digest(value), value }; + } + continue; + } + const value = contract.probes.map(p => probe(p, readFile)); + // A contract whose artifacts do not exist yet has not been introduced. + if (value.every(v => v?.absent)) continue; + out[contract.id] = { fingerprint: digest(value), value }; + } + return out; +} + +/** Combined digest of every contract in a checkout rooted at `root`. A missing + * registered file or directory is a build-context error, never an absent + * contract, so two build contexts cannot silently disagree. */ +export function packContractDigest(root, { read, list } = checkoutSources(root)) { + for (const path of probePaths()) { + if (read(path) === null) throw new Error(`Registered contract source is missing: ${path}`); + } + for (const dir of expandDirs()) { + if (list(dir).length === 0) throw new Error(`Registered contract directory is missing: ${dir}`); + } + const contracts = fingerprintContracts(read, list); + return digest(Object.fromEntries(Object.entries(contracts).map(([id, c]) => [id, c.fingerprint]))); +} + +/** Repository-relative readers over a checkout. */ +export function checkoutSources(root) { + return { + read: path => { + const absolute = join(root, path); + return existsSync(absolute) && statSync(absolute).isFile() ? readFileSync(absolute, "utf8") : null; + }, + list: dir => existsSync(join(root, dir)) ? readdirSync(join(root, dir), { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => relative(root, join(entry.parentPath, entry.name)).split("\\").join("/")) : [], + }; +} + +/** Merges probe values from several files: lists become sorted sets and + * objects merge by key. */ +function union(values) { + if (values.every(Array.isArray)) return [...new Set(values.flat())].sort(); + const keys = [...new Set(values.flatMap(Object.keys))].sort(); + return Object.fromEntries(keys.map(k => [k, union(values.map(v => v[k] ?? []))])); +} +/** Files that expose no contract artifacts produce no contract. */ +function isEmpty(value) { + const empty = v => v == null || (Array.isArray(v) ? v.every(empty) : typeof v === "object" ? Object.values(v).every(empty) : false); + return empty(value); +} +const probeCache = new Map(); +function probe(spec, readFile) { + const source = readFile(spec.path); + if (source === null) return { absent: true }; + const key = `${JSON.stringify(spec, (k, v) => v instanceof RegExp ? String(v) : v)}\0${createHash("sha1").update(source).digest("hex")}`; + if (!probeCache.has(key)) probeCache.set(key, probeUncached(spec, source)); + return probeCache.get(key); +} +function probeUncached(spec, source) { + switch (spec.kind) { + case "json": return canonicalJson(JSON.parse(source), spec); + case "rust-consts": return rustConsts(source); + case "rust-serde": return rustSerde(source, spec.typeShapes ?? {}); + case "rust-json-keys": return rustJsonKeys(source); + case "rust-match-literals": return rustMatchLiterals(source); + case "rust-env-keys": return rustEnvKeys(source, spec.constants ?? {}); + default: throw new Error(`unknown probe kind ${spec.kind}`); + } +} + +// ---------- canonical values ---------- + +export function digest(value) { + return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`; + } + return JSON.stringify(value ?? null); +} +/** With `stripDocs`, drops schema annotations (description, title, $comment, + * examples) by their value shape: annotations are text or example lists, while + * a property declared as `properties.description` holds a schema object and is + * kept with its constraints. */ +function canonicalJson(value, { stripDocs = false } = {}) { + if (Array.isArray(value)) return value.map(v => canonicalJson(v, { stripDocs })); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value) + .filter(([k, v]) => !(stripDocs && isAnnotation(k, v))) + .map(([k, v]) => [k, canonicalJson(v, { stripDocs })])); + } + return value; +} +function isAnnotation(key, value) { + return DOC_KEYS.has(key) && (typeof value === "string" || (key === "examples" && Array.isArray(value))); +} + +// ---------- Rust (lexical, no syn available) ---------- + +function stripRustComments(source) { + let out = "", i = 0; + while (i < source.length) { + const c = source[i], n = source[i + 1]; + if (c === "/" && n === "/") { while (i < source.length && source[i] !== "\n") i++; continue; } + if (c === "/" && n === "*") { let depth = 1; i += 2; while (i < source.length && depth) { if (source.startsWith("/*", i)) { depth++; i += 2; } else if (source.startsWith("*/", i)) { depth--; i += 2; } else i++; } continue; } + const raw = /^r(#*)"/.exec(source.slice(i, i + 10)); + if (raw && !/[\w]/.test(source[i - 1] ?? "")) { + const end = source.indexOf(`"${raw[1]}`, i + raw[0].length); + out += source.slice(i, end + 1 + raw[1].length); i = end + 1 + raw[1].length; continue; + } + if (c === '"') { let j = i + 1; while (j < source.length && source[j] !== '"') j += source[j] === "\\" ? 2 : 1; out += source.slice(i, j + 1); i = j + 1; continue; } + if (c === "'" && /^'(\\.|[^\\'])'/.test(source.slice(i, i + 4))) { const m = /^'(\\.|[^\\'])'/.exec(source.slice(i)); out += m[0]; i += m[0].length; continue; } + out += c; i++; + } + return out; +} +/** Files compiled only under `#[cfg(test)]`: modules declared as + * `#[cfg(test)] [#[path = "..."]] mod name;`, their submodules, and files under + * a test module's directory. Inline `#[cfg(test)] mod name { .. }` blocks are + * removed separately by withoutTestModules. */ +function rustTestOnlyFiles(paths, readFile) { + const known = new Set(paths); + const testOnly = new Set(); + const pending = [...paths]; + const declaration = /#\[cfg\(test\)\]\s*((?:#\[[^\]]*\]\s*)*)(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/g; + const visit = (path) => { + const source = readFile(path); + if (source === null) return []; + const directory = path.slice(0, path.lastIndexOf("/")); + const file = path.slice(path.lastIndexOf("/") + 1); + const ownDirectory = /^(lib|main|mod)\.rs$/.test(file) ? directory : `${directory}/${file.replace(/\.rs$/, "")}`; + const found = []; + for (const m of stripRustComments(source).matchAll(declaration)) { + const explicit = /#\[path\s*=\s*"([^"]+)"\s*\]/.exec(m[1])?.[1]; + // A #[path] on a module declared in a non-mod.rs file resolves from the file's directory. + const targets = explicit ? [normalizePath(`${directory}/${explicit}`)] + : [`${ownDirectory}/${m[2]}.rs`, `${ownDirectory}/${m[2]}/mod.rs`]; + for (const target of targets) { + found.push(target); + const moduleDirectory = target.endsWith("/mod.rs") ? target.slice(0, -"/mod.rs".length) : target.replace(/\.rs$/, ""); + for (const other of known) if (other.startsWith(`${moduleDirectory}/`)) found.push(other); + } + } + return found.filter(target => known.has(target)); + }; + while (pending.length) { + for (const target of visit(pending.pop())) { + if (testOnly.has(target)) continue; + testOnly.add(target); + pending.push(target); + } + } + return testOnly; +} +function normalizePath(path) { + const parts = []; + for (const part of path.split("/")) { + if (part === "..") parts.pop(); + else if (part !== "." && part !== "") parts.push(part); + } + return parts.join("/"); +} +function withoutTestModules(text) { + let out = text; + for (let m; (m = /#\[cfg\(test\)\]\s*mod\s+\w+\s*\{/.exec(out));) { + let depth = 0, j = m.index + m[0].length - 1; + for (; j < out.length; j++) { if (out[j] === "{") depth++; else if (out[j] === "}" && --depth === 0) break; } + out = out.slice(0, m.index) + out.slice(j + 1); + } + return out; +} +function rustStrings(text) { + return [...text.matchAll(/"((?:\\.|[^"\\])*)"/g)].map(m => m[1]); +} +function rustConsts(source) { + const text = stripRustComments(source); + const out = {}; + for (const m of text.matchAll(/\b(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:[^=]+=/g)) { + const end = text.indexOf(";", m.index + m[0].length); + out[m[1]] = [...rustStrings(text.slice(m.index + m[0].length, end))].sort(); + } + return out; +} +const SERDE_ITEM = /((?:#\[[^\]]*\]\s*)+)(?:pub(?:\([^)]*\))?\s+)?(struct|enum)\s+(\w+)(<[^>{]*>)?/g; +const isSerdeItem = attrs => /derive\([^)]*\b(Serialize|Deserialize)\b/.test(attrs); + +/** Serialized shapes of a file's derive(Serialize|Deserialize) items: serde + * attributes and field/variant bodies. Rust type names never reach JSON, so + * item names are dropped and a reference to a crate serde type is replaced by + * that type's shape digest; renaming a type is not a contract change, while + * field and variant names, field types (including which shape a reference + * names) and serde attributes are. */ +function rustSerde(source, typeShapes) { + return rustSerdeItems(source) + .map(item => serdeShape(item, name => Object.hasOwn(typeShapes, name) ? typeShapes[name] : null)) + .sort(); +} +function serdeShape({ kind, serde, body }, shapeOf) { + return stableStringify({ kind, serde, body: withNormalizedTypeReferences(body, shapeOf) }); +} +/** derive(Serialize|Deserialize) items with whitespace-normalized bodies. */ +function rustSerdeItems(source) { + const text = withoutTestModules(stripRustComments(source)); + const items = []; + for (const m of text.matchAll(SERDE_ITEM)) { + const attrs = m[1]; + if (!isSerdeItem(attrs)) continue; + let i = m.index + m[0].length; + while (i < text.length && text[i] !== "{" && text[i] !== ";" && text[i] !== "(") i++; + let body = ""; + if (text[i] === "{" || text[i] === "(") { + const open = text[i], close = open === "{" ? "}" : ")"; + let depth = 0, j = i; + for (; j < text.length; j++) { if (text[j] === open) depth++; else if (text[j] === close && --depth === 0) break; } + body = text.slice(i, j + 1); + } + // `#[serde(skip)]` fields never reach the wire or the persisted file. + const normalized = body.replace(/#\[serde\(skip\)\]\s*(?:pub(?:\([^)]*\))?\s+)?\w+\s*:[^,{}]*(?:<[^>]*>)?[^,{}]*,?/g, "") + .replace(/\bpub(\([^)]*\))?\s+/g, "").replace(/\s+/g, " ").replace(/\s*([{}(),:<>;\[\]=])\s*/g, "$1").replace(/,([})\]])/g, "$1"); + items.push({ + name: m[3], + kind: m[2], + serde: (attrs.match(/#\[serde\([^\]]*\)\]/g) ?? []).map(a => a.replace(/\s+/g, "")).sort(), + body: normalized, + }); + } + return items; +} +/** Replaces crate serde type names in type positions (after `:`, or inside + * `(..)` and `<..>`) with `$type:`. Names directly inside `{..}` after + * `{`, `,` or an attribute are field or variant names, which serialize, and + * stay. */ +function withNormalizedTypeReferences(body, shapeOf) { + const stack = []; + let previous = ""; + // Quoted strings (serde renames) are wire names and pass through unchanged. + return body.replace(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_]*|[^A-Za-z0-9_]/g, token => { + if (token.startsWith("\"")) { previous = "string"; return token; } + if (/^[A-Za-z_]/.test(token)) { + const namePosition = stack.at(-1) === "{" && ["{", ",", "]"].includes(previous); + previous = "ident"; + const shape = namePosition ? null : shapeOf(token); + return shape === null ? token : `$type:${shape}`; + } + if ("{(<[".includes(token)) stack.push(token); + else if ("})>]".includes(token)) stack.pop(); + previous = token; + return token; + }); +} +/** Shape digest of every derive(Serialize|Deserialize) item across the crate, + * keyed by its Rust name. A reference inside a shape resolves to the referenced + * shape, so the digest is independent of every Rust type name. A reference back + * into the current resolution path is recorded by its depth. Items that share a + * name in different modules resolve to all of their shapes. */ +function rustSerdeTypeShapes(paths, readFile) { + const byName = new Map(); + for (const path of paths) { + const source = readFile(path); + if (source === null) continue; + for (const item of rustSerdeItems(source)) byName.set(item.name, [...(byName.get(item.name) ?? []), item]); + } + const resolved = new Map(); + const resolve = (name, path) => { + if (resolved.has(name)) return { shape: resolved.get(name), cyclic: false }; + const back = path.indexOf(name); + if (back >= 0) return { shape: `cycle:${path.length - back}`, cyclic: true }; + let cyclic = false; + const shapes = byName.get(name).map(item => serdeShape(item, reference => { + if (!byName.has(reference)) return null; + const result = resolve(reference, [...path, name]); + cyclic ||= result.cyclic; + return result.shape; + })).sort(); + const shape = digest(shapes).slice("sha256:".length, "sha256:".length + 16); + // A shape inside a cycle depends on where resolution entered it. + if (!cyclic) resolved.set(name, shape); + return { shape, cyclic }; + }; + return Object.fromEntries([...byName.keys()].sort().map(name => [name, resolve(name, []).shape])); +} + +/** Payload keys built or read in code, `json!({"key": ...})`, `["key"]` and + * `.get("key")`, grouped by enclosing function. Groups carry no function name, + * so renaming or moving a function is not a contract change, while a parser + * that starts reading a different key is, even when another function already + * reads that key. */ +function rustJsonKeys(source) { + return rustScopeGroups(source, text => { + const writes = new Set(), reads = new Set(); + for (const m of text.matchAll(/json!\s*\(/g)) { + const body = balanced(text, m.index + m[0].length - 1, "(", ")"); + for (const k of body.matchAll(/"((?:\\.|[^"\\])*)"\s*:/g)) writes.add(k[1]); + } + for (const m of text.matchAll(/\[\s*"((?:\\.|[^"\\])*)"\s*\]|\.get\(\s*"((?:\\.|[^"\\])*)"\s*\)/g)) reads.add(m[1] ?? m[2]); + return writes.size || reads.size ? { writes: [...writes].sort(), reads: [...reads].sort() } : null; + }); +} +/** Inline accept-sets, string literals in `matches!(...)` and in match-arm + * patterns, grouped by enclosing function like rustJsonKeys. */ +function rustMatchLiterals(source) { + return rustScopeGroups(source, text => { + const out = new Set(); + for (const m of text.matchAll(/matches!\s*\(/g)) { + for (const s of rustStrings(balanced(text, m.index + m[0].length - 1, "(", ")"))) out.add(s); + } + const lit = String.raw`"(?:\\.|[^"\\])*"`; + for (const m of text.matchAll(new RegExp(`(${lit}(?:\\s*\\|\\s*${lit})*)\\s*(?:if\\b[^=]*)?=>`, "g"))) { + for (const s of rustStrings(m[1])) out.add(s); + } + return out.size ? [...out].sort() : null; + }); +} +/** Applies `collect` to each outermost function body, and to the code outside + * any function, and returns the sorted set of non-empty results. */ +function rustScopeGroups(source, collect) { + const groups = rustScopes(withoutTestModules(stripRustComments(source))).map(collect).filter(Boolean); + return [...new Set(groups.map(stableStringify))].sort(); +} +/** Outermost `fn` bodies, then the remaining text. String contents are masked + * while locating braces, so `"{}"` format strings do not end a body early. */ +function rustScopes(text) { + const masked = text.replace(/"(?:\\.|[^"\\])*"/g, m => `"${" ".repeat(m.length - 2)}"`); + const scopes = []; + let rest = "", last = 0; + const fn = /\bfn\s+\w+/g; + for (let m; (m = fn.exec(masked));) { + // A declaration without a body ends at `;` outside the signature's (..) and [..]. + let i = m.index + m[0].length, depth = 0; + for (; i < masked.length; i++) { + const c = masked[i]; + if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") depth--; + else if (depth === 0 && (c === "{" || c === ";")) break; + } + if (masked[i] !== "{") continue; + const end = i + balanced(masked, i, "{", "}").length; + rest += text.slice(last, m.index); + scopes.push(text.slice(i, end)); + last = end; + fn.lastIndex = end; + } + scopes.push(rest + text.slice(last)); + return scopes; +} +function balanced(text, start, open, close) { + let depth = 0, j = start; + for (; j < text.length; j++) { if (text[j] === open) depth++; else if (text[j] === close && --depth === 0) break; } + return text.slice(start, j + 1); +} +/** Environment keys passed or read in code: `keys.push(K)`, `.env(K, ..)`, + * `.env_remove(K)` and `env::var(K)` / `env::var_os(K)`, where K is a string + * literal or a string constant, and key lists: `vec![..]` or `&[..]` literals + * whose elements are all key-shaped strings or constants, at least one of them + * an upper-case `NAME_WITH_UNDERSCORE`. A constant resolves to its value, so + * renaming the Rust constant is not a contract change; an unresolved constant + * keeps its name so a rename remains visible. */ +function rustEnvKeys(source, constants) { + const text = withoutTestModules(stripRustComments(source)); + const keys = new Set(); + const constant = String.raw`(?:[a-z_][a-z0-9_]*::)*([A-Z][A-Z0-9_]*)\b`; + const resolve = (literal, name) => literal ?? constants[name] ?? `const:${name}`; + const pattern = new RegExp(String.raw`(?:keys\.push|\.env|\.env_remove|env::var|env::var_os)\(\s*(?:"([A-Z][A-Z0-9_]*)"|${constant})`, "g"); + for (const m of text.matchAll(pattern)) keys.add(resolve(m[1], m[2])); + const element = new RegExp(String.raw`^\s*(?:"([A-Za-z_][A-Za-z0-9_]*)"|${constant})\s*$`); + for (const m of text.matchAll(/(?:vec!\s*|&\s*)\[/g)) { + const body = balanced(text, m.index + m[0].length - 1, "[", "]").slice(1, -1); + const parts = body.split(",").filter((part, i, all) => i < all.length - 1 || part.trim()); + const matches = parts.map(part => element.exec(part)); + if (!matches.length || matches.some(e => !e)) continue; + const list = matches.map(e => resolve(e[1], e[2])); + if (list.some(key => /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(key))) for (const key of list) keys.add(key); + } + return [...keys].sort(); +} +/** String constants (`const NAME: &str = "VALUE";`) across the crate's sources. + * Names defined twice with different values are ambiguous and left unresolved. */ +function rustStringConstants(paths, readFile) { + const found = new Map(); + for (const path of paths) { + const source = readFile(path); + if (source === null) continue; + const text = withoutTestModules(stripRustComments(source)); + for (const m of text.matchAll(/\b(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:\s*&(?:'static\s+)?str\s*=\s*"([^"\\]*)"\s*;/g)) { + const values = found.get(m[1]) ?? new Set(); + values.add(m[2]); + found.set(m[1], values); + } + } + return Object.fromEntries([...found].filter(([, values]) => values.size === 1).map(([name, values]) => [name, [...values][0]]).sort()); +} diff --git a/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs new file mode 100644 index 00000000000..7e7ce61dee6 --- /dev/null +++ b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { checkoutSources, fingerprintContracts, packContractDigest } from "./contract-fingerprints.mjs"; + +const root = resolve(fileURLToPath(new URL("../../../..", import.meta.url))); +const sources = checkoutSources(root); +const R = "packages/paperclip-runner"; +const RUST = `${R}/runner/crates/runner-core/src`; +const baseline = packContractDigest(root); + +/** Digest of the checkout with one file's text rewritten in memory. */ +function digestWith(path, edit) { + const original = sources.read(path); + assert.ok(original, `${path} exists`); + const edited = edit(original); + assert.ok(edited !== original, `edit applies to ${path}`); + return packContractDigest(root, { read: p => p === path ? edited : sources.read(p), list: sources.list }); +} + +test("the digest is a stable sha256 of the checkout", () => { + assert.match(baseline, /^sha256:[a-f0-9]{64}$/); + assert.equal(packContractDigest(root), baseline); +}); + +const SIDECAR_SCHEMA = `${R}/protocol/provider-schemas/acpx-sidecar.schema.json`; + +test("incidental edits leave the digest unchanged", () => { + const unchanged = [ + [SIDECAR_SCHEMA, s => s.replace(/"description": "/, "\"description\": \"Edited. ")], + [SIDECAR_SCHEMA, s => s.replace(/\n {6}"/g, "\n \"")], + [`${R}/protocol/schemas/auth-credentials.schema.json`, s => s.replace(/"description": "/, "\"description\": \"Edited. ")], + // TypeScript is generated from or enforced by the schemas; it is not read. + [`${R}/src/cli/acpx-runtime-sidecar.ts`, s => s.replaceAll("parseOpenParams", "parseSessionOpenParams")], + [`${RUST}/configured_environment.rs`, s => `// header\n${s.replace("const RESERVED", "/// doc\nconst RESERVED")}`], + [`${RUST}/acpx_provider_checkpoint.rs`, s => s.replace("pub struct AcpxSuspensionCheckpoint {", + "pub struct AcpxSuspensionCheckpoint {\n #[serde(skip)]\n pub cache: Option,")], + [`${RUST}/acpx_provider_checkpoint.rs`, s => `${s}\n#[cfg(test)]\nmod extra {\n #[derive(Serialize)]\n struct T { a: u8 }\n}\n`], + // Rust type names never reach JSON; renaming a serde type keeps its shape. + [`${RUST}/acpx_provider_checkpoint.rs`, s => s.replaceAll("AcpxSuspensionCheckpoint", "AcpxSavedSuspension")], + // Renaming the Rust constant that carries an env key keeps the key itself. + [`${RUST}/process_supervisor.rs`, s => s.replaceAll("VERIFIED_RUNTIME_EXECUTABLE_ENV", "RUNTIME_EXECUTABLE_HANDOFF_ENV")], + // A file compiled only through `#[cfg(test)] #[path = ..] mod ..;`. + [`${RUST}/tests/provider_api_validation.rs`, s => s.replaceAll("PRIVATE_FIELD", "PRIVATE_FIXTURE_FIELD")], + ]; + for (const [path, edit] of unchanged) assert.equal(digestWith(path, edit), baseline, `${path} edit is incidental`); +}); + +test("contract edits change the digest", () => { + const changed = [ + // A new session.open field, the change an older pack sidecar rejects. + [SIDECAR_SCHEMA, s => s.replace("\"grokEffort\": true,", "\"grokEffort\": true,\n \"newField\": true,")], + // A property named `description` is a declaration, not an annotation. + [`${R}/protocol/schemas/result.schema.json`, s => s.replace("\"description\": { \"type\": \"string\", \"minLength\": 1 }", + "\"description\": { \"type\": \"string\", \"minLength\": 2 }")], + // A nested runtime context field, which the pack's strict parser rejects. + [`${R}/protocol/provider-schemas/runtime-context.schema.json`, s => s.replace("\"aggregateDigest\": { \"$ref\": \"#/$defs/sha256Hex\" }", + "\"aggregateDigest\": { \"$ref\": \"#/$defs/sha256Hex\" },\n \"newContextField\": true")], + [SIDECAR_SCHEMA, s => s.replace("\"readOnly\": { \"type\": \"boolean\" }", "\"readOnly\": { \"type\": \"boolean\" },\n \"newPolicyField\": { \"type\": \"boolean\" }")], + [`${RUST}/configured_environment.rs`, s => s.replace("const RESERVED: &[&str] = &[", "const RESERVED: &[&str] = &[\"NEW_KEY\",")], + [`${RUST}/acpx_provider_checkpoint.rs`, s => s.replace("pub struct AcpxSuspensionCheckpoint {", + "pub struct AcpxSuspensionCheckpoint {\n pub extra: Option,")], + [`${RUST}/acpx_sidecar_transport.rs`, s => s.replace(/keys\.push\("/, "keys.push(\"PAPERCLIP_NEW_SIDECAR_KEY\");\n keys.push(\"")], + // Field names serialize (here through rename_all = "camelCase"). + [`${RUST}/acpx_provider_checkpoint.rs`, s => s.replace("pub struct AcpxSuspensionCheckpoint {\n schema: String,\n run_id: String,", + "pub struct AcpxSuspensionCheckpoint {\n schema: String,\n turn_run_id: String,")], + // An env key passed through a constant: the verified executable handoff. + [`${RUST}/process_supervisor.rs`, s => s.replace("\"PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE\"", "\"PAPERCLIP_RUNTIME_EXECUTABLE_PATH\"")], + ]; + for (const [path, edit] of changed) assert.notEqual(digestWith(path, edit), baseline, `${path} edit changes a contract`); +}); + +test("variant names stay part of the shape even when they match a type name", () => { + const path = `${RUST}/acpx_provider_checkpoint.rs`; + const withEnum = original => `${original}\n#[derive(Serialize)]\nenum ProbeShape {\n AcpxSuspensionCheckpoint(AcpxSuspensionCheckpoint),\n}\n`; + const renamedVariant = original => `${original}\n#[derive(Serialize)]\nenum ProbeShape {\n Checkpoint(AcpxSuspensionCheckpoint),\n}\n`; + assert.notEqual(digestWith(path, withEnum), digestWith(path, renamedVariant)); +}); + +test("a field that names a differently shaped type changes the digest", () => { + const path = `${RUST}/acpx_provider_checkpoint.rs`; + // AcpxProviderSessionIdentity, defined in another file, lets permissionMode be absent. + assert.notEqual(digestWith(path, s => s.replace(" identity: PersistedAcpxProviderSessionIdentity,\n}", + " identity: AcpxProviderSessionIdentity,\n}")), baseline); + // Renaming the referenced type keeps its shape and the digest. + assert.equal(digestWith(path, s => s.replaceAll("PersistedAcpxProviderSessionIdentity", "SavedProviderSessionIdentity")), baseline); + // A change inside the referenced type also reaches the field that names it. + const nested = original => `${original}\n#[derive(Serialize)]\nstruct ProbeInner {\n a: u8,\n}\n#[derive(Serialize)]\nstruct ProbeOuter {\n inner: ProbeInner,\n}\n`; + const fingerprints = edit => { + const edited = edit(sources.read(path)); + return fingerprintContracts(p => p === path ? edited : sources.read(p), sources.list); + }; + const before = fingerprints(nested), after = fingerprints(s => nested(s).replace("a: u8", "a: u16")); + const outer = contracts => contracts.rust.value[0].find(shape => shape.includes("inner:")); + assert.notEqual(outer(before), outer(after)); +}); + +test("an env key handed to the sidecar in a key list is part of the contract", () => { + const path = `${RUST}/acpx_sidecar_transport.rs`; + assert.notEqual(digestWith(path, s => s.replace("\"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT\",", "\"PAPERCLIP_ACPX_PACKAGE_ROOT\",")), baseline); + // Lists outside compiled code are not contracts. + assert.equal(digestWith(path, s => s.replace("\"LD_API_KEY\", \"DYLD_API_KEY\"", "\"LD_API_KEY\", \"DYLD_OTHER_KEY\"")), baseline); +}); + +test("moving Rust code to another file leaves the digest unchanged", () => { + const from = `${RUST}/acpx_provider_checkpoint.rs`, to = `${RUST}/acpx_saved_suspension.rs`; + const moved = packContractDigest(root, { + read: p => p === from ? null : p === to ? sources.read(from) : sources.read(p), + list: dir => sources.list(dir).map(p => p === from ? to : p), + }); + assert.equal(moved, baseline); +}); + +test("a parser that reads another key changes the digest even when other code reads it", () => { + const path = `${RUST}/acpx_provider_session.rs`; + // provider_backend.rs already reads both `status` and `result`. + assert.notEqual(digestWith(path, s => s.replace("|| !value.get(\"status\").is_some_and(Value::is_object)", + "|| !value.get(\"result\").is_some_and(Value::is_object)")), baseline); + // Renaming the function keeps its keys and the digest. + assert.equal(digestWith(path, s => s.replaceAll("verify_open_response", "verify_session_open_response")), baseline); +}); + +test("recursive serde types resolve", () => { + const path = `${RUST}/acpx_provider_checkpoint.rs`; + const recursive = name => original => `${original}\n#[derive(Serialize)]\nstruct ${name} {\n children: Vec<${name}>,\n}\n`; + assert.equal(digestWith(path, recursive("ProbeTree")), digestWith(path, recursive("ProbeNode"))); +}); + +test("a build context without a registered source fails instead of producing another digest", () => { + const empty = mkdtempSync(join(tmpdir(), "contract-fingerprints-")); + try { + assert.throws(() => packContractDigest(empty), /Registered contract (source|directory) is missing/); + } finally { + rmSync(empty, { recursive: true, force: true }); + } +}); diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index e8840ebb75f..48573f6cb45 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v87-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v88-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 87 }; +const declaration = { ...historical.declaration, agentProfileVersion: 88 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", @@ -23,6 +23,7 @@ const sources = { singleReadEvidenceSourceSha256: "src/drivers/acpx/single-read-evidence.ts", nativeSessionRuntimeSourceSha256: "src/native-session-runtime.ts", nativeRuntimeContextSourceSha256: "src/contracts/runtime-context.ts", + nativeRuntimeContextFieldsSourceSha256: "src/contracts/generated-runtime-context-contract.ts", nativeRuntimePromptsSourceSha256: "src/contracts/runtime-context-prompts.ts", nativeSessionContractSourceSha256: "src/contracts/native-session-backend.ts", harnessSessionContractSourceSha256: "src/contracts/harness-driver.ts", diff --git a/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs index def605c4944..d682aed0d18 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs @@ -13,6 +13,17 @@ const schema = JSON.parse( ); const commands = schema.$defs.command.enum; const events = schema.$defs.eventType.enum; +// The sidecar enforces exactly the schema's closed session.open parameter set. +const openParamsSchema = schema.$defs.sessionOpenParams; +if (openParamsSchema?.additionalProperties !== false || !openParamsSchema.properties) { + throw new Error("ACPX sidecar session.open parameters must be a closed object schema"); +} +const openParams = Object.keys(openParamsSchema.properties); +const providerPolicySchema = schema.$defs.providerPolicy; +if (providerPolicySchema?.additionalProperties !== false || !providerPolicySchema.properties) { + throw new Error("ACPX sidecar providerPolicy must be a closed object schema"); +} +const providerPolicyFields = Object.keys(providerPolicySchema.properties); // Mutation wins for compound provider kinds so create-target attestation and // the normalized readOnly flag cannot disagree across the sidecar boundary. const toolOperationPrecedence = [ @@ -29,7 +40,7 @@ const rustVariant = (value) => .map((part) => part[0].toUpperCase() + part.slice(1)) .join(""); -const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n\nexport type GeneratedAcpxToolOperation =\n | "read"\n | "search"\n | "list"\n | "edit"\n | "execute"\n | "unknown";\n\nexport const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [\n${toolOperationPrecedence.map(([operation, tokens]) => ` { operation: ${quote(operation)}, tokens: [${tokens.map(quote).join(", ")}] },`).join("\n")}\n] as const;\n\nexport function classifyGeneratedAcpxToolOperation(\n toolKind: unknown,\n toolTitle: unknown,\n): GeneratedAcpxToolOperation {\n const candidate =\n typeof toolKind === "string" && toolKind\n ? toolKind\n : typeof toolTitle === "string"\n ? toolTitle\n : "";\n const normalized = candidate.slice(0, 240).toLowerCase();\n for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) {\n if (tokens.some((token) => normalized.includes(token))) return operation;\n }\n return normalized ? "execute" : "unknown";\n}\n`; +const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n\nexport const GENERATED_ACPX_SIDECAR_OPEN_PARAMS = [\n${openParams.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\n\nexport const GENERATED_ACPX_SIDECAR_PROVIDER_POLICY_FIELDS = [\n${providerPolicyFields.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\n\nexport type GeneratedAcpxToolOperation =\n | "read"\n | "search"\n | "list"\n | "edit"\n | "execute"\n | "unknown";\n\nexport const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [\n${toolOperationPrecedence.map(([operation, tokens]) => ` { operation: ${quote(operation)}, tokens: [${tokens.map(quote).join(", ")}] },`).join("\n")}\n] as const;\n\nexport function classifyGeneratedAcpxToolOperation(\n toolKind: unknown,\n toolTitle: unknown,\n): GeneratedAcpxToolOperation {\n const candidate =\n typeof toolKind === "string" && toolKind\n ? toolKind\n : typeof toolTitle === "string"\n ? toolTitle\n : "";\n const normalized = candidate.slice(0, 240).toLowerCase();\n for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) {\n if (tokens.some((token) => normalized.includes(token))) return operation;\n }\n return normalized ? "execute" : "unknown";\n}\n`; const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\npub const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE: &[(&str, &[&str])] = &[\n${toolOperationPrecedence.map(([operation, tokens]) => ` (${quote(operation)}, &[${tokens.map(quote).join(", ")}]),`).join("\n")}\n];\n\npub fn classify_generated_acpx_tool_operation(kind: &str, title: &str) -> &'static str {\n let kind = kind.to_ascii_lowercase();\n let title = title.to_ascii_lowercase();\n let candidate = if kind.is_empty() { &title } else { &kind };\n for (operation, tokens) in GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE {\n if tokens.iter().any(|token| candidate.contains(token)) {\n return operation;\n }\n }\n if candidate.is_empty() {\n "unknown"\n } else {\n "execute"\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`; // Classification must inspect the same complete provider value in both diff --git a/packages/paperclip-runner/scripts/generate-runtime-context-contract.mjs b/packages/paperclip-runner/scripts/generate-runtime-context-contract.mjs new file mode 100644 index 00000000000..9a1d2aa2143 --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-runtime-context-contract.mjs @@ -0,0 +1,37 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// The runtime context parser rejects unknown fields. Its accepted field lists +// come from the schema so the pack contract digest, which fingerprints the +// schema, changes whenever the parser's contract does. +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schema = JSON.parse( + await readFile(resolve(root, "protocol/provider-schemas/runtime-context.schema.json"), "utf8"), +); +const objects = ["runtimeContext", "prompt", "instructions", "workingCopy", "skill", "mcp", "connectionInstructions", "asset"]; +const fields = objects.map((name) => { + const definition = schema.$defs?.[name]; + if (definition?.type !== "object" || definition.additionalProperties !== false || !definition.properties) { + throw new Error(`runtime context ${name} must be a closed object schema`); + } + return [name, Object.keys(definition.properties)]; +}); + +const typescript = `// Generated by scripts/generate-runtime-context-contract.mjs. Do not edit. + +export const GENERATED_RUNTIME_CONTEXT_FIELDS = { +${fields.map(([name, keys]) => ` ${name}: [${keys.map((key) => JSON.stringify(key)).join(", ")}],`).join("\n")} +} as const; +`; + +const output = resolve(root, "src/contracts/generated-runtime-context-contract.ts"); +if (process.argv.includes("--check")) { + if ((await readFile(output, "utf8").catch(() => "")) !== typescript) { + process.stderr.write(`Generated runtime context contract is stale: ${output}\n`); + process.exitCode = 1; + } else process.stdout.write("Generated runtime context contract matches the schema.\n"); +} else { + await writeFile(output, typescript); + process.stdout.write("Generated runtime context contract.\n"); +} diff --git a/packages/paperclip-runner/scripts/grok-profile-identity.mjs b/packages/paperclip-runner/scripts/grok-profile-identity.mjs index de52546004b..3dea2f15e96 100644 --- a/packages/paperclip-runner/scripts/grok-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/grok-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(packageRoot, "../.."); -// Versions 1–13 remain decodable historical identities. Versioned fixtures are +// Versions 1–14 remain decodable historical identities. Versioned fixtures are // immutable; advance this version before changing a versioned declaration. -export const GROK_PROFILE_VERSION = 14; +export const GROK_PROFILE_VERSION = 15; // Contract surfaces: ACPX wire patch; sidecar parsing and error translation; // native effort/search/credential admission; host/sandbox/tool-bridge execution; // launcher integrity and platform pins; Rust descriptor writes, session state, @@ -24,6 +24,8 @@ export const GROK_PROFILE_SOURCE_FILES = [ "src/drivers/acpx/codex-runtime-adapter.ts", "src/drivers/acpx/runtime-host.ts", "src/drivers/preconfigured-home.ts", "src/drivers/acpx/runtime-sandbox.ts", "src/drivers/acpx/sidecar-protocol.ts", + // The sidecar's session.open allowlist, generated from the sidecar schema. + "src/drivers/acpx/generated-sidecar-contract.ts", "src/drivers/acpx/native-distribution-integrity.ts", "src/providers/grok/launcher.cjs", "src/providers/grok/platforms.json", "runner/crates/runner-core/src/acpx_provider_backend.rs", diff --git a/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs b/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs index ad877be3165..e5635089e2a 100644 --- a/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs +++ b/packages/paperclip-runner/scripts/grok-profile-identity.test.mjs @@ -18,7 +18,7 @@ test("the active Grok profile attests its exact admission source", () => { }); for (const contract of [ - "src/cli/acpx-runtime-sidecar.ts", + "src/drivers/acpx/generated-sidecar-contract.ts", "runner/crates/runner-core/src/acpx_provider_backend.rs", ]) test(`changing the Grok field in ${contract} invalidates the identity`, () => { const copy = mkdtempSync(join(tmpdir(), "grok-profile-source-")); @@ -62,7 +62,7 @@ test("the generator cannot overwrite a versioned identity after source changes", const unchanged = generate(); assert.equal(unchanged.status, 0, unchanged.stderr); assert.deepEqual(protectedFiles.map(path => readFileSync(join(copy, path), "utf8")), before); - const sidecar = join(copy, prefix, "src/cli/acpx-runtime-sidecar.ts"); + const sidecar = join(copy, prefix, "src/drivers/acpx/generated-sidecar-contract.ts"); writeFileSync(sidecar, readFileSync(sidecar, "utf8").replace('"grokEffort"', '"obsoleteGrokEffort"')); const result = generate(); assert.deepEqual(protectedFiles.map(path => readFileSync(join(copy, path), "utf8")), before, "a changed identity must be refused before any write"); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index bbab91adaa7..1ce332b9b91 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -72,6 +72,7 @@ import { type AcpxSidecarRequest, type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; +import { GENERATED_ACPX_SIDECAR_OPEN_PARAMS, GENERATED_ACPX_SIDECAR_PROVIDER_POLICY_FIELDS } from "../drivers/acpx/generated-sidecar-contract.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; @@ -1255,11 +1256,13 @@ function safeOutput(value: unknown): Record { }; } +// Generated from the sidecar schema's closed session.open parameter set. +const OPEN_PARAMS: ReadonlySet = new Set(GENERATED_ACPX_SIDECAR_OPEN_PARAMS); + function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { - const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "grokEffort", "grokDisableWebSearch", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity", "candidateAdmission"]); - if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field"); + if (Object.keys(value).some(key => !OPEN_PARAMS.has(key))) throw new Error("ACPX open parameters include an unsupported field"); const model = requiredText(value.model, "model"); const candidateAdmission = value.candidateAdmission === undefined ? undefined : parseAcpxCandidateStartupAdmission(value.candidateAdmission, { agent: String(value.agent), model, normalizedSessionId: String(value.normalizedSessionId) }); @@ -1313,7 +1316,8 @@ function parseOpenParams( function parseProviderPolicy(value: unknown): { readOnly: boolean } { const policy = record(value); - if (typeof policy.readOnly !== "boolean" || Object.keys(policy).some(key => key !== "readOnly")) { + if (typeof policy.readOnly !== "boolean" + || Object.keys(policy).some(key => !(GENERATED_ACPX_SIDECAR_PROVIDER_POLICY_FIELDS as readonly string[]).includes(key))) { throw new Error("providerPolicy requires only an explicit readOnly boolean"); } return { readOnly: policy.readOnly }; diff --git a/packages/paperclip-runner/src/contracts/generated-runtime-context-contract.ts b/packages/paperclip-runner/src/contracts/generated-runtime-context-contract.ts new file mode 100644 index 00000000000..4ef68724c05 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/generated-runtime-context-contract.ts @@ -0,0 +1,12 @@ +// Generated by scripts/generate-runtime-context-contract.mjs. Do not edit. + +export const GENERATED_RUNTIME_CONTEXT_FIELDS = { + runtimeContext: ["prompt", "instructions", "skills", "mcp", "connectionInstructions", "aggregateDigest"], + prompt: ["revision", "text", "digest"], + instructions: ["entryPath", "bundle", "workingCopy"], + workingCopy: ["rootPath", "entryPath", "kind"], + skill: ["key", "runtimeName", "versionId", "bundle"], + mcp: ["assignmentSetId", "digest", "bindingId"], + connectionInstructions: ["text", "digest"], + asset: ["schema", "digest", "manifestDigest", "rootPath", "fileCount", "totalBytes"], +} as const; diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 1137a7f410a..42db02ae693 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -5,6 +5,7 @@ import { PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_INSTRUCTION_SIBLING_ROOT_PROMPT, } from "./runtime-context-prompts.js"; +import { GENERATED_RUNTIME_CONTEXT_FIELDS } from "./generated-runtime-context-contract.js"; export { PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION } from "./runtime-context-prompts.js"; @@ -49,7 +50,7 @@ const object = (value: unknown, path: string): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeRuntimeContextError(`${path} must be an object`); return value as Record; }; -const exact = (value: Record, keys: string[], path: string) => { +const exact = (value: Record, keys: readonly string[], path: string) => { const allowed = new Set(keys); const unknown = Object.keys(value).find((key) => !allowed.has(key)); if (unknown) throw new NativeRuntimeContextError(`${path} contains unknown field ${unknown}`); @@ -77,7 +78,7 @@ const safeRelativePath = (value: unknown, path: string) => { function parseAsset(value: unknown, path: string): NativeRuntimeAssetReference { const asset = object(value, path); - exact(asset, ["schema", "digest", "manifestDigest", "rootPath", "fileCount", "totalBytes"], path); + exact(asset, GENERATED_RUNTIME_CONTEXT_FIELDS.asset, path); if (asset.schema !== NATIVE_RUNTIME_ASSET_SCHEMA) throw new NativeRuntimeContextError(`${path}.schema is unsupported`); return { schema: NATIVE_RUNTIME_ASSET_SCHEMA, @@ -112,9 +113,9 @@ export function nativeRuntimePromptDigest(): string { return sha256(PAPERCLIP_EX export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextSnapshot { const context = object(value, "input.runtimeContext"); - exact(context, ["prompt", "instructions", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext"); + exact(context, GENERATED_RUNTIME_CONTEXT_FIELDS.runtimeContext, "input.runtimeContext"); const prompt = object(context.prompt, "input.runtimeContext.prompt"); - exact(prompt, ["revision", "text", "digest"], "input.runtimeContext.prompt"); + exact(prompt, GENERATED_RUNTIME_CONTEXT_FIELDS.prompt, "input.runtimeContext.prompt"); // New runs use the current constants. Recovery uses the immutable saved // snapshot; its revision is metadata, not a lookup in this release's source. const promptRevision = text(prompt.revision, "input.runtimeContext.prompt.revision"); @@ -124,14 +125,14 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS throw new NativeRuntimeContextError("input.runtimeContext.prompt.digest does not match prompt text"); } const instructions = object(context.instructions, "input.runtimeContext.instructions"); - exact(instructions, ["entryPath", "bundle", "workingCopy"], "input.runtimeContext.instructions"); + exact(instructions, GENERATED_RUNTIME_CONTEXT_FIELDS.instructions, "input.runtimeContext.instructions"); const workingCopy = instructions.workingCopy === undefined ? undefined : object(instructions.workingCopy, "input.runtimeContext.instructions.workingCopy"); - if (workingCopy) exact(workingCopy, ["rootPath", "entryPath", "kind"], "input.runtimeContext.instructions.workingCopy"); + if (workingCopy) exact(workingCopy, GENERATED_RUNTIME_CONTEXT_FIELDS.workingCopy, "input.runtimeContext.instructions.workingCopy"); if (workingCopy?.kind !== undefined && workingCopy.kind !== "agent_files") throw new NativeRuntimeContextError("Unknown agent file contract"); if (!Array.isArray(context.skills)) throw new NativeRuntimeContextError("input.runtimeContext.skills must be an array"); const skills = context.skills.map((value, index) => { const skill = object(value, `input.runtimeContext.skills[${index}]`); - exact(skill, ["key", "runtimeName", "versionId", "bundle"], `input.runtimeContext.skills[${index}]`); + exact(skill, GENERATED_RUNTIME_CONTEXT_FIELDS.skill, `input.runtimeContext.skills[${index}]`); return { key: text(skill.key, `input.runtimeContext.skills[${index}].key`), runtimeName: safeRelativePath(skill.runtimeName, `input.runtimeContext.skills[${index}].runtimeName`), @@ -143,11 +144,11 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS throw new NativeRuntimeContextError("input.runtimeContext.skills contains duplicate identities"); } const mcp = object(context.mcp, "input.runtimeContext.mcp"); - exact(mcp, ["assignmentSetId", "digest", "bindingId"], "input.runtimeContext.mcp"); + exact(mcp, GENERATED_RUNTIME_CONTEXT_FIELDS.mcp, "input.runtimeContext.mcp"); let connectionInstructions: NativeRuntimeContextSnapshot["connectionInstructions"]; if (context.connectionInstructions !== undefined) { const block = object(context.connectionInstructions, "input.runtimeContext.connectionInstructions"); - exact(block, ["text", "digest"], "input.runtimeContext.connectionInstructions"); + exact(block, GENERATED_RUNTIME_CONTEXT_FIELDS.connectionInstructions, "input.runtimeContext.connectionInstructions"); const content = text(block.text, "input.runtimeContext.connectionInstructions.text"); const contentDigest = digest(block.digest, "input.runtimeContext.connectionInstructions.digest"); if (sha256(content) !== contentDigest) throw new NativeRuntimeContextError("Connection instruction digest does not match text"); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 0d4dba823e7..c10f8a76d2c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,9 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(87); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(88); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:ce2f4322cbc545932d6c725c57005eea1f44f2c743d5384c6ea90909563d958d"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d"); }); it("preserves published v65 while binding retained-maintenance waits in v66", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 131fb0d1286..cbd2e3ae2c1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -23,12 +23,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "grok", - "agentProfileVersion": 14, + "agentProfileVersion": 15, "agentServerPackage": "builtin:grok-acp", "agentServerVersion": "1", "agentRuntimePackage": "native:grok", "agentRuntimeVersion": "1.0.13", - "commandDigest": "sha256:ca59edc7f416cc1e86ac7d7d1e288d3d91065b7ab59e79bcb62bae5f0cc5cf49", + "commandDigest": "sha256:658e6d41ce6fc7080aa6655d7a97121b3cae6214f2d4c074284d958b95bbe769", "permissionPolicy": "interactive" }, "pi": { @@ -62,12 +62,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 87, + "agentProfileVersion": 88, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:ce2f4322cbc545932d6c725c57005eea1f44f2c743d5384c6ea90909563d958d", + "commandDigest": "sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts index ed4bfbae44b..74ba80e69f8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts @@ -36,6 +36,32 @@ export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [ export type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number]; +export const GENERATED_ACPX_SIDECAR_OPEN_PARAMS = [ + "runtimeDirectory", + "normalizedSessionId", + "workingDirectory", + "agent", + "model", + "permissionMode", + "mode", + "piThinkingLevel", + "claudeEffort", + "grokEffort", + "grokDisableWebSearch", + "permissionModePinned", + "providerPolicy", + "systemInstructions", + "runtimeContext", + "tools", + "providerSessionKey", + "expectedIdentity", + "candidateAdmission", +] as const; + +export const GENERATED_ACPX_SIDECAR_PROVIDER_POLICY_FIELDS = [ + "readOnly", +] as const; + export type GeneratedAcpxToolOperation = | "read" | "search" diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 651986ef925..2a8d9094ecf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86], + grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/providers/grok/launcher.cjs b/packages/paperclip-runner/src/providers/grok/launcher.cjs index a03c6cc4445..d936c0f8e1d 100755 --- a/packages/paperclip-runner/src/providers/grok/launcher.cjs +++ b/packages/paperclip-runner/src/providers/grok/launcher.cjs @@ -1,4 +1,4 @@ -// Grok admission source identity: sha256:b70868731d297017152e4675aebe0c5435b70468695f8c5cdf225faba8ced795 +// Grok admission source identity: sha256:7dfa4769aab33e8625b009b4f3efe59e0c6180dba43ebb486179638b9fbaa216 // This launcher is admitted by digest and receives a verified native executable. // Never resolve an executable through PATH or accept command-line overrides. const { isAbsolute, join } = require("node:path"); diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index b841a224e2d..bb5c3f3f6c2 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -85,6 +85,47 @@ test("Pi session admission requires an explicit supported thinking level includi } }); +test("session.open accepts only the schema's closed parameter set", () => { + const open = (params) => ({ protocolVersion, id: 1, command: "session.open", params }); + const names = Object.keys(schema.$defs.sessionOpenParams.properties); + assert.ok(names.includes("grokEffort") && names.includes("runtimeContext")); + for (const name of names.filter(name => !["agent", "piThinkingLevel", "claudeEffort", "candidateAdmission", "expectedIdentity", "providerPolicy"].includes(name))) { + assert.equal(validate(open({ agent: "codex", [name]: "value" })), true, name); + } + assert.equal(validate(open({ agent: "codex", unsupportedField: true })), false); + for (const providerPolicy of [null, { readOnly: true }, { readOnly: false }]) { + assert.equal(validate(open({ agent: "codex", providerPolicy })), true, JSON.stringify(providerPolicy)); + } + for (const providerPolicy of [{}, { readOnly: "yes" }, { readOnly: true, extra: true }]) { + assert.equal(validate(open({ agent: "codex", providerPolicy })), false, JSON.stringify(providerPolicy)); + } +}); + +test("the runtime context schema closes every nested object", async () => { + const runtimeSchema = JSON.parse(await readFile(new URL("../protocol/provider-schemas/runtime-context.schema.json", import.meta.url), "utf8")); + const validateContext = new Ajv2020({ allErrors: true, strict: true }).compile(runtimeSchema); + const hex = "a".repeat(64); + const asset = { schema: "paperclip.runtime-asset.v1", digest: hex, manifestDigest: hex, rootPath: "/bundle", fileCount: 1, totalBytes: 2 }; + const context = { + prompt: { revision: "paperclip-execution.v8", text: "prompt", digest: hex }, + instructions: { entryPath: "AGENTS.md", bundle: asset, workingCopy: { rootPath: "/work", entryPath: "AGENTS.md", kind: "agent_files" } }, + skills: [{ key: "skill", runtimeName: "skill", versionId: null, bundle: asset }], + mcp: { assignmentSetId: "set", digest: hex, bindingId: null }, + connectionInstructions: { text: "connect", digest: hex }, + aggregateDigest: hex, + }; + assert.equal(validateContext(context), true, JSON.stringify(validateContext.errors)); + const nested = [[], ["prompt"], ["instructions"], ["instructions", "bundle"], ["instructions", "workingCopy"], ["mcp"], ["connectionInstructions"]]; + for (const path of nested) { + const altered = structuredClone(context); + path.reduce((value, key) => value[key], altered).unexpected = true; + assert.equal(validateContext(altered), false, path.join(".")); + } + const alteredSkill = structuredClone(context); + alteredSkill.skills[0].unexpected = true; + assert.equal(validateContext(alteredSkill), false); +}); + test("public Pi descriptors preserve effective thinking levels without invalidating historical replay", async () => { const descriptorSchema = JSON.parse(await readFile(new URL("../protocol/schemas/provider-descriptor.schema.json", import.meta.url), "utf8")); const validateDescriptor = new Ajv2020({ allErrors: true, strict: true, strictRequired: false }).compile(descriptorSchema); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v88-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v88-identity.json new file mode 100644 index 00000000000..1f0e4fcecd3 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v88-identity.json @@ -0,0 +1,89 @@ +{ + "commandDigest": "sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 88, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "b60e4b924e827e122860d2a4cc13b465ba748ee521620887c88f3c58c0df6081", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "a5b81494bdae7c100b71c3a8024f04287c458c2e549eb094e3501168b54fa536", + "semanticDirectDriverSourceSha256": "4ec65aa1d4e905a1e732f2d043bbd39de1ab92f380276ede37d6a72efc19c64d", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59", + "semanticValidatorsSourceSha256": "b20ec8ee6e7165117233ce4421a975bb9d3f276220ce31655c0b9a717cb2d9cb", + "semanticSchemaBundleSourceSha256": "81ef98ca7b34896a0f76390ac7af3dd8ef7609fe6f38d54bd6a69373d50fe40b", + "semanticSidecarProtocolSourceSha256": "26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "ecef0ada320a762e0a3d91c38c72d4e19e2c8e8be8c060938a47e7f1a029058a", + "configuredEnvironmentRustSourceSha256": "1671cb2735b3fa119d32f08d4a12b21b9f78a0e4d7285eb63c35df79ce17bd3c", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "58841b2d3b47ab9956db20608802edcd0e55cf5856002dc7c51e05e75bdcb8f6", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "cbcefbceb0b2a24b0909cbb8361df6b8853b4f50b21d66328bc483951a8246ea", + "nativeRuntimeContextSourceSha256": "152d7187ce1dc91a15377d368776555c9897d6e018b65f0f59a92dcdbd6497d9", + "nativeRuntimeContextFieldsSourceSha256": "069f4bc3c4dc95888f30d3fea331adb0ee0a164d09f4f70ec9c132f24f69c7dd", + "nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "baba1a210b863518f01e0c875606ed37f9467c433222251438578bd999bc43b5", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "bb9bb3daac53ce5955ddf8f56839c7a57ae60c74ea3cdd08895ab6912fcf7851", + "transportContractSourceSha256": "06f06c53ecae304f72034ffb2f659ff087c82a70ed4994daf21a9d2edaf56e4e", + "nativeBackendSourceSha256": "7495d9898d22dca1ebf6336361d332bafefc4498f6e49f1135025fad846c8586", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "82ac6e5f7275b4ef79fb166c7330efc504e80cab3d7e9081e4e40a8b570bc50b", + "providerLaunchSourceSha256": "c744235b3067ea75973523f78f1e13399337bc155f1775af694e34782d5c9bef", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "8bf5c2ed6d6223144f7eb777e97973655301f5be10d9734f025cfc2b0bdff431", + "runtimeRequestFacadeSourceSha256": "23361d25e660ef7919bfb54f21727b899e7d6f8865307604ee15eca7488afeaa", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "64b137e3ef695e7d03013ea2b6c8830171e0a73e0bf2e73d55e9ced57a47bcf6", + "nativeExecutionSourceSha256": "116c348e232848a80c5556494c109314baafb20fb5c97133d9656c7732ee54be", + "profileCompatibilitySourceSha256": "331d9c8b7ad164d96a6533c422141181461315b547635b5e43f399cadc4994cc", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "e5bb6bc58b3f0e957a64b2cd99c710337ffcd9c0e1fadb341f48e033b64b99f5", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "2ed68b9aed9756fdd682721833eb2aeaa58f30f1dc8825c88886cc8e7ee92abe", + "admissionDiagnosticsSourceSha256": "df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af", + "grokAdmissionSourceSha256": "f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777", + "sidecarErrorSourceSha256": "17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811", + "preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e", + "runtimeHostSourceSha256": "26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662", + "runtimeAdapterSourceSha256": "fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e", + "runtimeSandboxSourceSha256": "b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/grok-profile-v15-identity.json b/packages/paperclip-runner/test/fixtures/grok-profile-v15-identity.json new file mode 100644 index 00000000000..c0d67912bcf --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/grok-profile-v15-identity.json @@ -0,0 +1,36 @@ +{ + "commandDigest": "sha256:658e6d41ce6fc7080aa6655d7a97121b3cae6214f2d4c074284d958b95bbe769", + "sourceDigest": "sha256:7dfa4769aab33e8625b009b4f3efe59e0c6180dba43ebb486179638b9fbaa216", + "declaration": { + "schema": "paperclip.grok-profile-identity.v1", + "agent": "grok", + "agentProfileVersion": 15, + "agentServerPackage": "builtin:grok-acp", + "agentServerVersion": "1", + "agentRuntimePackage": "native:grok", + "agentRuntimeVersion": "1.0.13", + "requiresProviderPolicy": false, + "acpxVersion": "0.13.1", + "sources": { + "patches/acpx@0.13.1.patch": "sha256:1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2", + "packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts": "sha256:a5b81494bdae7c100b71c3a8024f04287c458c2e549eb094e3501168b54fa536", + "packages/paperclip-runner/src/cli/acpx-sidecar-input.ts": "sha256:17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c", + "packages/paperclip-runner/src/drivers/acpx/admission-diagnostics.ts": "sha256:df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af", + "packages/paperclip-runner/src/drivers/acpx/grok-effort.ts": "sha256:f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777", + "packages/paperclip-runner/src/drivers/acpx/grok-web-search.ts": "sha256:754b5ca2345c045421f6007f34999508d996e1cea035f0f1952b3d5f00072b9c", + "packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts": "sha256:1c4ff0be6da798b6fcc604d20a9198dcc8523a3806d563b5b5345b7260549f73", + "packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts": "sha256:fab1e278e0f2aed55b35e57d14329eb431b79a08040fa079aa2403842d16085e", + "packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "sha256:26b2e115b3f4ae7c20e9d963d117e0286c958af81386deade83397f12abf9662", + "packages/paperclip-runner/src/drivers/preconfigured-home.ts": "sha256:bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e", + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "sha256:b9f05e522f92b6bade51c7a17f8d28bf31ab9db26510b8d5ddce8519d563a875", + "packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts": "sha256:26b6b21dad582810d00f46e947e682a9615ee9761c487f6315970d6d014c05ab", + "packages/paperclip-runner/src/drivers/acpx/generated-sidecar-contract.ts": "sha256:b60e4b924e827e122860d2a4cc13b465ba748ee521620887c88f3c58c0df6081", + "packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts": "sha256:aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b", + "packages/paperclip-runner/src/providers/grok/launcher.cjs": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + "packages/paperclip-runner/src/providers/grok/platforms.json": "sha256:931eed3abbf2bf041e6c72e6e9486d976f4352613cf069b216ddf010c9a8e704", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs": "sha256:eed57e3ba2ab2f12c2e6abba7b683e64adfc0e281e9e8a63c854b8eabdfb5183", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs": "sha256:fc8d7cce62e0c02efddece8696e8d24f6a9fe19e5288a5e67a01ffbf931a3f6e", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs": "sha256:839cb1d2ac19462f2bcef94d014e528e56beb7a1a1cbf6c3b10906d0ab7eb10c" + } + } +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 14681975ad8..a6f6ff86327 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1686,7 +1686,7 @@ describe("remote provider pack manifest", () => { opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs", }).map(([key, path]) => [key, { path, sha256: digest }])) as Record<"nodeCommand" | "opencodeCommand" | "opencodeProxy", { path: string; sha256: string }>; const payload = { - compatibility: { opencode: 1, acpx: 1 }, + compatibility: { opencode: 1, acpx: 1 }, contractDigest: digest, target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.test.ts b/server/src/services/native-runtime/remote-provider-pack-verification.test.ts index bc03c3a3a6f..ad8549798df 100644 --- a/server/src/services/native-runtime/remote-provider-pack-verification.test.ts +++ b/server/src/services/native-runtime/remote-provider-pack-verification.test.ts @@ -13,6 +13,7 @@ const sha = (text: string) => `sha256:${createHash("sha256").update(text).digest const manifest = (revision: string): ProviderPackIdentity => { const payload = { compatibility: { opencode: 1, acpx: 1 }, + contractDigest: sha("contracts"), pins: { opencode: "1.18.34", nodeMinimum: "24.11.0" }, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: revision.repeat(40), @@ -69,10 +70,12 @@ describe("installed provider pack compatibility", () => { provider: { kind: "opencode" }, requireExactIdentity: options.requireExactIdentity, requireCurrentOpenCodeProxy: options.requireCurrentOpenCodeProxy }); }; - it.each(["interface", "platform", "schema", "artifact_path", "digest", "legacy"])("rejects an incompatible %s", async field => { + it.each(["interface", "contracts", "platform", "schema", "artifact_path", "digest", "legacy", "legacy_contracts"])("rejects an incompatible %s", async field => { let pack = manifest("a"); if (field === "interface") pack.payload.compatibility!.opencode += 1; + if (field === "contracts") pack.payload.contractDigest = sha("other-contracts"); if (field === "legacy") delete pack.payload.compatibility; + if (field === "legacy_contracts") delete pack.payload.contractDigest; if (field === "platform") pack.payload.target.architecture = "arm64"; if (field === "schema") pack.schema = "unknown"; if (field === "artifact_path") pack.payload.artifacts.nodeCommand.path = "../node"; @@ -80,6 +83,14 @@ describe("installed provider pack compatibility", () => { if (field === "digest") pack.digest = sha("corrupt"); await expect(check(pack)).rejects.toThrow("runner_remote_provider_artifact_incompatible"); }); + it("does not reuse image packs when the controller's bundled pack records no contract digest", async () => { + const current = manifest("b"); + delete current.payload.contractDigest; + const installed = manifest("a"); + await expect(verifyRemoteProviderPack({ runner: { execute: vi.fn() }, cwd: "/workspace", packRoot: "/opt/paperclip-runner/provider-pack", + manifest: reseal(current), installation: { root: "/opt/paperclip-runner/provider-pack", manifest: installed, immutable: true }, + provider: { kind: "opencode" } })).rejects.toThrow("interface or installation is incompatible"); + }); it("keeps writable installations at the content-verification boundary", async () => { await expect(check(manifest("b"), { immutable: false })).rejects.toThrow("installation is incompatible"); }); diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.ts b/server/src/services/native-runtime/remote-provider-pack-verification.ts index 64f0bf0d649..9759ca9a52e 100644 --- a/server/src/services/native-runtime/remote-provider-pack-verification.ts +++ b/server/src/services/native-runtime/remote-provider-pack-verification.ts @@ -15,6 +15,7 @@ export type ProviderPackIdentity = { digest: string; payload: { compatibility?: { opencode: number; acpx: number }; + contractDigest?: string; acpxProfileDigests?: Record; providers?: Record; candidateProviders?: Record; @@ -30,6 +31,9 @@ export type PackProvider = { kind: "opencode" } | { kind: "acpx"; agent: string /** Admission of a build-qualified, OS-protected installation. Source revisions, * dependency pins and unrelated provider changes are provenance, not an interface. + * The interface is the hand-maintained compatibility revision plus the pack's + * computed contract digest; a pack without a digest predates that check and + * is replaced rather than reused. * The metadata comes from the trusted runner, never from a provider tool result. */ function compatibleInstallation( @@ -44,7 +48,9 @@ function compatibleInstallation( || actual.digest !== `sha256:${createHash("sha256").update(canonicalProviderPackJson(payload)).digest("hex")}` || canonicalProviderPackJson(payload.target) !== canonicalProviderPackJson(expected.payload.target) || (requireExactIdentity ? actual.digest !== expected.digest - : !Number.isSafeInteger(contract) || contract! < 1 || payload.compatibility?.[provider.kind] !== contract) + : !Number.isSafeInteger(contract) || contract! < 1 || payload.compatibility?.[provider.kind] !== contract + || !/^sha256:[a-f0-9]{64}$/.test(expected.payload.contractDigest ?? "") + || payload.contractDigest !== expected.payload.contractDigest) || typeof payload.pins?.opencode !== "string") { throw new Error("runner_remote_provider_artifact_incompatible: installed provider pack interface or installation is incompatible"); } diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index e5c7fdd6ad9..ad8e6b5bdc8 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -30,12 +30,15 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [...new Set([ `packages/paperclip-runner/test/fixtures/copilot-profile-v${acpxProfiles.profiles.copilot.agentProfileVersion}-identity.json`, "packages/paperclip-runner/cursor-distributions.json", "packages/paperclip-runner/cursor-contract.json", + // The pack's contract digest fingerprints these generated contracts. + "packages/paperclip-runner/generated", "packages/paperclip-runner/protocol", "packages/paperclip-runner/runner/Cargo.lock", "packages/paperclip-runner/runner/Cargo.toml", "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/build-provider-pack.mjs", + "packages/paperclip-runner/scripts/contracts/contract-fingerprints.mjs", "packages/paperclip-runner/scripts/provider-pack-executable-shims.mjs", "packages/paperclip-runner/scripts/build-node-startup-timeout.mjs", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs",