From 2e8d5e906e372a5fdf5f7de4239ac85bbdc33e59 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:24:15 -0500 Subject: [PATCH] feat(runner): admit Claude effort before native prompts and resume Version the shared Pi and pending Copilot source attestations without changing their executable closures or platform qualification. Co-Authored-By: Paperclip --- packages/paperclip-runner/acpx-profiles.json | 8 +- .../docs/protocol-compatibility.md | 11 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../provider-schemas/acpx-sidecar.schema.json | 15 ++- .../schemas/provider-descriptor.schema.json | 29 +++++ .../runner-core/src/acpx_provider_backend.rs | 23 +++- .../src/acpx_provider_checkpoint.rs | 5 + .../runner-core/src/acpx_provider_session.rs | 46 ++++++++ .../src/generated_acpx_profiles.rs | 4 +- .../tests/acpx_provider_checkpoint.rs | 2 + .../tests/acpx_provider_resolutions.rs | 1 + .../tests/acpx_provider_session.rs | 2 + .../tests/acpx_provider_suspend.rs | 1 + .../runner-core/tests/acpx_provider_turns.rs | 1 + .../tests/native_provider_backend.rs | 2 +- .../scripts/generate-acpx-profiles.mjs | 2 +- .../src/backends/codex-acpx-native-backend.ts | 3 +- .../src/cli/acpx-runtime-sidecar.ts | 7 +- .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-execution.test.ts | 29 ++++- .../src/contracts/native-execution.ts | 28 ++++- .../src/drivers/acpx/claude-effort.test.ts | 67 +++++++++++ .../src/drivers/acpx/claude-effort.ts | 21 ++++ .../src/drivers/acpx/codex-acpx-driver.ts | 4 + .../acpx/codex-runtime-adapter.test.ts | 25 ++++ .../src/drivers/acpx/codex-runtime-adapter.ts | 19 +++- .../src/drivers/acpx/config-admission.ts | 104 +++++++++++++++++ .../drivers/acpx/copilot-installation.test.ts | 14 ++- .../src/drivers/acpx/generated-profiles.ts | 8 +- .../src/drivers/acpx/pi-installation.test.ts | 40 ++++--- .../src/drivers/acpx/pi-thinking.ts | 107 ++---------------- .../drivers/acpx/recovery-identity.test.ts | 15 +++ .../src/drivers/acpx/recovery-identity.ts | 16 ++- .../src/drivers/acpx/runtime-host.ts | 7 ++ .../src/drivers/acpx/runtime-sandbox.test.ts | 14 +++ .../src/drivers/acpx/runtime-sandbox.ts | 4 + .../src/drivers/acpx/sidecar-protocol.ts | 2 + .../src/drivers/codex/codex-driver-values.ts | 3 + packages/paperclip-runner/src/index.ts | 1 + .../src/live/runnerd-codex-transport.ts | 7 ++ .../src/protocol/generated/schema-bundle.ts | 29 +++++ .../pi-acp/profile-v23-identity.json | 50 ++++++++ .../copilot-profile-v18-identity.json | 43 +++++++ tests/runner-e2e/pi-controls-evidence.test.ts | 8 +- 44 files changed, 681 insertions(+), 149 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/claude-effort.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/config-admission.ts create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 81a3e159050..c33720fdd9f 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "requiresProviderPolicy": true }, "cursor": { @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "requiresProviderPolicy": true, "qualificationStatus": "pending" }, diff --git a/packages/paperclip-runner/docs/protocol-compatibility.md b/packages/paperclip-runner/docs/protocol-compatibility.md index 1df8f015f95..862fbe0db93 100644 --- a/packages/paperclip-runner/docs/protocol-compatibility.md +++ b/packages/paperclip-runner/docs/protocol-compatibility.md @@ -154,7 +154,16 @@ policy in the closed provider configuration: `approvalPolicy` for Codex and provider-session identity, so an incompatible idle or recovered session is replaced on the next execution. An active turn is never mutated in place. -`paperclip.native-execution-input.v5` is the current input format. It retains +`paperclip.native-execution-input.v5` remains the ordinary input format. +Claude inputs with explicitly configured effort use v7 with the optional +`provider.claudeEffort` field. V7 retains the compact v3 model envelope; effort +is host configuration, never model-visible task text. V6 remains the separate +OpenAI Dot format. Readers retain persisted v1-v6 contracts. Claude effort is +acknowledged on each ACP connection before prompting, bound into the session +identity and recovery checkpoint, and cross-checked against the public provider +descriptor. Missing effort retains the provider default; values are never clamped. + +The v5 input format It retains the v4 permission pinning and adds optional completion-source references; `paperclip.native-model-envelope.v3` is the corresponding explicit model projection. Readers continue to accept persisted v1-v4 inputs, and v5 readers diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 2eefd4f3069..3bd089ba896 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -80,7 +80,7 @@ { "path": "schemas/provider-descriptor.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", - "sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519" + "sha256": "ef37ebba071ac239e0b4ff680a469eca6948b8eace451c5c88089c81b7acdae1" }, { "path": "schemas/provider-event.schema.json", diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index 89e6242f5bb..ee358cbab29 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -59,7 +59,7 @@ }, { "if": { "properties": { "command": { "const": "session.open" } } }, - "then": { "properties": { "params": { "$ref": "#/$defs/sessionOpenThinkingLevel" } } } + "then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } } } ] }, @@ -89,6 +89,19 @@ } } }, + "claudeEffort": { "enum": ["low", "medium", "high", "xhigh", "max"] }, + "sessionOpenClaudeEffort": { + "type": "object", + "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } }, + "if": { "properties": { "agent": { "const": "claude" } }, "required": ["agent"] }, + "then": { "properties": { "expectedIdentity": { + "type": ["object", "null"], "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } } + } } }, + "else": { "properties": { + "claudeEffort": false, + "expectedIdentity": { "type": ["object", "null"], "properties": { "claudeEffort": false } } + } } + }, "response": { "type": "object", "required": ["protocolVersion", "id", "ok"], diff --git a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json index 660054b6f7e..ad7f73d2909 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -153,6 +153,15 @@ "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -176,6 +185,26 @@ ] } }, + { + "if": { + "required": [ + "claudeEffort" + ] + }, + "then": { + "properties": { + "provider": { + "const": "acpx" + }, + "agent": { + "const": "claude" + } + }, + "required": [ + "agent" + ] + } + }, { "oneOf": [ { 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..01665c56bbf 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 @@ -143,6 +143,8 @@ struct AcpxProviderDescriptor { mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + claude_effort: Option, permission_mode_pinned: bool, #[serde(default)] provider_policy: Option, @@ -267,6 +269,10 @@ impl AcpxProviderDescriptor { || self.model.len() > 240 || self.model.contains('\0') || ((self.agent == "pi") != self.pi_thinking_level.is_some()) + || self.claude_effort.as_ref().is_some_and(|effort| { + self.agent != "claude" + || !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) || self.mode.as_ref().is_some_and(|mode| { mode.trim().is_empty() || mode.chars().count() > 240 @@ -368,6 +374,7 @@ impl AcpxProviderDescriptor { permission_mode: self.permission_mode, mode: self.mode.clone(), pi_thinking_level: self.pi_thinking_level, + claude_effort: self.claude_effort.clone(), permission_mode_pinned: self.permission_mode_pinned, provider_policy: self.provider_policy.clone(), system_instructions: self.instructions.clone(), @@ -472,6 +479,9 @@ impl AcpxProviderDescriptor { "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), "permissionMode": self.permission_mode, }); + if let Some(effort) = &self.claude_effort { + descriptor["claudeEffort"] = json!(effort); + } if let Some(level) = self.pi_thinking_level { descriptor["piThinkingLevel"] = json!(level); } @@ -611,6 +621,7 @@ impl AcpxDurableState { if identity.profile_digest != self.descriptor.command_digest || identity.mode != self.descriptor.mode || identity.pi_thinking_level != self.descriptor.pi_thinking_level + || identity.claude_effort != self.descriptor.claude_effort { return Err(DurableRunnerError::invalid( "ACPX durable identity no longer matches its qualified profile or provider mode", @@ -1295,6 +1306,9 @@ impl AcpxCommandExecutor { "sessionId": identity.agent_session_id, "processId": process_id, }); + if let Some(effort) = &identity.claude_effort { + result["claudeEffort"] = json!(effort); + } if let Some(level) = identity.pi_thinking_level { // This identity is admitted only after the sidecar's effective-mode ACK. result["piThinkingLevel"] = json!(level); @@ -2754,6 +2768,7 @@ mod tests { permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), pi_thinking_level: descriptor.pi_thinking_level, + claude_effort: descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3030,6 +3045,7 @@ mod tests { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; @@ -3184,7 +3200,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v22-identity.json" + "../../../../test-fixtures/pi-acp/profile-v23-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3208,6 +3224,7 @@ mod tests { include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v22-identity.json"), ] { let prior: Value = serde_json::from_str(prior).unwrap(); value["commandDigest"] = prior["commandDigest"].clone(); @@ -3507,6 +3524,7 @@ mod tests { permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), pi_thinking_level: original_descriptor.pi_thinking_level, + claude_effort: original_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3792,6 +3810,7 @@ mod tests { permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), pi_thinking_level: original_descriptor.pi_thinking_level, + claude_effort: original_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -4086,6 +4105,7 @@ mod tests { permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), pi_thinking_level: descriptor.pi_thinking_level, + claude_effort: descriptor.claude_effort.clone(), provider_lifetime_fence_candidates, }; let operations = Vec::new(); @@ -4259,6 +4279,7 @@ mod tests { permission_mode: Some(provider_descriptor.permission_mode), mode: provider_descriptor.mode.clone(), pi_thinking_level: provider_descriptor.pi_thinking_level, + claude_effort: provider_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates, }); state.provider_exit_unconfirmed = true; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs index 48c79292cdb..60f575b82e8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs @@ -53,6 +53,8 @@ struct PersistedAcpxProviderSessionIdentity { mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + claude_effort: Option, provider_lifetime_fence_candidates: [u16; 3], } @@ -77,6 +79,7 @@ impl PersistedAcpxProviderSessionIdentity { permission_mode, mode: identity.mode, pi_thinking_level: identity.pi_thinking_level, + claude_effort: identity.claude_effort.clone(), provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -95,6 +98,7 @@ impl PersistedAcpxProviderSessionIdentity { permission_mode: Some(self.permission_mode), mode: self.mode.clone(), pi_thinking_level: self.pi_thinking_level, + claude_effort: self.claude_effort.clone(), provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } @@ -117,6 +121,7 @@ impl AcpxSuspensionCheckpoint { || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode || identity.pi_thinking_level != config.pi_thinking_level + || identity.claude_effort != config.claude_effort || config .expected_identity .as_ref() diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 85da2d348b7..5d17a5276b3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -72,6 +72,8 @@ pub struct AcpxProviderSessionIdentity { pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_effort: Option, pub provider_lifetime_fence_candidates: [u16; 3], } @@ -94,6 +96,7 @@ pub struct AcpxProviderSessionConfig { pub permission_mode: AcpxPermissionMode, pub mode: Option, pub pi_thinking_level: Option, + pub claude_effort: Option, pub permission_mode_pinned: bool, pub provider_policy: Option, pub system_instructions: String, @@ -109,6 +112,14 @@ impl AcpxProviderSessionConfig { LocalRunnerError::invalid("ACPX agent must name a known immutable profile") })?; validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; + if self.claude_effort.as_ref().is_some_and(|effort| { + self.agent != "claude" + || !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) { + return Err(LocalRunnerError::invalid( + "Claude effort must be a supported Claude-only value", + )); + } if (self.agent == "pi") != self.pi_thinking_level.is_some() { return Err(LocalRunnerError::invalid( "ACPX Pi thinking level must be explicit for Pi and absent for other agents", @@ -188,6 +199,7 @@ impl AcpxProviderSessionConfig { || expected_identity.permission_mode != Some(self.permission_mode) || expected_identity.mode != self.mode || expected_identity.pi_thinking_level != self.pi_thinking_level + || expected_identity.claude_effort != self.claude_effort { return Err(LocalRunnerError::invalid( "ACPX expected identity conflicts with the requested session", @@ -208,6 +220,13 @@ impl AcpxProviderSessionIdentity { if let Some(mode) = self.mode.as_deref() { validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; } + if self.claude_effort.as_ref().is_some_and(|effort| { + !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) { + return Err(LocalRunnerError::invalid( + "ACPX identity contains invalid Claude effort", + )); + } for (value, label) in [ (&self.normalized_session_id, "normalized session"), (&self.acpx_record_id, "record"), @@ -1313,6 +1332,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu "tools": &sidecar_tools, "expectedIdentity": config.expected_identity, }); + if let Some(effort) = &config.claude_effort { + params["claudeEffort"] = json!(effort); + } if let Some(level) = config.pi_thinking_level { params["piThinkingLevel"] = json!(level); } @@ -1430,6 +1452,7 @@ fn verify_open_response( || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode || identity.pi_thinking_level != config.pi_thinking_level + || identity.claude_effort != config.claude_effort || config .expected_identity .as_ref() @@ -1755,6 +1778,7 @@ mod mode_tests { permission_mode: AcpxPermissionMode::ApproveReads, mode: Some("plan".to_owned()), pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }), system_instructions: String::new(), @@ -1782,6 +1806,7 @@ mod mode_tests { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: Some("plan".to_owned()), pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -1921,6 +1946,27 @@ mod mode_tests { identity } + #[test] + fn claude_effort_is_explicit_claude_only_and_forwarded_without_clamping() { + let mut cfg = config(); + cfg.agent = "claude".to_owned(); + for effort in ["low", "medium", "high", "xhigh", "max"] { + cfg.claude_effort = Some(effort.to_owned()); + cfg.validate().unwrap(); + assert_eq!( + session_open_params(&cfg, &[])["claudeEffort"], + json!(effort) + ); + } + cfg.claude_effort = Some("ultra".to_owned()); + assert!(cfg.validate().is_err()); + cfg.claude_effort = Some("high".to_owned()); + cfg.agent = "codex".to_owned(); + assert!(cfg.validate().is_err()); + cfg.claude_effort = None; + cfg.validate().unwrap(); + } + #[test] fn pi_thinking_level_is_closed_explicit_and_pi_only() { for (name, level) in [ 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..f255519807c 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 @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { @@ -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:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index 48daa24f6fa..619d20bef24 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -52,6 +52,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -80,6 +81,7 @@ fn identity() -> AcpxProviderSessionIdentity { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs index f64f89e4365..90460a2a294 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs @@ -51,6 +51,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index 71dcb93462d..5c89ae54055 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -47,6 +47,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -70,6 +71,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs index e8e65d76289..676df2529a3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -27,6 +27,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs index 973011b98f6..dc58137f012 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs @@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + claude_effort: None, pi_thinking_level: if mode.starts_with("controls") { Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low) } else { 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..4dde0516ef3 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 @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9"; + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index afdd2013b65..d73a3af6837 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-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v18-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/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index a77e4100263..1bf739229e7 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -15,7 +15,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext" + "model" | "permissionMode" | "mode" | "piThinkingLevel" | "claudeEffort" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -76,6 +76,7 @@ export function createAcpxNativeSessionBackend( permissionMode: input.provider.permissionMode ?? "approve-reads", mode: input.provider.mode, piThinkingLevel: input.provider.piThinkingLevel, + claudeEffort: "claudeEffort" in input.provider ? input.provider.claudeEffort : undefined, systemInstructions, runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 63fc827217a..c17c7a4882c 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js"; import { parseProviderMode } from "../contracts/provider-mode.js"; import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js"; @@ -297,6 +298,7 @@ async function dispatch( permissionMode: params.permissionMode, mode: params.mode, piThinkingLevel: params.piThinkingLevel, + claudeEffort: params.claudeEffort, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -1209,12 +1211,13 @@ function safeOutput(value: unknown): Record { function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { - const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); + const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field"); const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor"); const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel); + const claudeEffort = resolveClaudeEffort(agent, value.claudeEffort); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1236,6 +1239,7 @@ function parseOpenParams( permissionMode: requiredPermissionMode(value.permissionMode), ...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1307,6 +1311,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { : { permissionMode: requiredPermissionMode(input.permissionMode) }), ...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }), ...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }), + ...(input.claudeEffort === undefined ? {} : { claudeEffort: resolveClaudeEffort("claude", input.claudeEffort) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index a520a146431..f17af148250 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -457,6 +457,7 @@ export interface AcpxSessionIdentity { /** Effective provider mode identifier, bound to the session identity. */ mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 460a43534c6..ccb0c871d75 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it } from "vitest"; -import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, type NativeExecutionInputV1 } from "./native-execution.js"; +import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, type NativeExecutionInputV1 } from "./native-execution.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, @@ -680,3 +680,30 @@ describe("native task context ownership", () => { } }); }); + + +describe("Claude effort input v7", () => { + const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude; + const digest = "0".repeat(64); + const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } }, + skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null } }; + const current = { ...input, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, executionMode: "default", planningContext: null, + runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile } }; + it.each(["low", "medium", "high", "xhigh", "max"])("round trips explicit %s and retains the compact model envelope", claudeEffort => { + const parsed = parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort } }); + expect(parsed).toMatchObject({ schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, provider: { claudeEffort } }); + expect(buildNativeModelEnvelope(parsed).schema).toBe("paperclip.native-model-envelope.v3"); + expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("claudeEffort"); + }); + it("keeps historical readers closed and rejects aliases and foreign effort", () => { + for (const schema of ["paperclip.native-execution-input.v4", NATIVE_EXECUTION_INPUT_SCHEMA]) { + expect(() => parseNativeExecutionInput({ ...current, schema, provider: { ...current.provider, claudeEffort: "high" } })).toThrow("claudeEffort"); + expect(parseNativeExecutionInput({ ...current, schema }).schema).toBe(schema); + } + expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort: "ultra" } })).toThrow("Claude effort"); + expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, agent: "codex", profile: QUALIFIED_ACPX_PROFILES.codex, claudeEffort: "high" } })).toThrow("Claude effort"); + }); +}); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 194920c3e96..f1e9d8eebce 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js"; import type { DotBindingSnapshot } from "./external-provider.js"; import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js"; import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js"; @@ -9,6 +10,7 @@ import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeCo export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const; +export const NATIVE_EXECUTION_INPUT_SCHEMA_V7 = "paperclip.native-execution-input.v7" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const; @@ -238,6 +240,13 @@ export interface NativeExecutionInputV5 extends Omit + | (Extract & { claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max" }); +export interface NativeExecutionInputV7 extends Omit { + schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V7; + provider: NativeProviderConfigV7; +} + export interface NativeExecutionInputV6 extends Omit { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V6; provider: { kind: "openai_dot"; model: null; binding: DotBindingSnapshot }; @@ -245,7 +254,7 @@ export interface NativeExecutionInputV6 extends Omit & { driverKind: "openai_dot_mcp" }; } -export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6; +export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7; /** The only task data that may enter provider-visible model input. */ export interface NativeModelEnvelopeV1 { @@ -372,7 +381,8 @@ function parseDotNativeExecutionInput(input: Record): NativeExe export function parseNativeExecutionInput(value: unknown): NativeExecutionInput { const input = record(value, "input"); if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseDotNativeExecutionInput(input); - const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; + const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7; + const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4; const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3; const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2; @@ -504,7 +514,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "aws_agentcore" ? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"] : provider.kind === "acpx" - ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : []), ...(isV7 ? ["claudeEffort"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -527,7 +537,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) { throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form"); } - let parsedProvider: NativeProviderConfig | NativeProviderConfigV5; + let parsedProvider: NativeProviderConfigV7 | NativeProviderConfig | NativeProviderConfigV5; if (provider.kind === "claude_managed") { if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for claude_managed"); @@ -643,6 +653,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput } else if (provider.permissionPolicy !== "interactive") { throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive"); } + const claudeEffort = resolveClaudeEffort(provider.agent, provider.claudeEffort); const profile = record(provider.profile, "input.provider.profile"); exactKeys(profile, [ "driverKind", @@ -681,6 +692,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), ...(provider.mode === undefined ? {} : { mode: provider.mode as string }), + ...(claudeEffort === undefined ? {} : { claudeEffort }), ...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }), profile: { driverKind: "acpx_runtime", @@ -835,6 +847,12 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput provider: parsedProvider as NativeProviderConfigV4, }; if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 }; + if (isV7) return { + ...withPermissions, + schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, + provider: parsedProvider as NativeProviderConfigV7, + ...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}), + }; return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA, @@ -901,7 +919,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?: interactionResponses: structuredClone(input.interactionResponses), }; } - if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) { + if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) { const sourceBinding = input.completionSources; const references = sourceBinding?.contractRevision === input.completionContract.contract.revision && sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex") diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts new file mode 100644 index 00000000000..e21cd094851 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { admittedClaudeEffort, createClaudeEffortAdmission, resolveClaudeEffort } from "./claude-effort.js"; + +const response = (value: string, sessionId?: string) => ({ ...(sessionId ? { sessionId } : {}), configOptions: [{ id: "effort", currentValue: value }] }); +function loaded(method = "session/new") { + const admission = createClaudeEffortAdmission("high"); + const guard = admission.createGuard(); + guard("outbound", { id: 1, method, params: method === "session/new" ? {} : { sessionId: "s" } }); + guard("inbound", { id: 1, result: response("medium", method === "session/new" ? "s" : undefined) }); + return { admission, guard }; +} +function select(guard: ReturnType["createGuard"]>, acknowledged = "high") { + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "effort", value: "high" } }); + guard("inbound", { id: 2, result: response(acknowledged) }); +} +describe("Claude effort admission", () => { + it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts exact %s without aliases", value => { + expect(resolveClaudeEffort("claude", value)).toBe(value); + expect(admittedClaudeEffort("claude", value, { claudeEffort: value }, { claudeEffort: value })).toBe(value); + }); + it("rejects unsupported, foreign and conflicting effort", () => { + for (const value of ["ultra", "off", null, 2]) expect(() => resolveClaudeEffort("claude", value)).toThrow(); + expect(() => resolveClaudeEffort("pi", "high")).toThrow("only supported"); + expect(() => admittedClaudeEffort("claude", "high", { claudeEffort: "high" }, { claudeEffort: "medium" })).toThrow("differs"); + }); + it.each(["session/new", "session/load", "session/resume"])("requires acknowledged effort before prompting after %s", method => { + const { admission, guard } = loaded(method); + expect(admission.isReady()).toBe(false); + select(guard); + expect(admission.isReady()).toBe(true); + expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); + }); + + it("rejects a resumed session with missing effort configuration", () => { + const admission = createClaudeEffortAdmission("high"); + const guard = admission.createGuard(); + guard("outbound", { id: 1, method: "session/resume", params: { sessionId: "s" } }); + expect(() => guard("inbound", { id: 1, result: {} })).toThrow("missing or ambiguous"); + }); + it("blocks a prompt based only on a restored default", () => { + const { guard } = loaded("session/load"); + expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); + }); + + it("requires the bound effort acknowledgement on each cold connection", () => { + const { admission, guard } = loaded(); + select(guard); + const resumed = admission.createGuard(); + resumed("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + expect(() => resumed("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); + const fresh = loaded(); select(fresh.guard); + const acknowledged = fresh.admission.createGuard(); + acknowledged("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + acknowledged("inbound", { id: 4, result: response("high") }); + expect(() => acknowledged("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); + }); + it("rejects a provider that ignores the requested effort", () => { + const { guard } = loaded(); + expect(() => select(guard, "medium")).toThrow("did not apply"); + }); + it("rejects drift and wrong-session prompts after admission", () => { + const { guard } = loaded(); select(guard); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", ...response("low") } } })).toThrow("drifted"); + const fresh = loaded(); select(fresh.guard); + expect(() => fresh.guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "foreign" } })).toThrow("different session"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts new file mode 100644 index 00000000000..504ddd08afe --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts @@ -0,0 +1,21 @@ +import { createAcpxConfigAdmission } from "./config-admission.js"; + +export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort | undefined { + if (value === undefined || value === "") return undefined; + if (agent !== "claude") throw new Error("Claude effort is only supported by Claude"); + if (value !== "low" && value !== "medium" && value !== "high" && value !== "xhigh" && value !== "max") throw new Error("Claude effort must be low, medium, high, xhigh, or max"); + return value; +} +export function createClaudeEffortAdmission(expected: ClaudeEffort) { + // Every connection, including session/load, must select and acknowledge the + // configured effort. A restored preference alone never admits a prompt. + return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort", + supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] }); +} +export function admittedClaudeEffort(agent: string, requested: unknown, descriptor: Record, identity: Record): ClaudeEffort | undefined { + const expected = resolveClaudeEffort(agent, requested); + const effective = resolveClaudeEffort(agent, identity.claudeEffort); + if (effective !== expected || descriptor.claudeEffort !== expected) throw new Error("Claude effort differs from the admitted configuration"); + return effective; +} 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..1653bbab774 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -148,6 +148,7 @@ export interface CodexAcpxDriverOptions { permissionMode?: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -463,6 +464,7 @@ export class CodexAcpxDriver implements HarnessDriver { permissionMode: this.#options.permissionMode ?? "approve-all", mode: this.#options.mode, piThinkingLevel: this.#options.piThinkingLevel, + claudeEffort: this.#options.claudeEffort, providerPolicy: this.#options.providerPolicy, runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, @@ -1290,6 +1292,7 @@ class CodexAcpxSession implements HarnessSession { permissionMode: identity.permissionMode, ...(identity.mode === undefined ? {} : { mode: identity.mode }), ...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }), + ...(identity.claudeEffort === undefined ? {} : { claudeEffort: identity.claudeEffort }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -2153,6 +2156,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { identity.permissionMode, )) || (identity.mode !== undefined && !isProviderMode(identity.mode)) || + (identity.claudeEffort !== undefined && !["low", "medium", "high", "xhigh", "max"].includes(identity.claudeEffort)) || (identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) || !validProviderLifetimeFenceCandidates( identity.providerLifetimeFenceCandidates, 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..26e14d2f6da 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,31 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { + it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => { + const runtime = fakeRuntime(); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "claude" }; + options.claudeEffort = "high"; + options.restoringSession = restoring; + options.refreshConsumedCommand = vi.fn(async () => undefined); + let created!: AcpRuntimeOptions; + vi.mocked(runtime.setConfigOption).mockImplementation(async input => { + const guard = created.protocolGuardFactory!(); + guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } }); + guard("inbound", { id: 0, result: { configOptions: [{ id: "effort", currentValue: "default" }] } }); + guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } }); + guard("inbound", { id: 1, result: { configOptions: [{ id: "effort", currentValue: input.value }] } }); + }); + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" }); + expect(await port.identity()).toMatchObject({ claudeEffort: "high" }); + expect(options.refreshConsumedCommand).toHaveBeenCalledOnce(); + expect(runtime.startTurn).not.toHaveBeenCalled(); + await port.close({ reason: "test complete" }); + }); + 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..bac1e89cb95 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js"; import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import type { ChildProcess } from "node:child_process"; @@ -305,6 +306,8 @@ export async function openQualifiedAcpxRuntime( const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode); const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel); const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined; + const selectedClaudeEffort = resolveClaudeEffort(options.profile.agent, options.claudeEffort); + const claudeEffort = selectedClaudeEffort ? createClaudeEffortAdmission(selectedClaudeEffort) : undefined; const cursorInstructions = options.profile.agent === "cursor" ? createCursorInstructionAdmission(options.systemInstructions) : null; @@ -316,6 +319,7 @@ export async function openQualifiedAcpxRuntime( return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); }; } } : {}), ...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}), + ...(claudeEffort ? { protocolGuardFactory: () => claudeEffort.createGuard() } : {}), sessionStore, agentRegistry: createRegistry({ // Preserve Claude's ACP capability identity. This is metadata only: the @@ -527,6 +531,18 @@ export async function openQualifiedAcpxRuntime( await commandLaunches.refreshConsumedCommand?.(); } return ensuredHandle; + }) : claudeEffort ? ensuredSession.then(async (ensuredHandle) => { + handle = ensuredHandle; + options.signal?.throwIfAborted(); + if (!claudeEffort.isReady()) { + if (!runtime.setConfigOption) throw new Error("Claude effort admission requires native configuration"); + await runtime.setConfigOption({ handle: ensuredHandle, key: "effort", value: selectedClaudeEffort! }); + claudeEffort.assertReady(); + await children.verifyLifetimeOwnership(); + options.signal?.throwIfAborted(); + await commandLaunches.refreshConsumedCommand?.(); + } + return ensuredHandle; }) : ensuredSession) .catch((error: unknown) => { throw classifySessionEnsureFailure(error); @@ -543,6 +559,7 @@ export async function openQualifiedAcpxRuntime( cursorInstructions?.assertReady(); modeBinding?.assertReady(); piThinking?.assertReady(); + claudeEffort?.assertReady(); // A provider can answer only after the verified sentinel is armed, but do // not admit the session until the owner has observed that exact handoff. await children.verifyLifetimeOwnership(); @@ -597,7 +614,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) }, + { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}), ...(selectedClaudeEffort ? { claudeEffort: selectedClaudeEffort } : {}) }, baseStore, children, runtimeCloseTimeoutMs, diff --git a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts new file mode 100644 index 00000000000..be597f10e16 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -0,0 +1,104 @@ +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Native mode is independent of permissions. Every connection must prove its + * current mode before a prompt; a persisted ACPX preference is not proof. */ +export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) { + type State = { sessionId: string | null; mode: string | null; error: Error | null }; + let current: State | null = null; + let selectedOnce = options.restoring === true; + const failure = (detail: string) => new Error(`${options.label} admission failed: ${detail}`); + const assertReady = () => { + if (current?.error) throw current.error; + if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); + }; + const readMode = (result: Record, session: boolean): string => { + const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : []; + if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); + const mode = object(configs[0]).currentValue; + // Before exact model selection, Pi can legitimately report a different + // native model's level. Observation alone never admits the first prompt. + if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported"); + if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); + return mode; + }; + return { + assertReady, + isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; }, + createGuard() { + const state: State = { sessionId: null, mode: null, error: null }; + current = state; + const pending = new Map(); + return (direction: "inbound" | "outbound", value: unknown): void => { + if (current !== state) throw failure("connection authority was replaced"); + assertNoError(); + const message = object(value); + const params = object(message.params); + try { + if (direction === "outbound") { + const method = message.method; + if (method === "session/prompt") { + assertReady(); + if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); + } else if (method === "session/new" || method === "session/load" || method === "session/resume" || method === "session/set_config_option") { + if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); + if (pending.has(message.id)) throw failure("duplicate mode request identity"); + if (pending.size) throw failure("overlapping native admission controls"); + if (method === "session/new" || method === "session/load" || method === "session/resume") { + if (pending.size) throw failure("overlapping session admission"); + state.sessionId = null; state.mode = null; + } else { + if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session"); + if (params.configId === options.configId && params.value !== expected) throw failure("configuration changes the selected mode"); + state.mode = null; // No prompt may race unacknowledged native configuration. + } + pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId }); + } else if (options.sessionMode && method === "session/set_mode") { + if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); + // This method only returns {}; require a later config/session + // acknowledgement rather than treating that empty result as proof. + state.mode = null; + } + return; + } + if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { + const update = object(params.update); + // Config updates may contain only model options. A mode entry is + // authoritative observation, but never an admission acknowledgement. + if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) { + if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + const observed = readMode(update, false); + if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode"); + } + return; + } + if (options.sessionMode && message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") { + if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode"); + return; // Notifications alone never admit a prompt. + } + if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error !== undefined) { state.mode = null; return; } + const result = object(message.result); + const session = request.method !== "session/set_config_option"; + const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; + if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity"); + const mode = readMode(result, session); + if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode"); + if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode"); + if (!session && request.selectsMode) selectedOnce = true; + state.sessionId = sessionId; state.mode = mode; + } catch (error) { + state.error = error instanceof Error ? error : failure("invalid mode acknowledgement"); + throw state.error; + } + }; + }, + }; + function assertNoError() { if (current?.error) throw current.error; } +} + diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 999efa6eed2..6662f9fe2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,8 +87,16 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); - it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it("preserves v17 executable and policy identity while binding the shared Claude effort sources", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()) + .toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("admits the current v18 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -119,7 +127,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 3a0bf38cf33..92b3eadbcdd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "permissionPolicy": "interactive" }, "cursor": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index c04f4c3f71e..f34810516f9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -88,11 +88,19 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("preserves profile 22 runtime bytes and revises only shared Claude effort source attestations", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(22), current = await read(23); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "recoveryIdentitySourceSha256", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => 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); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v22-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v23-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -123,21 +131,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 22"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 23"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 23"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts index aa5d67cde3f..be7c80cb56c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts @@ -1,3 +1,5 @@ +import { createAcpxConfigAdmission } from "./config-admission.js"; + export type PiThinkingLevel = "off" | "low" | "high" | "max"; export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinkingLevel | undefined { @@ -10,108 +12,13 @@ export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinking return mode; } -function object(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) { + return createAcpxConfigAdmission(expected, { ...options, label: "Pi thinking mode", configId: "thought_level", + supportedValues: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], sessionMode: true }); } -/** Native mode is independent of permissions. Every connection must prove its - * current mode before a prompt; a persisted ACPX preference is not proof. */ -export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) { - type State = { sessionId: string | null; mode: string | null; error: Error | null }; - let current: State | null = null; - let selectedOnce = options.restoring === true; - const failure = (detail: string) => new Error(`Pi thinking mode admission failed: ${detail}`); - const assertReady = () => { - if (current?.error) throw current.error; - if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); - }; - const readMode = (result: Record, session: boolean): string => { - const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "thought_level") : []; - if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); - const mode = object(configs[0]).currentValue; - // Before exact model selection, Pi can legitimately report a different - // native model's level. Observation alone never admits the first prompt. - if (typeof mode !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(mode)) throw failure("native mode is unsupported"); - if (session && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); - return mode; - }; - return { - assertReady, - isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; }, - createGuard() { - const state: State = { sessionId: null, mode: null, error: null }; - current = state; - const pending = new Map(); - return (direction: "inbound" | "outbound", value: unknown): void => { - if (current !== state) throw failure("connection authority was replaced"); - assertNoError(); - const message = object(value); - const params = object(message.params); - try { - if (direction === "outbound") { - const method = message.method; - if (method === "session/prompt") { - assertReady(); - if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); - } else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") { - if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); - if (pending.has(message.id)) throw failure("duplicate mode request identity"); - if (pending.size) throw failure("overlapping native admission controls"); - if (method === "session/new" || method === "session/load") { - if (pending.size) throw failure("overlapping session admission"); - state.sessionId = null; state.mode = null; - } else { - if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session"); - if (params.configId === "thought_level" && params.value !== expected) throw failure("configuration changes the selected mode"); - state.mode = null; // No prompt may race unacknowledged native configuration. - } - pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "thought_level" }); - } else if (method === "session/set_mode") { - if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); - // This method only returns {}; require a later config/session - // acknowledgement rather than treating that empty result as proof. - state.mode = null; - } - return; - } - if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { - const update = object(params.update); - // Config updates may contain only model options. A mode entry is - // authoritative observation, but never an admission acknowledgement. - if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === "thought_level")) { - if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); - const observed = readMode(update, false); - if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode"); - } - return; - } - if (message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") { - if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); - if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode"); - return; // Notifications alone never admit a prompt. - } - if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; - const request = pending.get(message.id); - if (!request) return; - pending.delete(message.id); - if (message.error !== undefined) { state.mode = null; return; } - const result = object(message.result); - const session = request.method !== "session/set_config_option"; - const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; - if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity"); - const mode = readMode(result, session); - if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode"); - if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode"); - if (!session && request.selectsMode) selectedOnce = true; - state.sessionId = sessionId; state.mode = mode; - } catch (error) { - state.error = error instanceof Error ? error : failure("invalid mode acknowledgement"); - throw state.error; - } - }; - }, - }; - function assertNoError() { if (current?.error) throw current.error; } +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } /** Cross-check independently returned durable identity and public descriptor. */ diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index ddd5d29314b..f8b43855419 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -75,6 +75,21 @@ describe("ACPX recovery identity", () => { expect(customMode.mode).toBe("provider-custom-mode"); }); + it("binds Claude effort to recovery and rejects changed or omitted effort", async () => { + const fixture = await recoveryFixture(); + const profile = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"); + const input = { ...fixture.input, profile, requestedModel: profile.qualificationModel, claudeEffort: "high" as const }; + const high = await createAcpxRecoveryBinding(input); + const low = await createAcpxRecoveryBinding({ ...input, claudeEffort: "low" }); + expect(low.profileSessionKey).not.toBe(high.profileSessionKey); + const expected = { ...fixture.expected, profileDigest: high.commandDigest, requestedModel: high.requestedModel, effectiveModel: high.effectiveModel, claudeEffort: "high" as const }; + const saved = createAcpxIdentityRecord(expected, high); + expect(acpxProviderSessionIdentity(saved, high).claudeEffort).toBe("high"); + expect(() => verifyExpectedAcpxIdentity({ ...expected, claudeEffort: undefined }, high, saved)).toThrow("immutable"); + expect(() => verifyExpectedAcpxIdentity(expected, high, { ...saved, claudeEffort: undefined })).toThrow("persisted"); + expect(() => verifyExpectedAcpxIdentity(expected, low, saved)).toThrow("immutable"); + }); + it("binds Pi thinking mode and rejects old or mismatched warm identities", async () => { const fixture = await recoveryFixture(); const profile = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index 4439b02edcf..8393078cc5a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "./claude-effort.js"; import { parseProviderMode } from "../../contracts/provider-mode.js"; import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import { createHash } from "node:crypto"; @@ -23,6 +24,7 @@ export interface AcpxRecoveryBinding { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; profileSessionKey: string; } @@ -39,6 +41,7 @@ export interface AcpxIdentityRecord { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } @@ -51,12 +54,14 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerConfigurationDigest?: string; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; }): Promise { validateIdentity(input.normalizedSessionId, "normalized session"); const mode = parseProviderMode(input.mode); const piThinkingLevel = resolvePiThinkingLevel(input.profile.agent, input.piThinkingLevel); + const claudeEffort = resolveClaudeEffort(input.profile.agent, input.claudeEffort); if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy"); if (input.requestedModel !== input.profile.qualificationModel) { throw new Error("ACPX recovery requested model does not match its admitted profile"); @@ -104,6 +109,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -118,6 +124,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), profileSessionKey, }; } @@ -140,6 +147,7 @@ export function createAcpxIdentityRecord( permissionMode: binding.permissionMode, ...(binding.mode ? { mode: binding.mode } : {}), ...(binding.piThinkingLevel ? { piThinkingLevel: binding.piThinkingLevel } : {}), + ...(binding.claudeEffort ? { claudeEffort: binding.claudeEffort } : {}), providerLifetimeFenceCandidates: Object.freeze([ ...expected.providerLifetimeFenceCandidates, ]) as readonly [number, number, number], @@ -167,6 +175,7 @@ export function acpxProviderSessionIdentity( permissionMode: record.permissionMode, ...(record.mode ? { mode: record.mode } : {}), ...(record.piThinkingLevel ? { piThinkingLevel: record.piThinkingLevel } : {}), + ...(record.claudeEffort ? { claudeEffort: record.claudeEffort } : {}), providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); @@ -193,7 +202,8 @@ export function verifyExpectedAcpxIdentity( expected.effectiveModel !== binding.effectiveModel || expected.permissionMode !== binding.permissionMode || expected.mode !== binding.mode || - expected.piThinkingLevel !== binding.piThinkingLevel + expected.piThinkingLevel !== binding.piThinkingLevel || + expected.claudeEffort !== binding.claudeEffort ) { throw new Error( "ACPX recovery identity conflicts with the immutable session configuration", @@ -214,6 +224,7 @@ export function verifyExpectedAcpxIdentity( record.permissionMode !== binding.permissionMode || record.mode !== binding.mode || record.piThinkingLevel !== binding.piThinkingLevel || + record.claudeEffort !== binding.claudeEffort || !sameFenceCandidates( record.providerLifetimeFenceCandidates, expected.providerLifetimeFenceCandidates, @@ -240,6 +251,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "permissionMode", "mode", "piThinkingLevel", + "claudeEffort", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -267,6 +279,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { throw new Error("ACPX identity permission mode is invalid"); } if (value.mode !== undefined) parseProviderMode(value.mode); + resolveClaudeEffort("claude", value.claudeEffort); if (value.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", value.piThinkingLevel); validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; @@ -297,6 +310,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { throw new Error("Expected ACPX permission mode is invalid"); } if (expected.mode !== undefined) parseProviderMode(expected.mode); + resolveClaudeEffort("claude", expected.claudeEffort); if (expected.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", expected.piThinkingLevel); validateFenceCandidates(expected.providerLifetimeFenceCandidates); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 8be42da690f..f97d92b1690 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "./claude-effort.js"; import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; @@ -83,6 +84,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -163,6 +165,7 @@ export interface AcpxRuntimePortOpenOptions { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -238,6 +241,7 @@ export interface OpenAcpxRuntimeHostOptions { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -350,6 +354,7 @@ export class AcpxRuntimeHost { permissionMode: options.permissionMode, mode: resolveAcpxProviderMode(options.agent, options.mode), piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel), + claudeEffort: resolveClaudeEffort(options.agent, options.claudeEffort), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -581,6 +586,7 @@ export class AcpxRuntimeHost { permissionMode: binding.permissionMode, mode: binding.mode, piThinkingLevel: binding.piThinkingLevel, + claudeEffort: binding.claudeEffort, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -644,6 +650,7 @@ export class AcpxRuntimeHost { if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) { throw new Error("ACPX runtime Pi thinking level conflicts with session binding"); } + if (runtimeIdentity.claudeEffort !== binding.claudeEffort) throw new Error("ACPX runtime Claude effort conflicts with session binding"); const observedIdentity: AcpxExpectedSessionIdentity = { kind: "acpx", normalizedSessionId: binding.normalizedSessionId, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index ab14aa666f3..7225231f2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -119,6 +119,20 @@ describe("ACPX runtime sandbox", () => { } }); + it("restores bound Claude effort in private settings across cold connections", async () => { + const fixture = await sandboxFixture("claude"); + for (const claudeEffort of ["high", "xhigh", "max"] as const) { + const binding = { ...fixture.binding, claudeEffort }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); + expect(settings.effortLevel).toBe(claudeEffort); + } + } + const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "claude" }); + expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"))).not.toHaveProperty("effortLevel"); + }); + it.each([ ["pi", "OPENROUTER_API_KEY", "pi-home"], ["claude", "ANTHROPIC_API_KEY", "claude-home"], diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 3e0e52d250d..d5ae8ce9e14 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -403,6 +403,10 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], + // Claude reloads effort from settings on a new ACP connection. Keep + // the bound choice available during model verification and resume; + // the protocol guard still requires a provider acknowledgement. + ...(input.binding.claudeEffort ? { effortLevel: input.binding.claudeEffort } : {}), ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } : {}), diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index d9ce539a9f6..717ffc97748 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -47,6 +47,7 @@ export interface AcpxSidecarOpenParams { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -69,6 +70,7 @@ export interface AcpxExpectedSessionIdentity { permissionMode?: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index c6644732cb7..77a7b2ae959 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "../acpx/claude-effort.js"; import { resolvePiThinkingLevel } from "../acpx/pi-thinking.js"; import { isProviderMode } from "../../contracts/provider-mode.js"; import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js"; @@ -81,6 +82,7 @@ export function parseProviderIdentity( ); } const piThinkingLevel = identity.piThinkingLevel === undefined ? undefined : resolvePiThinkingLevel("pi", identity.piThinkingLevel); + const claudeEffort = resolveClaudeEffort("claude", identity.claudeEffort); const mode = identity.mode; if (mode !== undefined && !isProviderMode(mode)) { throw new Error("ACPX provider identity contains an invalid provider mode"); @@ -112,6 +114,7 @@ export function parseProviderIdentity( ...(permissionMode === undefined ? {} : { permissionMode }), ...(mode === undefined ? {} : { mode }), ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), + ...(claudeEffort === undefined ? {} : { claudeEffort }), providerLifetimeFenceCandidates: fenceCandidates as [ number, number, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 32103f88d94..ac89da39070 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -57,6 +57,7 @@ export { type QualifiedAcpxEnvironmentProbe, } from "./drivers/acpx/codex-acpx-driver.js"; export * from "./drivers/acpx/sidecar-protocol.js"; +export { resolveClaudeEffort } from "./drivers/acpx/claude-effort.js"; export * from "./drivers/runner-tool-bridge.js"; export { createRunnerdCodexTransport, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ff7dc3e8d56..3f49e94a6a0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,5 +1,6 @@ import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { resolveClaudeEffort, admittedClaudeEffort } from "../drivers/acpx/claude-effort.js"; import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { configuredEnvironment } from "../configured-environment.js"; import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; @@ -1191,6 +1192,7 @@ export interface CapabilityRunnerdProcessEvidence { providerDriver: string | null; /** Effective native mode, populated only from the admitted Pi identity. */ piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerVersion: string | null; acpxAgent: QualifiedAcpxAgent | null; agentServerVersion: string | null; @@ -1215,6 +1217,7 @@ export interface CapabilityRunnerdCodexTransportOptions { acpxPermissionMode?: NativeAcpxPermissionMode; acpxMode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -3617,6 +3620,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + resolveClaudeEffort(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.claudeEffort); resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel); if (options.acpxMode !== undefined && options.provider !== "acpx") { throw new Error("acpxMode requires the ACPX provider"); @@ -4912,6 +4916,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, ...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }), ...(acpxProfile!.agent === "pi" ? { piThinkingLevel: this.options.piThinkingLevel } : {}), + ...(acpxProfile!.agent === "claude" && this.options.claudeEffort ? { claudeEffort: this.options.claudeEffort } : {}), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), @@ -6597,6 +6602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) { throw new Error("ACPX capability identity differs from the admitted profile"); } + const claudeEffort = admittedClaudeEffort(this.options.acpxAgent ?? "codex", this.options.claudeEffort, descriptor, providerIdentity); + if (claudeEffort !== undefined) this.#evidence.claudeEffort = claudeEffort; const piThinkingLevel = admittedPiThinkingLevel(this.options.acpxAgent ?? "codex", this.options.piThinkingLevel, descriptor, providerIdentity); if (piThinkingLevel !== undefined) this.#evidence.piThinkingLevel = piThinkingLevel; this.#turnControls = parseAcpxTurnControlCapabilities(descriptor.turnControls, descriptor.agent); diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts index 9d9efa46acf..4874c6c0943 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -1178,6 +1178,15 @@ export const providerDescriptorSchema = { "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -1201,6 +1210,26 @@ export const providerDescriptorSchema = { ] } }, + { + "if": { + "required": [ + "claudeEffort" + ] + }, + "then": { + "properties": { + "provider": { + "const": "acpx" + }, + "agent": { + "const": "claude" + } + }, + "required": [ + "agent" + ] + } + }, { "oneOf": [ { diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json new file mode 100644 index 00000000000..e344c8eb532 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 23, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "8767456c125b53b1e85fbfce84245c9b213f9ef27a58970cf73dbc2ff82005d8", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json new file mode 100644 index 00000000000..eab746e74c7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 18, + "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": "d2777bfc3e15ee7861ce57a069ffb55d3dc266364adf7915845bfbad5aa2e402", + "semanticDirectDriverSourceSha256": "187772a17e5d4f854ec10f37abfcd294cf4f1db58a91d18e7645f4a8c617f329", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fb4a232460dc9e6c7e7221cb4ddd66d7cb82fa0b35e1b3eb04f2d1ee5ee18c86", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index b28f3b3091e..21691d1a965 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => { expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true); expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false); - expect(validateRunnerCatalog()).toHaveLength(722); + expect(validateRunnerCatalog()).toHaveLength(724); for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); @@ -189,8 +189,8 @@ describe("Pi controls catalog admission", () => { // Extended v3 states the strict file oracle's task-wide Bash limit. const pi = runnerMatrix.find(c => c.profile.qualificationCandidate === "pi")!.profile; expect(pi.modelQualification?.qualificationId).toBe("pi:0.0.33:1.0.0:openrouter"); - expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 19, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); - expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 19, + expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 23, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); + expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 23, remoteProcessIdentity: "observer-pid-startTicks-bootId", controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner" }); expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 5, piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" }); @@ -200,7 +200,7 @@ describe("Pi controls catalog admission", () => { } const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)])); expect(hashes).toEqual({ - "pi-native": "90f2e901d7f39bfb6bbadcf63bc78e410a3b071af980f3a23979747e3d5f2021", + "pi-native": "3c77ced7edfb99aa12052f451398da7580466a54abadcaf287d31ee12c16382d", "native-active-stop": "2d4fdeeb75bd531b309bd1b35cfa5680ceefdeee6b7155b068dd011ce9901980", "rich-acp-warm-continuity": "331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0", "extended-harnesses": "5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6",