fix(runner): reuse image packs only with matching contract digest (#18237)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Remote native runs use a provider pack. The controller reuses a
protected pack from the sandbox image when the pack is compatible.
> - #17749 gates this reuse on a hand-maintained interface revision in
`provider-pack-contract.json`.
> - Later changes altered the runner/pack boundary (#17922, #18188), but
nobody bumped the revision. A stale Claude or Codex image pack can still
be reused across a sidecar contract change. This is the failure class of
#17972.
> - A manual revision has a "forgot to bump" failure mode. Source-byte
hashes change on every refactor, so they are too brittle.
> - This pull request declares the sidecar `session.open` contract in
its JSON schema and computes a contract digest from the JSON contracts
and Rust shapes.
> - Image-pack reuse now requires an equal digest.
> - The benefit is that an incidental change keeps image packs reusable,
and a real contract change retires them automatically.

## Linked Issues or Issue Description

Refs #17749, #17972, #17922, #18188.

**What happened?**

The controller reused an image provider pack when `compatibility.acpx`
and the selected agent's profile digest matched. For Claude and Codex,
that profile digest binds only the upstream executable. The ACPX
interface revision stayed at 1 after later sidecar contract changes. A
pack built before those changes passed the reuse check.

**Expected behavior**

The controller reuses an image pack only when the pack uses the same
runner/pack contracts as the controller's bundled pack. Comments,
formatting and refactors do not change that result.

**Steps to reproduce**

1. Build an image pack from a commit before a sidecar input change, such
as a new `session.open` field.
2. Run a controller from a later commit that sends the new field, with
the same interface revision and Claude version.
3. Before this fix, `verifyRemoteProviderPack` accepts the old image
pack.

## What Changed

- Declare the sidecar `session.open` parameter set as a closed
`sessionOpenParams` definition in `acpx-sidecar.schema.json`.
- `generate-acpx-sidecar-contract.mjs` now generates
`GENERATED_ACPX_SIDECAR_OPEN_PARAMS` from it.
- `parseOpenParams` uses the generated list instead of an inline `Set`.
The accepted set is unchanged.
- This is the surface of #17972. The schema is now its source of truth.
- Declare the nested `session.open` contracts that the sidecar parses
strictly:
- `protocol/provider-schemas/runtime-context.schema.json` closes every
runtime context object. `parseNativeRuntimeContext` uses the field lists
that `scripts/generate-runtime-context-contract.mjs` generates from it.
- The sidecar schema declares the closed `providerPolicy` object, and
`parseProviderPolicy` uses its generated field list.
  - The accepted fields do not change.
- Add
`packages/paperclip-runner/scripts/contracts/contract-fingerprints.mjs`.
It fingerprints these contracts:
  - the PRP and sidecar JSON schemas, with annotation text removed
  - the generated catalogs in `generated/`
- the Rust serde shapes, `json!` payload keys, match literals and env
keys in `runner-core/src`, without test-only modules
  - the reserved env key lists in `configured_environment.rs`
- Read only the trees that every pack build context contains. Fail when
a registered source is missing, so two build contexts cannot silently
produce different digests.
- Use only Node built-ins. The registry adds no dependency.
- Write `contractDigest` as a top-level payload field in
`build-provider-pack.mjs`. The manifest digest algorithm and the
existing field layout do not change.
- Require an equal `contractDigest` for image-pack reuse in
`remote-provider-pack-verification.ts`. A pack without a digest is not
reused. The controller uploads its bundled pack, as it does today for
any incompatible pack.
- Re-attest the source-hashed profiles, because this PR changes their
bound sidecar sources:
- Grok becomes profile 15 and now also binds the generated allowlist, so
a Grok field change in the schema still invalidates the Grok identity.
- Copilot becomes profile 88, and also binds the generated runtime
context fields.
  - Earlier profiles stay decodable.
- Add the registry script and `generated/` to the Daytona image content
inputs.
- Document the digest and the schema-first rule in
`docker/daytona-runner/README.md` and `doc/DEVELOPING.md`.

## Verification

- `node --test scripts/contracts/contract-fingerprints.test.mjs` (in
`packages/paperclip-runner`): 4 tests pass.
  - These changes keep the digest:
    - schema description text
    - schema reformatting
    - renaming `parseOpenParams`
    - Rust comments, `#[serde(skip)]` fields and `#[cfg(test)]` modules
- edits to the `#[path]` test module `tests/provider_api_validation.rs`
  - These changes alter the digest:
    - a new `session.open` schema field
    - a changed constraint on the `remainingWork[].description` property
    - a new reserved env key
    - a new sidecar env key
    - a new checkpoint field
  - A build context without a registered source fails.
- `node --test test/acpx-sidecar-contract.test.mjs`: 13 tests pass. A
new case rejects an unknown `session.open` parameter and accepts each
declared parameter.
- `node --test scripts/grok-profile-identity.test.mjs`: 4 tests pass. A
change to the Grok field in the generated allowlist invalidates the Grok
identity.
- `generate-acpx-profiles.mjs --check`,
`generate-acpx-sidecar-contract.mjs --check` and
`generate-protocol-manifest.mjs --check` pass.
- Runner vitest suites pass: 470 passed, 1 skipped. They cover the
sidecar, sidecar input and protocol, Copilot and Pi installation,
profile compatibility, native execution, recovery identity, the backend
factory and the environment.
- Server tests: pack verification, executor and artifact preparation
pass (1,004 tests).
- `tsc --noEmit` passes for `packages/paperclip-runner` and `server`.
- `tests/runner-e2e/daytona-image.test.ts`: 10 of 11 tests pass. The
failing test also fails on `master`. It expects a literal
`profile-v19-identity.json` line in `.dockerignore`, which uses a
`profile-v*` glob.
- Not run locally:
- A complete `build-provider-pack.mjs` run. The builder rejects the
local Homebrew Node because it is not portable after relocation.
- Rust tests and `acpx-controller-recovery.test.ts`, because `cargo` is
not installed.
  - The full `pnpm -r typecheck`, `pnpm test:run` and `pnpm build`.

## Risks

- Only JSON contracts and Rust shapes are part of the digest. The
remaining TypeScript-only definitions are the `paperclip_finish` and
`paperclip_block` tool schemas in `completion-result.ts` and the
TypeScript env key lists. They are not covered until they move into a
schema. The nested `expectedIdentity` and `tools` values ignore unknown
fields, so a change there cannot cause a startup rejection.
- A change there can let a stale image pack be reused. The sidecar then
rejects the input with its strict parser error.
  - Today, Claude and Codex image packs have no contract check at all.
- The digest covers all registered contracts for the whole pack. A
contract change for one provider kind also retires image packs for the
other. Each replacement costs about 16 s per remote start until the
image is refreshed.
- Existing image packs have no digest. After deploy, the controller
uploads its bundled pack until the image is rebuilt.
- The Grok and Copilot re-attestations start fresh sessions for those
agents once after deploy, as each profile bump does today.
- The Daytona image content ID formula changes, so the next image build
does not reuse a cached image.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code in the
desktop app, with repository tools, shell execution and research
subagents. The session does not expose the context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-10-29 22:55:11 -07:00
1 parent 145214201d
commit fa760dbce4
30 files changed
+1084 -42

No files matched your search

+6 -1
View File
@@ -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
+12
View File
@@ -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.
+4 -4
View File
@@ -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": {
+3 -3
View File
@@ -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",
@@ -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": {
@@ -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
}
}
}
@@ -31,7 +31,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
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<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:ce2f4322cbc545932d6c725c57005eea1f44f2c743d5384c6ea90909563d958d",
"sha256:b9c13b83628be07086a88394ae671997291b77bbdbf45b0bd1c4e91b7121bc9d",
requires_provider_policy: true,
qualification_pending: false,
},
@@ -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,
@@ -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<string, { fingerprint: string, value: unknown }>}
*/
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:<shape>`. 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());
}
@@ -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<String>,")],
[`${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<String>,")],
[`${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 });
}
});
@@ -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",
@@ -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
@@ -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");
}
@@ -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",
@@ -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");
@@ -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<string, unknown> {
};
}
// Generated from the sidecar schema's closed session.open parameter set.
const OPEN_PARAMS: ReadonlySet<string> = new Set(GENERATED_ACPX_SIDECAR_OPEN_PARAMS);
function parseOpenParams(
value: Record<string, unknown>,
): 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 };
@@ -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;
@@ -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<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeRuntimeContextError(`${path} must be an object`);
return value as Record<string, unknown>;
};
const exact = (value: Record<string, unknown>, keys: string[], path: string) => {
const exact = (value: Record<string, unknown>, 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");
@@ -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 () => {
@@ -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": {
@@ -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"
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
@@ -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");
@@ -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);
@@ -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"
}
}
@@ -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"
}
}
}
@@ -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,
@@ -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");
});
@@ -15,6 +15,7 @@ export type ProviderPackIdentity = {
digest: string;
payload: {
compatibility?: { opencode: number; acpx: number };
contractDigest?: string;
acpxProfileDigests?: Record<string, string>;
providers?: Record<string, unknown>;
candidateProviders?: Record<string, unknown>;
@@ -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<T extends ProviderPackIdentity>(
@@ -44,7 +48,9 @@ function compatibleInstallation<T extends ProviderPackIdentity>(
|| 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");
}
@@ -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",