From d855e550ad8d2a267312983f26457aabf5393c3a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:31:47 -0500 Subject: [PATCH] Finish Copilot native runtime on current master (#15701) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner controls provider processes, permissions and task completion. > - Copilot needs its pinned distribution, native message identities and accepted semantic completion receipts. > - The release must preserve saved-token access and task lifecycle guarantees. > - This pull request will keep the current completion and recovery fixes while adding the Copilot runtime. > - The benefit is a reviewable part of the Copilot production release. ## Linked Issues or Issue Description Refs #14434. Supersedes the runtime work in #15560. This is the first PR in the stack; #15703, #15705 and #15706 follow. Related alternative adapter work: #15881. This change uses the shared Runner path. ## What Changed - Pin Copilot CLI 1.0.88 and bind the integrated implementation as profile45. - Keep native instruction, permission, receipt, process ownership and cancellation boundaries. - Preserve current master completion continuation and remote verification behavior. Other provider release identities stay unchanged. ## Verification Final reviewed source [`4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5`](https://github.com/paperclipai/paperclip/commit/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5) includes master `cde944ed75648adfb569e86133adc8b118b55dd6`. The combined four-part PR tree matches it except for the CI-owned lockfile. Every prior public PR head remains an ancestor. Released master migrations remain unchanged; Copilot adds migration361. No unrelated provider is enabled. Copilot CLI1.0.88 and profile47 retain digest `sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea`. The pending qualification marker is removed. The [qualification record](https://github.com/paperclipai/paperclip/blob/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5/doc/plans/2026-10-20-copilot-qualification-v47.json) reports **20/20 Product cells,154 assertions;7/7 protocol cases,46 assertions** against frozen installed application `c2616370800f5f6df45246b0890ba35a7cd2faab`. Both environments use the exact authenticated model `gpt-5.6-luna`. Every passing Product case confirms cleanup. Automatic behavior retries are disabled. Original failed attempts remain recorded. The ordinary installed remote journey passed saved-token selection, authenticated model discovery, normal UI save/reopen, metadata-only Test, task completion, accepted receipt and an exact19-byte browser download. An open model catalog caused no new discovery requests during117seconds. Actual installed runtime smokes passed on macOS ARM64, macOS x64 under Rosetta and native Linux x64 on Daytona. Raw token browser entry was not exercised; the encrypted connection was created through the normal API. The original Daytona file attempt timed out during delayed semantic dispatch. Its precise stalled layer remains unresolved. Independent ordinary delivery and the other19 cells passed before one instrumented recheck passed8/8 with unchanged source,prompt,assertions and deadlines. No causal latency repair is claimed. After current-master reconciliation,129 connection tests,59 runner tests,72 shared eval tests and88 recovery/workspace tests pass. Migration generation/safety, unchanged profile identity and UI token gates pass. Prior required CI was green. Fresh exact-head required CI and E2E are green for all four updated heads. Each has a fresh Greptile5/5 review and zero unresolved review threads. The [final normal-package cloud build](https://github.com/paperclipai/paperclip/actions/runs/39414239704) is running. Recursive typecheck,full CI test shards,build and all8 browser shards pass. These checks cover the final source; no additional local full-suite pass is claimed. The [frozen image build](https://github.com/paperclipai/paperclip/actions/runs/39334401638) and [original normal package build](https://github.com/paperclipai/paperclip/actions/runs/39334407471) passed. The exact image is published and independently retrieved anonymously. No separate full-image scan is claimed. All18 original normal archives were scanned with zero selected credentials. Docker builds run in the cloud. Provider USD remains unknown and GitHub-attributed. **Qualification is complete on the recorded frozen build. Normal protected merge, automatic master canary publication and final shipped-package local/Daytona canaries remain. No production deployment is claimed.** ## Risks Shared Runner behavior can affect active tasks. Exact profile admission rejects stale runtime identities. The final campaign and installed canaries remain release gates. ## Model Used OpenAI Codex, based on GPT-6. The exact serving model ID and context window are not exposed in this task. Used for code changes, review and tool-assisted verification. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .dockerignore | 5 + .github/workflows/pr-trusted.yml | 8 +- .github/workflows/release-verify.yml | 4 +- packages/paperclip-runner/acpx-profiles.json | 7 +- packages/paperclip-runner/package.json | 15 +- .../runner-core/src/acpx_provider_backend.rs | 305 +++++++++++++++++- .../src/acpx_provider_capabilities.rs | 8 +- .../runner-core/src/acpx_sidecar_transport.rs | 1 + .../runner-core/src/bin/fake-acpx-sidecar.rs | 33 ++ .../src/generated_acpx_profiles.rs | 2 +- .../tests/native_provider_backend.rs | 105 ++++++ .../scripts/build-provider-pack.mjs | 13 +- .../scripts/candidate-provider-pack.mjs | 6 +- .../scripts/candidate-provider-pack.test.mjs | 49 ++- .../scripts/copilot-profile-identity.mjs | 107 ++++++ .../scripts/copilot-provider-pack-smoke.mjs | 31 +- .../scripts/copilot-smoke-resources.mjs | 22 ++ .../scripts/copilot-smoke-resources.test.mjs | 18 +- .../scripts/discover-copilot-acp.mjs | 4 +- .../scripts/discover-copilot-acp.test.mjs | 9 + .../scripts/install-copilot-assets.mjs | 94 ++++++ .../scripts/install-copilot-assets.test.mjs | 114 +++++++ .../scripts/qualify-copilot-acp.mjs | 252 ++++++++------- .../scripts/qualify-copilot-acp.test.mjs | 21 +- .../scripts/release-provider-pack.mjs | 10 + .../scripts/release-provider-pack.test.mjs | 25 ++ .../backends/harness-driver-backend.test.ts | 6 +- .../src/backends/harness-driver-backend.ts | 6 +- .../backends/native-backend-factory.test.ts | 5 +- .../src/cli/acpx-runtime-sidecar.ts | 4 +- .../src/cli/copilot-metadata-probe.ts | 5 + .../src/cli/eval-session-contract.test.ts | 19 ++ .../src/contracts/harness-driver.ts | 3 +- .../src/contracts/native-execution.test.ts | 70 +++- .../src/contracts/native-execution.ts | 3 + .../src/contracts/native-session-backend.ts | 3 +- .../src/contracts/runtime-context.ts | 6 + .../acpx/acp-permission-adapter.test.ts | 40 +++ .../drivers/acpx/acp-permission-adapter.ts | 2 + .../drivers/acpx/codex-acpx-driver.test.ts | 30 ++ .../src/drivers/acpx/codex-acpx-driver.ts | 47 ++- .../drivers/acpx/codex-credentials.test.ts | 39 ++- .../acpx/codex-runtime-adapter.test.ts | 135 ++++++++ .../src/drivers/acpx/codex-runtime-adapter.ts | 68 +++- .../acpx/copilot-extension-adapter.test.ts | 5 +- .../drivers/acpx/copilot-extension-adapter.ts | 6 +- .../drivers/acpx/copilot-installation.test.ts | 62 ++-- .../src/drivers/acpx/copilot-installation.ts | 7 +- .../acpx/copilot-permission-context.ts | 32 +- .../src/drivers/acpx/copilot-profile.test.ts | 12 +- .../src/drivers/acpx/copilot-profile.ts | 10 + .../src/drivers/acpx/copilot-registry.test.ts | 8 +- .../drivers/acpx/copilot-runtime-host.test.ts | 10 +- .../acpx/copilot-tool-evidence.test.ts | 36 +++ .../src/drivers/acpx/copilot-tool-evidence.ts | 13 +- .../src/drivers/acpx/generated-profiles.ts | 5 +- .../drivers/acpx/installation-integrity.ts | 17 +- .../native-distribution-integrity.test.ts | 139 ++++++++ .../acpx/native-distribution-integrity.ts | 7 +- .../src/drivers/acpx/profile-activity.ts | 2 +- .../src/drivers/acpx/profile-compatibility.ts | 4 +- .../src/drivers/acpx/profile-installation.ts | 4 +- .../drivers/acpx/provider-assets-root.test.ts | 12 + .../src/drivers/acpx/provider-assets-root.ts | 4 +- .../src/drivers/acpx/qualified-profiles.ts | 1 + .../src/drivers/acpx/runtime-host.test.ts | 14 +- .../src/drivers/acpx/runtime-host.ts | 21 +- .../src/drivers/acpx/runtime-sandbox.test.ts | 14 +- .../drivers/acpx/single-read-evidence.test.ts | 2 +- .../src/drivers/codex/app-server-transport.ts | 2 + .../codex-app-server-driver.lifecycle.test.ts | 3 +- ...app-server-driver.runtime-requests.test.ts | 89 +++++ .../drivers/codex/codex-harness-session.ts | 4 +- .../codex/codex-notification-identity.test.ts | 4 + .../codex/codex-session-notifications.ts | 23 +- .../codex/codex-thread-normalization.ts | 1 + .../drivers/copilot-metadata-probe.test.ts | 117 +++++++ .../src/drivers/copilot-metadata-probe.ts | 198 ++++++++++++ packages/paperclip-runner/src/live/index.ts | 1 + .../runnerd-acpx-permission-ordering.test.ts | 4 + .../src/live/runnerd-codex-transport.test.ts | 93 +++++- .../src/live/runnerd-codex-transport.ts | 68 +++- .../src/native-session-runtime.test.ts | 23 +- .../src/native-session-runtime.ts | 8 +- .../test/acpx-codex-package-contract.test.mjs | 1 + .../copilot-mainline-v22-identity.json | 44 +++ .../copilot-mainline-v23-identity.json | 44 +++ .../copilot-mainline-v24-identity.json | 44 +++ ...ot-production-v26-offline-regressions.json | 23 ++ ...profile-v21-mainline-monitor-identity.json | 44 +++ .../copilot-profile-v43-identity.json | 82 +++++ .../copilot-profile-v44-identity.json | 82 +++++ .../copilot-profile-v47-identity.json | 82 +++++ .../runner-release-crate-cache.test.mjs | 1 + scripts/runner-release-crate-cache.mjs | 1 + server/scripts/install-copilot-assets.mjs | 18 ++ 96 files changed, 2979 insertions(+), 331 deletions(-) create mode 100644 packages/paperclip-runner/scripts/copilot-profile-identity.mjs create mode 100644 packages/paperclip-runner/scripts/install-copilot-assets.mjs create mode 100644 packages/paperclip-runner/scripts/install-copilot-assets.test.mjs create mode 100644 packages/paperclip-runner/src/cli/copilot-metadata-probe.ts create mode 100644 packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts create mode 100644 packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts create mode 100644 packages/paperclip-runner/test/fixtures/copilot-mainline-v22-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-mainline-v23-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-mainline-v24-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-production-v26-offline-regressions.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v21-mainline-monitor-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v43-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v44-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json create mode 100644 server/scripts/install-copilot-assets.mjs diff --git a/.dockerignore b/.dockerignore index 2b257d8eb55..83ef4daeb0d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -72,6 +72,11 @@ packages/paperclip-runner/test-fixtures/pi-acp/** packages/grok-acp/bin +# The Copilot build-time identity gate reads these sanitized source declarations. +!packages/paperclip-runner/test/fixtures/copilot-profile-v*-identity.json +# Images build their pinned Copilot assets instead of copying host binaries. +/packages/paperclip-runner/provider-assets/ +/server/provider-assets/ !packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-claude-qualification-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v26-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v19-claude-qualification-identity.json diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 6b6b2a1a7a6..bd7b53e1238 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -321,7 +321,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} - name: Mark the restored Runner release crate current if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }} @@ -834,7 +834,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} - name: Mark the restored Runner release crate current if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }} @@ -1051,7 +1051,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} - name: Mark the restored Runner release crate current if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }} @@ -1409,7 +1409,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} - name: Mark the restored Runner release crate current if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index fb5ed668f8a..befc71e6bad 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -471,7 +471,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} - name: Build the Runner release crate for its cache if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha && steps.runner_release_crate.outputs.cache-hit != 'true' }} @@ -492,7 +492,7 @@ jobs: packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-* packages/paperclip-runner/runner/target/release/paperclip-runnerd packages/paperclip-runner/runner/target/release/paperclip-runnerd.d - key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} + key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} build: if: ${{ !inputs.runner_binaries_only }} diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index b1f2837c5c1..dee74cff634 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,14 +32,13 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 24, + "agentProfileVersion": 47, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:87e275270e50b42290d10ecec9dfce86ffca2b7a7519cb60c584004adfc86437", - "requiresProviderPolicy": true, - "qualificationStatus": "pending" + "commandDigest": "sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea", + "requiresProviderPolicy": true }, "claude": { "agentProfileVersion": 1, diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 6728de3da4e..8080d157740 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -11,7 +11,8 @@ "paperclip-runner-eval-session": "./dist/cli/eval-session.js", "paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js", "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", - "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js" + "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", + "paperclip-runner-copilot-metadata-probe": "./dist/cli/copilot-metadata-probe.js" }, "exports": { ".": { @@ -53,7 +54,10 @@ "dist-issue-thread", "protocol", "styles.css", - "README.md" + "README.md", + "scripts/install-copilot-assets.mjs", + "scripts/materialize-copilot-binary.mjs", + "scripts/copilot-inner-distribution.mjs" ], "publishConfig": { "executableFiles": [ @@ -64,7 +68,7 @@ "sideEffects": false, "scripts": { "build": "node scripts/record-build-inputs.mjs --begin && pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && node scripts/generate-semantic-contracts.mjs --check && node scripts/check-runner-workflow-traceability.mjs && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check && node scripts/record-build-inputs.mjs --finish", - "build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", + "build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs && node scripts/install-copilot-assets.mjs", "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", "build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs", "stage:release-binaries": "node scripts/stage-release-runner-binaries.mjs", @@ -81,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/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.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", + "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/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.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 && 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", @@ -169,7 +173,8 @@ "verify:rootless": "bash scripts/verify-rootless-linux.sh", "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs", "generate:acpx-profiles": "node scripts/generate-acpx-profiles.mjs", - "check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check" + "check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check", + "postinstall": "node scripts/install-copilot-assets.mjs" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.73.0", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 81a535da873..c3f53e20b84 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -2015,6 +2015,12 @@ impl AcpxCommandExecutor { let Some(events) = events else { break }; let mut provider_turn_settled = false; for event in events { + let provider_process_lost = matches!( + &event, + crate::acpx_provider_state::AcpxProviderStateEvent::TurnTerminal { + error: Some(error), .. + } if error.get("code").and_then(Value::as_str) == Some("ACPX_PROVIDER_PROCESS_LOST") + ); let normalized = project_acpx_state_event(&self.context, &event) .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; let terminal = normalized.iter().find_map(|event| { @@ -2024,7 +2030,10 @@ impl AcpxCommandExecutor { ) .then(|| event.event_type.clone()) }); - if terminal.is_some() { + // A failed provider cannot answer session controls. Its + // terminal receipt must remain publishable without another + // protocol request, and must revoke future warm admission. + if terminal.is_some() && !provider_process_lost { let snapshot = self.goal_control("session.goal.get", &json!({}))?; let state = self.state.as_mut().expect("ACPX session has durable state"); for (event_type, priority, payload) in snapshot.events { @@ -2065,10 +2074,12 @@ impl AcpxCommandExecutor { } if let Some(event_type) = terminal { state.active_turn_id = None; - state.lifecycle = if self - .session - .as_ref() - .is_some_and(|session| session.runtime_retired()) + state.provider_exit_unconfirmed |= provider_process_lost; + state.lifecycle = if provider_process_lost + || self + .session + .as_ref() + .is_some_and(|session| session.runtime_retired()) { "closed" } else { @@ -2078,11 +2089,12 @@ impl AcpxCommandExecutor { provider_turn_settled = true; // An ACP goal has session lifetime, not prompt lifetime. // Out-of-prompt goal updates remain observable after quiescence. - if state - .goal_projection - .pointer("/goal/status") - .and_then(Value::as_str) - == Some("active") + if !provider_process_lost + && state + .goal_projection + .pointer("/goal/status") + .and_then(Value::as_str) + == Some("active") { continue; } @@ -4071,6 +4083,279 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(unix)] + #[test] + fn copilot_attachment_refreshes_current_grant_without_changing_profile() { + let directory = temporary_directory("cross-run-attach"); + let runtime = directory.join("runtime"); + let workspace = directory.join("workspace"); + fs::create_dir_all(&runtime).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap(); + let marker = directory.join("provider-started"); + let command = directory.join("sidecar"); + write_artifact( + &command, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()).as_bytes(), + true, + ); + let launch_profile = AcpxLaunchProfile { + authority_digest: format!("sha256:{}", "d".repeat(64)), + command: command.clone(), + args: Vec::new(), + artifacts: vec![artifact(&command)], + }; + let mut descriptor_value = descriptor("copilot"); + // The fixture factory uses the generated current release identity. + // Overwriting it with a historical digest prevents restore before the + // instruction-refresh behavior under test can run. + descriptor_value["providerPolicy"] = json!({ "readOnly": true }); + descriptor_value["sidecarCommand"] = json!(command); + descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" }); + descriptor_value["sidecarArgs"] = json!([]); + descriptor_value["runtimeDirectory"] = json!(runtime); + descriptor_value["cwd"] = json!(workspace); + let prior_root = directory.join("old-registered-copy"); + let current_root = directory.join("new-registered-copy"); + fs::create_dir_all(&prior_root).unwrap(); + fs::create_dir_all(¤t_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Custom entry.", + prior_root.display() + )); + descriptor_value["runtimeContext"] = json!({ + "aggregateDigest": "a".repeat(64), + "prompt": {"revision": "pinned", "digest": "b".repeat(64)}, + "instructions": { + "entryPath": "AGENTS.md", + "bundle": {"digest": "c".repeat(64), "rootPath": "/old-bundle"}, + "workingCopy": {"kind": "agent_files", "entryPath": "AGENTS.md", "rootPath": prior_root}, + }, + "skills": [{"key": "skill-1", "bundle": {"digest": "d".repeat(64), "rootPath": "/old-skill"}}], + "mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"}, + "futurePolicy": {"companyId": "company-1"}, + }); + let original_descriptor: AcpxProviderDescriptor = + serde_json::from_value(descriptor_value.clone()).unwrap(); + let identity = AcpxProviderSessionIdentity { + kind: "acpx".to_owned(), + normalized_session_id: "session-1".to_owned(), + acpx_record_id: "record-1".to_owned(), + backend_session_id: "backend-1".to_owned(), + agent_session_id: "agent-1".to_owned(), + profile_digest: original_descriptor.command_digest.clone(), + workspace_digest: format!("sha256:{}", "a".repeat(64)), + requested_model: original_descriptor.model.clone(), + effective_model: original_descriptor.model.clone(), + permission_mode: Some(original_descriptor.permission_mode), + mode: original_descriptor.mode.clone(), + pi_thinking_level: None, + claude_effort: None, + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], + }; + let operations = Vec::new(); + let tool_set = AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(), + operations, + }; + let launch_profile_digest = launch_profile.canonical_digest().unwrap(); + let mut state = AcpxDurableState::new(original_descriptor, tool_set, launch_profile_digest); + state.lifecycle = "suspended".to_owned(); + state.identity = Some(identity); + let original_config = test_config(&directory, Some(launch_profile.clone())); + let mut original = AcpxCommandExecutor::with_runner_config(&directory, &original_config); + original.state = Some(state); + let settled = original.state.clone().unwrap(); + let ready = original.snapshot().unwrap().result; + assert_eq!(ready["warmAttachReady"], true); + assert_eq!(ready["warmAttachBlockers"], json!([])); + let blocked_states: [(&str, fn(&mut AcpxDurableState)); 4] = [ + ("durable_closed", |state| { + state.lifecycle = "closed".to_owned() + }), + ("provider_exit_unconfirmed", |state| { + state.lifecycle = "prepared".to_owned(); + state.provider_exit_unconfirmed = true; + }), + ("provider_identity_unavailable", |state| { + state.lifecycle = "prepared".to_owned(); + state.identity = None; + }), + ("durable_active_turn", |state| { + state.lifecycle = "turn_active".to_owned(); + state.active_turn_id = Some("turn-1".to_owned()); + }), + ]; + for (blocker, mutate) in blocked_states { + original.state = Some(settled.clone()); + mutate(original.state.as_mut().unwrap()); + let snapshot = original.snapshot().unwrap().result; + assert_eq!(snapshot["warmAttachReady"], false, "{blocker}"); + assert_eq!(snapshot["warmAttachBlockers"], json!([blocker])); + } + // A readiness probe must retain the old authority's audit events until + // the durable runner commits and acknowledges them, including recovery + // notices that run.attach itself is allowed to consume. + for event_type in ["session.resumed", "harness.diagnostic"] { + original.state = Some(settled.clone()); + original + .state + .as_mut() + .unwrap() + .push(NormalizedProviderEvent { + event_type: event_type.to_owned(), + priority: EventPriority::P0, + payload: json!({}), + }) + .unwrap(); + let snapshot = original.snapshot().unwrap().result; + assert_eq!(snapshot["warmAttachReady"], false); + assert_eq!( + snapshot["warmAttachBlockers"], + json!(["durable_pending_events"]) + ); + assert_eq!(original.retained_events().unwrap().len(), 1); + original.acknowledge_events(1).unwrap(); + assert_eq!(original.snapshot().unwrap().result["warmAttachReady"], true); + } + original.state = Some(settled); + assert!(!marker.exists(), "readiness must not start a provider"); + original.save_state().unwrap(); + + let mut wrong_session_config = original_config.clone(); + wrong_session_config.run_id = "run-2".to_owned(); + wrong_session_config.normalized_session_id = "session-2".to_owned(); + let mut wrong_session = + AcpxCommandExecutor::with_runner_config(&directory, &wrong_session_config); + assert!(wrong_session.restore().is_err()); + + let mut attached_config = original_config.clone(); + attached_config.run_id = "run-2".to_owned(); + let mut attached = AcpxCommandExecutor::with_runner_config(&directory, &attached_config); + attached.restore().unwrap(); + assert!(!marker.exists()); + let non_attach_error = attached + .execute(&Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: "command-before-attach".to_owned(), + controller_seq: 1, + command_type: "session.snapshot".to_owned(), + issued_at: "2026-09-01T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload: json!({}), + }) + .unwrap_err(); + assert!(non_attach_error + .to_string() + .contains("requires run.attach before commands from a new run")); + + descriptor_value["runId"] = json!("run-2"); + fs::remove_dir_all(&prior_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Fresh custom entry.", + current_root.display() + )); + descriptor_value["runtimeContext"]["instructions"]["workingCopy"]["rootPath"] = + json!(current_root); + descriptor_value["runtimeContext"]["instructions"]["bundle"]["rootPath"] = + json!("/new-bundle"); + descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill"); + descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding"); + attached + .attach_run(&json!({"provider": descriptor_value})) + .unwrap(); + assert_eq!(attached.state.as_ref().unwrap().descriptor.run_id, "run-2"); + assert!(!marker.exists()); + let refreshed = &attached.state.as_ref().unwrap().descriptor; + assert_eq!( + refreshed.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!(refreshed.instructions, descriptor_value["instructions"]); + let session_config = refreshed + .session_config( + attached.state.as_ref().unwrap().tool_set.clone(), + attached.state.as_ref().unwrap().identity.clone(), + Some(&launch_profile), + ) + .unwrap(); + assert_eq!( + session_config.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!( + session_config.system_instructions, + descriptor_value["instructions"] + ); + // Both persistence and the sidecar launch config receive the new grant. + let persisted: AcpxDurableState = + serde_json::from_slice(&fs::read(attached.state_path()).unwrap()).unwrap(); + assert_eq!( + persisted.descriptor.runtime_context, + descriptor_value["runtimeContext"] + ); + let mut same_run_mutation = descriptor_value.clone(); + same_run_mutation["instructions"] = json!("A different grant in the same run"); + assert!(attached + .attach_run(&json!({"provider": same_run_mutation})) + .unwrap_err() + .to_string() + .contains("new authenticated run")); + + // In-place warm handoff executes under the old authority. Only the + // authenticated next-authority boundary may admit the new descriptor; + // event correlation stays on run-1 until durable activation completes. + assert!(original + .attach_run(&json!({"provider": descriptor_value})) + .is_err()); + let warm_payload = json!({ + "provider": descriptor_value, + "paperclipNextAuthority": { + "identity": { + "runnerInstanceId": original_config.runner_instance_id, + "environmentLeaseId": original_config.environment_lease_id, + "runId": "run-2", + "normalizedSessionId": original_config.normalized_session_id, + "turnId": "turn-2", + "itemId": "item-2", + }, + "connection": {"mode": "connect", "connectUrl": original_config.connect_url}, + }, + }); + let mut wrong_run = warm_payload.clone(); + wrong_run["paperclipNextAuthority"]["identity"]["runId"] = json!("run-3"); + assert!(original.attach_run(&wrong_run).is_err()); + let mut wrong_session = warm_payload.clone(); + wrong_session["paperclipNextAuthority"]["identity"]["normalizedSessionId"] = + json!("other-session"); + assert!(original.attach_run(&wrong_session).is_err()); + let mut changed_profile = warm_payload.clone(); + changed_profile["provider"]["cwd"] = json!("/different-workspace"); + assert!(original.attach_run(&changed_profile).is_err()); + let mut refreshed = warm_payload.clone(); + refreshed["provider"]["runtimeContext"]["mcp"] = json!({ "digest": "after" }); + refreshed["provider"]["runtimeContext"]["aggregateDigest"] = json!("after"); + let mut changed_context = refreshed.clone(); + changed_context["provider"]["runtimeContext"]["instructions"] = + json!({ "digest": "changed" }); + assert!(original.attach_run(&changed_context).is_err()); + original.attach_run(&refreshed).unwrap(); + assert_eq!( + original.state.as_ref().unwrap().descriptor.runtime_context["mcp"]["digest"], + "after" + ); + assert_eq!(original.state.as_ref().unwrap().descriptor.run_id, "run-2"); + assert_eq!(original.context.run_id, "run-1"); + original.rotate_authority(&attached_config); + assert_eq!(original.context.run_id, "run-2"); + assert!(!marker.exists()); + fs::remove_dir_all(directory).unwrap(); + } + #[cfg(unix)] #[test] fn active_turn_recovery_closes_without_starting_the_provider() { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs index d9ebde3208e..65169a5a7bd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs @@ -11,7 +11,7 @@ pub(crate) enum RunAttachmentPolicy { pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy { match agent { - "cursor" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants, + "cursor" | "copilot" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants, "claude" => RunAttachmentPolicy::AuthenticatedAssetPaths, _ => RunAttachmentPolicy::ImmutableInstructions, } @@ -27,6 +27,10 @@ mod tests { run_attachment_policy("cursor"), RunAttachmentPolicy::AuthenticatedRunGrants ); + assert_eq!( + run_attachment_policy("copilot"), + RunAttachmentPolicy::AuthenticatedRunGrants + ); assert_eq!( run_attachment_policy("pi"), RunAttachmentPolicy::AuthenticatedRunGrants @@ -35,7 +39,7 @@ mod tests { run_attachment_policy("claude"), RunAttachmentPolicy::AuthenticatedAssetPaths ); - for agent in ["codex", "grok", "copilot", "unknown"] { + for agent in ["codex", "grok", "unknown"] { assert_eq!( run_attachment_policy(agent), RunAttachmentPolicy::ImmutableInstructions diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index fe6d3a84f13..8f4e4dc9c18 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -908,6 +908,7 @@ fn response_error_classification(error: &ResponseError) -> &'static str { "ACPX_TOOL_CALL_STALE" => return "provider_tool_call_retired", "AUTH_REQUIRED" => return "authentication_required", "COPILOT_AUTH_REQUIRED" => return "authentication_required", + "COPILOT_INSTALLATION_INVALID" => return "agent_installation_invalid", "COPILOT_POLICY_VIOLATION" => return "copilot_policy_violation", "COPILOT_DETACHED_WORK_UNSUPPORTED" => return "copilot_detached_work_unsupported", "COPILOT_ENTITLEMENT_DENIED" => return "provider_entitlement_denied", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 86a932bad15..29468749552 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -64,6 +64,7 @@ fn run() -> Result<(), Box> { let mut stdout = io::stdout().lock(); let mut next_sequence = 1_u64; let mut goal = Value::Null; + let mut provider_lost = false; let mut session_identity = Value::Null; for line in stdin.lock().lines() { let request: Value = serde_json::from_str(&line?)?; @@ -75,6 +76,16 @@ fn run() -> Result<(), Box> { .get("command") .and_then(Value::as_str) .ok_or("request command is missing")?; + if mode == "turns-provider-death" && provider_lost && command == "session.goal.get" { + write_json( + &mut stdout, + &json!({ + "protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "id":id,"ok":false,"error":{"message":"provider has exited"} + }), + )?; + continue; + } if let Some(journal) = admission_journal.as_mut() { write_json(journal, &json!({"request":request}))?; let response = if command == "tool.resolve" { @@ -315,6 +326,7 @@ fn run() -> Result<(), Box> { | "turns-mismatched-reserved-result-terminal" | "turns-unauthorized-tool" | "turns-permission" + | "turns-provider-death" | "permissions-forged-origin" | "turns-retired" | "turns-retired-terminal-first" @@ -381,6 +393,27 @@ fn run() -> Result<(), Box> { .map(|suffix| format!("call-{suffix}")) .unwrap_or_else(|| "call-1".to_owned()) }; + if command == "turn.start" && mode == "turns-provider-death" { + write_turn_event( + &mut stdout, + next_sequence, + "runtime.permission_requested", + "run-1", + turn_id, + json!({"requestId":"dead-input","kind":"edit","title":"Write the file?","choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Deny"}]}), + )?; + next_sequence += 1; + provider_lost = true; + write_turn_event( + &mut stdout, + next_sequence, + "runtime.turn_terminal", + "run-1", + turn_id, + json!({"status":"failed","error":{"code":"ACPX_PROVIDER_PROCESS_LOST","message":"provider has exited","retryable":false}}), + )?; + next_sequence += 1; + } if command == "turn.start" && matches!(mode, "turns" | "turns-wrong-scope") { write_turn_event( &mut stdout, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 9fc6e08fbc9..d47283462c3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:87e275270e50b42290d10ecec9dfce86ffca2b7a7519cb60c584004adfc86437", + "sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 0ed2cf97667..872935a3a03 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -1103,6 +1103,111 @@ fn routes_reserved_completion_feedback_with_the_full_controller_catalog() { fs::remove_dir_all(directory).unwrap(); } +#[test] +fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_provider() { + let directory = temporary_directory("acpx-provider-death"); + let mode = "turns-provider-death"; + let mut config = acpx_config(&directory, mode); + let mut payload = prepare_payload_with_mode(&directory, "codex", mode); + // Keep the death/expiry assertions on the active immutable profile. A + // historical digest fails admission before a provider can die. + let identity: Value = + serde_json::from_str(include_str!("../../../../acpx-profiles.json")).unwrap(); + let digest = identity["profiles"]["copilot"]["commandDigest"] + .as_str() + .unwrap(); + *config + .acpx_launch_profile + .as_mut() + .unwrap() + .args + .last_mut() + .unwrap() = digest.to_owned(); + let provider = &mut payload["provider"]; + provider["agent"] = json!("copilot"); + provider["model"] = json!("gpt-5.6-luna"); + provider["permissionMode"] = json!("approve-paperclip"); + provider["agentServerPackage"] = json!("@github/copilot"); + provider["agentServerVersion"] = json!("1.0.88"); + provider["agentRuntimePackage"] = Value::Null; + provider["agentRuntimeVersion"] = Value::Null; + provider["providerPolicy"] = json!({"readOnly":false}); + provider["commandDigest"] = json!(digest); + provider["sidecarArgs"][3] = json!(digest); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command(1, "run.prepare", payload)) + .unwrap(); + executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + executor + .execute(&command( + 3, + "turn.start", + json!({"text":"Write", "turnId":"provider-turn-dead"}), + )) + .unwrap(); + let mut events = Vec::new(); + for _ in 0..20 { + let batch = executor.poll_events().unwrap(); + executor.acknowledge_events(batch.len()).unwrap(); + events.extend(batch); + if events + .iter() + .any(|event| event.event_type == "run.terminal") + { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let expired = events + .iter() + .position(|event| event.event_type == "runtime_request.expired") + .unwrap_or_else(|| panic!("provider death did not expire input: {events:?}")); + let failed = events + .iter() + .position(|event| event.event_type == "turn.failed") + .unwrap(); + assert!(expired < failed); + assert_eq!(events[expired].payload["reason"], "provider_process_lost"); + assert_eq!(events[expired].payload["replayAllowed"], false); + assert_eq!( + events[failed].payload["providerTurnId"], + "provider-turn-dead" + ); + assert_eq!( + events[failed].payload["error"]["code"], + "ACPX_PROVIDER_PROCESS_LOST" + ); + assert_eq!( + events + .iter() + .filter(|event| event.event_type == "turn.failed") + .count(), + 1 + ); + let snapshot = executor + .execute(&command(4, "session.snapshot", json!({}))) + .unwrap() + .result; + assert_eq!(snapshot["status"], "closed"); + assert_eq!(snapshot["activeProviderTurnId"], Value::Null); + assert_eq!(snapshot["warmAttachReady"], false); + assert!(executor + .execute(&command( + 5, + "turn.start", + json!({"text":"Replay", "turnId":"replay-turn"}) + )) + .is_err()); + executor + .execute(&command(6, "session.close", json!({}))) + .unwrap(); + shutdown_recovered_acpx_fixture(&mut executor, &directory); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() { let directory = temporary_directory("acpx-cold-idle-recovery"); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 9925bd7ff67..657e890aaa6 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -154,7 +154,15 @@ try { mkdirSync(dirname(stableNodeCommand), { recursive: true, mode: 0o755 }); copyFileSync(process.execPath, stableNodeCommand); chmodSync(stableNodeCommand, 0o755); - const relocatedNode = spawnSync(stableNodeCommand, ["--version"], { + // A freshly copied x64 Mach-O can stall when launched directly from a + // translated Node process. Select Rosetta explicitly for this build probe; + // native Intel and every other platform still execute the copied bytes directly. + const translated = process.platform === "darwin" && process.arch === "x64" + && spawnSync("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"], { + encoding: "utf8", timeout: 1_000, + }).stdout?.trim() === "1"; + const relocatedNode = spawnSync(translated ? "/usr/bin/arch" : stableNodeCommand, + translated ? ["-x86_64", stableNodeCommand, "--version"] : ["--version"], { cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(), }); if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) { @@ -169,7 +177,7 @@ try { || !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity"); candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest, - closureDigest: metadata.closureDigest, qualification: (provider === "cursor" || provider === "pi") ? "qualified" : "pending", path: assetPath, + closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) ? "qualified" : "pending", path: assetPath, sha256: sha256Tree(join(temporaryRoot, assetPath)) }; } @@ -200,6 +208,7 @@ try { writePortableExecutableShim(temporaryRoot, "opencode", "opencode-ai/bin/opencode.exe"); writePortableCopilotShims(temporaryRoot); writePortableNodeShim("acpx", "acpx/dist/cli.js"); + writePortableNodeShim("paperclip-runner-copilot-metadata-probe", "../dist/cli/copilot-metadata-probe.js"); writePortableNodeShim( "claude-agent-acp", "@agentclientprotocol/claude-agent-acp/dist/index.js", diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index cfa2fc13697..00a9d3bb4f5 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -6,14 +6,14 @@ const CANDIDATES = new Set(["cursor", "copilot", "pi"]); /** Only materialized providers enter the serialized manifest and its digest. */ export function providerPackManifestFields(providers, candidates) { return { - ...(["pi", "cursor"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), - ...(["pi", ...candidates].some(provider => providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", "cursor", "copilot"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor", "copilot"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", ...candidates].some(provider => provider !== "copilot" && providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => provider !== "copilot" && providers[provider]).map(provider => [provider, providers[provider]])) } : {}), }; } export function providerPackProviders(platform, architecture, candidates) { const nativeSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); - return [...new Set([...(nativeSupported ? ["pi", "cursor"] : []), ...candidates])]; + return [...new Set([...(nativeSupported ? ["pi", "cursor", "copilot"] : []), ...candidates])]; } export function parseProviderPackArguments(args) { diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index 64660c61bd8..636c40d536f 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; +import { createHash } from "node:crypto"; async function withCopilotTestPlatform(run) { const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -16,27 +17,42 @@ async function withCopilotTestPlatform(run) { } } +// Matches the canonical manifest hashing used by the builder and admission. +function digest(value) { + const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` + : JSON.stringify(value); + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +test("provider manifest digest survives disk JSON on supported and unsupported targets", () => { + const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" }; + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) { + const selected = providerPackProviders(platform, architecture, []); + const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) }; + const diskPayload = JSON.parse(JSON.stringify(payload)); + assert.deepEqual(diskPayload, payload); + assert.equal(digest(diskPayload), digest(payload)); + assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined); + } + const candidate = providerPackManifestFields({ cursor }, ["cursor"]); + assert.deepEqual(candidate.candidateProviders, { cursor }); + assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate)); +}); + test("candidate options stay explicit and supported native assets are included by default", () => { assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] }); assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] }); +}); +test("normal packs include Cursor and Copilot on their three pinned targets without breaking other hosts", () => { for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) { - assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor"]); - assert.deepEqual(providerPackProviders(platform, architecture, ["cursor", "pi", "copilot"]), ["pi", "cursor", "copilot"]); + assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor", "copilot"]); + assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["pi", "cursor", "copilot"]); } assert.deepEqual(providerPackProviders("linux", "arm64", []), []); assert.deepEqual(providerPackProviders("win32", "x64", []), []); assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]); }); -test("serialized manifests include every materialized qualified provider", () => { - const pi = { qualification: "qualified", profileDigest: "pi-digest" }; - const cursor = { qualification: "qualified", profileDigest: "cursor-digest" }; - const copilot = { qualification: "pending", profileDigest: "copilot-digest" }; - assert.deepEqual(providerPackManifestFields({ pi }, []), { providers: { pi }, candidateProviders: { pi } }); - assert.deepEqual(providerPackManifestFields({ pi, cursor, copilot }, ["copilot"]), { - providers: { pi, cursor }, candidateProviders: { pi, copilot }, - }); - assert.deepEqual(providerPackManifestFields({}, []), {}); -}); test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => { for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) { assert.throws(() => parseProviderPackArguments(args)); @@ -44,6 +60,15 @@ test("candidate builder cannot admit unknown providers, options or duplicate ass await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/); }); +test("normal Copilot inventory needs no candidate override and has no duplicate candidate", () => { + const copilot = { qualification: "qualified", version: "1.0.88" }; + for (const candidates of [[], ["copilot"]]) { + const fields = providerPackManifestFields({ copilot }, candidates); + assert.equal(fields.providers.copilot, copilot); + assert.equal(fields.candidateProviders?.copilot, undefined); + assert.equal(digest(JSON.parse(JSON.stringify(fields))), digest(fields)); + } +}); test("Copilot pack selection reaches only the pinned native archive builder", t => withCopilotTestPlatform(async () => { const requests = []; t.mock.method(globalThis, "fetch", async (url, options) => { diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs new file mode 100644 index 00000000000..5056ef83e0c --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -0,0 +1,107 @@ +// Regenerate after changing any bound implementation; live qualification must +// restart whenever the resulting identity changes. Historical identities remain. +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +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-v47-identity.json"); +const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); +const declaration = { ...historical.declaration, agentProfileVersion: 47 }; +delete declaration.permissionIdentitySourceSha256; +const sources = { + displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", + displayProjectionSourceSha256: "src/drivers/acpx/copilot-extension-adapter.ts", + configuredEnvironmentSourceSha256: "src/configured-environment.ts", + configuredEnvironmentRustSourceSha256: "runner/crates/runner-core/src/configured_environment.rs", + profileActivitySourceSha256: "src/drivers/acpx/profile-activity.ts", + profileExtensionsSourceSha256: "src/drivers/acpx/profile-extensions.ts", + profileInstallationSourceSha256: "src/drivers/acpx/profile-installation.ts", + attachmentPolicySourceSha256: "runner/crates/runner-core/src/acpx_provider_capabilities.rs", + singleReadEvidenceSourceSha256: "src/drivers/acpx/single-read-evidence.ts", + nativeSessionRuntimeSourceSha256: "src/native-session-runtime.ts", + nativeRuntimeContextSourceSha256: "src/contracts/runtime-context.ts", + nativeSessionContractSourceSha256: "src/contracts/native-session-backend.ts", + harnessSessionContractSourceSha256: "src/contracts/harness-driver.ts", + harnessBackendSourceSha256: "src/backends/harness-driver-backend.ts", + harnessSessionSourceSha256: "src/drivers/codex/codex-harness-session.ts", + transportContractSourceSha256: "src/drivers/codex/app-server-transport.ts", + nativeBackendSourceSha256: "src/backends/codex-native-backend.ts", + nativeBackendFactorySourceSha256: "src/backends/native-backend-factory.ts", + nativeInstructionsSourceSha256: "src/backends/runtime-context.ts", + runtimeRequestBridgeSourceSha256: "src/live/runnerd-codex-transport.ts", + runtimeRequestFacadeSourceSha256: "src/drivers/codex/codex-session-notifications.ts", + runtimeRequestNotificationSourceSha256: "src/drivers/codex/codex-thread-normalization.ts", + guardedInstallationSourceSha256: "src/drivers/acpx/installation-integrity.ts", + nativeExecutionSourceSha256: "src/contracts/native-execution.ts", + profileCompatibilitySourceSha256: "src/drivers/acpx/profile-compatibility.ts", + installationSourceSha256: "src/drivers/acpx/copilot-installation.ts", + assetOwnershipSourceSha256: "src/drivers/acpx/provider-assets-root.ts", + assetInstallerSourceSha256: "scripts/install-copilot-assets.mjs", + credentialBindingSourceSha256: "src/drivers/acpx/environment.ts", + sidecarErrorSourceSha256: "src/cli/acpx-sidecar-input.ts", + profileSourceSha256: "src/drivers/acpx/copilot-profile.ts", + metadataProbeSourceSha256: "src/drivers/copilot-metadata-probe.ts", + acpxPatchSha256: "../../patches/acpx@0.13.1.patch", + policySha256: "src/drivers/acpx/copilot-policy.ts", + distributionSourceSha256: "scripts/materialize-copilot-binary.mjs", + innerDistributionSourceSha256: "scripts/copilot-inner-distribution.mjs", + permissionContextSourceSha256: "src/drivers/acpx/copilot-permission-context.ts", + permissionAdapterSourceSha256: "src/drivers/acpx/acp-permission-adapter.ts", + permissionToolIdentitySourceSha256: "src/drivers/acpx/cursor-plan-tool-identity.ts", + permissionLocationsSourceSha256: "src/drivers/acpx/safe-locations.ts", + permissionRedactionSourceSha256: "src/semantic-tools/redaction.ts", + permissionClassifierSourceSha256: "src/drivers/acpx/generated-sidecar-contract.ts", + semanticReceiptSourceSha256: "src/drivers/semantic-tool-receipt.ts", + semanticBridgeSourceSha256: "src/drivers/runner-tool-bridge.ts", + toolEvidenceSourceSha256: "src/drivers/acpx/copilot-tool-evidence.ts", + semanticSidecarSourceSha256: "src/cli/acpx-runtime-sidecar.ts", + semanticContinuationSourceSha256: "src/drivers/completion-continuation.ts", + semanticDirectDriverSourceSha256: "src/drivers/acpx/codex-acpx-driver.ts", + semanticValidationSourceSha256: "src/protocol/replay-contract.ts", + semanticNormalizationSourceSha256: "src/protocol/result-normalization.ts", + semanticCompletionContractSourceSha256: "src/contracts/completion-result.ts", + semanticValidatorsSourceSha256: "src/protocol/generated/standalone-validators.ts", + semanticSchemaBundleSourceSha256: "src/protocol/generated/schema-bundle.ts", + semanticSidecarProtocolSourceSha256: "src/drivers/acpx/sidecar-protocol.ts", + runtimeHostSourceSha256: "src/drivers/acpx/runtime-host.ts", + runtimeAdapterSourceSha256: "src/drivers/acpx/codex-runtime-adapter.ts", + runtimeSandboxSourceSha256: "src/drivers/acpx/runtime-sandbox.ts", + cancellationSourceSha256: "src/drivers/acpx/turn-cancellation.ts", + agentFilesSourceSha256: "src/drivers/acpx/agent-files-binding.ts", + nativeDistributionSourceSha256: "src/drivers/acpx/native-distribution-integrity.ts", +}; +for (const [field, path] of Object.entries(sources)) { + declaration[field] = createHash("sha256").update(readFileSync(resolve(root, path))).digest("hex"); +} +const sorted = Object.fromEntries(Object.entries(declaration).sort(([a], [b]) => a.localeCompare(b))); +const commandDigest = `sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`; +const identity = `${JSON.stringify({ commandDigest, declaration }, null, 2)}\n`; +// The shared manifest now generates both TypeScript and Rust admission. Update +// only Copilot; other providers retain their upstream release attestations. +const manifestPath = resolve(root, "acpx-profiles.json"); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +if (process.argv.includes("--check")) { + if (manifest.profiles.copilot.commandDigest !== commandDigest + || manifest.profiles.copilot.agentProfileVersion !== declaration.agentProfileVersion) { + throw new Error("Copilot identity is stale in acpx-profiles.json"); + } +} else { + manifest.profiles.copilot.commandDigest = commandDigest; + manifest.profiles.copilot.agentProfileVersion = declaration.agentProfileVersion; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} +// The builder reads the active manifest; no duplicate digest literal is allowed. +const distributionSource = readFileSync(resolve(root, "scripts/build-copilot-distribution.mjs"), "utf8"); +if (!distributionSource.includes("profiles.profiles.copilot.commandDigest")) { + throw new Error("Copilot distribution builder must use the active manifest identity"); +} +// The shared generator validates this exact attestation before emitting admission. +if (process.argv.includes("--check")) { + if (readFileSync(fixture, "utf8") !== identity) throw new Error("Copilot source identity is stale"); +} else writeFileSync(fixture, identity); +execFileSync(process.execPath, [resolve(root, "scripts/generate-acpx-profiles.mjs"), + ...(process.argv.includes("--check") ? ["--check"] : [])], { stdio: "inherit" }); +console.log(commandDigest); diff --git a/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs b/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs index 3eeb6cac834..25e6ae59bc7 100644 --- a/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs +++ b/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; +import { stopCopilotSmokeChild, withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; // Build verification only. No prompt, credential, model request, or runtime // installation occurs; the provider runs offline with isolated state. @@ -13,9 +14,21 @@ const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "ut // Bind the audited pack explicitly, just as the verified sidecar launcher does. process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack; process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json"); -const candidate = manifest.payload.candidateProviders?.copilot; +const released = manifest.payload.providers?.copilot; +const pending = manifest.payload.candidateProviders?.copilot; +assert.notEqual(Boolean(released), Boolean(pending), "One Copilot asset registration is required"); +const candidate = released ?? pending; +// Rosetta must translate each fresh verified executable snapshot. Measured cold +// startup of the pinned Intel binary exceeds 20 seconds on an ARM host. Native +// platform smoke bounds stay unchanged; translation gets one bounded attempt. +let translatedIntel = false; +if (process.platform === "darwin" && process.arch === "x64") { + try { translatedIntel = execFileSync("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"], { timeout: 1_000, encoding: "utf8" }).trim() === "1"; } + catch { /* Native Intel has no translation flag. */ } +} +const initializeTimeoutMs = translatedIntel ? 60_000 : 20_000; assert.equal(candidate?.version, "1.0.88"); -assert.equal(candidate.qualification, "pending"); +assert.equal(candidate.qualification, released ? "qualified" : "pending"); const { assertAcpxProfileEnvironment, verifyAcpxProfileInstallation } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js"))); const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js"))); @@ -44,7 +57,7 @@ await withCopilotSmokeResources(installation, async ({ lease, root, fixture, fix let buffer = ""; let stderr = ""; let total = 0; - const timeout = setTimeout(() => reject(new Error(`Copilot ACP initialize timed out: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`)), 20_000); + const timeout = setTimeout(() => reject(new Error(`Copilot ACP initialize timed out: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`)), initializeTimeoutMs); timeout.unref(); child.once("error", reject); child.once("exit", () => reject(new Error(`Copilot exited before initialize: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`))); @@ -84,18 +97,16 @@ await withCopilotSmokeResources(installation, async ({ lease, root, fixture, fix process.stdout.write(`${JSON.stringify({ schema: "paperclip.copilot-provider-pack-smoke/v1", sourceRevision: manifest.payload.runnerSourceRevision, providerPackDigest: manifest.digest, platform: process.platform, architecture: process.arch, + translatedIntel, initializeTimeoutMs, candidate, executableSha256: `sha256:${createHash("sha256").update(executable).digest("hex")}`, registryLaunch: "verifyAcpxProfileInstallation/openCommand/spawn", initializeRequestId: 0, initialize: safeInitialize, missingCredentialPreflight: "COPILOT_AUTH_REQUIRED", cleanExit: true, inheritedCredentials: false, promptSent: false, networkMode: "COPILOT_OFFLINE=true; loopback metadata-only provider", fixtureRequests, protocolFixtureModel: "gpt-4.1 (not a qualified GitHub model)", costUsd: 0, - qualification: "pending: no credential, entitlement, model execution or Daytona", + assetAdmission: candidate.qualification, + qualification: "installation only: no credential, entitlement, model execution or Daytona", }, null, 2)}\n`); } finally { - if (child && child.exitCode === null && child.signalCode === null) { - child.stdin.end(); - child.kill("SIGTERM"); - await within(exited, 5_000); - } + await stopCopilotSmokeChild(child); } }); diff --git a/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs b/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs index 403603ad945..c08749f7677 100644 --- a/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs +++ b/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs @@ -35,3 +35,25 @@ export async function withCopilotSmokeResources(installation, callback, dependen } } } + +/** Confirm this smoke-owned provider exits, even when it ignores SIGTERM. */ +export async function stopCopilotSmokeChild(child, { terminateTimeoutMs = 5_000, killTimeoutMs = 5_000 } = {}) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + child.stdin?.end(); + child.kill("SIGTERM"); + try { await boundedExit(exited, terminateTimeoutMs); } + catch (error) { + if (child.exitCode !== null || child.signalCode !== null) throw error; + child.kill("SIGKILL"); + await boundedExit(exited, killTimeoutMs); + } +} +async function boundedExit(exited, timeoutMs) { + let timer; + try { + await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Copilot smoke cleanup was not confirmed")), timeoutMs); + })]); + } finally { clearTimeout(timer); } +} diff --git a/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs b/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs index 5ba83bb3200..45a2670bd8a 100644 --- a/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs +++ b/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import test from "node:test"; -import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; +import { stopCopilotSmokeChild, withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; function resources(failure) { const actions = []; @@ -41,3 +41,19 @@ test("successful callback result is returned after all resource cleanup", async assert.equal(await withCopilotSmokeResources(r.installation, async ({ root, fixtureRequests }) => { assert.equal(root, "/fixture/private"); assert.deepEqual(fixtureRequests, []); return "ok"; }, r.dependencies), "ok"); assert.deepEqual(r.actions, ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]); }); + +test("ignored SIGTERM is followed by bounded SIGKILL and confirmed process exit", async () => { + const { spawn } = await import("node:child_process"); + const { once } = await import("node:events"); + const child = spawn(process.execPath, ["-e", 'process.on("SIGTERM", () => {}); process.send("ready"); setInterval(() => {}, 1000);'], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + try { + await Promise.race([once(child, "message"), new Promise((_, reject) => setTimeout(() => reject(new Error("child readiness timed out")), 3_000).unref())]); + await stopCopilotSmokeChild(child, { terminateTimeoutMs: 50, killTimeoutMs: 1_000 }); + assert.equal(child.signalCode, "SIGKILL"); + assert.throws(() => process.kill(child.pid, 0), { code: "ESRCH" }); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); child.kill("SIGKILL"); await exited; + } + } +}); diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs index dc0b1fc5feb..3cbfc825d13 100644 --- a/packages/paperclip-runner/scripts/discover-copilot-acp.mjs +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs @@ -1,3 +1,4 @@ +import { StringDecoder } from "node:string_decoder"; // Metadata only. Supply an explicit token; this closed protocol client never prompts. // Usage: node discover-copilot-acp.mjs [exact-model-id] import assert from "node:assert/strict"; @@ -24,6 +25,7 @@ export async function withDiscoveryWorkspace(install, callback, filesystem = { m } export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_097_152 } = {}) { + const decoder = new StringDecoder("utf8"); const pending = new Map(), observedMethods = {}; let nextId = 0, buffer = "", bytes = 0, failure; function fail(code) { @@ -39,7 +41,7 @@ export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_09 child.stdout.on("data", chunk => { bytes += chunk.length; if (bytes > maxBytes) { fatal("provider_wire_limit"); return; } - buffer += chunk.toString(); + buffer += decoder.write(chunk); while (buffer.includes("\n")) { const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1); if (!line.trim()) continue; diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs index d33b2817006..b8602fe6560 100644 --- a/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs @@ -38,3 +38,12 @@ test("closed discovery protocol preserves numeric zero and rejects prompts", asy assert.deepEqual(await pending, { protocolVersion: 1 }); assert.throws(() => rpc.request("session/prompt", {}), /cannot send/); rpc.close(); }); + +test("discovery preserves multibyte model names when every byte arrives separately", async () => { + const child = childFixture(), rpc = createDiscoveryRpc(child); + const result = { models: { availableModels: [{ modelId: "gpt-5.6-luna", name: "東京 🚀 café" }] } }; + const pending = rpc.request("session/new", {}); + const wire = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 0, result })}\n`); + for (const byte of wire) child.stdout.emit("data", Buffer.from([byte])); + assert.deepEqual(await pending, result); rpc.close(); +}); diff --git a/packages/paperclip-runner/scripts/install-copilot-assets.mjs b/packages/paperclip-runner/scripts/install-copilot-assets.mjs new file mode 100644 index 00000000000..5ccd71702fd --- /dev/null +++ b/packages/paperclip-runner/scripts/install-copilot-assets.mjs @@ -0,0 +1,94 @@ +import { access, lstat, mkdir, readFile, realpath, rmdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { COPILOT_DISTRIBUTIONS, materializePinnedCopilotBinary } from "./materialize-copilot-binary.mjs"; + +const packageRoot = await realpath(fileURLToPath(new URL("../", import.meta.url))); +const owner = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); +if (!["@paperclipai/paperclip-runner", "@paperclipai/server"].includes(owner.name)) { + throw new Error("Copilot installer must belong to a runner package owner"); +} +const vendored = owner.name === "@paperclipai/server"; +const runtimeRoot = vendored ? join(packageRoot, "dist/vendor/paperclip-runner") : join(packageRoot, "dist"); + +/** Install only the native npm package for this platform; never execute it. */ +export async function installCopilotAssets() { + const { platform, arch: architecture } = process; + const target = `${platform}-${architecture}`; + if (!Object.prototype.hasOwnProperty.call(COPILOT_DISTRIBUTIONS, target)) { + return { status: "unsupported-platform", target }; + } + const verifierPath = join(runtimeRoot, "drivers/acpx/copilot-installation.js"); + try { await access(verifierPath); } + catch (error) { + if (error.code !== "ENOENT") throw error; + // A source workspace installs dependencies before it compiles the verifier. + // Published packages exclude src, so a malformed npm artifact cannot use + // this path. build:typescript performs installation after compilation. + await access(join(packageRoot, vendored ? "src/services/copilot-connection-probe.ts" : "src/drivers/acpx/copilot-installation.ts")); + await access(resolve(packageRoot, vendored ? "../pnpm-workspace.yaml" : "../../pnpm-workspace.yaml")); + return { status: "source-workspace-pending-build", target }; + } + const assets = join(packageRoot, "provider-assets", "copilot"); + for (const directory of [dirname(assets), assets]) { + await mkdir(directory, { recursive: true, mode: 0o755 }); + const stat = await lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== directory) { + throw new Error("Copilot install directory must not redirect through links"); + } + } + const destination = join(assets, target); + const originalRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; + const originalManifest = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST; + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = packageRoot; + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(packageRoot, "package.json"); + try { + const { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } = await import(pathToFileURL(join(runtimeRoot, "drivers/acpx/copilot-installation.js"))); + const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(runtimeRoot, "drivers/acpx/qualified-profiles.js"))); + const verify = () => verifyCopilotInstallation(QUALIFIED_ACPX_PROFILES.copilot); + try { await mkdir(destination, { mode: 0o755 }); } + catch (error) { + if (error.code !== "EEXIST") throw error; + const stat = await lstat(destination); + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(destination) !== destination) { + throw new Error("Copilot installed assets must not redirect through links"); + } + await verify(); + return { status: "verified-existing", target }; + } + const ownedDirectory = await lstat(destination, { bigint: true }); + try { + // Official native packages export their executable, not package.json. + // Resolve that path without loading or executing the native module. + const nativePackageRoot = dirname(createRequire(import.meta.url).resolve(COPILOT_DISTRIBUTIONS[target].packageName)); + const materialized = materializePinnedCopilotBinary({ packageRoot: nativePackageRoot, platform, architecture, targetDirectory: destination }); + if (materialized.closureSha256 !== COPILOT_CLOSURE_SHA256[target]) { + throw new Error("Copilot installation differs from the runtime closure pin"); + } + await verify(); + return { status: "installed", target }; + } catch (error) { + // The materializer rolls back only files it created. Remove this newly + // created directory only if it is now empty; retain foreign files or + // failed verification evidence rather than deleting a directory tree. + try { + const current = await lstat(destination, { bigint: true }); + if (current.isDirectory() && !current.isSymbolicLink() && current.dev === ownedDirectory.dev && current.ino === ownedDirectory.ino) await rmdir(destination); + } + catch (cleanupError) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(cleanupError.code)) throw new AggregateError([error, cleanupError], "Copilot install and empty-directory cleanup failed"); } + throw error; + } + } finally { + for (const [key, value] of [["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", originalRoot], ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", originalManifest]]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +const invokedPath = process.argv[1] ? await realpath(resolve(process.argv[1])) : null; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + const result = await installCopilotAssets(); + process.stdout.write(`Copilot runtime assets: ${result.status} (${result.target})\n`); +} diff --git a/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs b/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs new file mode 100644 index 00000000000..d437fea01bb --- /dev/null +++ b/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { build } from "esbuild"; +import { COPILOT_DISTRIBUTIONS } from "./materialize-copilot-binary.mjs"; + +const runnerRoot = fileURLToPath(new URL("../", import.meta.url)); +const serverRoot = fileURLToPath(new URL("../../../server/", import.meta.url)); +const target = `${process.platform}-${process.arch}`; +const distribution = COPILOT_DISTRIBUTIONS[target]; +const require = createRequire(import.meta.url); +const helpers = ["install-copilot-assets.mjs", "materialize-copilot-binary.mjs", "copilot-inner-distribution.mjs"]; + +function invoke(script) { + return spawnSync(process.execPath, [script], { + env: { PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, TMPDIR: tmpdir() }, + encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024, + }); +} + +function succeeded(result, status) { + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(status)); +} + +for (const owner of ["runner", "server"]) { + test(`fresh ${owner} package installs verified assets without a workspace or qualification override`, { + skip: !distribution && "Copilot does not advertise this platform", timeout: 180_000, + }, async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "copilot-install-package-"))); + try { + const native = dirname(require.resolve(distribution.packageName)); + await mkdir(join(root, "node_modules/@github"), { recursive: true }); + await symlink(native, join(root, "node_modules", distribution.packageName), "dir"); + await writeFile(join(root, "package.json"), JSON.stringify({ + name: owner === "server" ? "@paperclipai/server" : "@paperclipai/paperclip-runner", type: "module", + })); + const runtimeRoot = owner === "server" ? join(root, "dist/vendor/paperclip-runner") : join(root, "dist"); + // Compile the actual installation closure into a published package layout. + // A clean static-check checkout has no prebuilt dist directory. + await build({ + entryPoints: [join(runnerRoot, "src/drivers/acpx/copilot-installation.ts"), + join(runnerRoot, "src/drivers/acpx/qualified-profiles.ts")], + outbase: join(runnerRoot, "src"), outdir: runtimeRoot, + bundle: true, platform: "node", format: "esm", target: "node24", + logLevel: "silent", + }); + if (owner === "server") { + // The public server owns a compiled sidecar beside the vendored verifier. + // This installer fixture does not execute the sidecar. + await mkdir(join(runtimeRoot, "cli"), { recursive: true }); + await writeFile(join(runtimeRoot, "cli/acpx-runtime-sidecar.cjs"), "// installer fixture sidecar\n"); + } + const scriptRoot = owner === "server" ? join(root, "dist") : join(root, "scripts"); + await mkdir(scriptRoot, { recursive: true }); + for (const name of helpers) await cp(join(runnerRoot, "scripts", name), join(scriptRoot, name)); + let installer = join(scriptRoot, "install-copilot-assets.mjs"); + if (owner === "server") { + await mkdir(join(root, "scripts")); + installer = join(root, "scripts/install-copilot-assets.mjs"); + await cp(join(serverRoot, "scripts/install-copilot-assets.mjs"), installer); + } + succeeded(invoke(installer), "installed"); + succeeded(invoke(installer), "verified-existing"); + const assets = join(root, "provider-assets"); + const closure = join(assets, "copilot", target, ".paperclip-copilot-closure.json"); + const original = await readFile(closure); + const corrupted = JSON.parse(original.toString()); + corrupted.entries[0].sha256 = "0".repeat(64); + await writeFile(closure, JSON.stringify(corrupted)); + const failure = invoke(installer); + assert.notEqual(failure.status, 0); + assert.match(failure.stderr, /COPILOT_INSTALLATION_INVALID/); + assert.notDeepEqual(await readFile(closure), original, "failed verification must retain its evidence"); + await writeFile(closure, original); + succeeded(invoke(installer), "verified-existing"); + const retained = join(root, "retained-assets"); + const outside = join(root, "outside"); + await mkdir(outside); + await rename(assets, retained); + await symlink(outside, assets, "dir"); + const linked = invoke(installer); + assert.notEqual(linked.status, 0); + assert.match(linked.stderr, /must not redirect through links/); + await assert.rejects(readFile(join(outside, "copilot", target, "copilot")), { code: "ENOENT" }); + await rm(assets); + await rename(retained, assets); + succeeded(invoke(installer), "verified-existing"); + } finally { await rm(root, { recursive: true, force: true }); } + }); +} + +test("an unbuilt source workspace defers installation; a published package with missing build output fails", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "copilot-install-bootstrap-"))); + try { + const server = join(root, "server"); + await mkdir(join(server, "scripts"), { recursive: true }); + const script = join(server, "scripts/install-copilot-assets.mjs"); + await cp(join(serverRoot, "scripts/install-copilot-assets.mjs"), script); + assert.notEqual(invoke(script).status, 0); + await mkdir(join(server, "src/services"), { recursive: true }); + await writeFile(join(server, "src/services/copilot-connection-probe.ts"), "// source workspace\n"); + assert.notEqual(invoke(script).status, 0); + await writeFile(join(root, "pnpm-workspace.yaml"), "packages: [server]\n"); + succeeded(invoke(script), "source-workspace-pending-build"); + await assert.rejects(readFile(join(server, "provider-assets/copilot", target, "copilot")), { code: "ENOENT" }); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs index cbc9d3f370d..c5cbaf13caa 100644 --- a/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs +++ b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs @@ -1,3 +1,4 @@ +import { StringDecoder } from "node:string_decoder"; // Real-service qualification only. One explicit scenario sends one ACP prompt. // Usage: node qualify-copilot-acp.mjs // Bind only COPILOT_GITHUB_TOKEN. Do not run without a reconciled billing reservation. @@ -62,134 +63,139 @@ export async function runProbe(packInput, output, scenario, token) { finally { await lease.close(); } throw error; } - const marker = join(root, "qualification-marker.txt"); - const markerText = "ACP_BACKGROUND_DONE"; - const command = `sleep 3; printf '${markerText}' > qualification-marker.txt`; - const start = performance.now(); - const elapsed = () => Math.round(performance.now() - start); - const evidence = { schema: "paperclip.copilot-live-risk-probe/v1", scenario, model, sourceRevision: manifest.payload.runnerSourceRevision, - providerPackDigest: manifest.digest, profileDigest: install.commandDigest, promptRequestsSent: 0, upstreamModelRequestCount: null, - providerReportedCostUsd: null, qualificationStatus: "pending", activeTurnDeadlineSeconds: 120, outerDeadlineSeconds: 180, - automaticRetries: 0, permissions: [], markerSamples: [], wire: [], detachedToolObserved: false, - backgroundCompletionObservedBeforeTerminal: false, cleanupComplete: false }; - let child, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false; - let outerTimer, markerTimer; - const pending = new Map(); - const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" }; - async function sampleMarker(phase) { - let content; - try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } - const sample = { elapsedMs: elapsed(), phase, exists: content !== undefined, matches: content === markerText }; - evidence.markerSamples.push(sample); - return sample; - } - function failPending(code) { - for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); } - pending.clear(); - } - const onShutdown = () => { evidence.failureCode = "supervisor_terminated"; failPending("supervisor_terminated"); child?.kill("SIGTERM"); }; - process.once("SIGTERM", onShutdown); process.once("SIGINT", onShutdown); - function send(message) { child.stdin.write(`${JSON.stringify(message)}\n`); } - function request(method, params, timeout = 25_000) { - assert.ok(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/prompt", "session/close"].includes(method)); - const id = nextId++; - if (method === "session/prompt") promptId = id; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout); - pending.set(id, { resolve, reject, timer }); send({ jsonrpc: "2.0", id, method, params }); - }); - } try { - for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) { - env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 }); + const marker = join(root, "qualification-marker.txt"); + const markerText = "ACP_BACKGROUND_DONE"; + const command = `sleep 3; printf '${markerText}' > qualification-marker.txt`; + const start = performance.now(); + const elapsed = () => Math.round(performance.now() - start); + const evidence = { schema: "paperclip.copilot-live-risk-probe/v1", scenario, model, sourceRevision: manifest.payload.runnerSourceRevision, + providerPackDigest: manifest.digest, profileDigest: install.commandDigest, promptRequestsSent: 0, upstreamModelRequestCount: null, + providerReportedCostUsd: null, qualificationStatus: "pending", activeTurnDeadlineSeconds: 120, outerDeadlineSeconds: 180, + automaticRetries: 0, permissions: [], markerSamples: [], wire: [], detachedToolObserved: false, + backgroundCompletionObservedBeforeTerminal: false, cleanupComplete: false }; + let child, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false; + let outerTimer, markerTimer; + const decoder = new StringDecoder("utf8"); + const pending = new Map(); + const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" }; + async function sampleMarker(phase) { + let content; + try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } + const sample = { elapsedMs: elapsed(), phase, exists: content !== undefined, matches: content === markerText }; + evidence.markerSamples.push(sample); + return sample; } - await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 }); - await sampleMarker("before_launch"); - child = lease.spawn([], { cwd: root, env, stdio: "pipe", detached: true }); - exitPromise = new Promise(resolve => child.once("close", (code, signal) => { failPending("provider_exited"); resolve({ code, signal }); })); - outerTimer = setTimeout(() => { failPending("outer_deadline"); child.kill("SIGTERM"); }, 180_000); - child.once("error", () => failPending("provider_spawn_failure")); - child.stdin.on("error", () => failPending("provider_stdin_failure")); - child.stderr.on("data", () => {}); - child.stdout.on("data", chunk => { - bytes += chunk.length; - if (bytes > 8_388_608) { failPending("wire_limit"); child.kill("SIGTERM"); return; } - buffer += chunk.toString(); - while (buffer.includes("\n")) { - const index = buffer.indexOf("\n"), line = buffer.slice(0, index); buffer = buffer.slice(index + 1); - if (!line.trim()) continue; - let message; - try { message = JSON.parse(line); } catch { failPending("malformed_json"); child.kill("SIGTERM"); return; } - if (!message || typeof message !== "object" || Array.isArray(message)) { failPending("malformed_message"); child.kill("SIGTERM"); return; } - evidence.wire.push({ elapsedMs: elapsed(), message }); - if (message.method) { - const params = message.params; - if (message.method === "session/request_permission" && message.id !== undefined) { - const decision = permissionDecision(scenario, params, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined, command); - const writeAttempt = isExactMarkerWrite(params?.toolCall, marker); - evidence.permissions.push({ elapsedMs: elapsed(), requestId: message.id, offeredOptions: params.options, ...decision, writeAttempt }); - send({ jsonrpc: "2.0", id: message.id, result: { outcome: decision.outcome } }); - } else if (message.id !== undefined) { - send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }); - } - if (params?.sessionId === sessionId) { - const arguments_ = params.data?.arguments ?? params.toolCall?.rawInput ?? params.update?.rawInput; - if (arguments_?.command === command && arguments_.detach === true) evidence.detachedToolObserved = true; - const content = JSON.stringify(params); - if (!terminalSeen && content.includes("completed with exit code 0") && content.includes("shell")) evidence.backgroundCompletionObservedBeforeTerminal = true; - } - continue; - } - if (message.id === promptId) { terminalSeen = true; evidence.terminalElapsedMs = elapsed(); } - const entry = pending.get(message.id); - if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); } + function failPending(code) { + for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); } + pending.clear(); + } + const onShutdown = () => { evidence.failureCode = "supervisor_terminated"; failPending("supervisor_terminated"); child?.kill("SIGTERM"); }; + process.once("SIGTERM", onShutdown); process.once("SIGINT", onShutdown); + function send(message) { child.stdin.write(`${JSON.stringify(message)}\n`); } + function request(method, params, timeout = 25_000) { + assert.ok(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/prompt", "session/close"].includes(method)); + const id = nextId++; + if (method === "session/prompt") promptId = id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout); + pending.set(id, { resolve, reject, timer }); send({ jsonrpc: "2.0", id, method, params }); + }); + } + try { + for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) { + env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 }); } - }); - const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-real-service-risk-qualification", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES }); - assert.equal(initialized.agentInfo?.version, "1.0.88", "wrong provider version"); - evidence.agentCapabilities = initialized.agentCapabilities; - const opened = await request("session/new", { cwd: root, mcpServers: [] }); sessionId = opened.sessionId; - assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "exact model not advertised"); - await request("session/set_model", { sessionId, modelId: model }); - const configured = await request("session/set_config_option", { sessionId, configId: "model", value: model }); - assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, model, "model selection did not echo"); - const prompt = scenario === "deny-write" - ? "Try exactly once to create qualification-marker.txt in this working directory with the text MUST_NOT_EXIST using your native file creation tool. The operator will deny this write. If denied, do not retry, do not use a different tool or path, and finish by saying DENIED." - : `Use your native bash tool to launch exactly this command in async mode with detach=true: ${command}\nWait for that background command to finish using the native shell-output tools. Do not modify the command or use another write. Only finish after its successful exit, saying BACKGROUND_SETTLED.`; - markerTimer = setInterval(() => { sampleMarker("during_turn").catch(() => failPending("marker_read_failed")); }, 100); - evidence.promptRequestsSent = 1; - const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000); - evidence.stopReason = terminal.stopReason; - evidence.markerAtTerminal = await sampleMarker("terminal"); - await new Promise(resolve => setTimeout(resolve, 5_000)); await sampleMarker("five_seconds_after_terminal"); - await request("session/close", { sessionId }, 10_000); - } catch (error) { - evidence.failureCode = typeof error?.message === "string" ? error.message.slice(0, 200) : "probe_failure"; + await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 }); + await sampleMarker("before_launch"); + child = lease.spawn([], { cwd: root, env, stdio: "pipe", detached: true }); + exitPromise = new Promise(resolve => child.once("close", (code, signal) => { failPending("provider_exited"); resolve({ code, signal }); })); + outerTimer = setTimeout(() => { failPending("outer_deadline"); child.kill("SIGTERM"); }, 180_000); + child.once("error", () => failPending("provider_spawn_failure")); + child.stdin.on("error", () => failPending("provider_stdin_failure")); + child.stderr.on("data", () => {}); + child.stdout.on("data", chunk => { + bytes += chunk.length; + if (bytes > 8_388_608) { failPending("wire_limit"); child.kill("SIGTERM"); return; } + buffer += decoder.write(chunk); + while (buffer.includes("\n")) { + const index = buffer.indexOf("\n"), line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { failPending("malformed_json"); child.kill("SIGTERM"); return; } + if (!message || typeof message !== "object" || Array.isArray(message)) { failPending("malformed_message"); child.kill("SIGTERM"); return; } + evidence.wire.push({ elapsedMs: elapsed(), message }); + if (message.method) { + const params = message.params; + if (message.method === "session/request_permission" && message.id !== undefined) { + const decision = permissionDecision(scenario, params, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined, command); + const writeAttempt = isExactMarkerWrite(params?.toolCall, marker); + evidence.permissions.push({ elapsedMs: elapsed(), requestId: message.id, offeredOptions: params.options, ...decision, writeAttempt }); + send({ jsonrpc: "2.0", id: message.id, result: { outcome: decision.outcome } }); + } else if (message.id !== undefined) { + send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }); + } + if (params?.sessionId === sessionId) { + const arguments_ = params.data?.arguments ?? params.toolCall?.rawInput ?? params.update?.rawInput; + if (arguments_?.command === command && arguments_.detach === true) evidence.detachedToolObserved = true; + const content = JSON.stringify(params); + if (!terminalSeen && content.includes("completed with exit code 0") && content.includes("shell")) evidence.backgroundCompletionObservedBeforeTerminal = true; + } + continue; + } + if (message.id === promptId) { terminalSeen = true; evidence.terminalElapsedMs = elapsed(); } + const entry = pending.get(message.id); + if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); } + } + }); + const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-real-service-risk-qualification", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES }); + assert.equal(initialized.agentInfo?.version, "1.0.88", "wrong provider version"); + evidence.agentCapabilities = initialized.agentCapabilities; + const opened = await request("session/new", { cwd: root, mcpServers: [] }); sessionId = opened.sessionId; + assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "exact model not advertised"); + await request("session/set_model", { sessionId, modelId: model }); + const configured = await request("session/set_config_option", { sessionId, configId: "model", value: model }); + assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, model, "model selection did not echo"); + const prompt = scenario === "deny-write" + ? "Try exactly once to create qualification-marker.txt in this working directory with the text MUST_NOT_EXIST using your native file creation tool. The operator will deny this write. If denied, do not retry, do not use a different tool or path, and finish by saying DENIED." + : `Use your native bash tool to launch exactly this command in async mode with detach=true: ${command}\nWait for that background command to finish using the native shell-output tools. Do not modify the command or use another write. Only finish after its successful exit, saying BACKGROUND_SETTLED.`; + markerTimer = setInterval(() => { sampleMarker("during_turn").catch(() => failPending("marker_read_failed")); }, 100); + evidence.promptRequestsSent = 1; + const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000); + evidence.stopReason = terminal.stopReason; + evidence.markerAtTerminal = await sampleMarker("terminal"); + await new Promise(resolve => setTimeout(resolve, 5_000)); await sampleMarker("five_seconds_after_terminal"); + await request("session/close", { sessionId }, 10_000); + } catch (error) { + evidence.failureCode = typeof error?.message === "string" ? error.message.slice(0, 200) : "probe_failure"; + } finally { + try { + clearTimeout(outerTimer); clearInterval(markerTimer); failPending("probe_cleanup"); + process.removeListener("SIGTERM", onShutdown); process.removeListener("SIGINT", onShutdown); + if (child) { + child.stdin.end(); + const groupId = Number.isSafeInteger(child.pid) && child.pid > 0 ? child.pid : null; + const killGroup = signal => { if (groupId === null) return; try { process.kill(-groupId, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } }; + const term = setTimeout(() => killGroup("SIGTERM"), 1_000), kill = setTimeout(() => killGroup("SIGKILL"), 5_000); + evidence.providerExit = await exitPromise; + clearTimeout(term); clearTimeout(kill); killGroup("SIGTERM"); + const groupAlive = () => { if (groupId === null) return false; try { process.kill(-groupId, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; } }; + for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); + if (groupAlive()) killGroup("SIGKILL"); + for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); + evidence.cleanupComplete = !groupAlive(); + } else evidence.cleanupComplete = true; + } finally { await lease.close(); } + await sampleMarker("after_process_cleanup"); + evidence.result = evaluateProbe(evidence); + let text = JSON.stringify(evidence, null, 2).replaceAll(token, "[REDACTED]").replaceAll(root, "/fixture/workspace"); + if (sessionId) text = text.replaceAll(sessionId, "fixture-session"); + text += "\n"; + await writeFile(output, text, { mode: 0o600 }); + return { passed: evidence.result.passed, failures: evidence.result.failures, output, sha256: createHash("sha256").update(text).digest("hex"), promptRequestsSent: evidence.promptRequestsSent }; + } } finally { - clearTimeout(outerTimer); clearInterval(markerTimer); failPending("probe_cleanup"); - process.removeListener("SIGTERM", onShutdown); process.removeListener("SIGINT", onShutdown); - if (child) { - child.stdin.end(); - const groupId = Number.isSafeInteger(child.pid) && child.pid > 0 ? child.pid : null; - const killGroup = signal => { if (groupId === null) return; try { process.kill(-groupId, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } }; - const term = setTimeout(() => killGroup("SIGTERM"), 1_000), kill = setTimeout(() => killGroup("SIGKILL"), 5_000); - evidence.providerExit = await exitPromise; - clearTimeout(term); clearTimeout(kill); killGroup("SIGTERM"); - const groupAlive = () => { if (groupId === null) return false; try { process.kill(-groupId, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; } }; - for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); - if (groupAlive()) killGroup("SIGKILL"); - for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); - evidence.cleanupComplete = !groupAlive(); - } else evidence.cleanupComplete = true; - await lease.close(); - await sampleMarker("after_process_cleanup"); - evidence.result = evaluateProbe(evidence); - let text = JSON.stringify(evidence, null, 2).replaceAll(token, "[REDACTED]").replaceAll(root, "/fixture/workspace"); - if (sessionId) text = text.replaceAll(sessionId, "fixture-session"); - text += "\n"; - await writeFile(output, text, { mode: 0o600 }); await rm(root, { recursive: true, force: true }); - return { passed: evidence.result.passed, failures: evidence.result.failures, output, sha256: createHash("sha256").update(text).digest("hex"), promptRequestsSent: evidence.promptRequestsSent }; } } diff --git a/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs b/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs index faa26e11857..fb018fe3e08 100644 --- a/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs +++ b/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs @@ -55,7 +55,7 @@ test("missing credentials reject before resolving a pack or launching anything", }); test("full credential-free protocol fixture preserves numeric-zero denial and reaps its process", async () => { - const { mkdtemp, mkdir, writeFile, readFile, rm } = await import("node:fs/promises"); + const { mkdtemp, mkdir, writeFile, readFile, rm, access } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { runProbe } = await import("./qualify-copilot-acp.mjs"); @@ -69,23 +69,30 @@ test("full credential-free protocol fixture preserves numeric-zero denial and re await writeFile(join(moduleRoot, "copilot-events.js"), 'export const COPILOT_ACP_CLIENT_CAPABILITIES = {};'); const childScript = join(root, "child.mjs"); await writeFile(childScript, `import { createInterface } from 'node:readline'; -const send = m => process.stdout.write(JSON.stringify({jsonrpc:'2.0', ...m})+'\\n'); +const send = m => { + const wire=Buffer.from(JSON.stringify({jsonrpc:'2.0', ...m})+'\\n'); + const unicode=wire.indexOf(Buffer.from('🚀')); + if(unicode<0){process.stdout.write(wire);return;} + process.stdout.write(wire.subarray(0,unicode+2)); + setTimeout(()=>process.stdout.write(wire.subarray(unicode+2)),50); +}; let promptId; createInterface({input:process.stdin}).on('line', line => { const m=JSON.parse(line); if(!m.method){if(m.id!==0 || m.result?.outcome?.optionId!=='native-deny-0') process.exit(8); send({id:promptId,result:{stopReason:'end_turn'}});return;} let result={}; - if(m.method==='initialize')result={agentInfo:{version:'1.0.88'},agentCapabilities:{}}; + if(m.method==='initialize')result={agentInfo:{version:'1.0.88'},agentCapabilities:{label:'東京 🚀 café'}}; if(m.method==='session/new')result={sessionId:'fixture-session',models:{availableModels:[{modelId:'gpt-5.6-luna'}]}}; if(m.method==='session/set_config_option')result={configOptions:[{id:'model',currentValue:'gpt-5.6-luna'}]}; if(m.method==='session/prompt'){promptId=m.id;send({id:0,method:'session/request_permission',params:{sessionId:'fixture-session',toolCall:{kind:'edit',rawInput:{fileName:process.cwd()+'/qualification-marker.txt'}},options:${JSON.stringify(options)}}});return;} send({id:m.id,result}); }).on('close',()=>process.exit(0));`); - await writeFile(join(moduleRoot, "profile-installation.js"), `import { spawn } from 'node:child_process'; export const assertAcpxProfileEnvironment=()=>{}; export const verifyAcpxProfileInstallation=async()=>({commandDigest:'fixture-only',openCommand:async()=>({spawn:(_args,options)=>spawn(options.env.COPILOT_GITHUB_TOKEN === "fixture-spawn-failure" ? "/paperclip-fixture-missing-executable" : ${JSON.stringify(process.execPath)},[${JSON.stringify(childScript)}],options),close:async()=>{}})});`); + await writeFile(join(moduleRoot, "profile-installation.js"), `import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; let token; export const assertAcpxProfileEnvironment=()=>{}; export const verifyAcpxProfileInstallation=async()=>({commandDigest:'fixture-only',openCommand:async()=>({spawn:(_args,options)=>{token=options.env.COPILOT_GITHUB_TOKEN;writeFileSync(${JSON.stringify(join(root, "last-probe-root"))},options.cwd);return spawn(token === "fixture-spawn-failure" ? "/paperclip-fixture-missing-executable" : ${JSON.stringify(process.execPath)},[${JSON.stringify(childScript)}],options);},close:async()=>{if(token==="fixture-close-failure")throw new Error("fixture lease cleanup failed");}})});`); const output = join(root, "output.json"); const result = await runProbe(root, output, "deny-write", "fixture-token-never-used-for-network"); const evidence = JSON.parse(await readFile(output, "utf8")); assert.equal(result.passed, true); + assert.deepEqual(evidence.agentCapabilities, { label: "東京 🚀 café" }); assert.equal(evidence.permissions[0].requestId, 0); assert.deepEqual(evidence.permissions[0].outcome, { outcome: "selected", optionId: "native-deny-0" }); assert.equal(evidence.cleanupComplete, true); @@ -98,5 +105,11 @@ createInterface({input:process.stdin}).on('line', line => { assert.equal(failedEvidence.promptRequestsSent, 0); assert.equal(failedEvidence.cleanupComplete, true); assert.match(failedEvidence.failureCode, /provider_(spawn|stdin)_failure/); + const lastRoot = async () => await readFile(join(root, "last-probe-root"), "utf8"); + await assert.rejects(access(await lastRoot()), { code: "ENOENT" }); + await assert.rejects(runProbe(root, join(root, "missing/report.json"), "deny-write", "fixture-token-never-used-for-network"), { code: "ENOENT" }); + await assert.rejects(access(await lastRoot()), { code: "ENOENT" }); + await assert.rejects(runProbe(root, join(root, "close-failure.json"), "deny-write", "fixture-close-failure"), /fixture lease cleanup failed/); + await assert.rejects(access(await lastRoot()), { code: "ENOENT" }); } finally { await rm(root, { recursive: true, force: true }); } }); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.mjs b/packages/paperclip-runner/scripts/release-provider-pack.mjs index 4ac164dcc51..aee49d6b307 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.mjs @@ -36,5 +36,15 @@ export function verifyReleaseProviderPack(identity, sourceRevision, target = "li throw new Error("Remote provider-pack Cursor identity does not match this release"); } } + const copilot = payload.providers?.copilot; + const copilotProfile = releaseProfiles.profiles.copilot; + if (Object.hasOwn(payload.candidateProviders ?? {}, "copilot") || !copilot + || copilot.version !== copilotProfile.agentServerVersion || copilot.profileDigest !== copilotProfile.commandDigest + || copilot.closureDigest !== "sha256:a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07" + || copilot.qualification !== "qualified" || copilot.path !== "provider-assets/copilot/linux-x64" + || Object.keys(copilot).sort().join(",") !== "closureDigest,path,profileDigest,qualification,sha256,version" + || !/^sha256:[a-f0-9]{64}$/.test(copilot.sha256 ?? "")) { + throw new Error("Remote provider-pack Copilot identity does not match this release"); + } return identity; } diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 84b6ac811fb..cb3cb58887d 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -22,6 +22,9 @@ function fixture() { providers: { cursor: { version: profiles.profiles.cursor.agentServerVersion, profileDigest: profiles.profiles.cursor.commandDigest, closureDigest: `sha256:${distributions.platforms["linux-x64"].closureSha256}`, qualification: "qualified", path: "provider-assets/cursor/linux-x64", sha256: digest } } }; + payload.providers.copilot = { version: profiles.profiles.copilot.agentServerVersion, + profileDigest: profiles.profiles.copilot.commandDigest, closureDigest: "sha256:a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07", + qualification: "qualified", path: "provider-assets/copilot/linux-x64", sha256: digest }; return payload; } function seal(payload) { return { schema: "paperclip-runner/remote-provider-pack/v1", payload, @@ -95,6 +98,19 @@ test("daemon-only assembly retains an existing same-source Linux provider-pack i } finally { rmSync(root, { recursive: true, force: true }); } }); +for (const field of ["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"]) { + test(`rejects an independently rehashed stale Copilot ${field}`, () => { + const payload = fixture(); payload.providers.copilot[field] = "stale"; + assert.throws(() => verifyReleaseProviderPack(seal(payload), revision), /Copilot identity/); + }); +} +test("rejects omitted Copilot and a duplicate candidate inventory", () => { + const missing = fixture(); delete missing.providers.copilot; + assert.throws(() => verifyReleaseProviderPack(seal(missing), revision), /Copilot identity/); + const mixed = fixture(); mixed.candidateProviders = { copilot: mixed.providers.copilot }; + assert.throws(() => verifyReleaseProviderPack(seal(mixed), revision), /Copilot identity/); +}); + function armFixture() { const payload = fixture(); payload.target.architecture = "arm64"; @@ -147,3 +163,12 @@ test("frozen image validation uses release profiles rather than the newer workfl payload.acpxProfileDigests.claude = "sha256:" + "d".repeat(64); assert.throws(() => verifyReleaseProviderPack(seal(payload), revision, "linux-x64", expected), /profiles/); }); + +test("uses the frozen release's Copilot profile when the workflow checkout has advanced", () => { + const releaseProfiles = structuredClone(profiles); + releaseProfiles.profiles.copilot.commandDigest = "sha256:" + "e".repeat(64); + const payload = fixture(); payload.providers.copilot.profileDigest = releaseProfiles.profiles.copilot.commandDigest; + const manifest = seal(payload); + assert.equal(verifyReleaseProviderPack(manifest, revision, "linux-x64", { profiles: releaseProfiles, distributions }), manifest); + assert.throws(() => verifyReleaseProviderPack(manifest, revision), /Copilot identity/); +}); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts index a5cc423e60e..67352d7d080 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -497,9 +497,11 @@ describe("HarnessDriverBackend", () => { it("allows only the run id to change when a harness session is attached", async () => { const attachedRunIds: string[] = []; + const grants: unknown[] = []; class AttachableHarnessSession extends FakeHarnessSession { - async attachRun(input: { runId: string }) { + async attachRun(input: { runId: string; currentRunGrant?: { runtimeContext: null; instructions: string } }) { attachedRunIds.push(input.runId); + grants.push(input.currentRunGrant); } } const backend = new HarnessDriverBackend({ @@ -533,8 +535,10 @@ describe("HarnessDriverBackend", () => { await expect(session.attachRun?.({ identity: { ...originalIdentity, runId: "run-2" }, + currentRunGrant: { runtimeContext: null, instructions: "Current registered instructions." }, })).resolves.toBeUndefined(); expect(attachedRunIds).toEqual(["run-2"]); + expect(grants).toEqual([{ runtimeContext: null, instructions: "Current registered instructions." }]); expect(session.identity()).toEqual({ ...originalIdentity, runId: "run-2" }); }); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index 9df6fd13992..3d188cd10e2 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -13,6 +13,7 @@ import type { PersistedNativeSession, } from "../contracts/native-session-backend.js"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; +import type { NativeRunRuntimeGrant } from "../contracts/runtime-context.js"; import type { PrpEvent, PrpTerminalState, @@ -474,6 +475,7 @@ class HarnessNativeSession implements NativeSession { async attachRun(input: { identity: OpenNativeSessionInput["identity"]; + currentRunGrant?: NativeRunRuntimeGrant; }): Promise { this.#assertProtocolIntegrity(); const currentIdentity = this.#input.identity; @@ -489,7 +491,9 @@ class HarnessNativeSession implements NativeSession { throw new Error("native_session_multi_run_unavailable"); } try { - await this.#session.attachRun({ runId: input.identity.runId }); + await this.#session.attachRun({ runId: input.identity.runId, + ...(input.currentRunGrant ? { currentRunGrant: structuredClone(input.currentRunGrant) } : {}), + }); this.#assertProtocolIntegrity(); } catch (error) { this.#rethrowProtocolIntegrity(error); diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index a873eae5479..dd282d683e0 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -600,8 +600,9 @@ describe("native backend factory", () => { it.each(["v5", "v7", "v9"] as const)("selects prepared transport for %s native input and retains prepared constraints", async version => { const input = preparedExecution(execution(), version); const transport = new FakeCodexTransport(); + let currentInstructions: string | undefined; const backend = createCodexNativeSessionBackend(input, { - transportFactory: () => transport, + transportFactory: (context) => { currentInstructions = context?.baseInstructions; return transport; }, workingDirectoryAuthority: "remote_runner", environment: { PAPERCLIP_WORKSPACE_CWD: WORKSPACE }, }); @@ -619,6 +620,8 @@ describe("native backend factory", () => { expect( transport.calls.find((call) => call.method === "thread/start")?.params, ).toMatchObject({ conversationMode: "prepared" }); + expect(currentInstructions).toContain("Keep assigned instructions."); + expect(currentInstructions).toBe(transport.calls.find((call) => call.method === "thread/start")?.params.developerInstructions); expect(backend.preparedTaskConstraints).toEqual( expect.arrayContaining([ expect.stringContaining("Obtain one accepted result"), diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 1789079a809..e6c131fe6b0 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -37,7 +37,7 @@ import { normalizeAcpFormElicitation, type NormalizedAcpForm, } from "../drivers/acpx/acp-question-adapter.js"; -import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js"; +import { openCodexAcpxRuntime, AcpxProviderProcessLostError } from "../drivers/acpx/codex-runtime-adapter.js"; import { AcpxApprovalRequiredError } from "../drivers/acpx/permission-policy.js"; import { normalizeAcpxPermission, type NormalizedAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; import { acpxGoalProjection } from "../drivers/acpx/session-goals.js"; @@ -683,7 +683,7 @@ async function pumpTurn( terminal = { status: "failed", error: { - ...(error instanceof AcpxApprovalRequiredError ? { code: error.code } : {}), + ...(error instanceof AcpxApprovalRequiredError || error instanceof AcpxProviderProcessLostError ? { code: error.code } : {}), message: safeMessage(error), retryable: false, }, diff --git a/packages/paperclip-runner/src/cli/copilot-metadata-probe.ts b/packages/paperclip-runner/src/cli/copilot-metadata-probe.ts new file mode 100644 index 00000000000..fb33f0628fe --- /dev/null +++ b/packages/paperclip-runner/src/cli/copilot-metadata-probe.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { probeCopilotMetadata } from "../drivers/copilot-metadata-probe.js"; +const result = await probeCopilotMetadata(process.env.COPILOT_GITHUB_TOKEN ?? "", process.env.PAPERCLIP_COPILOT_PROBE_MODEL); +process.stdout.write(`${JSON.stringify(result)}\n`); +if (result.status === "failed") process.exitCode = 1; diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 1fd67456a26..559cba217ad 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -141,6 +141,25 @@ describe("eval-session request contract", () => { } finally { await rm(workspace, { recursive: true, force: true }); } }); + it("requires bounded exact Copilot profile admission before loading provider state", async () => { + const args = ["--request", "/tmp/request.json", "--output", "/tmp/result.json", "--candidate-profile", "copilot"]; + expect(() => parseEvalSessionCliArgs(args)).toThrow("require --expected-acpx-profile"); + for (const invalid of ["[]", "null", "malformed", JSON.stringify({ overflow: "x".repeat(4096) })]) { + expect(() => parseEvalSessionCliArgs([...args, "--expected-acpx-profile", invalid])).toThrow(); + } + const profile = resolveQualifiedAcpxProfile("copilot", "gpt-5.6-luna"); + expect(parseEvalSessionCliArgs([...args, "--expected-acpx-profile", JSON.stringify(profile)]).expectedAcpxProfile).toEqual(profile); + const workspace = await mkdtemp(join(tmpdir(), "eval-profile-admission-")); + try { + const path = join(workspace, "request.json"); + await writeFile(path, JSON.stringify(request({ provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }))); + const factory = vi.fn(); + await expect(runEvalSessionCli(["--request", path, "--output", join(workspace, "output.json"), "--candidate-profile", "copilot", "--expected-acpx-profile", JSON.stringify({ ...profile, commandDigest: `sha256:${"0".repeat(64)}` })], { serviceFactory: factory })).rejects.toThrow("does not match the built runner profile"); + expect(factory).not.toHaveBeenCalled(); + expect(await readdir(workspace)).toEqual(["request.json"]); + } finally { await rm(workspace, { recursive: true, force: true }); } + }); + it("materializes a production-v3 runtime context for direct live providers", async () => { const workspace = await mkdtemp(join(tmpdir(), "paperclip-eval-context-")); let instructionRoot: string | null = null; diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index 8ac8631917d..2d839e73946 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -1,3 +1,4 @@ +import type { NativeRunRuntimeGrant } from "./runtime-context.js"; import type { PrpEvent, PrpStructuredRunResult, @@ -508,7 +509,7 @@ export interface HarnessSession { displayId?: string | null; }; events(): AsyncIterable; - attachRun?(input: { runId: string }): Promise | void; + attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise | void; startTurn(input: { message: NativeUserMessage; /** Set by orchestration only after successful provider-session recovery. */ diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 23d7fd6414f..b161675f19f 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -428,16 +428,40 @@ describe("NativeExecutionInputV1", () => { })).toThrow("does not match"); }); - it("decodes saved Copilot v17 input after the current source declaration advances", () => { - const historical = JSON.parse(readFileSync(new URL("../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it.each([17, 43, 44] as const)("decodes saved Copilot v%s input after the current source declaration advances", (version) => { + const historical = JSON.parse(readFileSync(new URL(`../../test/fixtures/copilot-profile-v${version}-identity.json`, import.meta.url), "utf8")); const { permissionPolicy, qualificationStatus: _status, ...currentProfile } = QUALIFIED_ACPX_PROFILES.copilot; const provider = { kind: "acpx", agent: "copilot", model: "explicit-provider-model", permissionPolicy, - profile: { ...currentProfile, agentProfileVersion: 17, commandDigest: historical.commandDigest } }; + profile: { ...currentProfile, agentProfileVersion: version, commandDigest: historical.commandDigest } }; const parsed = parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, provider }); expect(parsed.provider).toEqual(provider); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); }); + it.each([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] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => { + const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId, + permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy, + ...snapshot } = QUALIFIED_ACPX_PROFILES.copilot; + const provider = { + kind: "acpx", agent: "copilot", model: "gpt-5.6-luna", permissionPolicy, + profile: { ...snapshot, agentProfileVersion }, + } as const; + const value = { ...input, session: { ...input.session, driverKind: "acpx_runtime" }, provider }; + const parsed = parseNativeExecutionInput(value); + expect(parsed.provider).toEqual(provider); + expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + for (const unsupportedVersion of [0, QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion + 1, 1.5, "34", null]) { + expect(() => parseNativeExecutionInput({ + ...value, provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, + })).toThrow("qualified ACPX v1 profile"); + } + expect(() => parseNativeExecutionInput({ + ...value, provider: { ...provider, profile: { ...provider.profile, agent: "pi" } }, + })).toThrow("qualified ACPX v1 profile"); + expect(() => parseNativeExecutionInput({ + ...value, session: { ...value.session, driverKind: "opencode_server" }, + })).toThrow("does not match"); + }); it.each(Object.values(QUALIFIED_ACPX_PROFILES))( "admits the current $agent profile through the native execution boundary", (declaration) => { @@ -595,6 +619,46 @@ describe("native task context ownership", () => { }); } + const { qualificationModel: _copilotQualificationModel, reportedModelId: _copilotReportedModelId, + permissionPolicy: _copilotPermissionPolicy, qualificationStatus: _copilotQualificationStatus, modelPolicy: _copilotModelPolicy, + ...copilotSnapshot } = QUALIFIED_ACPX_PROFILES.copilot; + + it.each([ + { driverKind: "acpx_runtime", provider: { kind: "acpx", agent: "copilot", model: "gpt-5.6-luna", permissionMode: "deny-all", profile: copilotSnapshot } }, + { driverKind: "opencode_server", provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny" } }, + { driverKind: "acpx_runtime", provider: { + kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny-all", + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "pi", agentProfileVersion: 1, + agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", + agentRuntimeVersion: "0.84.2", commandDigest: `sha256:${"a".repeat(64)}` }, + } }, + { driverKind: "openai_dot_mcp", provider: { + kind: "openai_dot", model: null, + binding: { bindingId: "dot-binding", bindingGeneration: 1, companyId: input.binding.companyId, agentId: input.binding.agentId, + acceptByUnixMs: 1_000, expiresAtUnixMs: 2_000 }, + } }, + ])("preserves an unregistered saved prompt for $provider.kind", ({ driverKind, provider }) => { + const current = currentInput(); + if (!("runtimeContext" in current)) throw new Error("Expected a runtime context"); + const text = "Saved instructions absent from the current release."; + const context = { ...current.runtimeContext, + prompt: { revision: "saved-prompt-before-upgrade", text, digest: createHash("sha256").update(text).digest("hex") } }; + context.aggregateDigest = canonicalNativeRuntimeContextDigest(context); + const persisted = JSON.parse(JSON.stringify({ + ...current, provider, session: { ...current.session, driverKind }, runtimeContext: context, + ...(provider.kind === "openai_dot" ? { + schema: NATIVE_EXECUTION_INPUT_SCHEMA_V6, + workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null }, + credentialBindings: [], + } : {}), + })); + const recovered = parseNativeExecutionInput(persisted); + expect(recovered.runtimeContext).toEqual(context); + expect(recovered.provider).toEqual(provider); + expect(recovered.session.driverKind).toBe(driverKind); + expect(parseNativeExecutionInput(recovered)).toEqual(recovered); + }); + it("carries an opaque provider mode without a vendor restriction and fences obsolete field names", () => { const current = currentInput(); const provider = { diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index a3443585ae5..295e44c89b6 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -96,6 +96,9 @@ export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; export type NativeCursorMode = "agent" | "plan" | "ask"; +/** Decodable history; current immutable admission comes from the shared manifest. */ +export type NativeAcpxProfileVersion = AcpxProfileVersion; + export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; protocolVersion: 1; diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index fa857b6c9b6..28866778dbe 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -1,3 +1,4 @@ +import type { NativeRunRuntimeGrant } from "./runtime-context.js"; import type { NativeRunIdentity, NativeSessionCapabilities, @@ -185,7 +186,7 @@ export class NativeSessionCleanupQuarantinedError extends Error { export interface NativeSession { identity(): NativeRunIdentity; capabilities(): Promise; - attachRun?(input: { identity: NativeRunIdentity }): Promise; + attachRun?(input: { identity: NativeRunIdentity; currentRunGrant?: NativeRunRuntimeGrant }): Promise; /** Relinquish controller authority without suspending provider execution. */ detachControllerForRestart?(): Promise; events(input?: { afterCursor?: string | null }): AsyncIterable; diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 810556ac49f..598cd9d8bda 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -27,6 +27,12 @@ export interface NativeRuntimeContextSnapshot { aggregateDigest: string; } +/** Authenticated current-run grants; immutable session policy is verified separately. */ +export interface NativeRunRuntimeGrant { + runtimeContext: NativeRuntimeContextSnapshot | null; + instructions: string; +} + export class NativeRuntimeContextError extends Error { readonly code = "native_runtime_context_invalid" as const; constructor(message: string) { super(message); this.name = "NativeRuntimeContextError"; } diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts index 99dc9803bc1..55f01bb9e82 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { AcpPermissionRequest } from "acpx/runtime"; import { normalizeAcpxPermission } from "./acp-permission-adapter.js"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; function request(kinds = ["allow_once", "allow_always", "reject_once"]): AcpPermissionRequest { return { sessionId: "session", inferredKind: "edit", raw: { @@ -51,6 +55,42 @@ describe("ACP permission normalization", () => { const duplicate = request(); duplicate.raw.options[1]!.optionId = duplicate.raw.options[0]!.optionId; expect(() => normalizeAcpxPermission(duplicate)).toThrow("ambiguous"); }); + it("keeps a native absolute workspace alias answerable with the same projected target", () => { + const root = mkdtempSync(join(tmpdir(), "copilot-permission-alias-")); + try { + const workspace = join(root, "workspace"), alias = join(root, "alias"); + mkdirSync(workspace); mkdirSync(join(workspace, "nested")); symlinkSync(workspace, alias); + const path = join(alias, "nested", "new.txt"), value = request(); + value.raw.toolCall = { toolCallId: "patch-call", kind: "edit", rawInput: { fileName: path }, locations: [{ path }] }; + const cwd = realpathSync(workspace), normalized = normalizeAcpxPermission(value, { provider: "copilot", workingDirectory: cwd }); + expect(normalized.title).toBe("Change file: nested/new.txt"); + expect(normalized.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" }); + const events: any[] = []; + const projection = createCopilotToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: cwd, active: () => true, emit: event => events.push(event) }); + projection.permission(value, "permission", normalized.choices.map(choice => choice.key)); + expect(events[0].payload.details).toContainEqual({ name: "target", value: "nested/new.txt" }); + writeFileSync(join(workspace, "nested", "new.txt"), "existing"); + expect(normalizeAcpxPermission(value, { provider: "copilot", workingDirectory: cwd }).title).toBe(normalized.title); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + it("refuses outside, dangling, conflicting and unresolvable alias targets", () => { + const root = mkdtempSync(join(tmpdir(), "copilot-permission-alias-")); + try { + const workspace = join(root, "workspace"), alias = join(root, "alias"), outside = join(root, "outside"); + mkdirSync(workspace); mkdirSync(outside); symlinkSync(workspace, alias); + symlinkSync(join(outside, "missing.txt"), join(workspace, "dangling.txt")); + symlinkSync(outside, join(workspace, "escape")); + writeFileSync(join(outside, "existing.txt"), "outside"); + for (const path of [join(alias, "dangling.txt"), join(alias, "escape", "new.txt"), join(alias, "escape", "existing.txt"), join(alias, "missing-parent", "new.txt"), join(outside, "new.txt")]) { + const value = request(); value.raw.toolCall = { toolCallId: "patch-call", kind: "edit", rawInput: { fileName: path }, locations: [{ path }] }; + const normalized = normalizeAcpxPermission(value, { provider: "copilot", workingDirectory: realpathSync(workspace) }); + expect(normalized.title).toContain("target unavailable"); + expect(normalized.choices.map(choice => choice.key)).toEqual(["decline", "cancel"]); + } + const conflict = request(); conflict.raw.toolCall = { toolCallId: "patch-call", kind: "edit", rawInput: { fileName: join(alias, "one.txt") }, locations: [{ path: join(alias, "two.txt") }] }; + expect(normalizeAcpxPermission(conflict, { provider: "copilot", workingDirectory: realpathSync(workspace) }).title).toContain("target unavailable"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts index fede31d3641..59e068afa9b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -2,6 +2,8 @@ import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; + + import { safeCopilotEditTarget } from "./copilot-permission-context.js"; export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel"; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 99a260da3d1..5cbfdbf0296 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; import { describe, expect, it, vi } from "vitest"; +import { AcpxProviderProcessLostError } from "./codex-runtime-adapter.js"; import type { AcpRuntimeEvent } from "acpx/runtime"; @@ -1686,6 +1687,35 @@ describe("Codex ACPX harness driver", () => { }); }); + it("expires a Copilot permission on provider death and rejects its late answer", async () => { + const failure = deferred(); + const fixture = driverFixture({ agent: "copilot", model: "gpt-5.6-luna", providerPolicy: { readOnly: false } }, { eventStreamFailure: failure.promise }); + const session = await fixture.driver.openSession({ runId: "run-provider-death", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const created = collectUntil(session.events(), "runtime_request.created"); + const { turnId } = await session.startTurn({ message: { text: "Edit the file" } }); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const controller = new AbortController(); + const response = callback({ inferredKind: "edit", raw: { + sessionId: "agent-session-1", toolCall: { toolCallId: "dead-edit", title: "Edit file" }, + options: [{ optionId: "once", kind: "allow_once", name: "Allow once" }], + } } as Parameters[0], { signal: controller.signal, responseDelivery: Promise.resolve() }); + await created; + const request = session.pendingRuntimeRequests!()[0]!; + const expired = collectUntil(session.events(), "runtime_request.expired"); + const error = new AcpxProviderProcessLostError(); + controller.abort(error); + await expect(response).resolves.toEqual({ outcome: "cancel" }); + expect((await expired).at(-1)).toMatchObject({ eventType: "runtime_request.expired", payload: { + requestId: request.requestId, reason: "provider_process_lost", replayAllowed: false, + } }); + await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "accept" } })).rejects.toThrow("no longer pending"); + const terminal = collectUntil(session.events(), "turn.failed"); + failure.reject(error); + expect((await terminal).at(-1)).toMatchObject({ payload: { error: { code: "ACPX_PROVIDER_PROCESS_LOST" } } }); + expect(session.pendingRuntimeRequests!()).toEqual([]); + await session.close({ reason: "death verified" }); + }); + it("delivers only offered permission choices and rejects stale or duplicate answers", async () => { const fixture = driverFixture({ agent: "claude", model: "claude-sonnet-5" }); const session = await fixture.driver.openSession({ diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 1e6381228a1..8ee4744023b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -60,6 +60,7 @@ import { } from "../runner-tool-bridge.js"; import { DEFAULT_CODEX_ACPX_RUNTIME_SHUTDOWN_BOUND_MS, + AcpxProviderProcessLostError, openCodexAcpxRuntime, } from "./codex-runtime-adapter.js"; import { normalizeAcpxPermission } from "./acp-permission-adapter.js"; @@ -1581,7 +1582,7 @@ class CodexAcpxSession implements HarnessSession { } catch (error) { if (this.#terminalTurns.has(turnId)) return; if (error instanceof TerminalEventCapacityError) throw error; - this.#cancelPendingRuntimeRequests("provider turn failed", turnId); + this.#cancelPendingRuntimeRequests(error instanceof AcpxProviderProcessLostError ? error : "provider turn failed", turnId); if (this.#closed || this.#closingStarted) { const reaffirmedSemanticResult = this.#pendingSemanticTransfer?.turnId === turnId @@ -1612,7 +1613,7 @@ class CodexAcpxSession implements HarnessSession { : { reaffirmedSemanticResult }), }), "turn.failed", - { status: "failed", error: { message: safeMessage(error) } }, + { status: "failed", error: { ...(error instanceof AcpxProviderProcessLostError ? { code: error.code } : {}), message: safeMessage(error) } }, ); } } @@ -1756,9 +1757,7 @@ class CodexAcpxSession implements HarnessSession { const pending = this.#pendingRuntimeRequests.get(requestId); if (!pending || pending.settling || !this.#pendingRuntimeRequests.delete(requestId)) return; pending.cleanup(); - this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(request, { - action: "cancel", reason: "provider request aborted", - }), { turnId, itemId }); + this.#emitRequestAbort(request, context.signal.reason); settle(input.cancel()); }; this.#pendingRuntimeRequests.set(requestId, { @@ -1807,9 +1806,7 @@ class CodexAcpxSession implements HarnessSession { const pending = this.#pendingRuntimeRequests.get(requestId); if (!pending || pending.settling || !this.#pendingRuntimeRequests.delete(requestId)) return; pending.cleanup(); - this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(runtimeRequest, { - action: "cancel", reason: "provider request aborted", - }), { turnId, itemId: requestId }); + this.#emitRequestAbort(runtimeRequest, signal.reason); settle({ outcome: "cancel" }); }; this.#pendingRuntimeRequests.set(requestId, { @@ -1943,14 +1940,7 @@ class CodexAcpxSession implements HarnessSession { if (!pending || pending.settling) return; if (!this.#pendingRuntimeRequests.delete(requestId)) return; pending.cleanup(); - this.#emit( - "runtime_request.cancelled", - harnessRuntimeRequestOutcome(runtimeRequest, { - action: "cancel", - reason: "provider request aborted", - }), - { turnId, itemId: requestId }, - ); + this.#emitRequestAbort(runtimeRequest, context.signal.reason); settle({ action: "cancel" }); }; context.signal.addEventListener("abort", cancel, { once: true }); @@ -1968,22 +1958,23 @@ class CodexAcpxSession implements HarnessSession { }); } - #cancelPendingRuntimeRequests(reason: string, turnId?: string): void { + #emitRequestAbort(request: HarnessRuntimeRequest, reason: unknown): void { + const lost = reason instanceof AcpxProviderProcessLostError; + this.#emit(lost ? "runtime_request.expired" : "runtime_request.cancelled", { + ...harnessRuntimeRequestOutcome(request, { + ...(lost ? {} : { action: "cancel" as const }), + reason: lost ? "provider_process_lost" : typeof reason === "string" ? boundedText(reason, 1_000) : "provider request aborted", + }), + ...(lost ? { replayAllowed: false } : {}), + }, { turnId: request.turnId, itemId: request.itemId }); + } + + #cancelPendingRuntimeRequests(reason: string | AcpxProviderProcessLostError, turnId?: string): void { for (const [requestId, pending] of this.#pendingRuntimeRequests) { if (turnId && pending.request.turnId !== turnId) continue; if (!this.#pendingRuntimeRequests.delete(requestId)) continue; pending.cleanup(); - this.#emit( - "runtime_request.cancelled", - harnessRuntimeRequestOutcome(pending.request, { - action: "cancel", - reason: boundedText(safeMessage(reason), 1_000), - }), - { - turnId: pending.request.turnId, - itemId: pending.request.itemId, - }, - ); + this.#emitRequestAbort(pending.request, reason); pending.cancel(); } } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index f1ac121755d..b5e8731b0d6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -830,9 +830,10 @@ describe("managed Codex credentials", () => { it.runIf(process.platform !== "win32")( "shares the four-slot parent filesystem budget across fresh modules", async () => { - const fixtures = await Promise.all( - Array.from({ length: 5 }, () => credentialFixture()), - ); + // Reserve fixture quorum candidates before holding filesystem operations. + // Otherwise an unrelated loopback listener can reject staging before + // fourOpens resolves, leaving an unhandled rejection and a false timeout. + const fixtures = await credentialFixturesWithAvailableQuorum(5); const retainedHomes = new Set( fixtures.slice(0, 4).map((fixture) => fixture.home), ); @@ -1669,6 +1670,38 @@ async function credentialFixture(): Promise<{ root: string; home: string }> { return { root, home: await realpath(home) }; } +async function credentialFixturesWithAvailableQuorum(count: number) { + const fixtures: Awaited>[] = []; + const reservations: Array>> = []; + try { + for (let index = 0; index < count; index++) { + let prepared = false; + for (let attempt = 0; attempt < 8; attempt++) { + const fixture = await credentialFixture(); + const owned: Array>> = []; + try { + for (const port of credentialLeasePorts(fixture.home)) { + owned.push(await listenSilently(port)); + } + fixtures.push(fixture); + reservations.push(...owned); + prepared = true; + break; + } catch (error) { + await Promise.all(owned.map((listener) => listener.close())); + if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE" || attempt === 7) throw error; + } + } + if (!prepared) throw new Error("Credential fixture quorum preparation failed"); + } + return fixtures; + } finally { + // Fixture setup alone may choose another unused home. Production staging + // still runs once, and a foreign bind racing this handoff remains a failure. + await Promise.all(reservations.map((listener) => listener.close())); + } +} + async function silentPrimaryQuorumFixture( nextFixture = credentialFixture, occupiedIndex: 0 | 1 | "quorum" = 0, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 146a0cc5365..ba5dd3b5449 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -127,6 +127,141 @@ describe("Codex ACPX runtime adapter", () => { expect(createRuntime).not.toHaveBeenCalled(); }); + it.each(["blocked", "ended"] as const)("settles a %s Copilot stream on verified provider death without replaying the permission", async (streamState) => { + const pending = pendingExtensionTurn("death-turn"); + if (streamState === "ended") pending.turn.events = (async function* () {})(); + const runtime = fakeRuntime(); + vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); + const child = fakeChild(); + const command = fakeCommand(); + vi.mocked(command.spawn).mockReturnValue(child); + let exit!: () => void; + const providerExit = new Promise(resolve => { exit = resolve; }); + let created!: AcpRuntimeOptions; + const options = openOptions(command); + options.profile = { ...options.profile, agent: "copilot" }; + options.permissionMode = "approve-paperclip"; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), + awaitProviderOwnership: providerOwnershipEstablished, + awaitProviderExit: () => providerExit, + createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); }, + }); + let answer!: (value: { outcome: "allow_once" }) => void; + let callbackSignal!: AbortSignal; + const handler = vi.fn((_request, context) => { + callbackSignal = context.signal; + return new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; }); + }); + const turn = port.startTurn({ text: "Write the file", requestId: "death-turn", onPermissionRequest: handler }); + await turn.promptStarted; + const events = turn.events[Symbol.asyncIterator](); + const draining = expect(events.next()).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + const settled = expect(turn.result).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce()); + // The guardian remains live: only independent provider EOF proves death. + expect(child.exitCode).toBeNull(); + exit(); + await Promise.all([draining, settled]); + expect(callbackSignal.aborted).toBe(true); + answer({ outcome: "allow_once" }); + await expect(request).resolves.toEqual({ outcome: "cancel" }); + expect(() => port.startTurn({ text: "Replay", requestId: "replay" })).toThrow("provider exited"); + expect(runtime.startTurn).toHaveBeenCalledOnce(); + pending.settle(); + await port.close({ reason: "test finished" }); + expect(child.kill).toHaveBeenCalled(); + }); + + it("refuses Copilot replay when native disconnection precedes the independent exit proof", async () => { + const pending = pendingExtensionTurn("disconnect-turn"); + let disconnect!: () => void; + const failed = new Promise<{ status: "failed"; error: { code: "RUNTIME"; detailCode: "AGENT_DISCONNECTED"; message: string } }>(resolve => { + disconnect = () => resolve({ status: "failed", error: { code: "RUNTIME", detailCode: "AGENT_DISCONNECTED", message: "Native ACP transport disconnected" } }); + }); + const runtime = fakeRuntime(); + vi.mocked(runtime.startTurn).mockReturnValue({ ...pending.turn, result: failed }); + const child = fakeChild(); + const command = fakeCommand(); + vi.mocked(command.spawn).mockReturnValue(child); + let exit!: () => void; + const providerExit = new Promise(resolve => { exit = resolve; }); + let created!: AcpRuntimeOptions; + const options = openOptions(command); + options.profile = { ...options.profile, agent: "copilot" }; + options.permissionMode = "approve-paperclip"; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), + awaitProviderOwnership: providerOwnershipEstablished, + awaitProviderExit: () => providerExit, + createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); }, + }); + let answer!: (value: { outcome: "allow_once" }) => void; + let callbackSignal!: AbortSignal; + const handler = vi.fn((_request, context) => { + callbackSignal = context.signal; + return new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; }); + }); + try { + const turn = port.startTurn({ text: "Write", requestId: "disconnect-turn", onPermissionRequest: handler }); + await turn.promptStarted; + const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce()); + disconnect(); + await expect(turn.result).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + await expect((async () => { for await (const _event of turn.events) { /* drain */ } })()) + .rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + expect(callbackSignal.aborted).toBe(true); + answer({ outcome: "allow_once" }); + await expect(request).resolves.toEqual({ outcome: "cancel" }); + expect(() => port.startTurn({ text: "Replay", requestId: "replay" })).toThrow("provider exited"); + expect(runtime.startTurn).toHaveBeenCalledOnce(); + // A typed disconnect does not manufacture independent process-exit proof. + // Cleanup still owns the live guardian and waits for the separate proof. + expect(child.exitCode).toBeNull(); + } finally { + pending.settle(); + exit(); + await port.close({ reason: "disconnect race verified" }); + } + }); + + it("keeps a Copilot callback deliverable when only the guardian exits", async () => { + const pending = pendingExtensionTurn("preserved-turn"); + const runtime = fakeRuntime(); + vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); + let created!: AcpRuntimeOptions; + const child = fakeChild(); + const command = fakeCommand(); + vi.mocked(command.spawn).mockReturnValue(child); + let exit!: () => void; + const providerExit = new Promise(resolve => { exit = resolve; }); + const options = openOptions(command); + options.profile = { ...options.profile, agent: "copilot" }; + options.permissionMode = "approve-paperclip"; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), + awaitProviderOwnership: providerOwnershipEstablished, awaitProviderExit: () => providerExit, + createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); }, + }); + let answer!: (value: { outcome: "allow_once" }) => void; + const handler = vi.fn(() => new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; })); + const turn = port.startTurn({ text: "Write", requestId: "preserved-turn", onPermissionRequest: handler }); + const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce()); + child.exitCode = 0; + child.emit("exit", 0, null); + // Guardian exit cannot replace the provider-only lifetime observation. + await new Promise(resolve => setTimeout(resolve, 10)); + answer({ outcome: "allow_once" }); + await expect(request).resolves.toEqual({ outcome: "allow_once" }); + pending.settle(); + await expect(turn.result).resolves.toMatchObject({ status: "completed" }); + exit(); + await port.close({ reason: "test finished" }); + }); + it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => { for (const agent of ["copilot", "codex", "claude", "grok"] as const) { const runtime = fakeRuntime(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 8258de9a35a..e8a4f8406ca 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -104,6 +104,14 @@ interface AcpxRuntimeExtensionBoundary { steering: AcpxRuntimeSteeringCapability | null; } +export class AcpxProviderProcessLostError extends Error { + readonly code = "ACPX_PROVIDER_PROCESS_LOST"; + constructor(message = "The owned ACPX provider exited during its active turn") { + super(message); + this.name = "AcpxProviderProcessLostError"; + } +} + class AcpxRuntimeCloseTimeoutError extends Error { constructor() { super("ACPX runtime close timed out"); @@ -644,6 +652,7 @@ export async function openQualifiedAcpxRuntime( commandLaunches, permissionBoundary, extensionBoundary, + options.profile.agent === "copilot", options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, ); } catch (error) { @@ -1074,6 +1083,7 @@ function runtimePort( commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"] }, extensionBoundary: AcpxRuntimeExtensionBoundary, + monitorProviderDeath: boolean, assertPromptPolicy?: (text: string) => void, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); @@ -1117,6 +1127,7 @@ function runtimePort( pendingExternalIntent: boolean; }; let runtimeClosed = false; + let providerProcessLost = false; let runtimeCloseAttempt: RuntimeCloseAttempt | undefined; let lateReconciliationOwner: Promise | undefined; // Each independently observed late failure receives a bounded reconciliation @@ -1432,10 +1443,12 @@ function runtimePort( } : {}), startTurn(input) { + if (providerProcessLost) throw new AcpxProviderProcessLostError(); assertPromptPolicy?.(input.text); if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); const approval = new AbortController(); const controller = new AbortController(); + const providerDeath = new AbortController(); const extensionTurn: AcpxRuntimeExtensionTurn = { requestId: input.requestId, sessionId: identity.backendSessionId, controller, signal: input.signal ? AbortSignal.any([controller.signal, input.signal, approval.signal]) : AbortSignal.any([controller.signal, approval.signal]), @@ -1443,7 +1456,25 @@ function runtimePort( onRequest: input.onExtensionRequest, onNotification: input.onExtensionNotification, }; extensionBoundary.active = extensionTurn; + // A guardian can outlive the provider while ACP awaits a callback. + // Native exit or a typed ACP agent disconnect retires this turn; losing + // the controller connection must preserve deliverable input instead. + // Cleanup still requires the independent provider-only lifetime proof. + const retireProviderTurn = (error = new AcpxProviderProcessLostError()): void => { + providerProcessLost = true; + providerDeath.abort(error); + approval.abort(error); + controller.abort(error); + }; + const stopWatchingProviderExit = !monitorProviderDeath + ? () => undefined + : children.watchProviderExit(() => { + if (!runtimeCloseAttempt && !runtimeClosed && !extensionTurn.signal.aborted && extensionBoundary.active === extensionTurn) { + retireProviderTurn(); + } + }); const releaseExtensionTurn = (): void => { + stopWatchingProviderExit(); controller.abort(new Error("ACPX extension turn expired")); if (extensionBoundary.active === extensionTurn) extensionBoundary.active = null; }; @@ -1473,9 +1504,18 @@ function runtimePort( throw error; } const guarded = turnWithVerifiedLifetimeOwnership(turn, finishOwnershipAdmission); - extensionTurn.promptStarted = guarded.promptStarted; - const result = guarded.result.then( + const promptStarted = abortableExtensionResult(guarded.promptStarted, providerDeath.signal); + void promptStarted.catch(() => undefined); + extensionTurn.promptStarted = promptStarted; + const result = abortableExtensionResult(guarded.result, providerDeath.signal).then( (value) => { + if (monitorProviderDeath && value.status === "failed" + && (value.error?.code === "AGENT_DISCONNECTED" || value.error?.detailCode === "AGENT_DISCONNECTED")) { + // ACP can terminalize before the independent exit observer runs. + // Retire callbacks and the session before finally removes the + // observer, so a late answer cannot admit another native turn. + retireProviderTurn(new AcpxProviderProcessLostError("The owned ACPX provider disconnected during its active turn")); + } approval.signal.throwIfAborted(); // ACPX may resolve its own permission policy before invoking the // host callback. Keep that typed denial on the same runner outcome. @@ -1492,6 +1532,7 @@ function runtimePort( void result.catch(() => undefined); return { ...guarded, + promptStarted, result, cancel: (input) => { controller.abort(new Error("ACPX extension turn cancelled")); @@ -1502,16 +1543,28 @@ function runtimePort( return guarded.closeStream(input); }, events: (async function* () { + const iterator = guarded.events[Symbol.asyncIterator](); try { - for await (const event of guarded.events) { + for (;;) { + const next = await abortableExtensionResult(iterator.next(), providerDeath.signal); + if (next.done) break; approval.signal.throwIfAborted(); - yield event; + yield next.value; } } catch (error) { approval.signal.throwIfAborted(); throw error; + } finally { + // A broken provider may never release its iterator. Observe its + // eventual close without delaying the failed turn's publication. + void iterator.return?.().catch(() => undefined); } approval.signal.throwIfAborted(); + // Copilot's transport can end its event iterator before independent + // provider-exit proof settles the pending permission and turn result. + // Do not publish a successful stream end while that turn still owns + // an unanswered callback or an unreported failure. + if (monitorProviderDeath) await result; })(), }; }, @@ -1768,6 +1821,7 @@ class SpawnedChildSet { readonly #providerExits = new Map(); readonly #terminations = new Map>(); readonly #lifetimeOwnership: Promise[] = []; + readonly #exitWatchers = new Set<() => void>(); #lifetimeOwnershipSealed = false; #sealed = false; @@ -1853,6 +1907,11 @@ class SpawnedChildSet { }; } + watchProviderExit(observer: () => void): () => void { + this.#exitWatchers.add(observer); + return () => { this.#exitWatchers.delete(observer); }; + } + #track(child: ChildProcess, providerExit: ProviderExitObservation): void { this.#children.add(child); const onError = (error: unknown) => this.#errors.add(error); @@ -1879,6 +1938,7 @@ class SpawnedChildSet { providerExit.observe((outcome) => { if (outcome.exited) { providerExited = true; + for (const observer of this.#exitWatchers) observer(); forgetIfReleased(); } else { this.#errors.add(outcome.error); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts index df319244fc3..1c0e1fd1262 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts @@ -90,7 +90,7 @@ describe("Copilot display extension adapter", () => { const normalize = vi.spyOn(copilotEvents, "normalizeCopilotSessionEvent").mockReturnValueOnce({ kind: "usage", sourceMethod: COPILOT_ACP_EVENT_METHOD, sourceType: "assistant.usage", sessionId: context.sessionId, turnId: null, - data: { connectionString: "plain-credential-value", credentials: { endpoint: "opaque-value" }, inputTokens: 42 }, + data: { connectionString: "plain-credential-value", credentials: { endpoint: "opaque-value" }, nested: { apiKey: { endpoint: "nested-secret" } }, inputTokens: 42 }, }); try { const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("assistant.usage", {})); @@ -98,9 +98,10 @@ describe("Copilot display extension adapter", () => { expect(events[0].payload.details).toEqual(expect.arrayContaining([ { name: "connectionString", value: "[REDACTED]" }, { name: "credentials.endpoint", value: "[REDACTED]" }, + { name: "nested.apiKey.endpoint", value: "[REDACTED]" }, { name: "inputTokens", value: "42" }, ])); - expect(JSON.stringify(events)).not.toMatch(/plain-credential-value|opaque-value/); + expect(JSON.stringify(events)).not.toMatch(/plain-credential-value|opaque-value|nested-secret/); } finally { normalize.mockRestore(); } }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts index 592e5813e9e..a3b766c8c57 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -82,7 +82,11 @@ function detailFields(data: Record): Array<{ name: string; valu // Preserve the complete field path for sensitive-key redaction. The // normalizer admits only known, typed counters/flags: their numeric token // counts are not credentials, so they retain value-only redaction. - const safe = typeof value === "string" ? name.split(".").reduce((safe, segment) => redactSemanticValue(safe, segment), value) : value; + // Flattening must not discard a sensitive ancestor such as credentials. + // Apply the shared key rule to each path segment before publishing it. + const safe = typeof value === "string" + ? name.split(".").reduce((entry, key) => redactSemanticValue(entry, key), value) + : value; details.push({ name: bounded(name, 160), value: bounded(String(safe), 4000) }); } }; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 00e9660fa55..71c5d6a4911 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -14,10 +14,15 @@ import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js"; const PERMISSION_POLICY_SOURCES = [ ["copilot-permission-context.ts", "permissionContextSourceSha256"], ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], + ["cursor-plan-tool-identity.ts", "permissionToolIdentitySourceSha256"], ["safe-locations.ts", "permissionLocationsSourceSha256"], ["../../semantic-tools/redaction.ts", "permissionRedactionSourceSha256"], ["generated-sidecar-contract.ts", "permissionClassifierSourceSha256"], - ["cursor-plan-tool-identity.ts", "permissionIdentitySourceSha256"], +] as const; + +const COPILOT_DISPLAY_SOURCES = [ + ["copilot-events.ts", "displayEventSourceSha256"], + ["copilot-extension-adapter.ts", "displayProjectionSourceSha256"], ] as const; const SEMANTIC_RECEIPT_SOURCES = [ @@ -31,6 +36,11 @@ const SEMANTIC_RECEIPT_SOURCES = [ ["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"], ["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"], ["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"], + ["../../contracts/native-execution.ts", "nativeExecutionSourceSha256"], + ["installation-integrity.ts", "guardedInstallationSourceSha256"], + ["../../live/runnerd-codex-transport.ts", "runtimeRequestBridgeSourceSha256"], + ["../codex/codex-session-notifications.ts", "runtimeRequestFacadeSourceSha256"], + ["../codex/codex-thread-normalization.ts", "runtimeRequestNotificationSourceSha256"], ["../../protocol/generated/standalone-validators.ts", "semanticValidatorsSourceSha256"], ["../../protocol/generated/schema-bundle.ts", "semanticSchemaBundleSourceSha256"], ] as const; @@ -75,6 +85,11 @@ async function withCopilotTestPlatform(arch: "x64" | "arm64", run: () => Prom vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { + it("admits the qualified profile without a pending qualification marker", () => { + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(47); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); + }); + it("preserves v12 history and changes only the version and four shared source bindings", () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); @@ -86,21 +101,18 @@ describe("Copilot build-owned installation", () => { const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest); expect(current.commandDigest).not.toBe(prior.commandDigest); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v13 history while binding the shared live request snapshot", () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSidecarSourceSha256"]); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v14 history while binding the corrected outbound ACPX delivery", () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v15-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v17 history while binding the shared Grok descriptor schema", () => { @@ -109,7 +121,6 @@ describe("Copilot build-owned installation", () => { expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSchemaBundleSourceSha256"]); expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); expect(current.commandDigest).not.toBe(prior.commandDigest); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves the historical Muse v18 declaration without granting current launch", async () => { @@ -123,7 +134,6 @@ describe("Copilot build-owned installation", () => { const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest); expect(current.commandDigest).not.toBe(prior.commandDigest); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); vi.mocked(verifyNativeAcpxInstallation).mockClear(); for (const override of [ { commandDigest: prior.commandDigest }, @@ -166,7 +176,6 @@ describe("Copilot build-owned installation", () => { .toEqual(["agentProfileVersion", "semanticSchemaBundleSourceSha256", "semanticValidationSourceSha256", "semanticValidatorsSourceSha256"]); expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); expect(current.commandDigest).not.toBe(prior.commandDigest); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("binds both immutable v19 histories in a new combined v20 declaration", async () => { const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8")); @@ -192,12 +201,11 @@ describe("Copilot build-owned installation", () => { } } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v20 history while binding the controller-owned monitor continuation", async () => { const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-mainline-monitor-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ "agentProfileVersion", "semanticCompletionContractSourceSha256", "semanticContinuationSourceSha256", "semanticDirectDriverSourceSha256", "semanticSidecarSourceSha256", ]); @@ -208,12 +216,11 @@ describe("Copilot build-owned installation", () => { expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(prior.commandDigest); await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, agentProfileVersion: 20, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned"); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v22 history while binding the Claude-only preload metadata branch", async () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v22-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v23-identity.json", import.meta.url), "utf8")); + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-mainline-v22-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-mainline-v23-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()) .toEqual(["agentProfileVersion", "semanticBridgeSourceSha256"]); expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); @@ -222,12 +229,11 @@ describe("Copilot build-owned installation", () => { expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(prior.commandDigest); await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, agentProfileVersion: 22, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned"); - expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); it("preserves v23 history while binding shared observability diagnostic redaction", () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v23-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v24-identity.json", import.meta.url), "utf8")); + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-mainline-v23-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-mainline-v24-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ "agentProfileVersion", "semanticSidecarSourceSha256", ]); @@ -235,8 +241,8 @@ describe("Copilot build-owned installation", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); - it("admits the current v24 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v24-identity.json", import.meta.url), "utf8")); + it("admits the current declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL(`../../../test/fixtures/copilot-profile-v${QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion}-identity.json`, import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -252,10 +258,12 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(identity.commandDigest); - for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { + for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["profile-compatibility.ts", "profileCompatibilitySourceSha256"], ["provider-assets-root.ts", "assetOwnershipSourceSha256"], ["../../../scripts/install-copilot-assets.mjs", "assetInstallerSourceSha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); } - expect(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")).toContain(identity.commandDigest); + const manifest = JSON.parse(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")); + expect(manifest.profiles.copilot.commandDigest).toBe(identity.commandDigest); + expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain("profiles.profiles.copilot.commandDigest"); expect(readFileSync(new URL("../../../runner/crates/runner-core/src/generated_acpx_profiles.rs", import.meta.url), "utf8")).toContain(identity.commandDigest); }); it("binds the complete executable permission-policy import closure", async () => { @@ -266,8 +274,8 @@ describe("Copilot build-owned installation", () => { const bound = new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href)); expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); - it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v24-identity.json", import.meta.url), "utf8")); + it.each([...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { + const identity = JSON.parse(readFileSync(new URL(`../../../test/fixtures/copilot-profile-v${QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion}-identity.json`, import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); @@ -318,16 +326,4 @@ describe("Copilot build-owned installation", () => { } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); }); - it("rejects the historical v12 digest even when the caller supplies the current version", async () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); - vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [ - { commandDigest: prior.commandDigest }, - { agentProfileVersion: prior.declaration.agentProfileVersion }, - { commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion }, - ]) { - await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned"); - } - expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); - }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts index 6dce44a0e3f..12c372bffff 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -24,14 +24,17 @@ export async function verifyCopilotInstallation(profile: AcpxReleaseProfile): Pr throw new Error(`Copilot native distribution is not pinned for ${platform}`); } const distributionRoot = join(resolveRunnerProviderAssetsRoot(import.meta.url, "copilot"), platform); - const native = await verifyNativeAcpxInstallation({ + let native: VerifiedAcpxInstallation; + try { native = await verifyNativeAcpxInstallation({ distributionRoot, manifestPath: join(distributionRoot, ".paperclip-copilot-closure.json"), expectedClosureSha256: COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256], executable: "copilot", fixedArguments: COPILOT_LAUNCH_ARGUMENTS, isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME", copilotDistributionDirectory: "distribution", - }); + }); } catch { + throw Object.assign(new Error("Install the verified Copilot runtime beside the admitted sidecar."), { code: "COPILOT_INSTALLATION_INVALID", retryable: false }); + } // The host identity binds the versioned profile. Native admission independently // binds the complete platform closure before creating each single-use lease. return Object.freeze({ ...native, commandDigest: expected.commandDigest }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts index d0a24575e06..96c59036fe6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts @@ -1,10 +1,35 @@ import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; import { safeAcpxLocations } from "./safe-locations.js"; +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; export const COPILOT_PERMISSION_CONTEXT_CONTRACT = "copilot-edit-permission-context-v1"; const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +/** Native providers can spell an absolute workspace path through a host alias + * (macOS /tmp and /private/tmp). Attest the real entry, or its existing parent + * for a new file, before converting it to a display target. */ +function aliasedLocation(path: string, cwd: string): string | undefined { + if (!isAbsolute(path)) return; + try { + const canonicalCwd = realpathSync(cwd); + let canonicalPath: string; + try { + lstatSync(path); + canonicalPath = realpathSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; + // A dangling symlink must not become an apparently safe create target. + try { lstatSync(path); return; } catch (missing) { + if ((missing as NodeJS.ErrnoException).code !== "ENOENT") return; + } + canonicalPath = join(realpathSync(dirname(path)), basename(path)); + } + return safeAcpxLocations([{ path: canonicalPath }], canonicalCwd, "edit")[0]?.path as string | undefined; + } catch { return; } +} + /** Display context only; this does not authorize filesystem access. */ export function safeCopilotEditTarget(value: unknown, workingDirectory: string | undefined): string | undefined { const call = record(value); @@ -14,7 +39,12 @@ export function safeCopilotEditTarget(value: unknown, workingDirectory: string | if (Array.isArray(call.locations) && (call.locations.length > 16 || call.locations.some(x => typeof record(x).path !== "string"))) return; const paths = [input.path, input.fileName, ...(Array.isArray(call.locations) ? call.locations.map(x => record(x).path) : [])].filter(x => x !== undefined); if (!paths.length || paths.some(x => typeof x !== "string" || !x || x.trim() !== x || x.length > 2048 || /[\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(x))) return; - const safe = paths.flatMap(path => safeAcpxLocations([{ path }], workingDirectory, call.kind, call.title)); + const safe = paths.flatMap(path => { + const locations = safeAcpxLocations([{ path }], workingDirectory, call.kind, call.title); + if (locations.length) return locations; + const aliased = aliasedLocation(path as string, workingDirectory); + return aliased ? [{ path: aliased }] : []; + }); const names = safe.map(x => x.path).filter((x): x is string => typeof x === "string" && x.length <= 1024 && x.trim() === x && !x.includes(":")); if (names.length !== paths.length || new Set(names).size !== 1) return; const target = names[0]; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts index 826927436ee..581f6548e7e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts @@ -1,7 +1,17 @@ import { describe, expect, it } from "vitest"; -import { classifyCopilotFailure, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; +import { classifyCopilotFailure, composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; describe("Copilot admission policy", () => { + it("bounds the complete native instruction file without retaining prior instructions", () => { + const suffix = `\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`; + expect(composeCopilotSystemInstructions("Current instructions.")).toBe(`Current instructions.${suffix}`); + expect(composeCopilotSystemInstructions("")).toBe(suffix); + const remaining = 32 * 1024 - Buffer.byteLength(`${suffix}\n`); + expect(Buffer.byteLength(`${composeCopilotSystemInstructions("x".repeat(remaining))}\n`)).toBe(32 * 1024); + expect(() => composeCopilotSystemInstructions("x".repeat(remaining + 1))).toThrow("bounded size"); + expect(() => composeCopilotSystemInstructions("é".repeat(Math.floor(remaining / 2) + 1))).toThrow("bounded size"); + expect(() => composeCopilotSystemInstructions("embedded\0instruction")).toThrow("bounded size"); + }); it("uses ACP directly without disabling questions or bypassing permissions", () => { expect(COPILOT_LAUNCH_ARGUMENTS.slice(0, 2)).toEqual(["--acp", "--stdio"]); expect(COPILOT_LAUNCH_ARGUMENTS).toContain("--no-auto-update"); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts index 2a88ab552ff..379a72b9ab4 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -5,6 +5,16 @@ export const COPILOT_VERSION = "1.0.88" as const; export const COPILOT_SYSTEM_INSTRUCTIONS_FILE = "copilot-instructions.md" as const; export const COPILOT_SYSTEM_INSTRUCTION_DELIVERY = "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1" as const; export const COPILOT_CREDENTIAL_ENVIRONMENT_NAME = "COPILOT_GITHUB_TOKEN" as const; +export const COPILOT_TASK_ORIENTATION_INSTRUCTIONS = "At the start of every task turn, use the paperclip MCP server's dedicated get_task_context tool with {} and wait for its successful result before task work, report_progress, or any other task-dependent mutation. This is an advertised MCP tool, not an HTTP API route: do not use search_api or call_api to discover or substitute for get_task_context. Loaded instructions, seed state, task descriptions, and prior-turn context do not replace this read. Decide the next action from the returned context. Do not issue the context read and a dependent action in parallel. If this tool is unavailable or its read fails, report the failure and do not perform dependent actions." as const; + +/** Compose Copilot-only guidance without changing other providers' sandbox identity. */ +export function composeCopilotSystemInstructions(instructions: string): string { + const composed = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`; + if (composed.includes("\0") || Buffer.byteLength(`${composed}\n`) > 32 * 1024) { + throw new Error("Provider runtime instructions exceed their bounded size"); + } + return composed; +} // Keep permission callbacks enabled even when Paperclip's policy is approve-all. // In particular COPILOT_ALLOW_ALL=true also trusts workspace hooks/plugins/MCP. diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts index a2fb584f5c2..444e717a5e5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts @@ -8,8 +8,8 @@ import { verifyCopilotInstallation } from "./copilot-installation.js"; import { verifyCursorInstallation } from "./cursor-installation.js"; vi.mock("./copilot-installation.js", () => ({ verifyCopilotInstallation: vi.fn(async () => ({ commandDigest: "verified-by-native-factory" })) })); +vi.mock("./cursor-installation.js", () => ({ verifyCursorInstallation: vi.fn(async () => ({ commandDigest: "verified-by-cursor-factory" })) })); -vi.mock("./cursor-installation.js", () => ({ verifyCursorInstallation: vi.fn(async () => ({ commandDigest: "verified-cursor-factory" })) })); const context = { workspacePath: "/workspace", sessionId: "backend-1", turnId: "turn-1" }; describe("Copilot provider registry conformance", () => { @@ -24,9 +24,9 @@ describe("Copilot provider registry conformance", () => { const profile = resolveQualifiedAcpxProfile("copilot", "explicit-exact-model"); expect(await verifyAcpxProfileInstallation(profile)).toMatchObject({ commandDigest: "verified-by-native-factory" }); expect(verifyCopilotInstallation).toHaveBeenCalledExactlyOnceWith(profile); - const cursorProfile = resolveQualifiedAcpxProfile("cursor", "exact-model"); - expect(await verifyAcpxProfileInstallation(cursorProfile)).toMatchObject({ commandDigest: "verified-cursor-factory" }); - expect(verifyCursorInstallation).toHaveBeenCalledExactlyOnceWith(cursorProfile); + const cursor = resolveQualifiedAcpxProfile("cursor", "exact-model"); + expect(await verifyAcpxProfileInstallation(cursor)).toMatchObject({ commandDigest: "verified-by-cursor-factory" }); + expect(verifyCursorInstallation).toHaveBeenCalledExactlyOnceWith(cursor); expect(verifyCopilotInstallation).toHaveBeenCalledTimes(1); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts index 2ce8ed5c2b7..536220811fa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { expect, it, vi } from "vitest"; +import { COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { AcpxRuntimeHost, type AcpxRuntimeHostDependencies, type AcpxRuntimePortOpenOptions } from "./runtime-host.js"; it("reports missing Copilot authentication before spawn and releases admission ownership for retry", async () => { @@ -66,24 +67,25 @@ it("does not refresh Copilot instructions for a contender rejected by the lifeti normalizedSessionId: "same-native-session", runtimeDirectory, workingDirectory, providerPolicy: { readOnly: false }, environment: { COPILOT_GITHUB_TOKEN: "synthetic-fixture" }, }; + const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; const winner = AcpxRuntimeHost.open({ ...options, systemInstructions: "Winner instructions." }, dependencies); // Attach the rejection handler before deliberately releasing the failed launch. const winnerResult = expect(winner).rejects.toThrow("fixture stops before native process launch"); try { await winnerEntered; - expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions.")); await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "Losing contender." }, dependencies)) .rejects.toThrow("already has an active lease"); expect(openRuntime).toHaveBeenCalledOnce(); - expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions.")); releaseWinner(); await winnerResult; await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "New owner instructions." }, dependencies)) .rejects.toThrow("fixture stops before native process launch"); - expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions.")); await expect(AcpxRuntimeHost.open({ ...options, environment: {}, systemInstructions: "Unauthenticated admission." }, dependencies)) .rejects.toMatchObject({ code: "COPILOT_AUTH_REQUIRED" }); - expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions.")); } finally { releaseWinner(); await winnerResult; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts index 9dcd6e8cdc7..8a02ff045dc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts @@ -127,6 +127,42 @@ describe("Copilot authoritative semantic receipt correlation", () => { const make = (operation = "paperclip_finish", isError = false) => appendSemanticToolReceipt( { tool: operation, callId: "3", arguments: args }, { content: [{ type: "text", text: '{"accepted":false}' }], ...(isError ? { isError } : {}) }); const terminal = (id: string, content: unknown, status = "completed") => ({ type: "tool_call", tag: "tool_call_update", toolCallId: id, status, rawOutput: { contents: content } }); + it("retains empty native context arguments when ACP classifies the semantic tool as a read", () => { + const h = harness(); + const bound = appendSemanticToolReceipt({ tool: "get_task_context", callId: "context", arguments: {} }, + { content: [{ type: "text", text: "PRIVATE CONTEXT" }] }); + h.projector.tool(tool("native-context", {}, "read")); + expect(h.events.map(details)).toEqual([{ stage: "tool", toolCallId: "native-context", operation: "read", status: "pending" }]); + h.projector.captureSemanticReceipt()!(bound.receipt); + h.projector.tool(terminal("native-context", bound.result.content)); + expect(details(h.events.at(-1)!)).toMatchObject({ stage: "tool", status: "completed", semanticOperationId: "get_task_context" }); + expect(h.events.map(details).some(d => d.stage === "evidence_incomplete")).toBe(false); + expect(h.events.map(e => details(e).stage)).toEqual(["tool", "semantic_result", "tool"]); + expect(JSON.stringify(h.events)).not.toContain("PRIVATE CONTEXT"); + }); + it("preserves streamed file-read attestation when the pending origin has no arguments yet", () => { + const h = harness(); + h.projector.tool(tool("file-read", undefined, "read")); + h.projector.tool({ ...tool("file-read", { path: "instructions.md" }, "read"), tag: "tool_call_update" }); + h.projector.tool(update("file-read", "PRIVATE FILE")); + expect(details(h.events.at(-1)!)).toMatchObject({ operation: "read", status: "completed", + readTargetSha256: `sha256:${createHash("sha256").update("instructions.md").digest("hex")}` }); + expect(h.events.map(details).some(d => d.stage === "evidence_incomplete")).toBe(false); + }); + it.each(["absent-origin", "changed-terminal-input", "changed-before-execution", "changed-to-file"])("rejects empty-read semantic correlation with %s", scenario => { + const h = harness(); + const bound = appendSemanticToolReceipt({ tool: "get_task_context", callId: "context", arguments: {} }, + { content: [{ type: "text", text: "PRIVATE CONTEXT" }] }); + h.projector.tool(tool("native-context", scenario === "absent-origin" ? undefined : {}, "read")); + if (scenario === "changed-before-execution") h.projector.tool({ ...tool("native-context", { changed: true }, "read"), tag: "tool_call_update" }); + if (scenario === "changed-to-file") h.projector.tool({ ...tool("native-context", { path: "instructions.md" }, "read"), tag: "tool_call_update" }); + h.projector.captureSemanticReceipt()!(bound.receipt); + h.projector.tool({ ...terminal("native-context", bound.result.content), + ...(scenario === "changed-terminal-input" ? { rawInput: { changed: true } } : {}) }); + expect(h.events.map(details)).toContainEqual(expect.objectContaining({ stage: "evidence_incomplete" })); + expect(h.events.map(details).filter(d => d.stage === "tool").every(d => d.semanticOperationId === undefined)).toBe(true); + expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + }); it.each(["paperclip_finish", "report_progress", "get_task_context"])("correlates %s with trusted callback, not native display name", operation => { const h = harness(); const bound = make(operation); h.projector.tool({ ...tool("native", args, "other"), title: "unrelated display" }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index 5d33731d5cf..1f960103267 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -13,6 +13,10 @@ const record = (v: unknown): Record => v !== null && typeof v = const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v); const digest = (v: string) => createHash("sha256").update(v).digest("hex"); +function boundedSemanticInputDigest(value: unknown): string | undefined { + try { if (Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024) return semanticInputSha256(value); } + catch { /* Malformed input cannot correlate. */ } +} /** * Observation only, bound to one active ACP prompt iterator and its permission @@ -104,14 +108,19 @@ export function createCopilotToolEvidence(binding: { let publishPendingRead = false; if (state.kind === "read") { state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + // Copilot classifies empty-argument semantic context calls as reads. + // Explicit {} is a complete input, so publish its real pending origin + // before execution/receipt. Only absent arguments may stream later; + // neither shape acquires file-path evidence without an actual path. + if (state.read.pendingOriginInput && call.tag === "tool_call" && call.rawInput !== null + && typeof call.rawInput === "object" && !Array.isArray(call.rawInput) && Object.keys(call.rawInput).length === 0) state.read = {}; if (state.read.pendingOriginInput && call.tag === "tool_call") state.pendingReadNotice = true; if (state.pendingReadNotice && state.read.pendingOriginInput) return; if (state.pendingReadNotice) { state.pendingReadNotice = false; publishPendingRead = true; } } if (call.rawInput !== undefined) { // Retain only a bounded digest of the native arguments, never their text. - let inputDigest: string | undefined; - try { if (Buffer.byteLength(JSON.stringify(call.rawInput)) <= 1024 * 1024) inputDigest = semanticInputSha256(call.rawInput); } catch { /* Malformed input cannot correlate. */ } + const inputDigest = boundedSemanticInputDigest(call.rawInput); if (state.semanticInput && inputDigest !== state.semanticInput) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } state.semanticInput = inputDigest; const fields = inputFields({ ...call, kind: state.kind }); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 7e479b476bb..bbf578b2787 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,13 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 24, + "agentProfileVersion": 47, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:87e275270e50b42290d10ecec9dfce86ffca2b7a7519cb60c584004adfc86437", - "qualificationStatus": "pending", + "commandDigest": "sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index c8f7d79765a..c091c2e9479 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1481,7 +1481,7 @@ function commandLease( privateSnapshot: AcpxPrivateSnapshot | null, ): VerifiedAcpxCommandLease { let consumed = false; - let directoriesReleased = false; + let directoriesRelease: Promise | undefined; let spawnedChild: ChildProcess | null = null; let childExit: Promise | null = null; let snapshotCleanup: Promise | null = null; @@ -1493,24 +1493,23 @@ function commandLease( } return snapshotCleanup; }; - const releaseDirectories = async (): Promise => { - if (directoriesReleased) return; - directoriesReleased = true; - await Promise.all([ + const releaseDirectories = (): Promise => { + return (directoriesRelease ??= Promise.all([ commandDirectory.close(), ...dependencyAncestors.map((handle) => handle.close()), ...(providerRuntimeExecutable === null ? [] : [providerRuntimeExecutable.close()]), - ]); + ]).then(() => undefined)); }; const releaseDirectoriesBestEffort = (): void => { void releaseDirectories().catch(() => undefined); }; const close = async (): Promise => { - if (consumed && privateSnapshot === null) return; + const wasConsumed = consumed; consumed = true; - verifiedBytes.fill(0); + // A spawned command owns its source buffer until its pipe write completes. + if (!wasConsumed) verifiedBytes.fill(0); await releaseDirectories(); // A spawned provider may still read the snapshot. Runtime shutdown runs in // parallel and retires that child; do not remove its bytes or report command @@ -1605,7 +1604,7 @@ function commandLease( environment[VERIFIED_PROVIDER_RUNTIME_TARGET_ENV] = providerRuntimeEnvironmentVariable; } - child = spawnChildProcess( + child = spawnedChild = spawnChildProcess( runtimeHandoff.executable, guarded ? [ diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts index 702f523549a..438a3db12e3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts @@ -10,6 +10,9 @@ import type { ChildProcess } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, verifyNativeAcpxInstallation } from "./installation-integrity.js"; import { createNativeAcpxDistributionSnapshot, readNativeAcpxDistributionEntries, parseNativeAcpxDistributionEntries, type NativeAcpxDistributionInput, type NativeAcpxDistributionEntry } from "./native-distribution-integrity.js"; +import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; +import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); @@ -322,6 +325,36 @@ describe("native ACPX execution closure", () => { expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/); expect(first.text).not.toBe(second.text); }); + it("reports failed deletion and retries retirement without relaunching", async () => { + const lease = await (await verifyNativeAcpxInstallation(await fixture())).openCommand(); + const originalRemove = vi.mocked(rm).getMockImplementation()!; + const deleting = gate(); const holdDeletion = gate(); + let privateRoot: string | undefined; + let failed = false; + vi.mocked(rm).mockImplementation(async (path, options) => { + if (String(path).includes("paperclip-acpx-native-") && !failed) { + privateRoot = String(path); deleting.release(); await holdDeletion.promise; + failed = true; throw new Error("snapshot deletion failed"); + } + return await originalRemove(path, options); + }); + try { + expect((await output(lease.spawn())).code).toBe(0); + await deleting.promise; + const rejected = expect(lease.close()).rejects.toThrow("snapshot deletion failed"); + await new Promise(resolve => setImmediate(resolve)); + holdDeletion.release(); + await rejected; + await expect(stat(privateRoot!)).resolves.toBeDefined(); + expect(() => lease.spawn()).toThrow("closed"); + await lease.close(); + await expect(stat(privateRoot!)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + holdDeletion.release(); + vi.mocked(rm).mockImplementation(originalRemove); + await lease.close(); + } + }); // Copy/hash the real Node closure and start its bootstrap + owned shim. These // operations share the adjacent native-closure test's bounded allowance; // Vitest's 5s default is not a provider startup or permission deadline. @@ -617,8 +650,114 @@ describe("native ACPX execution closure", () => { child.kill(); await Promise.all(fences.map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))); } }, 15_000); + + it("proves native child death before any caller cleanup, despite the launcher's pending guardian read", async () => { + const declaration = await fixture({ node: true, script: 'console.log(process.pid);setInterval(()=>{},1000);' }); + const fences = await Promise.all([listen(), listen()]); + const fds = fences.map(server => (server as Server & { _handle?: { fd?: number } })._handle!.fd!); + const child = (await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn([], {}, { + credentialFenceFds: [fds[0]!, fds[1]!], activateCredentialFenceOwner: async () => undefined, + }); + const exited = once(child, "exit"); + let deadline: ReturnType | undefined; + try { + const ready = once(child.stdout!, "data"); + await awaitVerifiedAcpxProviderOwnership(child); + const [chunk] = await ready; + const nativePid = Number(String(chunk).trim()); + expect(Number.isSafeInteger(nativePid) && nativePid > 0 && nativePid !== child.pid).toBe(true); + const proof = awaitVerifiedAcpxProviderExit(child); + process.kill(nativePid, "SIGKILL"); + // No guardian.kill/close is allowed to manufacture the lifetime proof. + await Promise.race([proof, new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Native exit proof remained open after child death")), 3_000); + })]); + await exited; + expect(() => process.kill(nativePid, 0)).toThrow(); + } finally { + clearTimeout(deadline); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await exited; + await Promise.all(fences.map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))); + } + }, 15_000); + + it("expires a real ACPX permission on native child death without replaying its mutation", async () => { + // This is a deterministic ACP peer in a verified native distribution. No + // provider credential, remote service or model is involved. + const declaration = await fixture({ node: true, script: blockingCopilotPeer }); + const fences = await Promise.all([listen(), listen()]); + const fds = fences.map(server => (server as Server & { _handle?: { fd?: number } })._handle!.fd!); + const installation = await verifyNativeAcpxInstallation(declaration); + const owner = createAcpxCommandLeaseOwner(await installation.openCommand(), installation.openCommand); + const pidFile = join(declaration.distributionRoot, "native-pid"); + let port: Awaited> | undefined; + let deadline: ReturnType | undefined; + try { + port = await openCodexAcpxRuntime({ + command: owner.command, refreshConsumedCommand: owner.refreshConsumedCommand, + profile: resolveQualifiedAcpxProfile("copilot", "gpt-5.6-luna"), + cwd: declaration.distributionRoot, stateDirectory: join(declaration.distributionRoot, "state"), + providerSessionKey: "native-death-fixture", permissionMode: "approve-reads", + permissionPolicy: { autoApprove: ["read"], escalate: ["write"], defaultAction: "escalate" }, + launchEnvironment: { PATH: "/usr/bin:/bin", NATIVE_TEST_PID_FILE: pidFile }, + credentialFenceFds: [fds[0]!, fds[1]!], activateCredentialFenceOwner: async () => undefined, + systemInstructions: "Read task context before editing.", mcpServers: [], retainFailedAdmissionCleanup: () => undefined, + }); + let pending!: () => void; + const requested = new Promise(resolve => { pending = resolve; }); + let callbackSignal!: AbortSignal; + let lateAnswer!: (value: { outcome: "allow_once" }) => void; + const handler = vi.fn((_request, context) => { + callbackSignal = context.signal; pending(); + return new Promise<{ outcome: "allow_once" }>(resolve => { lateAnswer = resolve; }); + }); + const turn = port.startTurn({ text: "Attempt one edit", requestId: "native-death-turn", onPermissionRequest: handler }); + const settled = expect(turn.result).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + const draining = expect((async () => { for await (const _event of turn.events) { /* drain */ } })()) + .rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" }); + const bounded = new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Real native ACPX death remained unsettled")), 5_000); + }); + await Promise.race([requested, bounded]); + const nativePid = Number(await readFile(pidFile, "utf8")); + expect(Number.isSafeInteger(nativePid) && nativePid > 0).toBe(true); + process.kill(nativePid, "SIGKILL"); + await Promise.race([Promise.all([settled, draining]), bounded]); + expect(callbackSignal.aborted).toBe(true); + lateAnswer({ outcome: "allow_once" }); + expect(() => port!.startTurn({ text: "Replay", requestId: "replay" })).toThrow("provider exited"); + expect(handler).toHaveBeenCalledOnce(); + expect(() => process.kill(nativePid, 0)).toThrow(); + } finally { + clearTimeout(deadline); + await port?.close({ reason: "offline native-death fixture complete" }); + await owner.command.close(); + await Promise.all(fences.map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))); + } + }, 15_000); }); +const blockingCopilotPeer = String.raw` +const send=m=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...m})+'\n'); +require('node:fs').writeFileSync(process.env.NATIVE_TEST_PID_FILE,String(process.pid)); +const mode='https://agentclientprotocol.com/protocol/session-modes#agent'; +const config=[{id:'mode',name:'Mode',type:'select',currentValue:mode,options:[{value:mode,name:'Agent'}]}, + {id:'allow_all',name:'Allow all',type:'select',currentValue:'off',options:[{value:'off',name:'Off'}]}, + {id:'model',name:'Model',type:'select',currentValue:'gpt-5.6-luna',options:[{value:'gpt-5.6-luna',name:'Luna'}]}]; +const snapshot={sessionId:'copilot-death-session',modes:{currentModeId:mode,availableModes:[{id:mode,name:'Agent'}]},configOptions:config, + models:{currentModelId:'gpt-5.6-luna',availableModels:[{modelId:'gpt-5.6-luna',name:'Luna'}]}}; +require('node:readline').createInterface({input:process.stdin}).on('line',line=>{ + const m=JSON.parse(line); + if(m.method==='initialize')send({id:m.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}}); + else if(['session/new','session/load'].includes(m.method))send({id:m.id,result:snapshot}); + else if(m.method==='session/set_model'||m.method==='session/close')send({id:m.id,result:{}}); + else if(m.method==='session/set_config_option')send({id:m.id,result:{configOptions:config}}); + else if(m.method==='session/prompt')send({id:'pending-edit',method:'session/request_permission',params:{sessionId:'copilot-death-session', + toolCall:{toolCallId:'native-edit',title:'Write file',kind:'edit',status:'pending',rawInput:{path:'target.txt',content:'FORBIDDEN'}}, + options:[{optionId:'allow',name:'Allow',kind:'allow_once'},{optionId:'deny',name:'Deny',kind:'reject_once'}]}}); +});`; + async function listen(): Promise { const server = createServer(); server.listen(0, "127.0.0.1"); await once(server, "listening"); return server; } diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts index 83f5b334da3..88f44996f74 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts @@ -258,7 +258,12 @@ function nativeBootstrap(executable: string, executableDigest: string, args: str `if(require("node:crypto").createHash("sha256").update(fs.readFileSync(fd)).digest("hex")!==${JSON.stringify(executableDigest)})throw new Error("Native ACPX executable changed before spawn");`, 'const stdio=guarded?[0,1,2,5,6,7,8]:[0,1,2]; const childExecutable=process.platform==="linux"?"/proc/self/fd/"+stdio.length:executable;stdio.push(fd);', `const child=spawn(childExecutable,${JSON.stringify(args)},{env,cwd:process.cwd(),shell:false,detached:false,stdio});fs.closeSync(fd);`, - 'child.once("error",()=>process.exit(1)); child.once("exit",(code,signal)=>process.exit(signal?1:code??1));', + // A guarded Node launcher has a pending filesystem read on its guardian + // pipe. process.exit can wait for that read while the guardian waits for + // this launcher to exit. Retire the live owned group through the kernel; + // no saved PID/group identity or provider JavaScript cleanup is required. + 'const retire=code=>{if(guarded){try{process.kill(0,"SIGKILL");}catch{process.kill(process.pid,"SIGKILL");}}else process.exit(code);};', + 'child.once("error",()=>retire(1)); child.once("exit",(code,signal)=>retire(signal?1:code??1));', 'for(const signal of ["SIGTERM","SIGINT","SIGHUP"])process.on(signal,()=>child.kill(signal));', ].join("\n"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts b/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts index 294e392f0a3..58f956db47e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts @@ -28,7 +28,7 @@ export interface AcpxActivityAdapter { const adapters: Readonly> = { cursor: cursorActivityAdapter, - copilot: { createToolEvidence: createCopilotToolEvidence }, + copilot: Object.freeze({ createToolEvidence: createCopilotToolEvidence }), }; const noActivity: AcpxActivityAdapter = Object.freeze({}); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index b2f3ad7066e..cd739f08e9e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27; +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; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], + grok: [1, 2, 3, 4, 5], 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], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index 0849c3fb79c..c2d7f11ace0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -1,9 +1,9 @@ +import { assertCopilotCredentials, classifyCopilotFailure } from "./copilot-profile.js"; +import { verifyCopilotInstallation } from "./copilot-installation.js"; import { verifyCursorInstallation } from "./cursor-installation.js"; import { assertCursorWorkspacePolicy } from "./cursor-launch-policy.js"; import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent, type QualifiedAcpxProfile } from "./qualified-profiles.js"; import { verifyPiInstallation } from "./pi-installation.js"; -import { assertCopilotCredentials, classifyCopilotFailure } from "./copilot-profile.js"; -import { verifyCopilotInstallation } from "./copilot-installation.js"; import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; /** Closed build-owned registry. Provider branches add their pinned installations here. */ diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts index a083a412646..b08d79b55d0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts @@ -31,6 +31,18 @@ it("resolves descriptor sidecars only through the runner-bound canonical package expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("normalized absolute"); }); +it("uses the public server owner for its vendored Copilot probe and selected sidecar", async () => { + const path = await serverFixture(); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + const moduleUrl = pathToFileURL(join(path, "dist/vendor/paperclip-runner/drivers/acpx/copilot-installation.js")).href; + expect(resolveRunnerProviderAssetsRoot(moduleUrl, "copilot")).toBe(join(path, "provider-assets/copilot")); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toBe(join(path, "provider-assets/copilot")); +}); + it("resolves the public server bundle without repository paths or candidate overrides", async () => { const path = await root(); const vendored = join(await realpath(path), "dist/vendor/paperclip-runner"); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts index 2eed816169e..5f4929963b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -44,7 +44,7 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat // runnerd derives this binding from the verified sidecar in the public // server package. Images may carry assets alongside that bundle; local // explicit setup uses the OS-account cache when package assets are absent. - packageRoot = join(dirname(canonicalManifest), "dist/vendor/paperclip-runner"); + packageRoot = provider === "copilot" ? dirname(canonicalManifest) : join(dirname(canonicalManifest), "dist/vendor/paperclip-runner"); if (realpathSync(packageRoot) !== packageRoot) throw new Error("Vendored runner directory is not contained by its server package"); } else if (value?.name === RUNNER_PACKAGE_NAME) { packageRoot = dirname(canonicalManifest); @@ -60,7 +60,7 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat const metadata = readPackageManifest(manifest, manifest); if (metadata.name !== SERVER_PACKAGE_NAME) throw new Error("Runner server vendor package identity is invalid"); assertServerVendorLayout(packageRoot); - packageRoot = join(packageRoot, "dist/vendor/paperclip-runner"); + if (provider !== "copilot") packageRoot = join(packageRoot, "dist/vendor/paperclip-runner"); } else if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url)); else if (/\/dist\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); else if (new RegExp(`/dist/vendor/paperclip-runner/drivers/acpx/${provider}-installation\\.(?:js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 9702f120b80..1a3012ff463 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -43,6 +43,7 @@ export function resolveQualifiedAcpxProfile( ): QualifiedAcpxProfile { const profile = QUALIFIED_ACPX_PROFILES[agent]; if (!requestedModel.trim()) throw new Error("ACPX model must not be empty"); + if (agent === "copilot" && ["auto", "default"].includes(requestedModel.trim().toLowerCase())) throw new Error("Copilot requires an explicit model ID"); return { ...structuredClone(profile), qualificationModel: requestedModel, reportedModelId: requestedModel }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 20bc11c713a..193a126d9ea 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1475,7 +1475,7 @@ describe("ACPX runtime host", () => { ).rejects.toThrow("initialization and cleanup failed"); expect(runtime.close).toHaveBeenCalledOnce(); expect(credential.close).not.toHaveBeenCalled(); - expect(fixture.commandClose).toHaveBeenCalledOnce(); + expect(fixture.commandClose).not.toHaveBeenCalled(); expect(retainedAdmissionCleanup).not.toBeNull(); let cleanupSettled = false; void retainedAdmissionCleanup!.finally(() => { @@ -1488,6 +1488,7 @@ describe("ACPX runtime host", () => { await retainedAdmissionCleanup; expect(cleanupSettled).toBe(true); expect(credential.close).toHaveBeenCalledOnce(); + expect(fixture.commandClose).toHaveBeenCalledOnce(); }); it("retains credential ownership when runtime shutdown fails until retry succeeds", async () => { @@ -1565,7 +1566,7 @@ describe("ACPX runtime host", () => { const first = host.close({ reason: "runtime close pending" }); await waitForAcpxOperation(() => expect(runtime.close).toHaveBeenCalledOnce()); - await waitForAcpxOperation(() => expect(fixture.commandClose).toHaveBeenCalledOnce()); + expect(fixture.commandClose).not.toHaveBeenCalled(); const second = host.close({ reason: "same exact close" }); await expect(readFile(authPath, "utf8")).resolves.toBe("{}"); await expect( @@ -1583,6 +1584,7 @@ describe("ACPX runtime host", () => { undefined, undefined, ]); + expect(fixture.commandClose).toHaveBeenCalledOnce(); await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }); const contender = await waitForAcpxOperation(() => stageManagedCodexCredential({ @@ -1595,9 +1597,10 @@ describe("ACPX runtime host", () => { await contender.close(); }, ACPX_LONG_WAIT_TEST_TIMEOUT_MS); - it("retains the exact pending cleanup while independent resources close", async () => { + it("retains the exact pending cleanup and command snapshot until runtime retirement", async () => { const fixture = await hostFixture(); - const firstClose = new Promise(() => undefined); + let resolveRuntimeClose!: () => void; + const firstClose = new Promise(resolve => { resolveRuntimeClose = resolve; }); const runtime = runtimePort({ onClose: vi .fn() @@ -1625,6 +1628,9 @@ describe("ACPX runtime host", () => { await Promise.resolve(); expect(settled).toBe(false); expect(runtime.close).toHaveBeenCalledOnce(); + expect(fixture.commandClose).not.toHaveBeenCalled(); + resolveRuntimeClose(); + await Promise.all([first, second]); expect(fixture.commandClose).toHaveBeenCalledOnce(); }, ACPX_LONG_WAIT_TEST_TIMEOUT_MS); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index af13be285f4..43217962a74 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import { composeCopilotSystemInstructions } from "./copilot-profile.js"; import { resolveClaudeEffort } from "./claude-effort.js"; import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; @@ -529,7 +530,10 @@ export class AcpxRuntimeHost { // Do not race this write against cancellation: retain the lifetime lease // until the atomic refresh settles, then honor an intervening abort. options.signal?.throwIfAborted(); - await refreshCopilotSystemInstructions(sandbox, boundedInstructions(options.systemInstructions)); + await refreshCopilotSystemInstructions( + sandbox, + composeCopilotSystemInstructions(boundedInstructions(options.systemInstructions)), + ); options.signal?.throwIfAborted(); } if (options.agent === "pi") { @@ -1277,11 +1281,9 @@ async function cleanupRuntimeResources( return error; } }; - // The command lease owns only the already-consumed verified launch - // snapshot, so it can be released as shutdown starts. The credential is - // different: a provider whose exact runtime close is pending or failed may - // still read or rewrite its home. Retain both the staged bytes and the - // exclusive home lease until that exact close succeeds. + // The runtime must retire before its command snapshot can be removed. + // Keep both the snapshot and credential while the provider is still alive; + // failed cleanup remains owned and can be retried after process retirement. const runtimeOutcome = runtime ? settle(() => runtime.close({ reason })) : Promise.resolve(null); @@ -1291,9 +1293,10 @@ async function cleanupRuntimeResources( await runtimeOutcome; return toolBridge === null ? null : await settle(() => toolBridge.close()); })(); - const commandOutcome = command - ? settle(() => command.close()) - : Promise.resolve(null); + const commandOutcome = (async (): Promise => { + await runtimeOutcome; + return command === null ? null : await settle(() => command.close()); + })(); const credentialOutcome = (async (): Promise => { const runtimeError = await runtimeOutcome; if (runtimeError !== null || credential === null) return null; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index 3b32e306121..7640f681d04 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -1,3 +1,4 @@ +import { composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { configuredEnvironmentProjection } from "../../configured-environment.js"; import { chmod, @@ -475,15 +476,16 @@ it("refreshes Copilot native instructions privately on every admission, includin const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions }, }); - await refreshCopilotSystemInstructions(sandbox, systemInstructions); + await refreshCopilotSystemInstructions(sandbox, composeCopilotSystemInstructions(systemInstructions)); return sandbox; }; + const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; const first = await prepare("Original registered instructions."); const path = join(first.agentHomeDirectory, "copilot-instructions.md"); - expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions: "Contender must not write during preparation." } }); - expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); expect((await stat(path)).mode & 0o777).toBe(0o600); expect(first.protectedPaths).toContain(dirname(dirname(first.root))); expect(path.startsWith(`${first.protectedPaths[0]}/`)).toBe(true); @@ -494,11 +496,11 @@ it("refreshes Copilot native instructions privately on every admission, includin await prepare("Current registered instructions."); expect((await lstat(path)).isSymbolicLink()).toBe(false); expect(await readFile(target, "utf8")).toBe("Do not overwrite."); - expect(await readFile(path, "utf8")).toBe("Current registered instructions.\n"); + expect(await readFile(path, "utf8")).toBe(composed("Current registered instructions.")); await prepare(""); - expect(await readFile(path, "utf8")).toBe("\n"); + expect(await readFile(path, "utf8")).toBe(composed("")); await expect(prepare("x".repeat(32 * 1024 + 1))).rejects.toThrow("bounded size"); - expect(await readFile(path, "utf8")).toBe("\n"); + expect(await readFile(path, "utf8")).toBe(composed("")); }); async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") { diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts index 8bd93e1c3cf..109889dd736 100644 --- a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts @@ -65,7 +65,7 @@ for (const [provider, create] of [["cursor", createCursorToolEvidence], ["copilo s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "other", kind: "read", status: "completed", rawInput: { path } }); expect(s.rows).toHaveLength(1); expect(s.rows[0].readTargetSha256).toBeUndefined(); }); - it("publishes the pending origin after its delayed complete input, retaining exact proof on every notice", () => { + it.runIf(provider === "cursor")("publishes the pending origin after its delayed complete input, retaining exact proof on every notice", () => { const s = setup(); s.projector.tool({ ...origin, rawInput: {}, locations: undefined }); expect(s.rows).toEqual([]); diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 32243ffaf7a..886e86c7a4e 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -1,5 +1,6 @@ import { configuredEnvironment } from "../../configured-environment.js"; import type { NativeTurnControlCapabilities } from "../../contracts/types.js"; +import type { NativeRunRuntimeGrant } from "../../contracts/runtime-context.js"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; import { githubCredentialEnvironment } from "../../github-credential-environment.js"; @@ -68,6 +69,7 @@ export interface CodexAppServerTransport { runId: string; turnId: string; itemId: string; + currentRunGrant?: NativeRunRuntimeGrant; }): Promise; /** Records post-rehydration driver mapping without affecting run authority. */ recordTraceInterpretation?(input: CodexTraceInterpretation): void; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.lifecycle.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.lifecycle.test.ts index c5ade4c19c2..7810e82bb4a 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.lifecycle.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.lifecycle.test.ts @@ -128,9 +128,10 @@ describe("Codex app-server Codex driver", () => { message: { role: "user", text: "first turn" }, }); await expect( - session.attachRun?.({ runId: "run-warm-second" }), + session.attachRun?.({ runId: "run-warm-second", currentRunGrant: { runtimeContext: null, instructions: "Current registered instructions." } }), ).resolves.toBeUndefined(); expect(transport.attachments).toHaveLength(1); + expect(transport.attachments[0]).toMatchObject({ currentRunGrant: { runtimeContext: null, instructions: "Current registered instructions." } }); expect((await session.snapshot()).activeTurnId).toBeNull(); await expect( diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts index f7a5dba8bd1..ada36dcf876 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts @@ -42,6 +42,95 @@ import { type PrpEvent, type PrpStructuredRunResult, } from "./codex-app-server-driver.test-support.js"; +import { runnerdRuntimeRequestExpiryNotification } from "../../live/runnerd-codex-transport.js"; + +it("preserves runnerd permission expiry before a failed provider terminal and refuses late approval", async () => { + const transport = new FakeCodexTransport(); + const resolve = vi.fn(async () => {}); + transport.runtimeRequestResolver = resolve; + const session = await makeDriver([transport]).openSession({ + runId: "run-native-expiry", normalizedSessionId: "session-native-expiry", workingDirectory: WORKSPACE, + }); + const events: PrpEvent[] = []; + const consume = (async () => { for await (const event of session.events()) events.push(event); })(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Leave the native edit unanswered." } }); + const pending = transport.invoke({ id: "native-edit-permission", method: "session/request_permission", params: { + threadId: "thread-1", turnId, itemId: "native-edit", reason: "Write file", + choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }], + origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" }, + } }); + await vi.waitFor(() => expect(session.pendingRuntimeRequests?.()).toHaveLength(1)); + // Rust's projector supplies durable identities; the facade binds its active provider turn. + const payload = { provider: "acpx", requestId: "native-edit-permission", requestKind: "permission_approval", + requestType: "permission", turnId: "durable-controller-turn", itemId: "durable-item", + reason: "provider_process_lost", replayAllowed: false, adapter: "acpx-runtime-sidecar" }; + for (const kind of ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.created"]) + expect(runnerdRuntimeRequestExpiryNotification(kind, payload, "thread-1", turnId)).toBeNull(); + const expiry = runnerdRuntimeRequestExpiryNotification("runtime_request.expired", payload, "thread-1", turnId)!; + transport.push(expiry.method, expiry.params); + transport.push(expiry.method, expiry.params); + await pending; + expect(session.pendingRuntimeRequests?.()).toEqual([]); + await expect(session.resolveRuntimeRequest?.({ requestId: "native-edit-permission", turnId, resolution: { action: "accept" } })) + .rejects.toThrow(); + expect(resolve).not.toHaveBeenCalled(); + transport.push("turn/completed", { threadId: "thread-1", turn: { id: turnId, status: "failed", error: { code: "ACPX_PROVIDER_PROCESS_LOST", message: "Provider died" } } }); + await vi.waitFor(() => expect(events.some(event => event.eventType === "turn.failed")).toBe(true)); + await session.close(); await consume; + const settlements = events.filter(event => ["runtime_request.expired", "runtime_request.cancelled", "runtime_request.resolved"].includes(event.eventType)); + expect(settlements).toHaveLength(1); + expect(settlements[0]).toMatchObject({ eventType: "runtime_request.expired", turnId, itemId: "native-edit", payload: { ...payload, turnId, itemId: "native-edit" } }); + expect(events.indexOf(settlements[0]!)).toBeLessThan(events.findIndex(event => event.eventType === "turn.failed")); + expect(validatePrpEvent(settlements[0]!)).toMatchObject({ ok: true }); +}); + +it("preserves canonical question expiry after provider loss and refuses late answers", async () => { + const transport = new FakeCodexTransport(); + const session = await makeDriver([transport]).openSession({ runId: "question-expiry", normalizedSessionId: "question-expiry", workingDirectory: WORKSPACE }); + const events: PrpEvent[] = []; + const consume = (async () => { for await (const event of session.events()) events.push(event); })(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Ask before changing files." } }); + const pending = transport.invoke({ id: "native-question", method: "item/tool/requestUserInput", params: { + threadId: "thread-1", turnId, itemId: "question-item", questions: [{ id: "environment", header: "Environment", question: "Where should we deploy?", options: [{ label: "Staging" }, { label: "Production" }] }], + } }); + await vi.waitFor(() => expect(session.pendingRuntimeRequests?.()).toHaveLength(1)); + const request = session.pendingRuntimeRequests!()[0]!; + expect(request.requestKind).toBe("user_input"); + const payload = { requestId: "native-question", requestKind: "runtime", requestType: "input", replayAllowed: false, reason: "provider_process_lost" }; + const expiry = runnerdRuntimeRequestExpiryNotification("runtime_request.expired", payload, "thread-1", turnId)!; + transport.push(expiry.method, expiry.params); + transport.push(expiry.method, expiry.params); + await pending; + expect(session.pendingRuntimeRequests?.()).toEqual([]); + await expect(session.resolveRuntimeRequest?.({ requestId: "native-question", turnId, resolution: { action: "accept" } })).rejects.toThrow(); + transport.push("turn/completed", { threadId: "thread-1", turn: { id: turnId, status: "failed", error: { code: "ACPX_PROVIDER_PROCESS_LOST", message: "Provider died" } } }); + await vi.waitFor(() => expect(events.some(event => event.eventType === "turn.failed" || event.eventType === "session.failed")).toBe(true)); + await session.close(); await consume; + expect(events.filter(event => event.eventType === "runtime_request.expired")).toHaveLength(1); + expect(events.find(event => event.eventType === "runtime_request.expired")).toMatchObject({ turnId, itemId: "question-item", payload }); + expect(events.some(event => event.eventType === "session.failed" && event.payload.code === "turn_binding_mismatch")).toBe(false); + expect(events.some(event => event.eventType === "runtime_request.cancelled" || event.eventType === "runtime_request.resolved")).toBe(false); +}); + +it.each(["turn", "kind", "type", "replay"])("refuses canonical permission expiry with a changed %s binding", async kind => { + const transport = new FakeCodexTransport(); + const session = await makeDriver([transport]).openSession({ runId: "expiry-bound", normalizedSessionId: "expiry-bound", workingDirectory: WORKSPACE }); + const events: PrpEvent[] = []; + const consume = (async () => { for await (const event of session.events()) events.push(event); })(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Wait for permission" } }); + const pending = transport.invoke({ id: "bound-permission", method: "session/request_permission", params: { + threadId: "thread-1", turnId, itemId: "edit", reason: "Edit", choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }], + origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" }, + } }); + await vi.waitFor(() => expect(session.pendingRuntimeRequests?.()).toHaveLength(1)); + const payload = { requestId: "bound-permission", requestKind: kind === "kind" ? "runtime" : "permission_approval", + requestType: kind === "type" ? "input" : "permission", replayAllowed: kind === "replay", reason: "provider_process_lost" }; + const notification = runnerdRuntimeRequestExpiryNotification("runtime_request.expired", payload, "thread-1", kind === "turn" ? "foreign-turn" : turnId)!; + transport.push(notification.method, notification.params); + await pending; await consume; await session.close(); + expect(events.some(e => e.eventType === "runtime_request.expired")).toBe(false); + expect(events.some(e => e.eventType === "session.failed" && e.payload.code === "turn_binding_mismatch")).toBe(true); +}); describe("Codex app-server Codex driver", () => { it("keeps a prompted MCP tool answerable after rejecting a session-wide decision", async () => { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 47270fe5bde..c4ea2a7bbad 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -23,6 +23,7 @@ import { parseHarnessRuntimeRequestResolution, } from "../../contracts/harness-driver.js"; import type { NativeUserMessage } from "../../contracts/types.js"; +import type { NativeRunRuntimeGrant } from "../../contracts/runtime-context.js"; import { CODEX_RESULT_OUTPUT_SCHEMA, type CodexModelContextSnapshot, @@ -101,7 +102,7 @@ export class CodexHarnessSession }; } - async attachRun(input: { runId: string }): Promise { + async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant }): Promise { this.assertProtocolIntegrity(); const transportOwnsQuiescence = this.transport.attachRun !== undefined; if ( @@ -116,6 +117,7 @@ export class CodexHarnessSession runId: input.runId, turnId: `turn_attachment_${randomUUID().replaceAll("-", "")}`, itemId: `item_attachment_${randomUUID().replaceAll("-", "")}`, + ...(input.currentRunGrant ? { currentRunGrant: structuredClone(input.currentRunGrant) } : {}), }); this.assertProtocolIntegrity(); if (transportOwnsQuiescence) { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-notification-identity.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-notification-identity.test.ts index 8d2ef99071e..1afb17aec24 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-notification-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-notification-identity.test.ts @@ -18,6 +18,10 @@ describe("provider notification authority", () => { ["paperclip/canonicalProviderEvent", { threadId: "child", eventType: "auth.credentials.updated" }, "invalid_authority"], ["paperclip/canonicalProviderEvent", { threadId: null, eventType: "tool.execution.started" }, "invalid_authority"], ["item/started", { threadId: "root", turnId: "active" }, "root"], + ["paperclip/runtimeRequestExpired", { threadId: "root", turnId: "active" }, "root"], + ["paperclip/runtimeRequestExpired", { threadId: "child", turnId: "active" }, "invalid_authority"], + ["paperclip/runtimeRequestExpired", { threadId: null, turnId: "active" }, "invalid_authority"], + ["paperclip/runtimeRequestExpired", { threadId: "root", turnId: "old" }, "stale_turn"], ["turn/completed", { threadId: "root", turnId: "old" }, "stale_turn"], [ "turn/completed", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts index ef7a4f44b0b..4e995d1ff1d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -8,7 +8,7 @@ import { isPrpAuthCredentialsUpdated, validatePrpStructuredRunResult } from "../ import type { CodexRpcNotification, CodexTraceInterpretation } from "./app-server-transport.js"; import { redactCodexDiagnostic } from "./app-server-transport.js"; import { boundedCodexPayload as boundedPayload, boundedCodexValue, isRetainableCodexPayload } from "./codex-boundaries.js"; -import { runtimeRequestResponse } from "./codex-question-adapter.js"; +import { runtimeRequestProtocolPayload, runtimeRequestResponse } from "./codex-question-adapter.js"; import { isSupportedCodexNotificationMethod, codexThreadLineage as lineageFromThread, @@ -150,6 +150,27 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex const threadId = text(params.threadId); const turnId = text(params.turnId, text(turn.id)); const itemId = text(item.id, text(params.itemId)); + if (notification.method === "paperclip/runtimeRequestExpired") { + const payload = record(params.payload); + const requestId = text(payload.requestId); + const pending = state.pendingRuntimeRequestMap.get(requestId); + if (pending === undefined) return; + const expected = runtimeRequestProtocolPayload(pending.request); + if (pending.request.turnId !== turnId || payload.requestKind !== expected.requestKind + || payload.requestType !== (pending.request.input === undefined ? "permission" : "input") + || payload.replayAllowed !== false || typeof payload.reason !== "string" || !payload.reason) { + state.failProtocol("turn_binding_mismatch", "Canonical request expiry changed its callback binding."); + return; + } + state.pendingRuntimeRequestMap.delete(requestId); + // Preserve durable expiry evidence with the same facade identity used + // when creating this callback. Terminal handling must not cancel it again. + state.emit("runtime_request.expired", { ...payload, + turnId: pending.request.turnId, itemId: pending.request.itemId, + }, { turnId: pending.request.turnId, itemId: pending.request.itemId }); + pending.settle(safeRequestResponse(pending.request.method, "cancel")); + return; + } if (notification.method === "paperclip/canonicalProviderEvent") { if (!isCanonicalProviderEventType(params.eventType)) { state.failProtocol("provider_event_type_invalid", "Unknown canonical provider event type."); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts b/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts index 6513b2446ae..d3b218bac4e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts @@ -142,6 +142,7 @@ export function isSupportedCodexNotificationMethod(method: string): boolean { method === "model/safetyBuffering/updated" || method.startsWith("item/") || method === "paperclip/canonicalProviderEvent" || + method === "paperclip/runtimeRequestExpired" || method === "paperclip/workspaceChange/updated" || method === "paperclip/runResult" || method === "turn/diff/updated" || diff --git a/packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts new file mode 100644 index 00000000000..d1c3e332ead --- /dev/null +++ b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts @@ -0,0 +1,117 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { access, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QUALIFIED_ACPX_PROFILES } from "./acpx/qualified-profiles.js"; +const mocks = vi.hoisted(() => ({ verify: vi.fn() })); +vi.mock("./acpx/copilot-installation.js", () => ({ verifyCopilotInstallation: mocks.verify })); +import { createMetadataRpc, probeCopilotMetadata, validateCopilotMetadata } from "./copilot-metadata-probe.js"; +function fakeChild(reply: (message: any) => unknown, closeOnEnd = true) { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + child.stdout = new PassThrough(); child.stderr = new PassThrough(); + child.kill = vi.fn(() => { child.emit("close", 0); return true; }); + child.stdin = new Writable({ write(chunk, _encoding, done) { + const message = JSON.parse(chunk.toString()); + const response = reply(message); + if (response !== undefined) queueMicrotask(() => child.stdout.emit("data", Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, ...response as object })}\n`))); + done(); + }, final(done) { if (closeOnEnd) queueMicrotask(() => child.emit("close", 0)); done(); } }); + return child; +} +const verified = { status: "verified", version: "1.0.88", profileDigest: QUALIFIED_ACPX_PROFILES.copilot.commandDigest, models: [{ id: "gpt-5.6-luna", label: "GPT" }], promptSent: false }; +beforeEach(() => vi.clearAllMocks()); +describe("Copilot metadata boundary", () => { + it("sends metadata only, binds an explicit token, and removes its private home", async () => { + const methods: string[] = []; let environment: NodeJS.ProcessEnv = {}, cwd = ""; + const close = vi.fn(async () => {}); + const child = fakeChild(message => { + methods.push(message.method); + if (message.method === "initialize") return { result: { agentInfo: { version: "1.0.88" } } }; + if (message.method === "session/new") return { result: { sessionId: "session", models: { availableModels: [{ modelId: "gpt-5.6-luna", name: "GPT" }] } } }; + if (message.method === "session/set_config_option") return { result: { configOptions: [{ id: "model", currentValue: "gpt-5.6-luna" }] } }; + return { result: {} }; + }); + mocks.verify.mockResolvedValue({ commandDigest: verified.profileDigest, openCommand: async () => ({ close, spawn: (_args: unknown, options: any) => { environment = options.env; cwd = options.cwd; return child; } }) }); + expect(await probeCopilotMetadata("github_pat_private_test", "gpt-5.6-luna")).toEqual(verified); + expect(methods).toEqual(["initialize", "session/new", "session/set_model", "session/set_config_option"]); + expect(environment.COPILOT_GITHUB_TOKEN).toBe("github_pat_private_test"); + for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "NODE_OPTIONS", "SSH_AUTH_SOCK"]) expect(environment).not.toHaveProperty(key); + expect(environment.HOME).toContain(cwd); expect(close).toHaveBeenCalledOnce(); + await expect(access(cwd)).rejects.toThrow(); + }); + it("retires a stubborn native descendant before releasing metadata state", async () => { + let launcher: ChildProcessWithoutNullStreams | undefined; + let providerPid = 0; + let probe: ReturnType | undefined; + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + const provider = `const readline=require("node:readline");process.on("SIGTERM",()=>{});setInterval(()=>{},1000);process.stderr.write("provider-pid:"+process.pid+"\\n"); + readline.createInterface({input:process.stdin}).on("line",line=>{const m=JSON.parse(line);const result=m.method==="initialize"?{agentInfo:{version:"1.0.88"}}:m.method==="session/new"?{sessionId:"metadata",models:{availableModels:[{modelId:"gpt-5.6-luna",name:"GPT"}]}}:{};process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:m.id,result})+"\\n");});`; + const script = `const {spawn}=require("node:child_process");process.on("SIGTERM",()=>{});const c=spawn(process.execPath,["-e",${JSON.stringify(provider)}],{stdio:"inherit",detached:false});c.once("exit",()=>process.exit(0));`; + const close = vi.fn(async () => { expect(alive(launcher!.pid!)).toBe(false); await vi.waitFor(() => expect(alive(providerPid)).toBe(false)); }); + mocks.verify.mockResolvedValue({ commandDigest: verified.profileDigest, openCommand: async () => ({ close, spawn: (_args: unknown, options: any) => { + launcher = spawn(process.execPath, ["-e", script], { cwd: options.cwd, detached: true, stdio: "pipe" }); + launcher.stderr.on("data", value => { const match = /provider-pid:(\d+)/.exec(value.toString()); if (match) providerPid = Number(match[1]); }); + return launcher; + } }) }); + let timer: ReturnType | undefined; + try { + probe = probeCopilotMetadata("token"); + const result = await Promise.race([probe, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("metadata cleanup exceeded its bound")), 3500); })]); + expect(result).toMatchObject({ status: "verified", promptSent: false }); + expect(providerPid).toBeGreaterThan(0); expect(close).toHaveBeenCalledOnce(); + } finally { + if (timer) clearTimeout(timer); + if (launcher?.pid) { try { process.kill(-launcher.pid, "SIGKILL"); } catch {} } + if (probe) await probe.catch(() => undefined); + } + }); + it("fails metadata verification and retains private state when shutdown is unconfirmed", async () => { + let cwd = ""; + const child = fakeChild(message => ({ result: message.method === "initialize" ? { agentInfo: { version: "1.0.88" } } : { sessionId: "metadata", models: { availableModels: [{ modelId: "gpt-5.6-luna" }] } } }), false); + child.kill = vi.fn(() => false); + const close = vi.fn(async () => {}); + mocks.verify.mockResolvedValue({ commandDigest: verified.profileDigest, openCommand: async () => ({ close, spawn: (_args: unknown, options: any) => { cwd = options.cwd; return child; } }) }); + try { + expect(await probeCopilotMetadata("token")).toMatchObject({ status: "failed", code: "COPILOT_REQUEST_FAILED", promptSent: false }); + expect(close).toHaveBeenCalledOnce(); + await expect(access(cwd)).resolves.toBeUndefined(); + } finally { child.emit("close", 0); if (cwd) await rm(cwd, { recursive: true, force: true }); } + }); + it("rejects an unavailable model without sending a prompt or selecting another model", async () => { + const methods: string[] = []; + const child = fakeChild(message => { methods.push(message.method); return { result: message.method === "initialize" ? { agentInfo: { version: "1.0.88" } } : { sessionId: "session", models: { availableModels: [{ modelId: "other" }] } } }; }); + const close = vi.fn(async () => {}); + mocks.verify.mockResolvedValue({ commandDigest: verified.profileDigest, openCommand: async () => ({ close, spawn: () => child }) }); + expect(await probeCopilotMetadata("token", "missing")).toMatchObject({ status: "failed", code: "COPILOT_MODEL_UNAVAILABLE", promptSent: false }); + expect(methods).toEqual(["initialize", "session/new"]); expect(close).toHaveBeenCalledOnce(); + }); + it("preserves model labels split inside every UTF-8 character across transport chunks", async () => { + const child = fakeChild(() => undefined), rpc = createMetadataRpc(child); + const result = { models: { availableModels: [{ modelId: "gpt-5.6-luna", name: "東京 🚀 café" }] } }; + const response = rpc.request("session/new", {}); + const wire = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 0, result })}\n`); + for (const byte of wire) child.stdout.emit("data", Buffer.from([byte])); + await expect(response).resolves.toEqual(result); + }); + it("cannot dispatch semantic or prompt methods", () => { + const child = fakeChild(() => undefined), rpc = createMetadataRpc(child); + expect(() => rpc.request("session/prompt", {})).toThrow("cannot send"); + expect(() => rpc.request("tools/call", {})).toThrow("cannot send"); + }); + it("rejects outstanding calls when the provider dies", async () => { + const child = fakeChild(() => undefined), rpc = createMetadataRpc(child); + const promise = rpc.request("initialize", {}); child.emit("close", 1); + await expect(promise).rejects.toThrow("provider exited"); + }); + it("accepts only current profile metadata and sanitized failures", () => { + expect(validateCopilotMetadata(verified)).toEqual(verified); + for (const mutation of [{ promptSent: true }, { profileDigest: "stale" }, { models: [] }, { models: [{ id: "bad id", label: "x" }] }, { models: [{ id: "safe", label: "github_pat_secret" }] }, { models: [...verified.models, ...verified.models] }]) { + expect(validateCopilotMetadata({ ...verified, ...mutation })).toMatchObject({ status: "failed", code: "COPILOT_REQUEST_FAILED" }); + } + const failure = validateCopilotMetadata({ status: "failed", code: "COPILOT_AUTH_REQUIRED", message: "github_pat_secret", promptSent: false }); + expect(failure).toMatchObject({ code: "COPILOT_AUTH_REQUIRED" }); expect(JSON.stringify(failure)).not.toContain("secret"); + expect(validateCopilotMetadata({ status: "failed", code: "untrusted", promptSent: false })).toMatchObject({ code: "COPILOT_REQUEST_FAILED" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts new file mode 100644 index 00000000000..5a171f206af --- /dev/null +++ b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts @@ -0,0 +1,198 @@ +import { StringDecoder } from "node:string_decoder"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { verifyCopilotInstallation } from "./acpx/copilot-installation.js"; +import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "./acpx/qualified-profiles.js"; +import { classifyCopilotFailure, copilotConfiguration, copilotSandboxEnvironment } from "./acpx/copilot-profile.js"; +import { COPILOT_ACP_CLIENT_CAPABILITIES } from "./acpx/copilot-events.js"; + +type RecordValue = Record; +const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; +const METHODS = new Set(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/close"]); +export type CopilotMetadataResult = + | { status: "verified"; version: "1.0.88"; profileDigest: string; models: Array<{ id: string; label: string }>; promptSent: false } + | { status: "failed"; code: string; message: string; promptSent: false }; + +/** A metadata-only client: no prompt, tool server, workspace, or ambient login. */ +export async function probeCopilotMetadata(token: string, model?: string): Promise { + if (model && ["auto", "default"].includes(model.trim().toLowerCase())) return { status: "failed", code: "COPILOT_MODEL_UNAVAILABLE", message: "Select an explicit Copilot model.", promptSent: false }; + if (!token.trim() || token.includes("\0")) return { status: "failed", code: "COPILOT_AUTH_REQUIRED", message: "Enter a valid Copilot personal access token.", promptSent: false }; + let installation; + try { installation = await verifyCopilotInstallation(resolveQualifiedAcpxProfile("copilot", model ?? "metadata-discovery")); } + catch { return { status: "failed", code: "COPILOT_INSTALLATION_INVALID", message: "The selected environment does not have the verified Copilot runtime installed.", promptSent: false }; } + let root: string | undefined; + let lease: Awaited> | undefined; + let child: ChildProcessWithoutNullStreams | undefined; + let closed: Promise | undefined; + let deadline: ReturnType | undefined; + let phase = "installation"; + try { + lease = await installation.openCommand(); + root = await mkdtemp(join(tmpdir(), "paperclip-copilot-metadata-")); + const directories = { homeDirectory: join(root, "home"), configDirectory: join(root, "config"), dataDirectory: join(root, "data"), cacheDirectory: join(root, "cache"), agentHomeDirectory: join(root, "copilot") }; + for (const directory of Object.values(directories)) await mkdir(directory, { mode: 0o700 }); + await writeFile(join(directories.agentHomeDirectory, "config.json"), JSON.stringify(copilotConfiguration()), { mode: 0o600 }); + child = lease.spawn([], { cwd: root, env: { PATH: "/usr/bin:/bin", ...copilotSandboxEnvironment(directories), COPILOT_GITHUB_TOKEN: token }, stdio: "pipe" }) as ChildProcessWithoutNullStreams; + closed = new Promise(resolve => child!.once("close", () => resolve())); + const rpc = createMetadataRpc(child); + const work = async (): Promise => { + phase = "initialize"; + const initialized = record(await rpc.request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-copilot-metadata", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES })); + if (record(initialized.agentInfo).version !== "1.0.88") throw Object.assign(new Error("Verified Copilot installation changed"), { code: "COPILOT_INSTALLATION_INVALID" }); + phase = "session-new"; + const opened = record(await rpc.request("session/new", { cwd: root, mcpServers: [] })); + const advertised = record(opened.models).availableModels; + const models = (Array.isArray(advertised) ? advertised : []).slice(0, 200).flatMap(value => { + const entry = record(value), id = entry.modelId; + return typeof id === "string" && !["auto", "default"].includes(id.toLowerCase()) && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,199}$/.test(id) + ? [{ id, label: typeof entry.name === "string" && !/[\x00-\x1f\x7f]|github_pat_|gh[pousr]_/.test(entry.name) ? entry.name.slice(0, 200) : id }] : []; + }); + if (!models.length) throw new Error("Copilot model discovery unavailable"); + if (model) { + if (!models.some(value => value.id === model)) throw Object.assign(new Error("Selected Copilot model unavailable"), { code: "COPILOT_MODEL_UNAVAILABLE" }); + phase = "model-selection"; + await rpc.request("session/set_model", { sessionId: opened.sessionId, modelId: model }); + phase = "model-confirmation"; + const configured = record(await rpc.request("session/set_config_option", { sessionId: opened.sessionId, configId: "model", value: model })); + if (!Array.isArray(configured.configOptions) || !configured.configOptions.some(option => record(option).id === "model" && record(option).currentValue === model)) { + throw Object.assign(new Error("Selected Copilot model unavailable"), { code: "COPILOT_MODEL_UNAVAILABLE" }); + } + } + // EOF is the authoritative stdio shutdown; session/close is optional. + return { status: "verified", version: "1.0.88", profileDigest: installation.commandDigest, models, promptSent: false }; + }; + return await Promise.race([work(), new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new Error("Copilot metadata probe timed out")), 25_000); + })]); + } catch (error) { + const code = record(error).code; + if (code === "COPILOT_INSTALLATION_INVALID") return { status: "failed", code, message: "The selected environment has an incompatible Copilot installation.", promptSent: false }; + if (code === "COPILOT_MODEL_UNAVAILABLE") return { status: "failed", code, message: "The selected Copilot model is unavailable for this account.", promptSent: false }; + const failure = classifyCopilotFailure(error); + const cause = error instanceof Error && error.message === "Copilot metadata probe timed out" ? "deadline" + : error instanceof Error && error.message === "Copilot provider exited during metadata discovery" ? "provider-exit" : "request-rejected"; + // Only closed diagnostic categories; never log provider errors, wire data, + // credentials, model catalogs, session IDs or private workspace paths. + console.warn("Copilot metadata verification failed", JSON.stringify({ phase, code: failure.code, cause })); + return { status: "failed", ...failure, promptSent: false }; + } finally { + if (deadline) clearTimeout(deadline); + let providerRetired = child === undefined; + try { + try { + if (child && closed) { + await stopMetadataChild(child, closed); + providerRetired = true; + } + } finally { + try { await lease?.close(); } + finally { if (providerRetired && root) await rm(root, { recursive: true, force: true }); } + } + } catch { + // Unconfirmed execution retains its private home; never report verified + // metadata or expose paths/provider text after incomplete retirement. + console.warn("Copilot metadata verification failed", JSON.stringify({ phase: "cleanup", code: "COPILOT_REQUEST_FAILED", cause: "shutdown-incomplete" })); + return { status: "failed", code: "COPILOT_REQUEST_FAILED", message: "Copilot metadata verification failed.", promptSent: false }; + } + } +} + +async function stopMetadataChild(child: ChildProcessWithoutNullStreams, closed: Promise): Promise { + let retired = false; + const observed = closed.then(() => { retired = true; }); + const signal = (value: NodeJS.Signals) => { + if (retired) return; + try { + // Verified command leases create a private detached POSIX group. Native + // descendants inherit it and its output pipes, so close (not just the + // launcher's exit) proves retirement before we release the snapshot. + if (process.platform !== "win32" && child.pid !== undefined) process.kill(-child.pid, value); + else child.kill(value); + } catch { /* The bounded close observation below remains authoritative. */ } + }; + const term = setTimeout(() => signal("SIGTERM"), 500); + const kill = setTimeout(() => signal("SIGKILL"), 2_000); + let timeout: ReturnType | undefined; + try { + child.stdin.end(); + await Promise.race([observed, new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Copilot metadata cleanup timed out")), 3_000); + })]); + } finally { + clearTimeout(term); clearTimeout(kill); + if (timeout) clearTimeout(timeout); + } +} + +export function createMetadataRpc(child: ChildProcessWithoutNullStreams) { + const decoder = new StringDecoder("utf8"); + const pending = new Map(); + let nextId = 0, bytes = 0, buffer = "", failure: Error | undefined; + const fail = (error: Error) => { failure ??= error; for (const call of pending.values()) call.reject(failure); pending.clear(); }; + child.once("close", () => fail(new Error("Copilot provider exited during metadata discovery"))); + child.once("error", () => fail(new Error("Copilot installation could not start"))); + child.stdin.on("error", () => fail(new Error("Copilot metadata transport unavailable"))); + child.stderr.on("data", () => {}); + child.stdout.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > 2 * 1024 * 1024) { fail(new Error("Copilot metadata exceeded its bound")); child.kill("SIGTERM"); return; } + buffer += decoder.write(chunk); + while (buffer.includes("\n")) { + const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + let message: RecordValue; + try { message = record(JSON.parse(line)); } catch { fail(new Error("Invalid Copilot metadata response")); return; } + if (message.method) { + if (message.id !== undefined) child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Metadata discovery does not support inbound methods" } })}\n`); + continue; + } + const call = pending.get(message.id as number); + if (!call) continue; + pending.delete(message.id as number); + if (message.error) { + const details = record(message.error); + call.reject(new Error(typeof details.message === "string" ? details.message.slice(0, 8192) : "Copilot metadata request failed")); + } else call.resolve(message.result); + } + }); + return { request(method: string, params: RecordValue): Promise { + if (!METHODS.has(method)) throw new Error("Metadata discovery cannot send this method"); + if (failure) return Promise.reject(failure); + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + } }; +} + +const FAILURE_MESSAGES = { + COPILOT_AUTH_REQUIRED: "Enter a valid Copilot personal access token.", + COPILOT_ENTITLEMENT_DENIED: "Copilot access is denied by the account entitlement or organization policy.", + COPILOT_MODEL_UNAVAILABLE: "The selected Copilot model is unavailable for this account.", + COPILOT_INSTALLATION_INVALID: "Install the verified Copilot runtime in this environment.", + COPILOT_REQUEST_FAILED: "Copilot metadata verification failed.", +} as const; +/** Validate remote metadata and discard all provider-authored error text. */ +export function validateCopilotMetadata(value: unknown): CopilotMetadataResult { + const result = record(value); + const invalid = (): CopilotMetadataResult => ({ status: "failed", code: "COPILOT_REQUEST_FAILED", message: FAILURE_MESSAGES.COPILOT_REQUEST_FAILED, promptSent: false }); + if (result.promptSent !== false) return invalid(); + if (result.status === "failed") { + const code = typeof result.code === "string" && Object.hasOwn(FAILURE_MESSAGES, result.code) ? result.code as keyof typeof FAILURE_MESSAGES : "COPILOT_REQUEST_FAILED"; + return { status: "failed", code, message: FAILURE_MESSAGES[code], promptSent: false }; + } + if (result.status !== "verified" || result.version !== "1.0.88" || result.profileDigest !== QUALIFIED_ACPX_PROFILES.copilot.commandDigest + || !Array.isArray(result.models) || result.models.length === 0 || result.models.length > 200) return invalid(); + const models: Array<{ id: string; label: string }> = []; + const ids = new Set(); + for (const value of result.models) { + const entry = record(value); + if (typeof entry.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(entry.id) || ids.has(entry.id) + || typeof entry.label !== "string" || entry.label.length > 200 || /[\x00-\x1f\x7f]|github_pat_|gh[pousr]_/.test(entry.label)) return invalid(); + ids.add(entry.id); models.push({ id: entry.id, label: entry.label }); + } + return { status: "verified", version: "1.0.88", profileDigest: result.profileDigest, models, promptSent: false }; +} diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index cd3f85e2f4e..ab80904063e 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -7,6 +7,7 @@ export * from "./turn-stream.js"; export * from "./linux-process-start.js"; export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeCopilotMetadata, validateCopilotMetadata, type CopilotMetadataResult } from "../drivers/copilot-metadata-probe.js"; export { probeAcpxCursorInstallation } from "../drivers/acpx/profile-installation.js"; diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts index 3da30eae8f7..3e0c5314b78 100644 --- a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts @@ -158,6 +158,10 @@ it("does not dispatch an expired durable request when its consumer resumes", asy fixture.emitPermission(); peer.current!.emit("runtime_request.expired", { requestId: "permission-7" }); fixture.emitMarker(); + expect((await fixture.iterator.next()).value).toMatchObject({ + method: "paperclip/runtimeRequestExpired", + params: { payload: { requestId: "permission-7" } }, + }); expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); expect(handler).not.toHaveBeenCalled(); } finally { await fixture.close(); } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 2395aa7ac4f..652293d843b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -9,6 +9,7 @@ import { readFile, readdir, readlink, + realpath, rename, rm, stat, @@ -18,6 +19,8 @@ import { import { createHash, randomUUID } from "node:crypto"; import { execFileSync, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; +import { bindAcpxAgentFiles } from "../drivers/acpx/agent-files-binding.js"; +import { resolveRunnerProviderAssetsRoot } from "../drivers/acpx/provider-assets-root.js"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -25,7 +28,6 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { ControlPlanePort } from "../contracts/control-plane-port.js"; -import { bindAcpxAgentFiles } from "../drivers/acpx/agent-files-binding.js"; import type { NativeExecutionInputV1 } from "../contracts/native-execution.js"; import type { NativeSession, @@ -655,6 +657,69 @@ it("carries the provider attachment seed across consecutive authority rotations" }); }); +it("projects a warm Copilot grant onto the remote runner filesystem", () => { + const runtimeContext = assignedRuntimeContext("/controller/skills", "/controller/instructions"); + runtimeContext.instructions.workingCopy = { kind: "agent_files", rootPath: "/remote/registered-agent", entryPath: "AGENTS.md" }; + const grant = { runtimeContext, instructions: "Read-only instruction sibling root: /controller/instructions" }; + const projected = runnerdRecoveryInternals.projectCopilotRunGrant(grant, "/remote/session/filesystem"); + expect(projected.runtimeContext?.instructions.bundle.rootPath).toBe("/remote/session/filesystem/context/instructions"); + expect(projected.runtimeContext?.skills[0]?.bundle.rootPath).toBe(`/remote/session/filesystem/context/skills/0-${runtimeContext.skills[0]!.bundle.digest.slice(0, 12)}`); + expect(projected.runtimeContext?.instructions.workingCopy).toEqual(runtimeContext.instructions.workingCopy); + expect(projected.runtimeContext?.aggregateDigest).toBe(runtimeContext.aggregateDigest); + expect(projected.instructions).toBe("Read-only instruction sibling root: /remote/session/filesystem/context/instructions"); + expect(grant.runtimeContext.instructions.bundle.rootPath).toBe("/controller/instructions"); + expect(runnerdRecoveryInternals.projectCopilotRunGrant(grant)).toEqual(grant); +}); + +it("reopens Copilot with the current registered files after collecting the previous run copy", async () => { + const root = await mkdtemp(join(tmpdir(), "copilot-current-attachment-")); + const previousRoot = join(root, "previous-agent"); + const currentRoot = join(root, "current-agent"); + try { + await mkdir(previousRoot); + await mkdir(currentRoot); + await writeFile(join(currentRoot, "AGENTS.md"), "Current instructions.\n"); + const previousContext = assignedRuntimeContext("/skills", "/bundle"); + previousContext.instructions.workingCopy = { kind: "agent_files", rootPath: previousRoot, entryPath: "AGENTS.md" }; + const currentContext = structuredClone(previousContext); + currentContext.instructions.workingCopy!.rootPath = currentRoot; + const seed = { + provider: { kind: "acpx", agent: "copilot", runId: "prior-run", normalizedSessionId: "same-session", + instructions: "Previous instructions.", runtimeContext: previousContext, commandDigest: "unchanged", model: "selected-model" }, + }; + await rm(previousRoot, { recursive: true }); + const attached = runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: seed }, + { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "current-run", normalizedSessionId: "same-session", turnId: "turn", itemId: "item" }, + null, undefined, + currentContext, { text: "Current instructions.", context: currentContext }, + ); + const provider = attached.provider as typeof seed.provider; + const files = bindAcpxAgentFiles(provider.runtimeContext, []); + expect(files?.root).toBe(await realpath(currentRoot)); + files?.assertHeld(); + expect(provider).toMatchObject({ instructions: "Current instructions.", commandDigest: "unchanged", model: "selected-model", normalizedSessionId: "same-session" }); + expect(seed.provider.runtimeContext).toEqual(previousContext); + expect(seed.provider.instructions).toBe("Previous instructions."); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it.each(["claude", "codex", "cursor", "pi", "copilot"])("keeps %s's pinned grants on same-run recovery", (agent) => { + const identity = { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "same-run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }; + const provider = { kind: "acpx", agent, runId: identity.runId, instructions: "Pinned instructions.", runtimeContext: assignedRuntimeContext("/skills", "/pinned") }; + const payload = runnerdRecoveryInternals.rotatedRunAttachPayload({ runAttachTemplate: { provider } }, identity, null, undefined, + undefined); + expect(payload.provider).toMatchObject(provider); +}); + +it.each(["claude", "codex", "cursor", "pi"])("preserves %s's cross-run context when no new grant is supplied", (agent) => { + const provider = { kind: "acpx", agent, runId: "prior-run", instructions: "Pinned instructions.", runtimeContext: assignedRuntimeContext("/skills", "/pinned") }; + const payload = runnerdRecoveryInternals.rotatedRunAttachPayload({ runAttachTemplate: { provider } }, + { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }, null, undefined, + undefined); + expect(payload.provider).toMatchObject({ ...provider, runId: "new-run" }); + }); + it("restores ACPX with the current registered copy after the old run copy is collected", async () => { const root = await mkdtemp(join(tmpdir(), "runnerd-acpx-context-rotation-")); const priorRoot = join(root, "prior-run-copy"); @@ -1830,6 +1895,32 @@ it.each([false, true])("binds a vendored source server to its pnpm dependency ro await rm(workspace, { recursive: true, force: true }); } }); +it.each(["source", "scoped-npm", "deployment"])( + "admits the controller-selected Copilot package authority in a %s layout", + async (layout) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), "copilot-package-authority-"))); + const runnerRoot = layout === "source" + ? join(directory, "packages/paperclip-runner") + : layout === "scoped-npm" + ? join(directory, "node_modules/@paperclipai/paperclip-runner") + : directory; + await mkdir(join(runnerRoot, "dist/cli"), { recursive: true }); + await writeFile(join(runnerRoot, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + try { + const authority = runnerdLaunchProfileInternals.acpxProviderPackageAuthority( + join(runnerRoot, "dist/cli/acpx-runtime-sidecar.cjs"), runnerRoot, "copilot", + ); + expect(authority).toEqual({ root: runnerRoot, manifest: join(runnerRoot, "package.json") }); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", authority.root); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", authority.manifest); + expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")) + .toBe(join(runnerRoot, "provider-assets/copilot")); + } finally { + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + } + }, +); it("keeps a self-rooted pnpm deployment inside its dependency authority", async () => { const deploymentRoot = await mkdtemp( diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 7913b7fc6a5..0fa9d5aeebf 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -31,7 +31,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname, posix, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { @@ -80,7 +80,7 @@ import { createSanitizedAwsAgentCoreEnvironment, createSanitizedClaudeManagedEnvironment, } from "../drivers/claude-managed/environment.js"; -import { composeNativeSystemInstructions, type NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; +import { composeNativeSystemInstructions, type NativeRunRuntimeGrant, type NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; import type { NativeAcpxPermissionMode, NativeOpenCodePermissionMode, @@ -429,6 +429,22 @@ async function rotateExternalAuthorityEpoch( return controlPlaneState; } +function projectCopilotRunGrant( + grant: NativeRunRuntimeGrant, + runnerFilesystemRoot?: string, +): NativeRunRuntimeGrant { + const projected = structuredClone(grant); + const context = projected.runtimeContext; + if (!runnerFilesystemRoot || !context) return projected; + const sourceInstructionRoot = context.instructions.bundle.rootPath; + context.instructions.bundle.rootPath = posix.join(runnerFilesystemRoot, "context", "instructions"); + context.skills.forEach((skill, index) => { + skill.bundle.rootPath = posix.join(runnerFilesystemRoot, "context", "skills", `${index}-${skill.bundle.digest.slice(0, 12)}`); + }); + projected.instructions = projected.instructions.replaceAll(sourceInstructionRoot, context.instructions.bundle.rootPath); + return projected; +} + function rotatedRunAttachPayload( state: { commands?: unknown; runAttachTemplate?: unknown }, desired: DurableRecoveryIdentity, @@ -1466,6 +1482,17 @@ export function expandRunnerdCanonicalNotifications( }); } +/** Keep durable expiry visible to the facade before its terminal notification. */ +export function runnerdRuntimeRequestExpiryNotification( + eventType: string, payload: Record, threadId: string | null, turnId: string | null, +): CodexRpcNotification | null { + if (eventType !== "runtime_request.expired") return null; + return { method: "paperclip/runtimeRequestExpired", params: { + ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}), + payload, itemId: payload.itemId, + } }; +} + export function runnerdCanonicalNotificationMethod( eventType: string, payload: Record, @@ -3013,6 +3040,7 @@ function resolveBuildOwnedCliArtifact( function acpxProviderPackageAuthority( sidecarScript: string, ownerPackageRoot = packageRoot, + agent?: QualifiedAcpxAgent, ): { root: string; manifest: string; @@ -3051,6 +3079,15 @@ function acpxProviderPackageAuthority( ); } const sidecarPackageRoot = resolve(cliDirectory, "../.."); + // Copilot's native closure belongs to the runner package itself. Its asset + // guard requires the bound manifest directly inside that same package root; + // the workspace dependency root used by JS providers is a different owner. + if (agent === "copilot") { + return { + root: sidecarPackageRoot, + manifest: resolve(sidecarPackageRoot, "package.json"), + }; + } // A local source build lives at /packages/paperclip-runner and // resolves dependencies from /node_modules. `pnpm deploy` makes // the package itself the deployment root and owns /node_modules/.pnpm. @@ -3302,7 +3339,9 @@ function createCapabilityRunnerdProviderEnvironmentBase(input: { input.acpxSidecarPath ?? input.options.acpxSidecarPath ?? resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs"); - const providerPackageAuthority = acpxProviderPackageAuthority(sidecarPath); + const providerPackageAuthority = acpxProviderPackageAuthority( + sidecarPath, packageRoot, input.options.acpxAgent, + ); // This is the trusted runner/sidecar boundary. The provider sandbox still // uses createSanitizedAcpxSpawnInput and does not inherit gateway tokens. const assignedGateway = nativeMcpLaunchBinding(input.options.environment ?? {}); @@ -4102,6 +4141,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { runId: string; turnId: string; itemId: string; + currentRunGrant?: NativeRunRuntimeGrant; }): Promise { if (this.#pendingWarmRecoveryCompletion !== null) { throw new Error("native_runner_warm_transition_completion_pending"); @@ -4111,6 +4151,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_runner_prp_run_rotation_unavailable"); } await this.#awaitWarmRunAttachmentReady(); + const currentRunGrant = input.currentRunGrant + ? projectCopilotRunGrant(input.currentRunGrant, this.options.runnerFilesystemRoot) + : undefined; + if (this.options.provider === "acpx" && this.options.acpxAgent === "copilot" && !currentRunGrant) { + throw new Error("native_runner_current_instruction_grant_unavailable"); + } const prior = core.store.state.identity; const desired: DurableRecoveryIdentity = { ...prior, @@ -4140,12 +4186,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { desired, this.#authorizedTools, this.options.resumeCompletionContract, + currentRunGrant?.runtimeContext, + currentRunGrant ? { text: currentRunGrant.instructions, context: currentRunGrant.runtimeContext } : undefined, ) : rotatedRunAttachPayload( core.store.state, desired, this.#authorizedTools, this.options.resumeCompletionContract, + currentRunGrant?.runtimeContext, + currentRunGrant ? { text: currentRunGrant.instructions, context: currentRunGrant.runtimeContext } : undefined, ); this.#runAttachTemplate = structuredClone(runAttachTemplate); const payload = { @@ -5562,6 +5612,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }); this.#core = core; if (rotatedAuthority) { + if (provider === "acpx" && this.options.acpxAgent === "copilot" + && typeof this.options.baseInstructions !== "string") { + throw new Error("native_runner_current_instruction_grant_unavailable"); + } const runAttachTemplate = rotatedRunAttachPayload( controlPlaneState, desiredIdentity, @@ -6526,6 +6580,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ).requestId; if (typeof requestId === "string") this.#bridgedRuntimeInputs.delete(requestId); + // Operator resolution/Stop keep their existing acknowledgement path. + const expiry = runnerdRuntimeRequestExpiryNotification(event.eventType, record(eventPayload), this.#threadId, this.#turnId); + if (expiry) this.#queue.push({ ...expiry, + ...(this.options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH ? { paperclipTrace: { + sourceEventId: event.sourceEventId, sourceEventType: event.eventType, + } } : {}), + }); continue; } const sessionUpdatePayload = record(eventPayload); @@ -7459,6 +7520,7 @@ export const runnerdLaunchProfileInternals = Object.freeze({ }); export const runnerdRecoveryInternals = Object.freeze({ + projectCopilotRunGrant, completedMaintenanceTerminalReceipt, completedMaintenanceTerminalReplayMatches, readControlPlaneState, diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index a6a2279081f..68ab9d278d8 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { resolveQualifiedAcpxProfile } from "./drivers/acpx/qualified-profiles.js"; import { describe, expect, it, vi } from "vitest"; import type { ControlPlanePort } from "./contracts/control-plane-port.js"; @@ -5004,7 +5005,19 @@ describe("executeNativeSession recovery", () => { expect(attachRun).not.toHaveBeenCalled(); }); - it("quarantines a retained session when attachment partially mutates then fails", async () => { + it.each(["codex", "copilot"] as const)("quarantines a retained %s session when attachment partially mutates then fails", async (provider) => { + const qualified = resolveQualifiedAcpxProfile("copilot", "selected-model"); + const executionInput: NativeExecutionInputV1 = provider === "copilot" ? { + ...input, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider: { kind: "acpx", agent: "copilot", model: "selected-model", permissionPolicy: "interactive", profile: { + driverKind: qualified.driverKind, protocolVersion: qualified.protocolVersion, acpxVersion: qualified.acpxVersion, + agent: qualified.agent, agentProfileVersion: qualified.agentProfileVersion, + agentServerPackage: qualified.agentServerPackage, agentServerVersion: qualified.agentServerVersion, + agentRuntimePackage: qualified.agentRuntimePackage, agentRuntimeVersion: qualified.agentRuntimeVersion, + commandDigest: qualified.commandDigest, + } }, + } : input; const attachmentFailure = new Error("provider attachment failed"); const openRun = vi.fn(async () => undefined); let retainedIdentity = { ...identity, runId: "run-previous" }; @@ -5041,7 +5054,7 @@ describe("executeNativeSession recovery", () => { async descriptor() { return { kind: "mock", - name: "attachment-failure-backend", + name: `attachment-failure-backend-${provider}`, version: "1", capabilities: { resume: true, @@ -5069,7 +5082,7 @@ describe("executeNativeSession recovery", () => { await expect( executeNativeSession({ - input, + input: executionInput, backend, controlPlane: port, runnerInstanceId: "runner-recovery", @@ -5078,7 +5091,9 @@ describe("executeNativeSession recovery", () => { onSession, }), ).rejects.toBe(attachmentFailure); - expect(attachRun).toHaveBeenCalledWith({ identity }); + expect(attachRun).toHaveBeenCalledWith(provider === "copilot" + ? { identity, currentRunGrant: { runtimeContext: null, instructions: expect.any(String) } } + : { identity }); expect(retainedIdentity).toEqual(identity); expect(onSession).toHaveBeenCalledOnce(); expect(onSession).toHaveBeenCalledWith(null); diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index fe2b2f09acf..c11c446aa63 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { nativeSystemInstructions } from "./backends/runtime-context.js"; import type { CheckpointControlPlaneSessionOptions, @@ -2004,7 +2005,12 @@ export async function executeNativeSession( // state). Prove the provider attachment before opening durable // control-plane state because ControlPlanePort has no rollback operation. try { - await options.existingSession.attachRun({ identity }); + await options.existingSession.attachRun({ identity, + ...(input.provider.kind === "acpx" && input.provider.agent === "copilot" + ? { currentRunGrant: { runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, + instructions: nativeSystemInstructions(input) } } + : {}), + }); } catch (error) { // attachRun has no transactional guarantee: a provider may bind the new // run before reporting a later failure. Conservatively quarantine the diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index 4113b0ba321..3cd71aa3198 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -87,6 +87,7 @@ test("the package exposes only the reviewed runner CLI binaries", () => { "paperclip-runner-eval-session": "./dist/cli/eval-session.js", "paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js", "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", + "paperclip-runner-copilot-metadata-probe": "./dist/cli/copilot-metadata-probe.js", "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", }); diff --git a/packages/paperclip-runner/test/fixtures/copilot-mainline-v22-identity.json b/packages/paperclip-runner/test/fixtures/copilot-mainline-v22-identity.json new file mode 100644 index 00000000000..0b7b75483fe --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-mainline-v22-identity.json @@ -0,0 +1,44 @@ +{ + "commandDigest": "sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 22, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "ddd98aabe20f4d40b04bb58126bda548a9d85b8234a3f9ea84b6db414e9b4814", + "semanticDirectDriverSourceSha256": "72d5134a9567ced685bc942adf14ec5dfba04bcd6b0eeabe77eac0cec5c434c3", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "5e957fe0f0ba164441d6a5b8a276e52cf6f2ffaf99741faca99cff05e539221c", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-mainline-v23-identity.json b/packages/paperclip-runner/test/fixtures/copilot-mainline-v23-identity.json new file mode 100644 index 00000000000..abf8d388cc9 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-mainline-v23-identity.json @@ -0,0 +1,44 @@ +{ + "commandDigest": "sha256:6ca1e2c9efa23887ba6f08d89980c91d13114640ac8a1a79f00f27aa8cd39d63", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 23, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "ddd98aabe20f4d40b04bb58126bda548a9d85b8234a3f9ea84b6db414e9b4814", + "semanticDirectDriverSourceSha256": "72d5134a9567ced685bc942adf14ec5dfba04bcd6b0eeabe77eac0cec5c434c3", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "5e957fe0f0ba164441d6a5b8a276e52cf6f2ffaf99741faca99cff05e539221c", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-mainline-v24-identity.json b/packages/paperclip-runner/test/fixtures/copilot-mainline-v24-identity.json new file mode 100644 index 00000000000..8fcbfae75f7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-mainline-v24-identity.json @@ -0,0 +1,44 @@ +{ + "commandDigest": "sha256:87e275270e50b42290d10ecec9dfce86ffca2b7a7519cb60c584004adfc86437", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 24, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "6fc6ceb1222cec5586952cf51e2df045ecf751adfc937f5125550a311f5d3896", + "semanticDirectDriverSourceSha256": "72d5134a9567ced685bc942adf14ec5dfba04bcd6b0eeabe77eac0cec5c434c3", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "5e957fe0f0ba164441d6a5b8a276e52cf6f2ffaf99741faca99cff05e539221c", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-production-v26-offline-regressions.json b/packages/paperclip-runner/test/fixtures/copilot-production-v26-offline-regressions.json new file mode 100644 index 00000000000..057fc8ba21d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-production-v26-offline-regressions.json @@ -0,0 +1,23 @@ +{ + "schema": "paperclip.copilot.offline-regression-evidence/v1", + "profileVersion": 26, + "profileDigest": "sha256:1a2a0c35764105e9a85112d05187fee0862af23eeb82a75569b5198e52eaba4f", + "priorSourceRevision": "6f424f0f042639631dd6c74053c4a339f6a6ef93", + "runtimeVersion": "1.0.88", + "corrections": [ + "Preserve sensitive ancestor names when flattening native notice fields", + "Bind Copilot native event normalization and notice projection in the source identity", + "Assert expiry notification while forbidding stale permission callbacks", + "Mock installed provider registries independently of host assets" + ], + "testFiles": 7, + "testsPassed": 154, + "providerCalls": 0, + "qualification": "pending", + "liveQualificationPasses": 0, + "priorProtocolPassesHistorical": 7, + "fullWorkspaceChecksPending": true, + "assertionsOrDeadlinesRelaxed": false, + "otherProviderProfilesChanged": false, + "originalFailuresRetained": true +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v21-mainline-monitor-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v21-mainline-monitor-identity.json new file mode 100644 index 00000000000..775b2c35671 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v21-mainline-monitor-identity.json @@ -0,0 +1,44 @@ +{ + "commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 21, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "17371b10bef0acfe7a5980e20fd575165f3e28c91608d38590fdca67d58c4edf", + "semanticDirectDriverSourceSha256": "9c27a6ef55d65190af11b597ee2214ba836b4d992cb75d2eccf9c3ea3311d07c", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "36ad8a83a17111167d3cc4ef4123b36fa648a1825acd59bd90b3f8b566a4d335", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v43-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v43-identity.json new file mode 100644 index 00000000000..1b04c8c2d5e --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v43-identity.json @@ -0,0 +1,82 @@ +{ + "commandDigest": "sha256:6ff8e40718abc0ce25e3013923e2435c86ef87a2cea9ca9d2c3f2a891828ce9c", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 43, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "2b24c094da0793888cdb5197b8961a69c410679dad85ab341f89bb812931e972", + "semanticDirectDriverSourceSha256": "4538023d8a2735505b194dee35628807796c2d6c8a77c3309648d2c8f1fca7d5", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "36ad8a83a17111167d3cc4ef4123b36fa648a1825acd59bd90b3f8b566a4d335", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "afc83fa093afa7a7458ffb46adda0ed9d766723c2d233d5262f9136ef2e4909f", + "configuredEnvironmentRustSourceSha256": "982ed1a1b917a43bd3ea741f9fe23a306c05aee53d633c081409534a18d1a07b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "c010aef271368b27fdbb0a923f6abd376233f2c9bba786f48cc7cf85d87722d4", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "79ab6ab323d5b02b6f649bdc61703f8d53f26215fa3555ab72b21d80c9a61278", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "1cb1949a0a9db38a3ed61595533a5464fd8ee8c586ec1ad13700b9a28e9636eb", + "nativeBackendSourceSha256": "0c3791a8a9f7d632804215a0574cec480f6ba130a32ccafbcfce03d7ee5847a1", + "nativeBackendFactorySourceSha256": "02c5cd564029751c4569ff8960d0356d9da63a2e5d2fda958d787f98ee408794", + "nativeInstructionsSourceSha256": "9adf4ff11b5cdaccb312fa9b0343d83213aa071c4ea997d157fbcaa97249aa02", + "runtimeRequestBridgeSourceSha256": "d14744006e0d162f6a1597dd8e8fadd6dcf2a51a61fe0891cabfe3d2d678a5dc", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "4f6c49b0ca172ac8fcd8f8558a58bec560bbf778f4894b70b0ea4613efb20c70", + "nativeExecutionSourceSha256": "b2bf3ba87219c094e8eb659723d43e394d31bfdd1655999d82fb9c97af66c00f", + "profileCompatibilitySourceSha256": "c843647818af25cf69907187f13ee140a45ca8512d28951975b39b285c4fe8d7", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "c9c3650b6415430b045533eea2d7cddfe87302e03817f40ae83f7447ef0ae957", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c", + "runtimeHostSourceSha256": "687b96bf222b5f132a57668059df61f1cad8f0ef96189c504a7bbc8f990591cc", + "runtimeAdapterSourceSha256": "5bf35ecee5c3853a01f4b226b881af76e0785944189e37d5b891cc2b1cf1de34", + "runtimeSandboxSourceSha256": "4c0e8b3dca5ecaf60edbf5db26bb55a00bdf800f4523a072c69ed8a92d67f8ca", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v44-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v44-identity.json new file mode 100644 index 00000000000..429e7c86d03 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v44-identity.json @@ -0,0 +1,82 @@ +{ + "commandDigest": "sha256:eb3111dae64c506ccdeaab99c8fc42c64f9b46e757af404825fcd55f37e3d96c", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 44, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "2b24c094da0793888cdb5197b8961a69c410679dad85ab341f89bb812931e972", + "semanticDirectDriverSourceSha256": "4538023d8a2735505b194dee35628807796c2d6c8a77c3309648d2c8f1fca7d5", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "36ad8a83a17111167d3cc4ef4123b36fa648a1825acd59bd90b3f8b566a4d335", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "afc83fa093afa7a7458ffb46adda0ed9d766723c2d233d5262f9136ef2e4909f", + "configuredEnvironmentRustSourceSha256": "982ed1a1b917a43bd3ea741f9fe23a306c05aee53d633c081409534a18d1a07b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "c010aef271368b27fdbb0a923f6abd376233f2c9bba786f48cc7cf85d87722d4", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "79ab6ab323d5b02b6f649bdc61703f8d53f26215fa3555ab72b21d80c9a61278", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "1cb1949a0a9db38a3ed61595533a5464fd8ee8c586ec1ad13700b9a28e9636eb", + "nativeBackendSourceSha256": "0c3791a8a9f7d632804215a0574cec480f6ba130a32ccafbcfce03d7ee5847a1", + "nativeBackendFactorySourceSha256": "02c5cd564029751c4569ff8960d0356d9da63a2e5d2fda958d787f98ee408794", + "nativeInstructionsSourceSha256": "9adf4ff11b5cdaccb312fa9b0343d83213aa071c4ea997d157fbcaa97249aa02", + "runtimeRequestBridgeSourceSha256": "0ff157cf80a767e15312d42f54577a795e49b89298974d4872eb9ab299c3bb79", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "4f6c49b0ca172ac8fcd8f8558a58bec560bbf778f4894b70b0ea4613efb20c70", + "nativeExecutionSourceSha256": "b2bf3ba87219c094e8eb659723d43e394d31bfdd1655999d82fb9c97af66c00f", + "profileCompatibilitySourceSha256": "c843647818af25cf69907187f13ee140a45ca8512d28951975b39b285c4fe8d7", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "c9c3650b6415430b045533eea2d7cddfe87302e03817f40ae83f7447ef0ae957", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c", + "runtimeHostSourceSha256": "687b96bf222b5f132a57668059df61f1cad8f0ef96189c504a7bbc8f990591cc", + "runtimeAdapterSourceSha256": "5bf35ecee5c3853a01f4b226b881af76e0785944189e37d5b891cc2b1cf1de34", + "runtimeSandboxSourceSha256": "4c0e8b3dca5ecaf60edbf5db26bb55a00bdf800f4523a072c69ed8a92d67f8ca", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json new file mode 100644 index 00000000000..a335e7ec203 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json @@ -0,0 +1,82 @@ +{ + "commandDigest": "sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 47, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "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": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "5e957fe0f0ba164441d6a5b8a276e52cf6f2ffaf99741faca99cff05e539221c", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "c010aef271368b27fdbb0a923f6abd376233f2c9bba786f48cc7cf85d87722d4", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", + "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", + "runtimeRequestBridgeSourceSha256": "e87de28ae7337642b545d95ae955ed1e09bdd23e9a0a9528c8009afd9be63ae0", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "2e81a115a20df53643a5adc81e95f10f8cce6c7a318d4910a95a5d51a6eea2c2", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "54fd09ad430dac04df98d829061f6701e73b5bf2a0f4de836d2ac36ee511009f", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c", + "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/scripts/__tests__/runner-release-crate-cache.test.mjs b/scripts/__tests__/runner-release-crate-cache.test.mjs index e85b3b7b45f..7a2627788f2 100644 --- a/scripts/__tests__/runner-release-crate-cache.test.mjs +++ b/scripts/__tests__/runner-release-crate-cache.test.mjs @@ -79,6 +79,7 @@ test("the script's path list and key inputs cover what the release build of the ]); assert.deepEqual(HASHED_INPUTS, [ `${RUNNER_PACKAGE}/rust-toolchain.toml`, + `${RUNNER_PACKAGE}/acpx-profiles.json`, `${RUNNER_PACKAGE}/runner/Cargo.toml`, `${RUNNER_PACKAGE}/runner/Cargo.lock`, `${RUNNER_PACKAGE}/runner/crates/**`, diff --git a/scripts/runner-release-crate-cache.mjs b/scripts/runner-release-crate-cache.mjs index 898781a8124..f0de40d6034 100644 --- a/scripts/runner-release-crate-cache.mjs +++ b/scripts/runner-release-crate-cache.mjs @@ -53,6 +53,7 @@ export const CACHED_PATHS = [ // crate, whatever its form, must land under one of these patterns. export const HASHED_INPUTS = [ `${RUNNER_PACKAGE}/rust-toolchain.toml`, + `${RUNNER_PACKAGE}/acpx-profiles.json`, `${RUNNER_PACKAGE}/runner/Cargo.toml`, `${RUNNER_PACKAGE}/runner/Cargo.lock`, `${RUNNER_PACKAGE}/runner/crates/**`, diff --git a/server/scripts/install-copilot-assets.mjs b/server/scripts/install-copilot-assets.mjs new file mode 100644 index 00000000000..9960da267c0 --- /dev/null +++ b/server/scripts/install-copilot-assets.mjs @@ -0,0 +1,18 @@ +import { access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const installer = new URL("../dist/install-copilot-assets.mjs", import.meta.url); +try { + await access(fileURLToPath(installer)); +} catch (error) { + if (error.code !== "ENOENT") throw error; + // Dependencies install before compilation in a source workspace. Published + // server packages omit src and cannot take this bootstrap exception. + await access(fileURLToPath(new URL("../src/services/copilot-connection-probe.ts", import.meta.url))); + await access(fileURLToPath(new URL("../../pnpm-workspace.yaml", import.meta.url))); + process.stdout.write("Copilot runtime assets: source-workspace-pending-build\n"); + process.exit(0); +} +const { installCopilotAssets } = await import(installer.href); +const result = await installCopilotAssets(); +process.stdout.write(`Copilot runtime assets: ${result.status} (${result.target})\n`);