From 27b601b172b47b38b8dbc648a71e4e2ca223c332 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 10:54:07 -0500 Subject: [PATCH] Finish Copilot production runtime on current master Preserve the reviewed candidate source and mainline provider behavior. Co-Authored-By: Paperclip --- packages/paperclip-runner/acpx-profiles.json | 7 +- packages/paperclip-runner/package.json | 12 +- .../runner-core/src/acpx_provider_backend.rs | 309 +++++++++++++++++- .../src/acpx_provider_capabilities.rs | 8 +- .../runner-core/src/acpx_sidecar_transport.rs | 1 + .../runner-core/src/bin/fake-acpx-sidecar.rs | 33 ++ .../src/generated_acpx_profiles.rs | 2 +- .../tests/native_provider_backend.rs | 103 ++++++ .../scripts/build-provider-pack.mjs | 12 +- .../scripts/candidate-provider-pack.mjs | 6 +- .../scripts/candidate-provider-pack.test.mjs | 51 ++- .../scripts/copilot-profile-identity.mjs | 106 ++++++ .../scripts/copilot-smoke-resources.mjs | 22 ++ .../scripts/copilot-smoke-resources.test.mjs | 18 +- .../scripts/discover-copilot-acp.mjs | 4 +- .../scripts/discover-copilot-acp.test.mjs | 9 + .../scripts/generate-acpx-profiles.mjs | 2 +- .../scripts/install-copilot-assets.mjs | 94 ++++++ .../scripts/install-copilot-assets.test.mjs | 114 +++++++ .../scripts/qualify-copilot-acp.mjs | 252 +++++++------- .../scripts/qualify-copilot-acp.test.mjs | 21 +- .../scripts/release-provider-pack.mjs | 10 + .../scripts/release-provider-pack.test.mjs | 16 + .../backends/harness-driver-backend.test.ts | 6 +- .../src/backends/harness-driver-backend.ts | 6 +- .../backends/native-backend-factory.test.ts | 5 +- .../src/cli/acpx-runtime-sidecar.ts | 4 +- .../src/cli/copilot-metadata-probe.ts | 5 + .../src/cli/eval-session-contract.test.ts | 19 ++ .../src/contracts/harness-driver.ts | 3 +- .../src/contracts/native-execution.test.ts | 64 ++++ .../src/contracts/native-execution.ts | 3 + .../src/contracts/native-session-backend.ts | 3 +- .../src/contracts/runtime-context.ts | 6 + .../drivers/acpx/acp-permission-adapter.ts | 2 + .../drivers/acpx/codex-acpx-driver.test.ts | 30 ++ .../src/drivers/acpx/codex-acpx-driver.ts | 47 ++- .../drivers/acpx/codex-credentials.test.ts | 39 ++- .../acpx/codex-runtime-adapter.test.ts | 135 ++++++++ .../src/drivers/acpx/codex-runtime-adapter.ts | 68 +++- .../drivers/acpx/copilot-extension-adapter.ts | 6 +- .../src/drivers/acpx/copilot-installation.ts | 7 +- .../acpx/copilot-permission-context.ts | 32 +- .../src/drivers/acpx/copilot-profile.ts | 1 + .../src/drivers/acpx/generated-profiles.ts | 5 +- .../drivers/acpx/installation-integrity.ts | 17 +- .../native-distribution-integrity.test.ts | 139 ++++++++ .../acpx/native-distribution-integrity.ts | 7 +- .../src/drivers/acpx/profile-activity.ts | 2 +- .../acpx/profile-compatibility.test.ts | 13 + .../src/drivers/acpx/profile-compatibility.ts | 4 +- .../src/drivers/acpx/profile-installation.ts | 4 +- .../drivers/acpx/provider-assets-root.test.ts | 12 + .../src/drivers/acpx/provider-assets-root.ts | 4 +- .../src/drivers/acpx/qualified-profiles.ts | 1 + .../src/drivers/acpx/runtime-host.test.ts | 14 +- .../src/drivers/acpx/runtime-host.ts | 15 +- .../src/drivers/acpx/runtime-sandbox.ts | 10 +- .../drivers/acpx/single-read-evidence.test.ts | 2 +- .../src/drivers/codex/app-server-transport.ts | 2 + .../codex-app-server-driver.lifecycle.test.ts | 3 +- .../codex/codex-app-server-driver.test.ts | 62 ++++ .../drivers/codex/codex-harness-session.ts | 4 +- .../codex/codex-notification-identity.test.ts | 4 + .../codex/codex-session-notifications.ts | 20 ++ .../codex/codex-thread-normalization.ts | 1 + .../drivers/copilot-metadata-probe.test.ts | 78 +++++ .../src/drivers/copilot-metadata-probe.ts | 162 +++++++++ packages/paperclip-runner/src/live/index.ts | 1 + .../runnerd-acpx-permission-ordering.test.ts | 4 + .../src/live/runnerd-codex-transport.test.ts | 66 +++- .../src/live/runnerd-codex-transport.ts | 54 ++- .../src/native-session-runtime.ts | 8 +- .../test/acpx-codex-package-contract.test.mjs | 1 + ...ilot-profile-v14-mainline-pi-identity.json | 43 +++ ...ilot-profile-v15-mainline-pi-identity.json | 43 +++ ...ilot-profile-v16-mainline-pi-identity.json | 43 +++ ...ilot-profile-v17-mainline-pi-identity.json | 43 +++ .../copilot-profile-v33-identity.json | 81 +++++ .../copilot-profile-v34-identity.json | 81 +++++ server/scripts/install-copilot-assets.mjs | 18 + 81 files changed, 2517 insertions(+), 257 deletions(-) create mode 100644 packages/paperclip-runner/scripts/copilot-profile-identity.mjs create mode 100644 packages/paperclip-runner/scripts/install-copilot-assets.mjs create mode 100644 packages/paperclip-runner/scripts/install-copilot-assets.test.mjs create mode 100644 packages/paperclip-runner/src/cli/copilot-metadata-probe.ts create mode 100644 packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts create mode 100644 packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v14-mainline-pi-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json create mode 100644 server/scripts/install-copilot-assets.mjs diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 81a3e159050..3a79a740325 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,14 +32,13 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 17, + "agentProfileVersion": 34, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", - "requiresProviderPolicy": true, - "qualificationStatus": "pending" + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "requiresProviderPolicy": true }, "claude": { "agentProfileVersion": 1, diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 8c1249044ca..f1ec09e14ec 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -53,7 +53,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": [ @@ -81,7 +84,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 --test 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/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-package-executable.test.mjs scripts/stage-runner-binary.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test 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/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-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/provider-pack-executable-shims.test.mjs && pnpm run test:copilot:contracts", "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 +172,10 @@ "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", + "check:copilot-profile": "node scripts/copilot-profile-identity.mjs --check", + "test:copilot:contracts": "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", + "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 3757c6ea68d..8f01f705a43 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 @@ -2001,6 +2001,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| { @@ -2010,7 +2016,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 { @@ -2051,10 +2060,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 { @@ -2064,11 +2075,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; } @@ -4042,6 +4054,283 @@ 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"); + descriptor_value["agentServerPackage"] = json!("@github/copilot"); + descriptor_value["agentServerVersion"] = json!("1.0.88"); + descriptor_value["agentRuntimePackage"] = Value::Null; + descriptor_value["agentRuntimeVersion"] = Value::Null; + descriptor_value["commandDigest"] = serde_json::from_str::(include_str!( + "../../../../test/fixtures/copilot-profile-v34-identity.json" + )) + .unwrap()["commandDigest"] + .clone(); + 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(), + 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 331f6ed17f3..ca65f299343 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 @@ -860,6 +860,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 28f4d2a775a..dd2ed8962b7 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:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", 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 cc94d4834c0..2fb94b988b7 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 @@ -1023,6 +1023,109 @@ 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); + let identity: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/copilot-profile-v34-identity.json" + )) + .unwrap(); + let digest = identity["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 a58e8cb3156..bdf0886af4a 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -152,7 +152,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}`) { @@ -167,7 +175,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)) }; } 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 c11110babae..353b8db6bb1 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,28 +1,44 @@ 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"; -test("candidate options stay explicit and supported native assets are included by default", () => { +// 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 selection is explicit", () => { 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)); @@ -30,6 +46,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", async t => { 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..3b932b141be --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -0,0 +1,106 @@ +// 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-v34-identity.json"); +const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); +const declaration = { ...historical.declaration, agentProfileVersion: 34 }; +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", + 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-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/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index afdd2013b65..38322261173 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v34-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); 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 cedbde6bbf2..726d63db362 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.mjs @@ -28,5 +28,15 @@ export function verifyReleaseProviderPack(identity, sourceRevision) { throw new Error("Remote provider-pack Cursor identity does not match this release"); } } + const copilot = payload.providers?.copilot; + const copilotProfile = profiles.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 33d6de5874f..9b42d0419f5 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -21,6 +21,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, @@ -77,3 +80,16 @@ test("release assembly rejects rehashed stale or missing packs before staging an } } 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/); +}); 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 e1542606a24..0f3c82dd677 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -469,9 +469,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({ @@ -505,8 +507,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 6e3897e4e57..fbd32544037 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, @@ -471,6 +472,7 @@ class HarnessNativeSession implements NativeSession { async attachRun(input: { identity: OpenNativeSessionInput["identity"]; + currentRunGrant?: NativeRunRuntimeGrant; }): Promise { this.#assertProtocolIntegrity(); const currentIdentity = this.#input.identity; @@ -486,7 +488,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 d7f0c1c9d95..264c4e02676 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("selects prepared transport only for v5 native input and retains prepared constraints", async () => { const input = preparedExecution(execution()); 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 63fc827217a..c351b33cfa4 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -34,7 +34,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"; @@ -678,7 +678,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 410496afce5..6596e2b9c88 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, @@ -505,7 +506,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 460a43534c6..7bf605ef8d4 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -407,6 +407,30 @@ describe("NativeExecutionInputV1", () => { })).toThrow("does not match"); }); + 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] 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, 35, 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) => { @@ -564,6 +588,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 194920c3e96..ca23b5f0246 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -91,6 +91,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 f473b56a03e..ef19e5fe272 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, @@ -184,7 +185,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.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 871ff1cebe2..43ed6d27f09 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -59,6 +59,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"; @@ -1568,7 +1569,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 @@ -1599,7 +1600,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) } }, ); } } @@ -1743,9 +1744,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, { @@ -1794,9 +1793,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, { @@ -1930,14 +1927,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 }); @@ -1955,22 +1945,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 8a18b200122..6c29f84e5b7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -813,9 +813,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), ); @@ -1652,6 +1653,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 = 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 a5457816bff..3058f734fef 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 @@ -29,6 +29,141 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { + 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 9255b0bc94e..d67ba30a5a9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -102,6 +102,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"); @@ -605,6 +613,7 @@ export async function openQualifiedAcpxRuntime( commandLaunches, permissionBoundary, extensionBoundary, + options.profile.agent === "copilot", options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, ); } catch (error) { @@ -1035,6 +1044,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]); @@ -1078,6 +1088,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 @@ -1393,10 +1404,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]), @@ -1404,7 +1417,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; }; @@ -1434,9 +1465,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. @@ -1453,6 +1493,7 @@ function runtimePort( void result.catch(() => undefined); return { ...guarded, + promptStarted, result, cancel: (input) => { controller.abort(new Error("ACPX extension turn cancelled")); @@ -1463,16 +1504,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; })(), }; }, @@ -1729,6 +1782,7 @@ class SpawnedChildSet { readonly #providerExits = new Map(); readonly #terminations = new Map>(); readonly #lifetimeOwnership: Promise[] = []; + readonly #exitWatchers = new Set<() => void>(); #lifetimeOwnershipSealed = false; #sealed = false; @@ -1814,6 +1868,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); @@ -1840,6 +1899,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.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.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.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts index 2a88ab552ff..73c8d599a05 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -5,6 +5,7 @@ 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; // 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/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 3a0bf38cf33..e63e7896fdb 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": 17, + "agentProfileVersion": 34, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", - "qualificationStatus": "pending", + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", "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 05fd89fdc47..836212c19ec 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1444,7 +1444,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; @@ -1456,24 +1456,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 @@ -1568,7 +1567,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.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index 9ac4d51f87e..e706200ec77 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -19,6 +19,19 @@ describe("historical ACPX profile decoding", () => { expect(isSupportedAcpxProfileVersion(agent, 6)).toBe(false); } }); + it("decodes retained Copilot revisions and the current profile without widening other providers", () => { + for (const version of [1, 5, 6, 12, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]) { + expect(isSupportedAcpxProfileVersion("copilot", version)).toBe(true); + } + expect(isSupportedAcpxProfileVersion("copilot", 35)).toBe(false); + for (const agent of ["cursor", "pi", "claude", "codex", "grok"]) { + expect(isSupportedAcpxProfileVersion(agent, 23)).toBe(false); + expect(isSupportedAcpxProfileVersion(agent, 24)).toBe(false); + expect(isSupportedAcpxProfileVersion(agent, 25)).toBe(false); + expect(isSupportedAcpxProfileVersion(agent, 26)).toBe(false); + expect(isSupportedAcpxProfileVersion(agent, 27)).toBe(false); + } + }); it.each([0, 1.5, "11", null, undefined, NaN])("rejects invalid revisions: %s", version => { expect(isSupportedAcpxProfileVersion("cursor", version)).toBe(false); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 87dd26db7e9..fb546726832 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; +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; 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], 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], + 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], 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 2459dab2763..41ec9bde429 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1286,7 +1286,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(() => { @@ -1299,6 +1299,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 () => { @@ -1376,7 +1377,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( @@ -1394,6 +1395,7 @@ describe("ACPX runtime host", () => { undefined, undefined, ]); + expect(fixture.commandClose).toHaveBeenCalledOnce(); await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }); const contender = await waitForAcpxOperation(() => stageManagedCodexCredential({ @@ -1406,9 +1408,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() @@ -1436,6 +1439,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 8be42da690f..0f441eb29b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1239,11 +1239,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); @@ -1253,9 +1251,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.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 3e0e52d250d..3fd9b1ba8eb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,6 +1,6 @@ import { piProviderConfiguration } from "./pi-provider-config.js"; import { configuredEnvironmentKeys } from "../../configured-environment.js"; -import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js"; +import { COPILOT_SYSTEM_INSTRUCTIONS_FILE, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { randomBytes } from "node:crypto"; import { constants, @@ -646,13 +646,15 @@ export async function refreshCopilotSystemInstructions( sandbox: Pick, instructions: string, ): Promise { - if (instructions.includes("\0") || Buffer.byteLength(instructions) > 32 * 1024) { + const current = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; + if (current.includes("\0") || Buffer.byteLength(current) > 32 * 1024) { throw new Error("Provider runtime instructions exceed their bounded size"); } - // Native Copilot reloads this file on session/load. Empty text clears old text. + // Native Copilot reloads this file on session/load. Empty caller text clears + // old caller instructions while retaining the current task-orientation rule. await writePrivateFile( join(sandbox.agentHomeDirectory, COPILOT_SYSTEM_INSTRUCTIONS_FILE), - `${instructions}\n`, + current, ); } 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 3e08de1f22f..cee7bb80b2e 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"; @@ -67,6 +68,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.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts index b5a8ee9ec12..a0f457e1115 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { runnerdRuntimeRequestExpiryNotification } from "../../live/runnerd-codex-transport.js"; import { resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { SemanticToolOutcomeUnknownError } from "../../contracts/native-session-backend.js"; @@ -2407,6 +2408,67 @@ describe("Codex app-server Codex driver", () => { for (const event of terminal) expect(validatePrpEvent(event).ok).toBe(true); }); + 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: TEST_WORKING_DIRECTORY, + }); + 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)); + // Exact Rust ACPX projector settlement shape; the facade uses its own + // provider-turn identity just as it does when creating the callback. + 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); // Same durable retirement cannot settle twice. + 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.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: TEST_WORKING_DIRECTORY }); + 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); + }); + it("expires a canonical input with its full question set when the provider transport is lost", async () => { const transport = new FakeCodexTransport(); const session = await makeDriver([transport]).openSession({ 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 4efc9eec23d..955040bf7be 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, @@ -97,7 +98,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 ( @@ -112,6 +113,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 e1fbc00415f..cdfc4bc15cf 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 @@ -16,6 +16,10 @@ describe("provider notification authority", () => { ["paperclip/canonicalProviderEvent", { threadId: null, eventType: "provider.notice.recorded" }, "root"], ["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 ba2581bbcf3..ad678244262 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -150,6 +150,26 @@ 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; + if (pending.request.turnId !== turnId || payload.requestKind !== pending.request.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..0b7a3f69262 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.test.ts @@ -0,0 +1,78 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { access } from "node:fs/promises"; +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) { + 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) { 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("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..f5c5b4289e7 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/copilot-metadata-probe.ts @@ -0,0 +1,162 @@ +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); + if (child && closed) { + child.stdin.end(); + const term = setTimeout(() => child?.kill("SIGTERM"), 500); + const kill = setTimeout(() => child?.kill("SIGKILL"), 2_000); + await closed; + clearTimeout(term); clearTimeout(kill); + } + try { await lease?.close(); } finally { if (root) await rm(root, { recursive: true, force: true }); } + } +} + +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 857109376d3..f3db235e71a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -10,6 +10,7 @@ import { readFile, readdir, readlink, + realpath, rename, rm, stat, @@ -19,6 +20,7 @@ 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 { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -26,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, @@ -529,6 +530,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"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4290d68d90b..18d14c588dc 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -25,7 +25,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 { @@ -73,7 +73,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, @@ -422,6 +422,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, @@ -1472,6 +1488,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, @@ -4021,6 +4048,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"); @@ -4030,6 +4058,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, @@ -4059,12 +4093,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 = { @@ -5408,6 +5446,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, @@ -6342,6 +6384,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); @@ -7045,6 +7094,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.ts b/packages/paperclip-runner/src/native-session-runtime.ts index e01dd745e23..c40f591176d 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-profile-v14-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-mainline-pi-identity.json new file mode 100644 index 00000000000..74191266281 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-mainline-pi-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:5e5955c57917a7e7c25703b3ed9e420cd72105eab033b611508dc37fdba3858b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 14, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "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": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "b83898b2453d0f425275d3bc58140675fc8f14505703103079094093f16be43f", + "semanticDirectDriverSourceSha256": "2db465e56ba41f06b406e039e566d9e3aea0a014fc6a47a6a54d5bc682dabcab", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "72d9800603a652d6580084bb3b5dac4d4dd6d644324b0d15d293d7c198fd6b55", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-identity.json new file mode 100644 index 00000000000..12ef2b3780d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:8faf47520f156c62c79ea6ca7d1d90a85ddc9a6621f798bd14e87bc6c643bd04", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 15, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "c0139d0b3af085ec5272a7812c9f89aff0e8618d1d44d9c9e9a59525d1972436", + "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": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "b83898b2453d0f425275d3bc58140675fc8f14505703103079094093f16be43f", + "semanticDirectDriverSourceSha256": "2db465e56ba41f06b406e039e566d9e3aea0a014fc6a47a6a54d5bc682dabcab", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "72d9800603a652d6580084bb3b5dac4d4dd6d644324b0d15d293d7c198fd6b55", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-identity.json new file mode 100644 index 00000000000..710031028ab --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 16, + "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": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06", + "semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "eec0e3740173ea377e73f54da1260a9063606e0dcbc3f08ddb01942a50085f56", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-identity.json new file mode 100644 index 00000000000..a35bae20552 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 17, + "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": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06", + "semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json new file mode 100644 index 00000000000..e5313db602d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json @@ -0,0 +1,81 @@ +{ + "commandDigest": "sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 33, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "fedf1e498c1e19daf9c973d475234488aa9acabbf1b9de890245bfac7a534483", + "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": "eebcc1ec9bc8caa8d1edf0cbda74bd69f5c55cf8d65a0503e95798b9979a19dc", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "49f7cb882fa09dcf879a2234d3e7b43fe72895d2c0f1e9b72fcbe18f012423bb", + "semanticDirectDriverSourceSha256": "3cea77a03c8822d68f77f090457176a9190facbec7d88ddf8c583c0e42245f70", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "328436d29148e0b79ce36f36c764b3d780913bdb90d174d1ea03cf1f59b5ef13", + "semanticSchemaBundleSourceSha256": "d8cef4a9ebc136a3d37f3d0be7161986e6d65420e2ec734cfd0dfa468c665c97", + "semanticSidecarProtocolSourceSha256": "907b2cdc1a5291c6fb570855bbcc66cb196b5cf64195d5dc201148940678460a", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "a03575b3a737dea60da94cf0fa1feed80719a9aaa7f09456eb57a845d9e8e087", + "configuredEnvironmentRustSourceSha256": "55d44e48e217385b5c64b1b7d8df16dda62319ca3fb524e452fd78c9ed804d3b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "0565d13d4064ed8c501d6ffda6d7bffca4493552de8aaa7fc12178dabb3ca435", + "profileInstallationSourceSha256": "7c26545922fc85db75a5db6bf3f6e21e381b6e189f4f51243b19ee237b93f8b1", + "attachmentPolicySourceSha256": "ce8027d1f8e2b7fd35df56a75ba162fd9696d1569c016b60bc9f0de1252cd958", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "aac818c1cf50a25899ae95cafc70f093c8cd8af37d19f88cbb7847b4d202d2b6", + "nativeRuntimeContextSourceSha256": "af192af3fa511347045dfdb86d443be9aa046a4c56bdb48198668cf4d30581b8", + "nativeSessionContractSourceSha256": "93251b1eadc686e9d8339d4dfc50b995a3d0a9e6ad8aedc6f2bdf7e30c16138d", + "harnessSessionContractSourceSha256": "75ae4607312ec13466dd2f4ac87f000302b7b5a952cb6d4b51573fa8b7c35d82", + "harnessBackendSourceSha256": "3b11fd93b82ef13d5e6396217e3f102f900437f4d32efb00a9d75a9d2214e6ff", + "harnessSessionSourceSha256": "a9d748b8530544342bbc9f59968816664872782a0865db76ca15f28a1705697b", + "transportContractSourceSha256": "16f358c75dd4ae80af3ca921394c5899b15942b80b553e43ddfabb41c1bf31d3", + "nativeBackendSourceSha256": "a23afeab7e7094cb7e3df05e7a9eac822d0144d8c44893dbcc8a0e7b2bce0c85", + "nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a", + "nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2", + "runtimeRequestBridgeSourceSha256": "c449568f8abf7e90d5ae7a83b49809d9166461398f8b3046e4fe5f3367f3b052", + "runtimeRequestFacadeSourceSha256": "3d70c6025169779f7886ddf363a1c6fda4add5d14fa3cd49aea46aa1f6a9d8ec", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "8afd25df307772e484e179e58bf54ce1510fe92be694482111d589f9f0ae6e77", + "nativeExecutionSourceSha256": "4cdd702c233bf0d086ff2aeea8f02c6db8817ba0c0bcb5e64e355ee7a1c2dd77", + "profileCompatibilitySourceSha256": "7e6afda2d2e5f1cdd09aaa75e4697905a40be175c6065b630b5dffa39c2c99a6", + "installationSourceSha256": "9934653d6983905930099b1691b1f0c05461e04e950bb27dd0f92e64fc8e28e1", + "assetOwnershipSourceSha256": "43005b7e896e0546c1f44cbe4ff3f34ad6d71f275f4b9a8113f73a71e77930c4", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "d5c200bea2e8ab730eb56e32ce2070c8a1b5407e5907108640f76a463d9b9c9c", + "sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a", + "profileSourceSha256": "baf0b64371fada6c92331f9f88e915292b2761c029fd86452f6de32e26dc7fd6", + "metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "runtimeHostSourceSha256": "83a5ae07c19681ab195f76363661c2381494b243343270ba363d4db2088932a5", + "runtimeAdapterSourceSha256": "a1a8784f5883d2c93df6e3695d5499b83e226efb4f26e558639c4e7dbcc2d67a", + "runtimeSandboxSourceSha256": "4acec4c9757fe5ef5f2788ce20ced8ca4fde8edcfb63b59d0effcfd4316c048d", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "9bbe8329064ee88418528394f8e1cdc716a0b2b35092dc70729b8850e46b7a68" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json new file mode 100644 index 00000000000..e65d6be0e4a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json @@ -0,0 +1,81 @@ +{ + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 34, + "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": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "7fd06053db98cc763c7205424069a5286c9d4cfefb3d197a4a765c693dccb7e6", + "semanticDirectDriverSourceSha256": "77882e21ad45b66a688fd1933dbd4fc89fad38e8c1f092805cb9485bdd5223c5", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "a03575b3a737dea60da94cf0fa1feed80719a9aaa7f09456eb57a845d9e8e087", + "configuredEnvironmentRustSourceSha256": "55d44e48e217385b5c64b1b7d8df16dda62319ca3fb524e452fd78c9ed804d3b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "aac818c1cf50a25899ae95cafc70f093c8cd8af37d19f88cbb7847b4d202d2b6", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "93251b1eadc686e9d8339d4dfc50b995a3d0a9e6ad8aedc6f2bdf7e30c16138d", + "harnessSessionContractSourceSha256": "9e3d62b712cae91168c96d11ff850b97a144809b5cddfe08ec9a91229e9bfc40", + "harnessBackendSourceSha256": "46fd6523f986d5406eca3b420fe920f1d51a6435fb7afc2acf4d21be13af447f", + "harnessSessionSourceSha256": "4dff9bb3370c5560cfc910a7ee814a27725b469c3b1b5dc021e3c0b158ebe746", + "transportContractSourceSha256": "5cd208fe11c3bedd476494b0609fb32efbabe4a1170e2c5854d0aa5e429bd6f4", + "nativeBackendSourceSha256": "294b9d830bc58c05985087875523db133d32cf999703140fd927e843399424db", + "nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a", + "nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2", + "runtimeRequestBridgeSourceSha256": "a66ee14f858ccf75c69dfbce132900a30724fe1e6baffc26b02d58a6bc5924ac", + "runtimeRequestFacadeSourceSha256": "e1993906ac9f47eb4ea04ae815db869ecde72f582af460cc0080308670612c8f", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "2881b655112f165320a73753385cd54286139a0273a5fa30c1b43fc3b5981a25", + "nativeExecutionSourceSha256": "9c702e518862c396ccfa12d84d11cb8dec1fc064c595359e31cc3e299a7378cc", + "profileCompatibilitySourceSha256": "20f8aaa363496a4cbede0f33aeacf869fb50171eaee2d63a892c65b3bda4c6a1", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a", + "profileSourceSha256": "baf0b64371fada6c92331f9f88e915292b2761c029fd86452f6de32e26dc7fd6", + "metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "runtimeHostSourceSha256": "cd825fe44b1d142aff2d293c3fc8a64d55e42a98bdea48e4d4b90f3a3163d232", + "runtimeAdapterSourceSha256": "3aea6448dc6474e465e5267b61798ad5449bdaec3f4a41bc4be831426893a9bb", + "runtimeSandboxSourceSha256": "b68686298054da576baa5544fe1364d9febe3f5d6aab5c49f9c69f011764afd3", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} 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`);