diff --git a/.dockerignore b/.dockerignore index 62d5e970ad2..a863f36141c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,8 +45,9 @@ packages/paperclip-runner/test/fixtures/** !packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json -!packages/paperclip-runner/test/fixtures/copilot-profile-v18-muse-historical-identity.json +!packages/paperclip-runner/test/fixtures/copilot-profile-v18-claude-qualification-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v19-identity.json +!packages/paperclip-runner/test/fixtures/copilot-profile-v18-muse-historical-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v19-muse-historical-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v20-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json @@ -61,6 +62,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json !packages/paperclip-runner/docs/capability-contract.md # check:runner-workflow-traceability access()es every regression test the # stress-traceability spec names — those are src/**/*.test.ts files, so @@ -69,3 +71,12 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/src/**/*.test.tsx packages/grok-acp/bin + +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-claude-qualification-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v26-identity.json +!packages/paperclip-runner/test/fixtures/copilot-profile-v19-claude-qualification-identity.json +!packages/paperclip-runner/test/fixtures/copilot-profile-v22-identity.json + +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-claude-historical-identity.json + +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v27-identity.json diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 6046bfb2236..c367ce6acd3 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1,6 +1,29 @@ # Pi rich ACP runtime -## Current integration — 2026-10-08 +## Current source integration — 2026-10-19 + +The composed Claude source declares **Pi profile 27** +(`sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba`) +and **Copilot profile 22**, still pending qualification +(`sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2`). +These identities bind the shared Claude effort and lifecycle changes plus +master's OpenRouter attribution. Provider executable closures, model/effort +options and qualification boundaries are unchanged. Current package and source +checks remain separate from retained provider execution results, which keep +their literal profile/source identities. Earlier immutable fixtures remain +historical; they are not accepted as the current source identity. + +## Historical source integration — 2026-10-12 + +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) +advances the shared sandbox source attestation after Claude settings initialization +moved behind provider lifetime ownership. Profile 23 remains an immutable +historical identity. Pi's provider executable closures, model/effort options and +platform qualification are unchanged; retained live evidence keeps its literal +profile/source identity. Copilot remains pending at profile 18. Current package +and source controls are separate from those retained provider execution results. + +## Historical integration — 2026-10-08 The experimental Runner admits **Pi profile 22** (`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`) and **Cursor profile 15**. Copilot remains pending at **profile 17**. Legacy diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index c1e6e612602..a1d611b7423 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,6 +1,40 @@ # Rich ACP integration and qualification report -## Current integration — 2026-10-08 +## Current source integration — 2026-10-19 + +The composed Claude source declares **Pi profile 27** +(`sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba`) +and **Copilot profile 22**, still pending qualification +(`sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2`). +These identities bind the shared Claude effort and lifecycle changes plus +master's OpenRouter attribution. Provider executable closures, model/effort +options and qualification boundaries are unchanged. Current package and source +checks remain separate from retained provider execution results, which keep +their literal profile/source identities. Earlier immutable fixtures remain +historical; they are not accepted as the current source identity. + +## Historical source integration — 2026-10-14 + +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) +advances the shared sandbox source attestation after Claude settings initialization +moved behind provider lifetime ownership. Profile 23 remains an immutable +historical identity. Pi's provider executable closures, model/effort options and +platform qualification are unchanged; retained live evidence keeps its literal +profile/source identity. Copilot remains pending at profile 19. Current package +and source controls are separate from those retained provider execution results. + +The actual Codex squash `adb69e8f09852c772d9901ae270e5b2d66e39c77` +already owns Copilot profile 18 for the Grok descriptor schema. Its immutable +fixture is retained. The combined Claude effort sources use profile 19 +(`sha256:a4c7e955df18bf2448224ecce2120b43ede8054fe00e7b33b4044b8bd284b321`), +with executable, permission policy and pending qualification unchanged. The +prior unmerged Claude profile-18 attestation is retained separately as +`copilot-profile-v18-claude-qualification-identity.json`; earlier evidence keeps +its literal source. Pi profile 25 and its source digest remain unchanged by +this reconciliation. Existing settings and effort/session admission still need +final packaged and real-provider qualification on the composed Claude source. + +## Historical integration — 2026-10-08 The experimental Runner admits **Pi profile 22** (`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`) and **Cursor profile 15**. Copilot remains pending at **profile 17**. Legacy diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 2b78bd9f858..3b404f2af54 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -240,6 +240,13 @@ ownership checks still apply. No agent gains session or recovery control. PostgreSQL regressions cover valid native/Legacy callbacks and rejected bindings; revised live execution and actor authorization remain `not-run`. +Scoped source-only evidence: [configuration changes](../../server/src/services/agent-configuration.ts) +and [native execution/recovery](../../server/src/services/native-runtime/native-session-executor.ts) +retain existing execution on unrelated edits and use recorded run configuration +for active/recovered runs. Explicit runner changes use revisions and session +invalidation. Verify saved configuration, current versus historical run identity, +restart and follow-up without granting session-reset authority to an agent. + Scoped Evidence: `source-only` — the [native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) joins owned startup before certifying cleanup. Rejected duplicates cannot replace diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 71b6649d16d..ef0af37fba6 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -148,6 +148,7 @@ state. Readiness observes shutdown; resumed startup cannot reactivate ingress after close. These are lifecycle controls, separate from installed provider proof. The real daemon readiness and recovery controls require their existing native fixtures in hosted CI; passing credential-free controls does not qualify them. +A primary setup timeout remains a failure even when teardown also fails. For remote setup shutdown, verify that each durable-state read respects the remaining suspension deadline. A late result cannot establish suspension or diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 33106408346..e483929ad19 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 24, + "agentProfileVersion": 27, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39", + "commandDigest": "sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba", "requiresProviderPolicy": true }, "cursor": { @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 21, + "agentProfileVersion": 22, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a", + "commandDigest": "sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2", "requiresProviderPolicy": true, "qualificationStatus": "pending" }, diff --git a/packages/paperclip-runner/docs/protocol-compatibility.md b/packages/paperclip-runner/docs/protocol-compatibility.md index 1df8f015f95..cafc8ddb618 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 v9 with the optional +`provider.claudeEffort` field. V9 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-v8 contracts, including v7 home modes and v8 Muse. 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 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 @@ -295,3 +304,16 @@ not part of the contract. Native attempt output is fields but rejects unknown required versions and inconsistent terminal, semantic-denial, usage, or transcript facts. Full fields and the deterministic gate are exercised by the package-local deterministic conformance suite. + + +## Claude effort after the Codex default merge + +Native input v9 adds configured Claude effort while retaining v1–v8 readers. +It carries the explicit home mode from v7; v8 Muse remains separate. The shared +executor retains master's provider and cleanup behavior. Copilot's shared source +attestation advances from pending profile 21 to 22 without replacing historical +fixtures. Pi advances from master's profile 24 to 27 to bind the combined source, +including master's OpenRouter attribution. The prior branch profile 24 is +preserved separately from master's published profile 24. +Provider executable bytes remain unchanged. These new source identities require +fresh compatibility checks; earlier provider runs retain their original identities. diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index eb7edd0ea15..5d0b0e7c844 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -85,7 +85,7 @@ { "path": "schemas/provider-descriptor.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", - "sha256": "8383e8e73b3f7a9bd2e863eab9bd524591d10f4f92c8014e63e5c2d25e2dc597" + "sha256": "862c0c13f23564c3530cefcf01579b0535a565b919cc3dcff7bc1bee9a668baf" }, { "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 6b6cd7f1eb0..90193d35313 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -159,6 +159,15 @@ "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -182,6 +191,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 dbe7b83da63..6537fbbfeb8 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-v24-identity.json" + "../../../../test-fixtures/pi-acp/profile-v27-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3201,6 +3217,8 @@ mod tests { current.validate(&context()).unwrap(); for prior in [ + include_str!("../../../../test-fixtures/pi-acp/profile-v24-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v23-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v17-identity.json"), @@ -3210,6 +3228,12 @@ mod tests { include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v22-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v23-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v24-identity.json"), + include_str!( + "../../../../test-fixtures/pi-acp/profile-v24-claude-historical-identity.json" + ), + include_str!("../../../../test-fixtures/pi-acp/profile-v25-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v26-identity.json"), ] { let prior: Value = serde_json::from_str(prior).unwrap(); value["commandDigest"] = prior["commandDigest"].clone(); @@ -3509,6 +3533,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(); @@ -3794,6 +3819,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(); @@ -4088,6 +4114,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(); @@ -4261,6 +4288,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 0e9901daa6f..1a72ae27b75 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:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39", + "sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba", 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:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a", + "sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2", 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 27ecdd61295..4198fbb33a4 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:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc"; const PI_ACPX_DIGEST: &str = - "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39"; + "sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba"; 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 2fd65420e57..9d57a42f8e4 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-v24-identity.json"], ["copilot", "test/fixtures/copilot-profile-v21-identity.json"]]) { +for (const [agent, path] of [["pi", `test-fixtures/pi-acp/profile-v${manifest.profiles.pi.agentProfileVersion}-identity.json`], ["copilot", `test/fixtures/copilot-profile-v${manifest.profiles.copilot.agentProfileVersion}-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/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index bb1633c8700..36a4eb298ca 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -1,7 +1,7 @@ import { describeRunnerdMuseDriver } from "../drivers/muse/runnerd-muse-driver.js"; import { createCodexTaskEnvelope } from "../contracts/codex.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; -import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js"; +import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA_V9 } from "../contracts/native-execution.js"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; import type { PersistedHarnessSession } from "../contracts/harness-driver.js"; import type { @@ -116,7 +116,9 @@ function createTransportBackedNativeSessionBackend( } const driverIdentity = transportDriverIdentity(input); const isCodex = input.provider.kind === "codex"; - const preparedContext = (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7); + const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V9; const supportsCollaborativePlanning = isCodex || input.provider.kind === "opencode" || diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index d7f0c1c9d95..a873eae5479 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -4,13 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js"; +import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest, composeNativeSystemInstructions } from "../contracts/runtime-context.js"; const contextRoots: string[] = []; afterEach(() => { for (const root of contextRoots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function preparedExecution(base: NativeExecutionInput): NativeExecutionInput { +function preparedExecution(base: NativeExecutionInput, version: "v5" | "v7" | "v9" = "v5"): NativeExecutionInput { const root = mkdtempSync(join(tmpdir(), "paperclip-prepared-backend-")); contextRoots.push(root); writeFileSync(join(root, "AGENTS.md"), "Keep assigned instructions."); @@ -21,7 +21,7 @@ function preparedExecution(base: NativeExecutionInput): NativeExecutionInput { skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null }, }; return parseNativeExecutionInput({ - ...base, schema: "paperclip.native-execution-input.v5", executionMode: "default", planningContext: null, + ...base, schema: `paperclip.native-execution-input.${version}`, ...(version === "v5" ? {} : { homeMode: "isolated" }), executionMode: "default", planningContext: null, runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, completionContract: { ...base.completionContract, contract: { ...base.completionContract.contract, criteria: [{ id: "objective", requirement: "Complete the task." }] } }, }); @@ -31,7 +31,7 @@ import { WORKSPACE, } from "../drivers/codex/codex-app-server-driver.test-support.js"; import { createNativeSessionBackend } from "../index.js"; -import { createCodexNativeSessionBackend } from "./codex-native-backend.js"; +import { createCodexNativeSessionBackend, createRunnerdNativeSessionBackend } from "./codex-native-backend.js"; import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js"; function execution( @@ -597,8 +597,8 @@ describe("native backend factory", () => { ).toThrow("Codex native backend requires provider kind codex"); }); - it("selects prepared transport only for v5 native input and retains prepared constraints", async () => { - const input = preparedExecution(execution()); + it.each(["v5", "v7", "v9"] as const)("selects prepared transport for %s native input and retains prepared constraints", async version => { + const input = preparedExecution(execution(), version); const transport = new FakeCodexTransport(); const backend = createCodexNativeSessionBackend(input, { transportFactory: () => transport, @@ -626,8 +626,49 @@ describe("native backend factory", () => { ); }); - it("projects v5 OpenCode constraints to the prepared runtime boundary", () => { - const input = preparedExecution(opencodeExecution()); + it("allows a completion-only turn after v9 Claude work without wrapping prepared input", async () => { + const { qualificationModel: _model, reportedModelId: _reported, permissionPolicy: _policy, ...profile } = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"); + const input = preparedExecution({ ...acpxExecution("claude"), provider: { + kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile, + } }, "v9"); + if (input.schema !== "paperclip.native-execution-input.v9" || input.provider.kind !== "acpx") throw new Error("invalid fixture"); + input.provider.claudeEffort = "high"; + const transport = new FakeCodexTransport("thread-1", "provider-session-1", { + kind: "acpx", normalizedSessionId: "session", acpxRecordId: "fixture-record", + backendSessionId: "fixture-backend", agentSessionId: "fixture-agent", + profileDigest: `sha256:${"a".repeat(64)}`, workspaceDigest: `sha256:${"b".repeat(64)}`, + requestedModel: input.provider.model, effectiveModel: input.provider.model, permissionMode: "approve-all", + providerLifetimeFenceCandidates: [60001, 60002, 60003], + }); + const backend = createRunnerdNativeSessionBackend(input, { + transportFactory: () => transport, + environment: { PAPERCLIP_WORKSPACE_CWD: WORKSPACE }, + }); + const session = await backend.openSession({ + identity: { companyId: "company", agentId: "agent", runId: "run", sessionId: "session" }, + workingDirectory: WORKSPACE, + }); + const first = JSON.stringify(buildNativeModelEnvelope(input)); + const completion = "Call paperclip_finish with the result of the work already completed."; + try { + for (const [index, text] of [first, completion].entries()) { + transport.turnStartResponse = Promise.resolve({ turn: { id: `turn-${index + 1}`, status: "inProgress", items: [] } }); + const turn = await session.startTurn({ message: { role: "user", text } }); + transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } }); + for await (const event of session.events()) { + if (["turn.completed", "turn.failed", "turn.cancelled"].includes(event.eventType)) break; + } + } + const turns = transport.calls.filter(call => call.method === "turn/start"); + expect(turns.map(call => call.params.input)).toEqual([first, completion].map(text => [{ type: "text", text, text_elements: [] }])); + expect(backend.preparedTaskConstraints).toEqual(expect.arrayContaining([expect.stringContaining("Obtain one accepted result")])); + } finally { + await session.close({ reason: "test-completed" }); + } + }); + + it.each(["v5", "v7", "v9"] as const)("projects %s OpenCode constraints to the prepared runtime boundary", version => { + const input = preparedExecution(opencodeExecution(), version); const backend = createOpenCodeNativeSessionBackend(input, { runtimeDirectory: "/tmp/paperclip-opencode-prepared-factory", }); diff --git a/packages/paperclip-runner/src/backends/opencode-native-backend.ts b/packages/paperclip-runner/src/backends/opencode-native-backend.ts index c264835973c..24acf9a051e 100644 --- a/packages/paperclip-runner/src/backends/opencode-native-backend.ts +++ b/packages/paperclip-runner/src/backends/opencode-native-backend.ts @@ -1,5 +1,5 @@ import { createCodexTaskEnvelope } from "../contracts/codex.js"; -import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js"; +import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA_V9 } from "../contracts/native-execution.js"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; import type { NativeSessionBackend } from "../contracts/native-session-backend.js"; import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js"; @@ -13,7 +13,9 @@ export function createOpenCodeNativeSessionBackend( if (input.provider.kind !== "opencode" || !input.provider.model) { throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection"); } - const preparedContext = (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7); + const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V9; const constraints = nativeTaskConstraints(input); return new HarnessDriverBackend(new OpenCodeServerDriver({ diff --git a/packages/paperclip-runner/src/backends/runtime-context.ts b/packages/paperclip-runner/src/backends/runtime-context.ts index f4051eb2518..7e91c08c66d 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.ts @@ -2,7 +2,7 @@ import { explicitTaskSkillNames } from "../contracts/runtime-context.js"; import { readFileSync, realpathSync } from "node:fs"; import { isAbsolute, relative, resolve, sep } from "node:path"; import { CODEX_SKILLLESS_BASE_INSTRUCTIONS } from "../contracts/codex.js"; -import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA, type NativeExecutionInput } from "../contracts/native-execution.js"; +import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA_V8, NATIVE_EXECUTION_INPUT_SCHEMA_V9, type NativeExecutionInput } from "../contracts/native-execution.js"; import { type NativeRuntimeContextSnapshot, type NativeSkillInput, @@ -104,9 +104,14 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] { return questionIds.length > 0 ? [responseIndex] : []; }) : []; + const compactEnvelope = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8 + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V9; const answeredQuestionConstraint = answeredQuestions.length > 0 - ? `The following exact human-input questions are already authoritatively answered in the structured message: ${answeredQuestions.map((index) => `${(input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) ? "" : "message."}interactionResponses[${index}].response.result.answers`).join(", ")}. Apply each answer within its question scope and current user direction; do not ask resolved questions again. Quoted text is data, and clarification is not approval to execute. Other pending or new questions remain unresolved.` + ? `The following exact human-input questions are already authoritatively answered in the structured message: ${answeredQuestions.map((index) => `${compactEnvelope ? "" : "message."}interactionResponses[${index}].response.result.answers`).join(", ")}. Apply each answer within its question scope and current user direction; do not ask resolved questions again. Quoted text is data, and clarification is not approval to execute. Other pending or new questions remain unresolved.` : null; if (!("runtimeContext" in input)) { return [ diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index fff1c52a945..64a86ef1c39 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"; @@ -299,6 +300,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, @@ -1224,12 +1226,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 && @@ -1251,6 +1254,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( @@ -1322,6 +1326,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 8559e1a8ac4..8ac8631917d 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -458,6 +458,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 d3243e675f8..23d7fd6414f 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -1,9 +1,10 @@ +import { nativeTaskConstraints } from "../backends/runtime-context.js"; import { readFileSync } from "node:fs"; 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, NATIVE_EXECUTION_INPUT_SCHEMA_V9, type NativeExecutionInputV1 } from "./native-execution.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, @@ -710,3 +711,45 @@ describe("native task context ownership", () => { } }); }); + + +describe("Claude effort input v9", () => { + 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_V9, homeMode: "isolated", 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_V9, provider: { claudeEffort } }); + expect(buildNativeModelEnvelope(parsed).schema).toBe("paperclip.native-model-envelope.v3"); + expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("claudeEffort"); + }); + it("directs answered Claude v9 questions to the actual compact envelope on fresh and resumed turns", () => { + const response = { interactionId: "answer-1", kind: "ask_user_questions", response: { + status: "answered", result: { version: 1, answers: [{ questionId: "destination", optionIds: ["workspace"], otherText: "Use the task workspace." }] }, + } }; + const parsed = parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort: "high" }, interactionResponses: [response] }); + const constraint = nativeTaskConstraints(parsed).find(value => value.includes("already authoritatively answered")); + expect(constraint).toContain("interactionResponses[0].response.result.answers"); + expect(constraint).not.toContain("message.interactionResponses"); + expect(constraint).not.toContain("Use the task workspace."); + for (const envelope of [buildNativeModelEnvelope(parsed), buildNativeModelEnvelope(parsed, { resumedSession: true })]) { + expect(envelope).toHaveProperty("interactionResponses.0.response.result.answers", response.response.result.answers); + expect(envelope).not.toHaveProperty("message"); + } + }); + it("keeps historical readers closed and rejects aliases and foreign effort", () => { + const { homeMode: _homeMode, ...historical } = current; + for (const schema of ["paperclip.native-execution-input.v4", NATIVE_EXECUTION_INPUT_SCHEMA]) { + expect(() => parseNativeExecutionInput({ ...historical, schema, provider: { ...current.provider, claudeEffort: "high" } })).toThrow("claudeEffort"); + expect(parseNativeExecutionInput({ ...historical, 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 13dcc3295d7..a3443585ae5 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, MuseBindingSnapshot, ExternalBotProviderKind } 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_V9 = "paperclip.native-execution-input.v9" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V8 = "paperclip.native-execution-input.v8" 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; @@ -253,13 +255,20 @@ export interface NativeExecutionInputV7 extends Omit + | (Extract & { claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max" }); +export interface NativeExecutionInputV9 extends Omit { + schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V9; + provider: NativeProviderConfigV9; +} + export interface NativeExecutionInputV8 extends Omit { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V8; provider: { kind: "muse"; model: null; binding: MuseBindingSnapshot; bridgeRevision: "muse-v1" }; session: Omit & { driverKind: "muse_external" }; } -export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8; +export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9; /** The only task data that may enter provider-visible model input. */ export interface NativeModelEnvelopeV1 { @@ -403,7 +412,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput const input = record(value, "input"); if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8) return parseMuseNativeExecutionInput(input); if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseExternalNativeExecutionInput(input); - const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7; + const isV9 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V9; + const isV7 = isV9 || 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; @@ -556,7 +566,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"] : []), ...(isV9 ? ["claudeEffort"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -579,7 +589,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: NativeProviderConfigV9 | NativeProviderConfig | NativeProviderConfigV5; if (provider.kind === "claude_managed") { if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for claude_managed"); @@ -695,6 +705,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", @@ -733,6 +744,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", @@ -887,6 +899,11 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput provider: parsedProvider as NativeProviderConfigV4, }; if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 }; + if (isV9) return { + ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V9, homeMode: input.homeMode as NativeHomeMode, + provider: parsedProvider as NativeProviderConfigV9, + ...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}), + }; return { ...withPermissions, ...(isV7 ? { schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, homeMode: input.homeMode as NativeHomeMode } : { schema: NATIVE_EXECUTION_INPUT_SCHEMA }), @@ -953,7 +970,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 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8) { + if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V8 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V9) { 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..e8b048a53b5 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts @@ -0,0 +1,19 @@ +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) { + 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 e22e54759bc..1e6381228a1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -149,6 +149,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; @@ -464,6 +465,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, @@ -1300,6 +1302,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, }, @@ -2163,6 +2166,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 1e927024304..146a0cc5365 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 @@ -34,6 +34,41 @@ 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 child = fakeChild(); + const options = openOptions({ spawn: () => child, close: async () => undefined }); + options.profile = { ...options.profile, agent: "claude" }; + options.claudeEffort = "high"; + options.restoringSession = restoring; + options.refreshConsumedCommand = vi.fn(async () => undefined); + let created!: AcpRuntimeOptions; + const spawnProvider = () => created.spawnAgent!({ command: "ignored", args: [], options: {} }); + vi.mocked(runtime.ensureSession).mockImplementation(async () => { + if (!restoring) spawnProvider(); + return HANDLE; + }); + vi.mocked(runtime.setConfigOption).mockImplementation(async input => { + if (restoring) spawnProvider(); + 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; }, + awaitProviderOwnership: providerOwnershipEstablished, + awaitProviderExit: providerOwnershipEstablished, + }); + expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" }); + expect(await port.identity()).toMatchObject({ claudeEffort: "high" }); + if (restoring) expect(options.refreshConsumedCommand).toHaveBeenCalledOnce(); + else expect(options.refreshConsumedCommand).not.toHaveBeenCalled(); + expect(runtime.startTurn).not.toHaveBeenCalled(); + await port.close({ reason: "test complete" }); + }); + it("admits an exact Claude model through the real ACP without a user model allowlist", async () => { const root = await mkdtemp(join(tmpdir(), "preconfigured-claude-model-")); const home = join(root, "home"), cwd = join(root, "workspace"), config = join(home, ".claude"); 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 17567489fbf..8258de9a35a 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 { usesPreconfiguredHome } from "../preconfigured-home.js"; import type { ChildProcess } from "node:child_process"; @@ -308,6 +309,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; @@ -319,6 +322,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 @@ -544,6 +548,23 @@ 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"); + const spawnsBeforeControl = commandLaunches.count; + await runtime.setConfigOption({ handle: ensuredHandle, key: "effort", value: selectedClaudeEffort! }); + claudeEffort.assertReady(); + await children.verifyLifetimeOwnership(); + options.signal?.throwIfAborted(); + // Fresh sessions retain their initialized provider. Only a cold + // control connection consumes another single-use launch snapshot. + if (commandLaunches.count > spawnsBeforeControl) { + await commandLaunches.refreshConsumedCommand?.(); + } + } + return ensuredHandle; }) : ensuredSession) .catch((error: unknown) => { throw classifySessionEnsureFailure(error); @@ -560,6 +581,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(); @@ -614,7 +636,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..73e5faf73bc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -0,0 +1,96 @@ +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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; + 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"); + state.mode = null; + } + return; + } + if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { + const update = object(params.update); + 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 93cfa18c13e..0a81202310b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -211,8 +211,8 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); - it("admits the current v21 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-identity.json", import.meta.url), "utf8")); + it("admits the current v22 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v22-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); @@ -243,7 +243,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-v21-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v22-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 865e61b3214..58740700729 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": 24, + "agentProfileVersion": 27, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39", + "commandDigest": "sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba", "permissionPolicy": "interactive" }, "cursor": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 21, + "agentProfileVersion": 22, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a", + "commandDigest": "sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index eaa1da855ef..c8f7d79765a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -30,6 +30,7 @@ import { import type { Readable, Writable } from "node:stream"; import { resolveQualifiedAcpxProfile, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { resolveInstalledProviderAuthority } from "./installed-provider-authority.js"; import { VERIFIED_RUNTIME_EXECUTABLE_ENV, verifiedRuntimeExecutableHandoff, @@ -652,11 +653,13 @@ export async function verifyQualifiedAcpxInstallation( ): Promise { // Keep the selected package authority available when its bundled provider // wrapper delegates platform installation to the published server manifest. - if ((profile.agent === "codex" || profile.agent === "claude") && resolvePackageJson === defaultPackageJsonResolver && process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT !== undefined) { - resolvePackageJson = createAcpxPackageJsonResolver( - process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, - process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, - ); + if ((profile.agent === "codex" || profile.agent === "claude") && resolvePackageJson === defaultPackageJsonResolver) { + const boundRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; + const authority = boundRoot === undefined ? resolveInstalledProviderAuthority(import.meta.url) : { + root: boundRoot, + manifest: process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, + }; + if (authority) resolvePackageJson = createAcpxPackageJsonResolver(authority.root, authority.manifest); } const builtin = profile.agent === "grok"; if (builtin && (profile.agentServerPackage !== "builtin:grok-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:grok" || profile.agentRuntimeVersion !== "1.0.13")) { diff --git a/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.test.ts b/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.test.ts new file mode 100644 index 00000000000..c1274972577 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveInstalledProviderAuthority } from "./installed-provider-authority.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture(name = "@paperclipai/server") { + const root = await realpath(await mkdtemp(join(tmpdir(), "installed-acpx-authority-"))); + roots.push(root); + const directory = join(root, "dist/vendor/paperclip-runner/drivers/acpx"); + await mkdir(directory, { recursive: true }); + const module = join(directory, "installation-integrity.js"); + await writeFile(module, "export {};\n"); + const manifest = join(root, "package.json"); + await writeFile(manifest, JSON.stringify({ name })); + return { root, module, manifest }; +} + +describe("installed provider authority", () => { + it("derives the owning server only from the normal installed verifier layout", async () => { + const f = await fixture(); + expect(resolveInstalledProviderAuthority(pathToFileURL(f.module).href)).toEqual({ root: f.root, manifest: f.manifest }); + expect(resolveInstalledProviderAuthority(pathToFileURL(join(f.root, "src/drivers/acpx/installation-integrity.ts")).href)).toBeUndefined(); + expect(resolveInstalledProviderAuthority(pathToFileURL(join(f.root, "dist/drivers/acpx/installation-integrity.js")).href)).toBeUndefined(); + }); + + it("rejects a substituted package owner", async () => { + const f = await fixture("unrelated-package"); + expect(() => resolveInstalledProviderAuthority(pathToFileURL(f.module).href)).toThrow("owning server package"); + }); + + it("rejects a symlinked manifest before adopting its declarations", async () => { + const f = await fixture(); + const other = join(f.root, "other.json"); + await writeFile(other, JSON.stringify({ name: "@paperclipai/server" })); + await rm(f.manifest); + await symlink(other, f.manifest); + expect(() => resolveInstalledProviderAuthority(pathToFileURL(f.module).href)).toThrow(); + }); + + it("rejects a redirected verifier directory", async () => { + const f = await fixture(); + const old = join(f.root, "dist/vendor/paperclip-runner/drivers/acpx"); + const other = join(f.root, "other"); + await mkdir(other); + await writeFile(join(other, "installation-integrity.js"), "export {};\n"); + await rm(old, { recursive: true }); + await symlink(other, old); + expect(() => resolveInstalledProviderAuthority(pathToFileURL(f.module).href)).toThrow("package layout"); + }); + + it.each(["", " ".repeat(256 * 1024 + 1)])("rejects unbounded or empty owner metadata", async contents => { + const f = await fixture(); + await writeFile(f.manifest, contents); + expect(() => resolveInstalledProviderAuthority(pathToFileURL(f.module).href)).toThrow("bounded regular file"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.ts b/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.ts new file mode 100644 index 00000000000..fd0979295c4 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/installed-provider-authority.ts @@ -0,0 +1,51 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function resolveInstalledProviderAuthority(moduleUrl: string): { root: string; manifest: string } | undefined { + const url = new URL(moduleUrl); + if (url.protocol !== "file:") return undefined; + const modulePath = fileURLToPath(url); + const suffix = "/dist/vendor/paperclip-runner/drivers/acpx/installation-integrity.js"; + if (!modulePath.endsWith(suffix)) return undefined; + const root = modulePath.slice(0, -suffix.length); + if (!isAbsolute(root) || resolve(root) !== root || realpathSync(root) !== root) { + throw new Error("Installed ACPX server authority must be a canonical package directory"); + } + for (const relative of ["", "dist", "dist/vendor", "dist/vendor/paperclip-runner", "dist/vendor/paperclip-runner/drivers", "dist/vendor/paperclip-runner/drivers/acpx"]) { + const path = join(root, relative); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink() || realpathSync(path) !== path) { + throw new Error("Installed ACPX server authority escapes its package layout"); + } + } + const moduleMetadata = lstatSync(modulePath); + if (!moduleMetadata.isFile() || moduleMetadata.isSymbolicLink() || realpathSync(modulePath) !== modulePath) { + throw new Error("Installed ACPX verifier must remain in its package layout"); + } + const manifest = join(root, "package.json"); + const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > 256n * 1024n) { + throw new Error("Installed ACPX server manifest must be a bounded regular file"); + } + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const named = lstatSync(manifest, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs + || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink() + || bytes.length !== Number(before.size) || realpathSync(manifest) !== manifest) { + throw new Error("Installed ACPX server manifest changed during admission"); + } + const value = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value) + || !Object.hasOwn(value, "name") || value.name !== "@paperclipai/server") { + throw new Error("Installed ACPX verifier requires the owning server package"); + } + } finally { + closeSync(fd); + } + return { root, manifest }; +} 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 707848f7014..220e20c9e72 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -118,11 +118,40 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("retains master profile 23 and binds the combined Claude effort sources in profile 26", 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(23), current = await read(26); + 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("retains both independently authored profile 24 declarations", async () => { + const read = async (name: string) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/${name}`, import.meta.url), "utf8")); + const published = await read("profile-v24-identity.json"), historical = await read("profile-v24-claude-historical-identity.json"); + expect(published.declaration.agentProfileVersion).toBe(24); + expect(historical.declaration.agentProfileVersion).toBe(24); + expect(published.commandDigest).not.toBe(historical.commandDigest); + }); + + it("combines master OpenRouter attribution with the retained Claude source attestations in profile 27", 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 current = await read(27); + for (const [version, expected] of [ + [24, ["agentProfileVersion", "recoveryIdentitySourceSha256", "runtimeSandboxSourceSha256"]], + [26, ["agentProfileVersion", "credentialEnvironmentSourceSha256", "openRouterAttributionSourceSha256", "providerConfigurationSourceSha256"]], + ] as const) { + const prior = await read(version); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()).toEqual(expected); + 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-v24-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v27-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))}`); @@ -153,21 +182,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 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 24"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 27"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 27"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 27"); 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/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 5a4fec39b89..a38fc6fed8b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { - pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 75e0b014beb..08010a68352 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -77,6 +77,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 50db81cde16..835a3047831 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,6 +54,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerConfigurationDigest?: string; homeBinding?: Readonly>; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; @@ -58,6 +62,7 @@ export async function createAcpxRecoveryBinding(input: { 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"); @@ -106,6 +111,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -120,6 +126,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), profileSessionKey, }; } @@ -142,6 +149,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], @@ -169,6 +177,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); @@ -195,7 +204,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", @@ -216,6 +226,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, @@ -242,6 +253,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "permissionMode", "mode", "piThinkingLevel", + "claudeEffort", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -269,6 +281,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; @@ -299,6 +312,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.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index c7f7da9e596..ba93401fde0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -316,6 +316,69 @@ describe("ACPX runtime host", () => { expect(openRuntime).not.toHaveBeenCalled(); }); + it("leaves an active Claude owner's effort settings unchanged when a second opener loses the lifetime lease", async () => { + const fixture = await hostFixture(); + let settingsPath = ""; + const openRuntime = vi.fn(async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) })); + const dependencies = fixture.dependencies({ openRuntime }); + const prepare = dependencies.prepareSandbox!; + dependencies.prepareSandbox = async input => { + const sandbox = await prepare(input); + settingsPath = join(sandbox.agentHomeDirectory, "settings.json"); + return sandbox; + }; + const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", permissionMode: "approve-all" as const }; + const host = await AcpxRuntimeHost.open(options, dependencies); + try { + const ownedSettings = `${JSON.stringify({ ...JSON.parse(await readFile(settingsPath, "utf8")), effortLevel: "high", activeOwnerMarker: true })}\n`; + await writeFile(settingsPath, ownedSettings, { mode: 0o600 }); + await expect(AcpxRuntimeHost.open(options, dependencies)).rejects.toThrow("already has an active lease"); + expect(await readFile(settingsPath, "utf8")).toBe(ownedSettings); + expect(openRuntime).toHaveBeenCalledTimes(1); + } finally { await host.close({ reason: "ownership regression complete" }); } + const nextOwner = await waitForAcpxOperation(() => AcpxRuntimeHost.open(options, dependencies)); + try { + expect(JSON.parse(await readFile(settingsPath, "utf8"))).not.toHaveProperty("activeOwnerMarker"); + expect(openRuntime).toHaveBeenCalledTimes(2); + } finally { await nextOwner.close({ reason: "next owner complete" }); } + }); + + it.each([false, true])("cancels Claude startup promptly while retaining its settings write and lease (late failure: %s)", async failWrite => { + const fixture = await hostFixture(); + const controller = new AbortController(); + let announceWrite!: () => void; + let finishWrite!: () => void; + const writeStarted = new Promise(resolve => { announceWrite = resolve; }); + const writePending = new Promise(resolve => { finishWrite = resolve; }); + const openRuntime = vi.fn(async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) })); + const dependencies = fixture.dependencies({ openRuntime }); + const prepare = dependencies.prepareSandbox!; + dependencies.prepareSandbox = async input => { + const sandbox = await prepare(input); + const initialize = sandbox.initializeOwnedConfiguration; + return { ...sandbox, initializeOwnedConfiguration: async () => { + announceWrite(); + await writePending; + if (failWrite) throw new Error("settings write failed after cancellation"); + await initialize?.(); + } }; + }; + const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", permissionMode: "approve-all" as const }; + const opening = AcpxRuntimeHost.open({ ...options, signal: controller.signal }, dependencies); + const rejected = expect(opening).rejects.toThrow("settings admission cancelled"); + try { + await writeStarted; + controller.abort(new Error("settings admission cancelled")); + await runAcpxOperationAttempt(() => rejected, Date.now() + 1_000); + expect(openRuntime).not.toHaveBeenCalled(); + await expect(AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))).rejects.toThrow("already has an active lease"); + } finally { finishWrite(); } + await rejected; + expect(openRuntime).not.toHaveBeenCalled(); + const nextOwner = await waitForAcpxOperation(() => AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))); + await nextOwner.close({ reason: "cancelled initializer ownership released" }); + }); + it("registers the assigned connection gateway alongside the Claude task bridge", async () => { const fixture = await hostFixture(); let servers: AcpxRuntimePortOpenOptions["mcpServers"] = []; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 61d3cbf950b..070dbe8d4d1 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"; @@ -85,6 +86,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; @@ -165,6 +167,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. */ @@ -239,6 +242,7 @@ export interface OpenAcpxRuntimeHostOptions { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -361,6 +365,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), ...(preconfigured ? { homeBinding: preconfiguredHomeEnvironment(options.environment) } : {}), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), @@ -489,6 +494,21 @@ export class AcpxRuntimeHost { dependencies.reportRetainedCleanupFailure(failure), }); } + options.signal?.throwIfAborted(); + const configurationLifetime = credential; + await runAbortableAdmissionStage( + options.signal, + async () => { await sandbox.initializeOwnedConfiguration?.(); }, + cleanup => { + pendingRuntimeOwnsCredential = true; + const retained = cleanup.then(() => cleanupAbortedRuntimeAdmission( + null, configurationLifetime, "ACPX owned configuration cancelled", + )); + retainRuntimeHostCleanup(retained); + dependencies.retainAdmissionCleanup?.(retained); + }, + ); + options.signal?.throwIfAborted(); assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); let launchEnvironment = sandbox.launchEnvironment; // This copy is registered by the authenticated controller for its company, @@ -593,6 +613,7 @@ export class AcpxRuntimeHost { permissionMode: binding.permissionMode, mode: binding.mode, piThinkingLevel: binding.piThinkingLevel, + claudeEffort: binding.claudeEffort, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -657,6 +678,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 9a2f52e4be3..6c4b9ce4833 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -121,6 +121,7 @@ describe("ACPX runtime sandbox", () => { binding: { ...fixture.binding, requestedModel: model }, agent: "claude", }); + await sandbox.initializeOwnedConfiguration?.(); const settingsPath = join(sandbox.agentHomeDirectory, "settings.json"); expect(JSON.parse(await readFile(settingsPath, "utf8"))).toMatchObject({ model, @@ -136,12 +137,29 @@ describe("ACPX runtime sandbox", () => { const binding = { ...fixture.binding, requestedModel: model, effectiveModel: model }; for (let attempt = 0; attempt < 2; attempt += 1) { const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + await sandbox.initializeOwnedConfiguration?.(); const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); expect(settings).toMatchObject({ model, availableModels: [model] }); } } }); + 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" }); + await sandbox.initializeOwnedConfiguration?.(); + 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" }); + await sandbox.initializeOwnedConfiguration?.(); + 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 2b721cf2572..8f99b671010 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -54,6 +54,7 @@ export interface AcpxRuntimeSandbox { cacheDirectory: string; agentHomeDirectory: string; workspaceRecordPath: string; + initializeOwnedConfiguration?: () => Promise; launchEnvironment: Readonly; persistedEnvironment: Readonly; /** The host sandbox must protect these runtime/config paths from tools. */ @@ -421,7 +422,7 @@ export async function prepareAcpxRuntimeSandbox(input: { const configuration = piProviderConfiguration(input.environment); if (configuration) await writePrivateFile(join(agentHomeDirectory, "models.json"), configuration.json); } - if (input.agent === "claude") { + const initializeOwnedConfiguration = input.agent === "claude" ? async () => { // ACP otherwise rewrites exact IDs (including user-entered model IDs) to // picker aliases such as "sonnet". Its supported availableModels setting // preserves our requested ID through selection and model verification. @@ -430,12 +431,13 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], + ...(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) } } : {}), })}\n`, ); - } + } : undefined; if (input.agent === "grok") { // Native project rules merge with global rules. Ask rules win over project // allows, keeping operation approval at the runner's authenticated boundary. @@ -586,6 +588,7 @@ export async function prepareAcpxRuntimeSandbox(input: { cacheDirectory, agentHomeDirectory, workspaceRecordPath, + ...(initializeOwnedConfiguration ? { initializeOwnedConfiguration } : {}), protectedPaths, launchEnvironment: Object.freeze({ ...launchEnvironment }), persistedEnvironment: Object.freeze(persistedEnvironment), 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 ba1fa06cb1d..27a166daed4 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 4d76126b578..2f6ecb2086e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,6 +1,7 @@ import type { RunnerExecutionDriver } from "../control-plane/runner-execution-driver.js"; 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 { githubCredentialEnvironment } from "../github-credential-environment.js"; @@ -1220,6 +1221,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; @@ -1247,6 +1249,7 @@ export interface CapabilityRunnerdCodexTransportOptions extends RunnerExecutionD 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. */ @@ -3623,6 +3626,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"); @@ -4987,6 +4991,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, ), @@ -6721,6 +6726,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 fc4877a9029..84276d3969d 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -1365,6 +1365,15 @@ export const providerDescriptorSchema = { "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -1388,6 +1397,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-claude-qualification-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-claude-qualification-identity.json new file mode 100644 index 00000000000..e344c8eb532 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-claude-qualification-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/pi-acp/profile-v24-claude-historical-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-claude-historical-identity.json new file mode 100644 index 00000000000..b01f1d71ad1 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-claude-historical-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 24, + "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": "1119cd01f131d814d5bdb77bcb38af359b9328876ff8537894d19ec2eac35e7f", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json new file mode 100644 index 00000000000..8c3e9a4f4cd --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 25, + "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": "144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v26-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v26-identity.json new file mode 100644 index 00000000000..31d47bc5fe2 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v26-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:cbf43cd25d8e8523507ce4af82b042eddc8641a7c24627556263940ffccf8a5b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 26, + "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": "c9c3650b6415430b045533eea2d7cddfe87302e03817f40ae83f7447ef0ae957", + "runtimeSandboxSourceSha256": "e5a034b2e4008b56b33e654a8c299bd3b329a9d8940bdb1eafdbffe3574e9372", + "recoveryIdentitySourceSha256": "d2c9fc161fcc135fb9760110bfa3d984ee2bb38d87fa5ce7cb7418a49dd89e66", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v27-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v27-identity.json new file mode 100644 index 00000000000..d63f89c4c20 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v27-identity.json @@ -0,0 +1,51 @@ +{ + "commandDigest": "sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 27, + "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": "75a9a13f13f224dc092bd8ebf49e8747162172943761e44476c8eb18d80c52d4", + "credentialEnvironmentSourceSha256": "b29464dd2fcbd1bff3a9cdb4ba97a00d9c4a07a6cdd878f1baaa8a4948d020ca", + "runtimeSandboxSourceSha256": "e5a034b2e4008b56b33e654a8c299bd3b329a9d8940bdb1eafdbffe3574e9372", + "recoveryIdentitySourceSha256": "d2c9fc161fcc135fb9760110bfa3d984ee2bb38d87fa5ce7cb7418a49dd89e66", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814", + "openRouterAttributionSourceSha256": "1211191529d6073b733b311c3d69cada4973eede0f3e540c46e261f78e0b90f9" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v18-claude-qualification-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-claude-qualification-identity.json new file mode 100644 index 00000000000..eab746e74c7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-claude-qualification-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/packages/paperclip-runner/test/fixtures/copilot-profile-v19-claude-qualification-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v19-claude-qualification-identity.json new file mode 100644 index 00000000000..d7886e5a989 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v19-claude-qualification-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:a4c7e955df18bf2448224ecce2120b43ede8054fe00e7b33b4044b8bd284b321", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 19, + "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": "e9ca8d1a7449fe28b63dcd0f858bfc233f1ec791e159cdd8436d6ea34a3089fe", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v22-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v22-identity.json new file mode 100644 index 00000000000..0b7b75483fe --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v22-identity.json @@ -0,0 +1,44 @@ +{ + "commandDigest": "sha256:9027ee2bdf9180e3267b7d05b56b0703943251a29713c393f7a3f2ef1072efd2", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 22, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "ddd98aabe20f4d40b04bb58126bda548a9d85b8234a3f9ea84b6db414e9b4814", + "semanticDirectDriverSourceSha256": "72d5134a9567ced685bc942adf14ec5dfba04bcd6b0eeabe77eac0cec5c434c3", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623", + "semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c", + "semanticSchemaBundleSourceSha256": "5e957fe0f0ba164441d6a5b8a276e52cf6f2ffaf99741faca99cff05e539221c", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c" + } +} diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 91d1c416f89..4fa24c5c6bd 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -52,8 +52,8 @@ assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).na const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js'))); const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js'))); const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'))); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 24); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39'); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 27); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:b1b75a1ae3ac498eb07b95b96bebf4da86227b83d09276076dd5c222e3964dba'); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary(); @@ -97,7 +97,7 @@ try { assert.equal(state.activeTurnId, null); assert.equal(state.identity, null); assert.equal(state.providerExitUnconfirmed, false); - console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 24, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true })); + console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true })); } finally { try { await bundle?.transport.close(); } finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } diff --git a/tests/runner-e2e/native-instruction-consolidation.test.ts b/tests/runner-e2e/native-instruction-consolidation.test.ts index 407ed385ed1..4096905dba6 100644 --- a/tests/runner-e2e/native-instruction-consolidation.test.ts +++ b/tests/runner-e2e/native-instruction-consolidation.test.ts @@ -2,12 +2,13 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { runnerSuites, buildRunnerMatrix } from "./catalog.js"; import { gradeNativeDefault, NATIVE_MASTER_DEFAULT_SHA256 } from "./native-completion-defaults.js"; import { nativeCompletionTasks } from "./native-completion-cases.js"; +import { nativeCompletionPreflightEnvironment } from "./native-completion-admission.js"; import { assertNativeInstructionLineage, assertNativeInstructionSelection, nativeInstructionVariant, validateNativeInstructionMeasurement, NATIVE_INSTRUCTION_BASE_SHA, NATIVE_INSTRUCTION_DEFAULT_SHA256, NATIVE_INSTRUCTION_SUITE, NATIVE_INSTRUCTION_VARIANTS } from "./native-instruction-consolidation.js"; describe("native instruction comparison admission", () => { @@ -67,12 +68,15 @@ describe("native instruction comparison admission", () => { }); it("accepts each complete source variant and rejects a mixed variant", () => { const files = Object.keys(NATIVE_INSTRUCTION_VARIANTS.baseline); - const baseline = new Map(files.map(file => [file, execFileSync("git", ["show", `${NATIVE_INSTRUCTION_BASE_SHA}:${file}`])])); + const historicalFile = (sha: string, file: string) => execFileSync("git", ["--no-replace-objects", "show", `${sha}:${file}`], { + cwd: resolve(import.meta.dirname, "../.."), env: nativeCompletionPreflightEnvironment(process.env), timeout: 30_000, + }); + const baseline = new Map(files.map(file => [file, historicalFile(NATIVE_INSTRUCTION_BASE_SHA, file)])); expect(nativeInstructionVariant(file => baseline.get(file)!)).toBe("baseline"); // This historical admission intentionally rejects later production changes. // Exercise the retained qualified tree rather than requiring HEAD to remain // one of this completed comparison's frozen variants forever. - const candidate = new Map(files.map(file => [file, execFileSync("git", ["show", `16ef1a5744b23bb043a8b286e6288921da1de98f:${file}`])])); + const candidate = new Map(files.map(file => [file, historicalFile("16ef1a5744b23bb043a8b286e6288921da1de98f", file)])); const currentVariant = nativeInstructionVariant(file => candidate.get(file)!); expect(["baseline", "candidate", "corrected", "feedback", "opencodeFeedback", "opencodeFeedbackSettlement", "opencodeFeedbackResponses"]).toContain(currentVariant); candidate.set(files[0]!, currentVariant === "candidate" ? baseline.get(files[0]!)! : Buffer.from("unknown source")); diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 261e32e1c67..34dc9359ab6 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -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: 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, + 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: 27, 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: 27, 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": "3c77ced7edfb99aa12052f451398da7580466a54abadcaf287d31ee12c16382d", + "pi-native": "7bf1477d5ca92569aad29d31278fad220ff59baa9f6622c8c9b1302ecaa47b83", "native-active-stop": "2d4fdeeb75bd531b309bd1b35cfa5680ceefdeee6b7155b068dd011ce9901980", "rich-acp-warm-continuity": "331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0", "extended-harnesses": "5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6",