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`);