mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
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:
1 parent
145214201d
commit
fa760dbce4
30 files changed
+1084
-42
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user