From a200495cc7488f4d59da570407e516cf702e5af8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:14:38 -0500 Subject: [PATCH 1/6] fix: preserve TOML table semantics during Codex startup trust Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 46 ++++++++++++++ .../drivers/codex/codex-startup-trust.test.ts | 62 +++++++++++++++++-- .../src/drivers/codex/codex-startup-trust.ts | 13 ++-- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 70743130406..fdc17a36a60 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -129,6 +129,52 @@ and require fresh Linux CI. Keep ARM startup sign-off unresolved until proven on a compatible target; package installation and direct execution alone do not qualify the native runner. All Docker/native builds remain remote. +Checkpoint `598e190cb308f4d7f8f43c188c2e7d88c3fc90d1` passes +[ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38279960223): +both required gates, 54 successful checks/statuses, and four conditional skips. +Fresh review is 5/5; all 15 review threads are resolved. Human code-owner approval +is still absent. The test-drive serves `2026.1005.0+350.git.598e190cb3`; all four +execution configurations and both historical native runs survive restart. +The verified Apple Silicon daemon remains unchanged, `needsBuild: false`, with +no binary override, native compilation, Docker build, or provider turn locally. +Receipts: `/private/tmp/ci-final-598e190-ci-review-receipt.json` and +`/private/tmp/pr2-final-preservation-after.json`. + +The [direct/native ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38280515437) +records GNU libc 2.39, AppArmor enabled with restricted unprivileged user +namespaces, an unconfined current profile, zero effective capabilities, and no +seccomp filter. Native startup still fails at bubblewrap loopback `RTM_NEWADDR`. +The direct control stopped before Codex spawn at a TOML preservation guard, +so it does not establish the sandbox failure's cause. Receipt: +`/private/tmp/ci-final-arm-direct-diagnostic-598-receipt.json`. + +Pure, integrity-bound public `smol-toml` 1.9.1 reproduction identifies that guard +failure: its null-prototype tables differ from the writer's spread-created +ordinary objects. Preserve the parser's tables while changing only the intended +trust field; keep strict comparison, original comments/bytes, private writes, +and malformed-input rejection. No parser pin, semantic bypass, or permission +change. The existing suite passes 22 controls across both table representations; +real 1.9.1 treatment repairs five valid configurations and keeps two invalid +configurations unchanged. Runner/server typechecks pass. Receipt: +`/private/tmp/codex-default-startup-trust-fix-receipt.json`. + +The [four-target performance qualifier](https://github.com/paperclipai/paperclip/actions/runs/38281631416) +uses literal `598e190` product/workflow source. Observed Linux x64/ARM64 and Apple +Silicon builds pass with actual concurrent JavaScript/native preparation. +Cache restore is exercised; observed results are cold misses, not measured +speedups. The cache writer is reachable through the existing verified master +canary push, with exact source and repository guards; no write-gate expansion +is needed. Linux daemon bytes match the measured GNU 2.39 baseline. Remaining +producer/consumer results must be recorded before qualification claims. + +Next action: freeze the narrow trust correction, repeat the bounded direct +control with explicit corrected-tooling provenance, and retain native startup +as mandatory. A private namespace negative control and bounded read-only +AppArmor journal may help distinguish host denial. Do not modify host policy, +relax Codex's sandbox, or substitute direct execution for native qualification. +Require fresh CI/review after the correction. ARM startup and managed staging +remain unresolved; no merge or production recommendation is made. + ## Current addition: Linux ARM64 The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index aad5b5c737c..02d377d0451 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -5,16 +5,66 @@ import { rmSync, writeFileSync, realpathSync, + statSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { parse } from "smol-toml"; import { trustCodexStartupRoot } from "./codex-startup-trust.js"; -describe("isolated Codex startup trust", () => { +const parserStyle = vi.hoisted(() => ({ nullPrototypeTables: false })); +vi.mock("smol-toml", async (importOriginal) => { + const actual = await importOriginal(); + const setTablePrototypes = (value: unknown): void => { + if (value === null || typeof value !== "object" || value instanceof Date) return; + if (Array.isArray(value)) { + value.forEach(setTablePrototypes); + return; + } + Object.values(value).forEach(setTablePrototypes); + Object.setPrototypeOf(value, parserStyle.nullPrototypeTables ? null : Object.prototype); + }; + return { + ...actual, + parse: (...args: Parameters) => { + const result = actual.parse(...args); + // Exercise both table representations independently of the installed + // parser version while retaining the real TOML values. + setTablePrototypes(result); + return result; + }, + }; +}); + +describe.each([ + { name: "ordinary tables", nullPrototypeTables: false }, + { name: "null-prototype tables", nullPrototypeTables: true }, +])("isolated Codex startup trust with $name", ({ nullPrototypeTables }) => { + beforeEach(() => { parserStyle.nullPrototypeTables = nullPrototypeTables; }); + afterEach(() => { parserStyle.nullPrototypeTables = false; }); + + it.each(["missing", "empty"])("initializes %s config in an isolated home", (kind) => { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-empty-"))); + try { + const cwd = join(temp, "project"); + const home = join(temp, "home"); + mkdirSync(cwd); + if (kind === "empty") { + mkdirSync(home); + writeFileSync(join(home, "config.toml"), ""); + } + trustCodexStartupRoot(home, cwd); + const path = join(home, "config.toml"); + const updated = readFileSync(path, "utf8"); + expect(parse(updated)).toEqual({ projects: { [cwd]: { trust_level: "trusted" } } }); + expect(statSync(path).mode & 0o777).toBe(0o600); + trustCodexStartupRoot(home, cwd); + expect(readFileSync(path, "utf8")).toBe(updated); + } finally { rmSync(temp, { recursive: true, force: true }); } + }); it.each(["missing", "table", "empty-table", "inline", "empty-inline", "inline-projects"])( "preserves managed comments and unrelated content with %s project configuration", (kind) => { const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-comments-"))); @@ -60,15 +110,17 @@ describe("isolated Codex startup trust", () => { mkdirSync(home); writeFileSync( join(home, "config.toml"), - 'model = "test"\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', + 'model = "test"\nstarted = 2026-10-12T12:00:00Z\nlimits = [inf, nan, 9007199254740991]\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', ); + const original = readFileSync(join(home, "config.toml"), "utf8"); + const unrelated = parse(original); trustCodexStartupRoot(home, cwd); trustCodexStartupRoot(home, cwd); expect(parse(readFileSync(join(home, "config.toml"), "utf8"))).toEqual({ - model: "test", - mcp_servers: { fixture: { url: "http://localhost/example" } }, + ...unrelated, projects: { [cwd]: { trust_level: "trusted" } }, }); + expect(readFileSync(join(home, "config.toml"), "utf8")).toContain(original); expect(readFileSync(join(home, "config.toml"), "utf8")).not.toContain( `[projects."${temp}"]`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index d85f002552f..488b025148d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,24 +92,25 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - const projects = config.projects ?? {}; + // Retain the parser's table prototypes so strict candidate comparison also + // works with parsers that return null-prototype TOML tables. + const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || Array.isArray(projects) || projects instanceof Date ) throw new Error("codex_startup_trust_invalid_projects"); - const project = projects[root] ?? {}; + const project = projects[root] ?? parse(""); if ( typeof project !== "object" || Array.isArray(project) || project instanceof Date ) throw new Error("codex_startup_trust_invalid_project"); - config.projects = { - ...projects, - [root]: { ...project, trust_level: "trusted" }, - }; + project.trust_level = "trusted"; + projects[root] = project; + config.projects = projects; const updated = editTrust(source, root, config); if (updated === source) return; const temporary = resolve(codexHome, `config.toml.${randomUUID()}.tmp`); From 2e8d5e906e372a5fdf5f7de4239ac85bbdc33e59 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:24:15 -0500 Subject: [PATCH 2/6] feat(runner): admit Claude effort before native prompts and resume Version the shared Pi and pending Copilot source attestations without changing their executable closures or platform qualification. Co-Authored-By: Paperclip --- packages/paperclip-runner/acpx-profiles.json | 8 +- .../docs/protocol-compatibility.md | 11 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../provider-schemas/acpx-sidecar.schema.json | 15 ++- .../schemas/provider-descriptor.schema.json | 29 +++++ .../runner-core/src/acpx_provider_backend.rs | 23 +++- .../src/acpx_provider_checkpoint.rs | 5 + .../runner-core/src/acpx_provider_session.rs | 46 ++++++++ .../src/generated_acpx_profiles.rs | 4 +- .../tests/acpx_provider_checkpoint.rs | 2 + .../tests/acpx_provider_resolutions.rs | 1 + .../tests/acpx_provider_session.rs | 2 + .../tests/acpx_provider_suspend.rs | 1 + .../runner-core/tests/acpx_provider_turns.rs | 1 + .../tests/native_provider_backend.rs | 2 +- .../scripts/generate-acpx-profiles.mjs | 2 +- .../src/backends/codex-acpx-native-backend.ts | 3 +- .../src/cli/acpx-runtime-sidecar.ts | 7 +- .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-execution.test.ts | 29 ++++- .../src/contracts/native-execution.ts | 28 ++++- .../src/drivers/acpx/claude-effort.test.ts | 67 +++++++++++ .../src/drivers/acpx/claude-effort.ts | 21 ++++ .../src/drivers/acpx/codex-acpx-driver.ts | 4 + .../acpx/codex-runtime-adapter.test.ts | 25 ++++ .../src/drivers/acpx/codex-runtime-adapter.ts | 19 +++- .../src/drivers/acpx/config-admission.ts | 104 +++++++++++++++++ .../drivers/acpx/copilot-installation.test.ts | 14 ++- .../src/drivers/acpx/generated-profiles.ts | 8 +- .../src/drivers/acpx/pi-installation.test.ts | 40 ++++--- .../src/drivers/acpx/pi-thinking.ts | 107 ++---------------- .../drivers/acpx/recovery-identity.test.ts | 15 +++ .../src/drivers/acpx/recovery-identity.ts | 16 ++- .../src/drivers/acpx/runtime-host.ts | 7 ++ .../src/drivers/acpx/runtime-sandbox.test.ts | 14 +++ .../src/drivers/acpx/runtime-sandbox.ts | 4 + .../src/drivers/acpx/sidecar-protocol.ts | 2 + .../src/drivers/codex/codex-driver-values.ts | 3 + packages/paperclip-runner/src/index.ts | 1 + .../src/live/runnerd-codex-transport.ts | 7 ++ .../src/protocol/generated/schema-bundle.ts | 29 +++++ .../pi-acp/profile-v23-identity.json | 50 ++++++++ .../copilot-profile-v18-identity.json | 43 +++++++ tests/runner-e2e/pi-controls-evidence.test.ts | 8 +- 44 files changed, 681 insertions(+), 149 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/claude-effort.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/config-admission.ts create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 81a3e159050..c33720fdd9f 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "requiresProviderPolicy": true }, "cursor": { @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "requiresProviderPolicy": true, "qualificationStatus": "pending" }, diff --git a/packages/paperclip-runner/docs/protocol-compatibility.md b/packages/paperclip-runner/docs/protocol-compatibility.md index 1df8f015f95..862fbe0db93 100644 --- a/packages/paperclip-runner/docs/protocol-compatibility.md +++ b/packages/paperclip-runner/docs/protocol-compatibility.md @@ -154,7 +154,16 @@ policy in the closed provider configuration: `approvalPolicy` for Codex and provider-session identity, so an incompatible idle or recovered session is replaced on the next execution. An active turn is never mutated in place. -`paperclip.native-execution-input.v5` is the current input format. It retains +`paperclip.native-execution-input.v5` remains the ordinary input format. +Claude inputs with explicitly configured effort use v7 with the optional +`provider.claudeEffort` field. V7 retains the compact v3 model envelope; effort +is host configuration, never model-visible task text. V6 remains the separate +OpenAI Dot format. Readers retain persisted v1-v6 contracts. Claude effort is +acknowledged on each ACP connection before prompting, bound into the session +identity and recovery checkpoint, and cross-checked against the public provider +descriptor. Missing effort retains the provider default; values are never clamped. + +The v5 input format It retains the v4 permission pinning and adds optional completion-source references; `paperclip.native-model-envelope.v3` is the corresponding explicit model projection. Readers continue to accept persisted v1-v4 inputs, and v5 readers diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 2eefd4f3069..3bd089ba896 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -80,7 +80,7 @@ { "path": "schemas/provider-descriptor.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", - "sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519" + "sha256": "ef37ebba071ac239e0b4ff680a469eca6948b8eace451c5c88089c81b7acdae1" }, { "path": "schemas/provider-event.schema.json", diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index 89e6242f5bb..ee358cbab29 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -59,7 +59,7 @@ }, { "if": { "properties": { "command": { "const": "session.open" } } }, - "then": { "properties": { "params": { "$ref": "#/$defs/sessionOpenThinkingLevel" } } } + "then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } } } ] }, @@ -89,6 +89,19 @@ } } }, + "claudeEffort": { "enum": ["low", "medium", "high", "xhigh", "max"] }, + "sessionOpenClaudeEffort": { + "type": "object", + "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } }, + "if": { "properties": { "agent": { "const": "claude" } }, "required": ["agent"] }, + "then": { "properties": { "expectedIdentity": { + "type": ["object", "null"], "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } } + } } }, + "else": { "properties": { + "claudeEffort": false, + "expectedIdentity": { "type": ["object", "null"], "properties": { "claudeEffort": false } } + } } + }, "response": { "type": "object", "required": ["protocolVersion", "id", "ok"], diff --git a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json index 660054b6f7e..ad7f73d2909 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -153,6 +153,15 @@ "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -176,6 +185,26 @@ ] } }, + { + "if": { + "required": [ + "claudeEffort" + ] + }, + "then": { + "properties": { + "provider": { + "const": "acpx" + }, + "agent": { + "const": "claude" + } + }, + "required": [ + "agent" + ] + } + }, { "oneOf": [ { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 3757c6ea68d..01665c56bbf 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -143,6 +143,8 @@ struct AcpxProviderDescriptor { mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + claude_effort: Option, permission_mode_pinned: bool, #[serde(default)] provider_policy: Option, @@ -267,6 +269,10 @@ impl AcpxProviderDescriptor { || self.model.len() > 240 || self.model.contains('\0') || ((self.agent == "pi") != self.pi_thinking_level.is_some()) + || self.claude_effort.as_ref().is_some_and(|effort| { + self.agent != "claude" + || !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) || self.mode.as_ref().is_some_and(|mode| { mode.trim().is_empty() || mode.chars().count() > 240 @@ -368,6 +374,7 @@ impl AcpxProviderDescriptor { permission_mode: self.permission_mode, mode: self.mode.clone(), pi_thinking_level: self.pi_thinking_level, + claude_effort: self.claude_effort.clone(), permission_mode_pinned: self.permission_mode_pinned, provider_policy: self.provider_policy.clone(), system_instructions: self.instructions.clone(), @@ -472,6 +479,9 @@ impl AcpxProviderDescriptor { "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), "permissionMode": self.permission_mode, }); + if let Some(effort) = &self.claude_effort { + descriptor["claudeEffort"] = json!(effort); + } if let Some(level) = self.pi_thinking_level { descriptor["piThinkingLevel"] = json!(level); } @@ -611,6 +621,7 @@ impl AcpxDurableState { if identity.profile_digest != self.descriptor.command_digest || identity.mode != self.descriptor.mode || identity.pi_thinking_level != self.descriptor.pi_thinking_level + || identity.claude_effort != self.descriptor.claude_effort { return Err(DurableRunnerError::invalid( "ACPX durable identity no longer matches its qualified profile or provider mode", @@ -1295,6 +1306,9 @@ impl AcpxCommandExecutor { "sessionId": identity.agent_session_id, "processId": process_id, }); + if let Some(effort) = &identity.claude_effort { + result["claudeEffort"] = json!(effort); + } if let Some(level) = identity.pi_thinking_level { // This identity is admitted only after the sidecar's effective-mode ACK. result["piThinkingLevel"] = json!(level); @@ -2754,6 +2768,7 @@ mod tests { permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), pi_thinking_level: descriptor.pi_thinking_level, + claude_effort: descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3030,6 +3045,7 @@ mod tests { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; @@ -3184,7 +3200,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v22-identity.json" + "../../../../test-fixtures/pi-acp/profile-v23-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3208,6 +3224,7 @@ mod tests { include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v22-identity.json"), ] { let prior: Value = serde_json::from_str(prior).unwrap(); value["commandDigest"] = prior["commandDigest"].clone(); @@ -3507,6 +3524,7 @@ mod tests { permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), pi_thinking_level: original_descriptor.pi_thinking_level, + claude_effort: original_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3792,6 +3810,7 @@ mod tests { permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), pi_thinking_level: original_descriptor.pi_thinking_level, + claude_effort: original_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -4086,6 +4105,7 @@ mod tests { permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), pi_thinking_level: descriptor.pi_thinking_level, + claude_effort: descriptor.claude_effort.clone(), provider_lifetime_fence_candidates, }; let operations = Vec::new(); @@ -4259,6 +4279,7 @@ mod tests { permission_mode: Some(provider_descriptor.permission_mode), mode: provider_descriptor.mode.clone(), pi_thinking_level: provider_descriptor.pi_thinking_level, + claude_effort: provider_descriptor.claude_effort.clone(), provider_lifetime_fence_candidates, }); state.provider_exit_unconfirmed = true; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs index 48c79292cdb..60f575b82e8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs @@ -53,6 +53,8 @@ struct PersistedAcpxProviderSessionIdentity { mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + claude_effort: Option, provider_lifetime_fence_candidates: [u16; 3], } @@ -77,6 +79,7 @@ impl PersistedAcpxProviderSessionIdentity { permission_mode, mode: identity.mode, pi_thinking_level: identity.pi_thinking_level, + claude_effort: identity.claude_effort.clone(), provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -95,6 +98,7 @@ impl PersistedAcpxProviderSessionIdentity { permission_mode: Some(self.permission_mode), mode: self.mode.clone(), pi_thinking_level: self.pi_thinking_level, + claude_effort: self.claude_effort.clone(), provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } @@ -117,6 +121,7 @@ impl AcpxSuspensionCheckpoint { || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode || identity.pi_thinking_level != config.pi_thinking_level + || identity.claude_effort != config.claude_effort || config .expected_identity .as_ref() diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 85da2d348b7..5d17a5276b3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -72,6 +72,8 @@ pub struct AcpxProviderSessionIdentity { pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_effort: Option, pub provider_lifetime_fence_candidates: [u16; 3], } @@ -94,6 +96,7 @@ pub struct AcpxProviderSessionConfig { pub permission_mode: AcpxPermissionMode, pub mode: Option, pub pi_thinking_level: Option, + pub claude_effort: Option, pub permission_mode_pinned: bool, pub provider_policy: Option, pub system_instructions: String, @@ -109,6 +112,14 @@ impl AcpxProviderSessionConfig { LocalRunnerError::invalid("ACPX agent must name a known immutable profile") })?; validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; + if self.claude_effort.as_ref().is_some_and(|effort| { + self.agent != "claude" + || !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) { + return Err(LocalRunnerError::invalid( + "Claude effort must be a supported Claude-only value", + )); + } if (self.agent == "pi") != self.pi_thinking_level.is_some() { return Err(LocalRunnerError::invalid( "ACPX Pi thinking level must be explicit for Pi and absent for other agents", @@ -188,6 +199,7 @@ impl AcpxProviderSessionConfig { || expected_identity.permission_mode != Some(self.permission_mode) || expected_identity.mode != self.mode || expected_identity.pi_thinking_level != self.pi_thinking_level + || expected_identity.claude_effort != self.claude_effort { return Err(LocalRunnerError::invalid( "ACPX expected identity conflicts with the requested session", @@ -208,6 +220,13 @@ impl AcpxProviderSessionIdentity { if let Some(mode) = self.mode.as_deref() { validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; } + if self.claude_effort.as_ref().is_some_and(|effort| { + !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") + }) { + return Err(LocalRunnerError::invalid( + "ACPX identity contains invalid Claude effort", + )); + } for (value, label) in [ (&self.normalized_session_id, "normalized session"), (&self.acpx_record_id, "record"), @@ -1313,6 +1332,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu "tools": &sidecar_tools, "expectedIdentity": config.expected_identity, }); + if let Some(effort) = &config.claude_effort { + params["claudeEffort"] = json!(effort); + } if let Some(level) = config.pi_thinking_level { params["piThinkingLevel"] = json!(level); } @@ -1430,6 +1452,7 @@ fn verify_open_response( || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode || identity.pi_thinking_level != config.pi_thinking_level + || identity.claude_effort != config.claude_effort || config .expected_identity .as_ref() @@ -1755,6 +1778,7 @@ mod mode_tests { permission_mode: AcpxPermissionMode::ApproveReads, mode: Some("plan".to_owned()), pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }), system_instructions: String::new(), @@ -1782,6 +1806,7 @@ mod mode_tests { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: Some("plan".to_owned()), pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -1921,6 +1946,27 @@ mod mode_tests { identity } + #[test] + fn claude_effort_is_explicit_claude_only_and_forwarded_without_clamping() { + let mut cfg = config(); + cfg.agent = "claude".to_owned(); + for effort in ["low", "medium", "high", "xhigh", "max"] { + cfg.claude_effort = Some(effort.to_owned()); + cfg.validate().unwrap(); + assert_eq!( + session_open_params(&cfg, &[])["claudeEffort"], + json!(effort) + ); + } + cfg.claude_effort = Some("ultra".to_owned()); + assert!(cfg.validate().is_err()); + cfg.claude_effort = Some("high".to_owned()); + cfg.agent = "codex".to_owned(); + assert!(cfg.validate().is_err()); + cfg.claude_effort = None; + cfg.validate().unwrap(); + } + #[test] fn pi_thinking_level_is_closed_explicit_and_pi_only() { for (name, level) in [ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 28f4d2a775a..f255519807c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index 48daa24f6fa..619d20bef24 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -52,6 +52,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -80,6 +81,7 @@ fn identity() -> AcpxProviderSessionIdentity { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs index f64f89e4365..90460a2a294 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs @@ -51,6 +51,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index 71dcb93462d..5c89ae54055 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -47,6 +47,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -70,6 +71,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, pi_thinking_level: None, + claude_effort: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs index e8e65d76289..676df2529a3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -27,6 +27,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { permission_mode: AcpxPermissionMode::ApproveReads, mode: None, pi_thinking_level: None, + claude_effort: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs index 973011b98f6..dc58137f012 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs @@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + claude_effort: None, pi_thinking_level: if mode.starts_with("controls") { Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low) } else { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index cc94d4834c0..4dde0516ef3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9"; + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index afdd2013b65..d73a3af6837 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v18-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index a77e4100263..1bf739229e7 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -15,7 +15,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext" + "model" | "permissionMode" | "mode" | "piThinkingLevel" | "claudeEffort" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -76,6 +76,7 @@ export function createAcpxNativeSessionBackend( permissionMode: input.provider.permissionMode ?? "approve-reads", mode: input.provider.mode, piThinkingLevel: input.provider.piThinkingLevel, + claudeEffort: "claudeEffort" in input.provider ? input.provider.claudeEffort : undefined, systemInstructions, runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 63fc827217a..c17c7a4882c 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js"; import { parseProviderMode } from "../contracts/provider-mode.js"; import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js"; @@ -297,6 +298,7 @@ async function dispatch( permissionMode: params.permissionMode, mode: params.mode, piThinkingLevel: params.piThinkingLevel, + claudeEffort: params.claudeEffort, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -1209,12 +1211,13 @@ function safeOutput(value: unknown): Record { function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { - const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); + const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field"); const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor"); const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel); + const claudeEffort = resolveClaudeEffort(agent, value.claudeEffort); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1236,6 +1239,7 @@ function parseOpenParams( permissionMode: requiredPermissionMode(value.permissionMode), ...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1307,6 +1311,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { : { permissionMode: requiredPermissionMode(input.permissionMode) }), ...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }), ...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }), + ...(input.claudeEffort === undefined ? {} : { claudeEffort: resolveClaudeEffort("claude", input.claudeEffort) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index a520a146431..f17af148250 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -457,6 +457,7 @@ export interface AcpxSessionIdentity { /** Effective provider mode identifier, bound to the session identity. */ mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 460a43534c6..ccb0c871d75 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it } from "vitest"; -import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, type NativeExecutionInputV1 } from "./native-execution.js"; +import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, type NativeExecutionInputV1 } from "./native-execution.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, @@ -680,3 +680,30 @@ describe("native task context ownership", () => { } }); }); + + +describe("Claude effort input v7", () => { + const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude; + const digest = "0".repeat(64); + const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } }, + skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null } }; + const current = { ...input, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, executionMode: "default", planningContext: null, + runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile } }; + it.each(["low", "medium", "high", "xhigh", "max"])("round trips explicit %s and retains the compact model envelope", claudeEffort => { + const parsed = parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort } }); + expect(parsed).toMatchObject({ schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, provider: { claudeEffort } }); + expect(buildNativeModelEnvelope(parsed).schema).toBe("paperclip.native-model-envelope.v3"); + expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("claudeEffort"); + }); + it("keeps historical readers closed and rejects aliases and foreign effort", () => { + for (const schema of ["paperclip.native-execution-input.v4", NATIVE_EXECUTION_INPUT_SCHEMA]) { + expect(() => parseNativeExecutionInput({ ...current, schema, provider: { ...current.provider, claudeEffort: "high" } })).toThrow("claudeEffort"); + expect(parseNativeExecutionInput({ ...current, schema }).schema).toBe(schema); + } + expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort: "ultra" } })).toThrow("Claude effort"); + expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, agent: "codex", profile: QUALIFIED_ACPX_PROFILES.codex, claudeEffort: "high" } })).toThrow("Claude effort"); + }); +}); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 194920c3e96..f1e9d8eebce 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js"; import type { DotBindingSnapshot } from "./external-provider.js"; import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js"; import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js"; @@ -9,6 +10,7 @@ import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeCo export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const; +export const NATIVE_EXECUTION_INPUT_SCHEMA_V7 = "paperclip.native-execution-input.v7" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const; export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const; @@ -238,6 +240,13 @@ export interface NativeExecutionInputV5 extends Omit + | (Extract & { claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max" }); +export interface NativeExecutionInputV7 extends Omit { + schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V7; + provider: NativeProviderConfigV7; +} + export interface NativeExecutionInputV6 extends Omit { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V6; provider: { kind: "openai_dot"; model: null; binding: DotBindingSnapshot }; @@ -245,7 +254,7 @@ export interface NativeExecutionInputV6 extends Omit & { driverKind: "openai_dot_mcp" }; } -export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6; +export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7; /** The only task data that may enter provider-visible model input. */ export interface NativeModelEnvelopeV1 { @@ -372,7 +381,8 @@ function parseDotNativeExecutionInput(input: Record): NativeExe export function parseNativeExecutionInput(value: unknown): NativeExecutionInput { const input = record(value, "input"); if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseDotNativeExecutionInput(input); - const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; + const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7; + const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4; const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3; const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2; @@ -504,7 +514,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "aws_agentcore" ? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"] : provider.kind === "acpx" - ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : []), ...(isV7 ? ["claudeEffort"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -527,7 +537,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) { throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form"); } - let parsedProvider: NativeProviderConfig | NativeProviderConfigV5; + let parsedProvider: NativeProviderConfigV7 | NativeProviderConfig | NativeProviderConfigV5; if (provider.kind === "claude_managed") { if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for claude_managed"); @@ -643,6 +653,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput } else if (provider.permissionPolicy !== "interactive") { throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive"); } + const claudeEffort = resolveClaudeEffort(provider.agent, provider.claudeEffort); const profile = record(provider.profile, "input.provider.profile"); exactKeys(profile, [ "driverKind", @@ -681,6 +692,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), ...(provider.mode === undefined ? {} : { mode: provider.mode as string }), + ...(claudeEffort === undefined ? {} : { claudeEffort }), ...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }), profile: { driverKind: "acpx_runtime", @@ -835,6 +847,12 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput provider: parsedProvider as NativeProviderConfigV4, }; if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 }; + if (isV7) return { + ...withPermissions, + schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, + provider: parsedProvider as NativeProviderConfigV7, + ...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}), + }; return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA, @@ -901,7 +919,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?: interactionResponses: structuredClone(input.interactionResponses), }; } - if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) { + if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) { const sourceBinding = input.completionSources; const references = sourceBinding?.contractRevision === input.completionContract.contract.revision && sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex") diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts new file mode 100644 index 00000000000..e21cd094851 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { admittedClaudeEffort, createClaudeEffortAdmission, resolveClaudeEffort } from "./claude-effort.js"; + +const response = (value: string, sessionId?: string) => ({ ...(sessionId ? { sessionId } : {}), configOptions: [{ id: "effort", currentValue: value }] }); +function loaded(method = "session/new") { + const admission = createClaudeEffortAdmission("high"); + const guard = admission.createGuard(); + guard("outbound", { id: 1, method, params: method === "session/new" ? {} : { sessionId: "s" } }); + guard("inbound", { id: 1, result: response("medium", method === "session/new" ? "s" : undefined) }); + return { admission, guard }; +} +function select(guard: ReturnType["createGuard"]>, acknowledged = "high") { + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "effort", value: "high" } }); + guard("inbound", { id: 2, result: response(acknowledged) }); +} +describe("Claude effort admission", () => { + it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts exact %s without aliases", value => { + expect(resolveClaudeEffort("claude", value)).toBe(value); + expect(admittedClaudeEffort("claude", value, { claudeEffort: value }, { claudeEffort: value })).toBe(value); + }); + it("rejects unsupported, foreign and conflicting effort", () => { + for (const value of ["ultra", "off", null, 2]) expect(() => resolveClaudeEffort("claude", value)).toThrow(); + expect(() => resolveClaudeEffort("pi", "high")).toThrow("only supported"); + expect(() => admittedClaudeEffort("claude", "high", { claudeEffort: "high" }, { claudeEffort: "medium" })).toThrow("differs"); + }); + it.each(["session/new", "session/load", "session/resume"])("requires acknowledged effort before prompting after %s", method => { + const { admission, guard } = loaded(method); + expect(admission.isReady()).toBe(false); + select(guard); + expect(admission.isReady()).toBe(true); + expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); + }); + + it("rejects a resumed session with missing effort configuration", () => { + const admission = createClaudeEffortAdmission("high"); + const guard = admission.createGuard(); + guard("outbound", { id: 1, method: "session/resume", params: { sessionId: "s" } }); + expect(() => guard("inbound", { id: 1, result: {} })).toThrow("missing or ambiguous"); + }); + it("blocks a prompt based only on a restored default", () => { + const { guard } = loaded("session/load"); + expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); + }); + + it("requires the bound effort acknowledgement on each cold connection", () => { + const { admission, guard } = loaded(); + select(guard); + const resumed = admission.createGuard(); + resumed("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + expect(() => resumed("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge"); + const fresh = loaded(); select(fresh.guard); + const acknowledged = fresh.admission.createGuard(); + acknowledged("outbound", { id: 4, method: "session/load", params: { sessionId: "s" } }); + acknowledged("inbound", { id: 4, result: response("high") }); + expect(() => acknowledged("outbound", { id: 5, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow(); + }); + it("rejects a provider that ignores the requested effort", () => { + const { guard } = loaded(); + expect(() => select(guard, "medium")).toThrow("did not apply"); + }); + it("rejects drift and wrong-session prompts after admission", () => { + const { guard } = loaded(); select(guard); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", ...response("low") } } })).toThrow("drifted"); + const fresh = loaded(); select(fresh.guard); + expect(() => fresh.guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "foreign" } })).toThrow("different session"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts new file mode 100644 index 00000000000..504ddd08afe --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts @@ -0,0 +1,21 @@ +import { createAcpxConfigAdmission } from "./config-admission.js"; + +export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort | undefined { + if (value === undefined || value === "") return undefined; + if (agent !== "claude") throw new Error("Claude effort is only supported by Claude"); + if (value !== "low" && value !== "medium" && value !== "high" && value !== "xhigh" && value !== "max") throw new Error("Claude effort must be low, medium, high, xhigh, or max"); + return value; +} +export function createClaudeEffortAdmission(expected: ClaudeEffort) { + // Every connection, including session/load, must select and acknowledge the + // configured effort. A restored preference alone never admits a prompt. + return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort", + supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] }); +} +export function admittedClaudeEffort(agent: string, requested: unknown, descriptor: Record, identity: Record): ClaudeEffort | undefined { + const expected = resolveClaudeEffort(agent, requested); + const effective = resolveClaudeEffort(agent, identity.claudeEffort); + if (effective !== expected || descriptor.claudeEffort !== expected) throw new Error("Claude effort differs from the admitted configuration"); + return effective; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 871ff1cebe2..1653bbab774 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -148,6 +148,7 @@ export interface CodexAcpxDriverOptions { permissionMode?: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -463,6 +464,7 @@ export class CodexAcpxDriver implements HarnessDriver { permissionMode: this.#options.permissionMode ?? "approve-all", mode: this.#options.mode, piThinkingLevel: this.#options.piThinkingLevel, + claudeEffort: this.#options.claudeEffort, providerPolicy: this.#options.providerPolicy, runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, @@ -1290,6 +1292,7 @@ class CodexAcpxSession implements HarnessSession { permissionMode: identity.permissionMode, ...(identity.mode === undefined ? {} : { mode: identity.mode }), ...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }), + ...(identity.claudeEffort === undefined ? {} : { claudeEffort: identity.claudeEffort }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -2153,6 +2156,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { identity.permissionMode, )) || (identity.mode !== undefined && !isProviderMode(identity.mode)) || + (identity.claudeEffort !== undefined && !["low", "medium", "high", "xhigh", "max"].includes(identity.claudeEffort)) || (identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) || !validProviderLifetimeFenceCandidates( identity.providerLifetimeFenceCandidates, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index a5457816bff..26e14d2f6da 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -29,6 +29,31 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { + it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => { + const runtime = fakeRuntime(); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "claude" }; + options.claudeEffort = "high"; + options.restoringSession = restoring; + options.refreshConsumedCommand = vi.fn(async () => undefined); + let created!: AcpRuntimeOptions; + vi.mocked(runtime.setConfigOption).mockImplementation(async input => { + const guard = created.protocolGuardFactory!(); + guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } }); + guard("inbound", { id: 0, result: { configOptions: [{ id: "effort", currentValue: "default" }] } }); + guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } }); + guard("inbound", { id: 1, result: { configOptions: [{ id: "effort", currentValue: input.value }] } }); + }); + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" }); + expect(await port.identity()).toMatchObject({ claudeEffort: "high" }); + expect(options.refreshConsumedCommand).toHaveBeenCalledOnce(); + expect(runtime.startTurn).not.toHaveBeenCalled(); + await port.close({ reason: "test complete" }); + }); + it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => { for (const agent of ["copilot", "codex", "claude", "grok"] as const) { const runtime = fakeRuntime(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 9255b0bc94e..bac1e89cb95 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js"; import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import type { ChildProcess } from "node:child_process"; @@ -305,6 +306,8 @@ export async function openQualifiedAcpxRuntime( const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode); const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel); const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined; + const selectedClaudeEffort = resolveClaudeEffort(options.profile.agent, options.claudeEffort); + const claudeEffort = selectedClaudeEffort ? createClaudeEffortAdmission(selectedClaudeEffort) : undefined; const cursorInstructions = options.profile.agent === "cursor" ? createCursorInstructionAdmission(options.systemInstructions) : null; @@ -316,6 +319,7 @@ export async function openQualifiedAcpxRuntime( return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); }; } } : {}), ...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}), + ...(claudeEffort ? { protocolGuardFactory: () => claudeEffort.createGuard() } : {}), sessionStore, agentRegistry: createRegistry({ // Preserve Claude's ACP capability identity. This is metadata only: the @@ -527,6 +531,18 @@ export async function openQualifiedAcpxRuntime( await commandLaunches.refreshConsumedCommand?.(); } return ensuredHandle; + }) : claudeEffort ? ensuredSession.then(async (ensuredHandle) => { + handle = ensuredHandle; + options.signal?.throwIfAborted(); + if (!claudeEffort.isReady()) { + if (!runtime.setConfigOption) throw new Error("Claude effort admission requires native configuration"); + await runtime.setConfigOption({ handle: ensuredHandle, key: "effort", value: selectedClaudeEffort! }); + claudeEffort.assertReady(); + await children.verifyLifetimeOwnership(); + options.signal?.throwIfAborted(); + await commandLaunches.refreshConsumedCommand?.(); + } + return ensuredHandle; }) : ensuredSession) .catch((error: unknown) => { throw classifySessionEnsureFailure(error); @@ -543,6 +559,7 @@ export async function openQualifiedAcpxRuntime( cursorInstructions?.assertReady(); modeBinding?.assertReady(); piThinking?.assertReady(); + claudeEffort?.assertReady(); // A provider can answer only after the verified sentinel is armed, but do // not admit the session until the owner has observed that exact handoff. await children.verifyLifetimeOwnership(); @@ -597,7 +614,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) }, + { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}), ...(selectedClaudeEffort ? { claudeEffort: selectedClaudeEffort } : {}) }, baseStore, children, runtimeCloseTimeoutMs, diff --git a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts new file mode 100644 index 00000000000..be597f10e16 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -0,0 +1,104 @@ +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Native mode is independent of permissions. Every connection must prove its + * current mode before a prompt; a persisted ACPX preference is not proof. */ +export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) { + type State = { sessionId: string | null; mode: string | null; error: Error | null }; + let current: State | null = null; + let selectedOnce = options.restoring === true; + const failure = (detail: string) => new Error(`${options.label} admission failed: ${detail}`); + const assertReady = () => { + if (current?.error) throw current.error; + if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); + }; + const readMode = (result: Record, session: boolean): string => { + const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : []; + if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); + const mode = object(configs[0]).currentValue; + // Before exact model selection, Pi can legitimately report a different + // native model's level. Observation alone never admits the first prompt. + if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported"); + if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); + return mode; + }; + return { + assertReady, + isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; }, + createGuard() { + const state: State = { sessionId: null, mode: null, error: null }; + current = state; + const pending = new Map(); + return (direction: "inbound" | "outbound", value: unknown): void => { + if (current !== state) throw failure("connection authority was replaced"); + assertNoError(); + const message = object(value); + const params = object(message.params); + try { + if (direction === "outbound") { + const method = message.method; + if (method === "session/prompt") { + assertReady(); + if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); + } else if (method === "session/new" || method === "session/load" || method === "session/resume" || method === "session/set_config_option") { + if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); + if (pending.has(message.id)) throw failure("duplicate mode request identity"); + if (pending.size) throw failure("overlapping native admission controls"); + if (method === "session/new" || method === "session/load" || method === "session/resume") { + if (pending.size) throw failure("overlapping session admission"); + state.sessionId = null; state.mode = null; + } else { + if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session"); + if (params.configId === options.configId && params.value !== expected) throw failure("configuration changes the selected mode"); + state.mode = null; // No prompt may race unacknowledged native configuration. + } + pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId }); + } else if (options.sessionMode && method === "session/set_mode") { + if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); + // This method only returns {}; require a later config/session + // acknowledgement rather than treating that empty result as proof. + state.mode = null; + } + return; + } + if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { + const update = object(params.update); + // Config updates may contain only model options. A mode entry is + // authoritative observation, but never an admission acknowledgement. + if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) { + if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + const observed = readMode(update, false); + if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode"); + } + return; + } + if (options.sessionMode && message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") { + if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode"); + return; // Notifications alone never admit a prompt. + } + if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error !== undefined) { state.mode = null; return; } + const result = object(message.result); + const session = request.method !== "session/set_config_option"; + const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; + if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity"); + const mode = readMode(result, session); + if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode"); + if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode"); + if (!session && request.selectsMode) selectedOnce = true; + state.sessionId = sessionId; state.mode = mode; + } catch (error) { + state.error = error instanceof Error ? error : failure("invalid mode acknowledgement"); + throw state.error; + } + }; + }, + }; + function assertNoError() { if (current?.error) throw current.error; } +} + diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 999efa6eed2..6662f9fe2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,8 +87,16 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); - it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it("preserves v17 executable and policy identity while binding the shared Claude effort sources", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()) + .toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("admits the current v18 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -119,7 +127,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 3a0bf38cf33..92b3eadbcdd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "permissionPolicy": "interactive" }, "cursor": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index c04f4c3f71e..f34810516f9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -88,11 +88,19 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("preserves profile 22 runtime bytes and revises only shared Claude effort source attestations", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(22), current = await read(23); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "recoveryIdentitySourceSha256", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v22-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v23-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -123,21 +131,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 22"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 23"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 23"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts index aa5d67cde3f..be7c80cb56c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts @@ -1,3 +1,5 @@ +import { createAcpxConfigAdmission } from "./config-admission.js"; + export type PiThinkingLevel = "off" | "low" | "high" | "max"; export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinkingLevel | undefined { @@ -10,108 +12,13 @@ export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinking return mode; } -function object(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) { + return createAcpxConfigAdmission(expected, { ...options, label: "Pi thinking mode", configId: "thought_level", + supportedValues: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], sessionMode: true }); } -/** Native mode is independent of permissions. Every connection must prove its - * current mode before a prompt; a persisted ACPX preference is not proof. */ -export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) { - type State = { sessionId: string | null; mode: string | null; error: Error | null }; - let current: State | null = null; - let selectedOnce = options.restoring === true; - const failure = (detail: string) => new Error(`Pi thinking mode admission failed: ${detail}`); - const assertReady = () => { - if (current?.error) throw current.error; - if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); - }; - const readMode = (result: Record, session: boolean): string => { - const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "thought_level") : []; - if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); - const mode = object(configs[0]).currentValue; - // Before exact model selection, Pi can legitimately report a different - // native model's level. Observation alone never admits the first prompt. - if (typeof mode !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(mode)) throw failure("native mode is unsupported"); - if (session && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); - return mode; - }; - return { - assertReady, - isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; }, - createGuard() { - const state: State = { sessionId: null, mode: null, error: null }; - current = state; - const pending = new Map(); - return (direction: "inbound" | "outbound", value: unknown): void => { - if (current !== state) throw failure("connection authority was replaced"); - assertNoError(); - const message = object(value); - const params = object(message.params); - try { - if (direction === "outbound") { - const method = message.method; - if (method === "session/prompt") { - assertReady(); - if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); - } else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") { - if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); - if (pending.has(message.id)) throw failure("duplicate mode request identity"); - if (pending.size) throw failure("overlapping native admission controls"); - if (method === "session/new" || method === "session/load") { - if (pending.size) throw failure("overlapping session admission"); - state.sessionId = null; state.mode = null; - } else { - if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session"); - if (params.configId === "thought_level" && params.value !== expected) throw failure("configuration changes the selected mode"); - state.mode = null; // No prompt may race unacknowledged native configuration. - } - pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "thought_level" }); - } else if (method === "session/set_mode") { - if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); - // This method only returns {}; require a later config/session - // acknowledgement rather than treating that empty result as proof. - state.mode = null; - } - return; - } - if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { - const update = object(params.update); - // Config updates may contain only model options. A mode entry is - // authoritative observation, but never an admission acknowledgement. - if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === "thought_level")) { - if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); - const observed = readMode(update, false); - if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode"); - } - return; - } - if (message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") { - if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); - if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode"); - return; // Notifications alone never admit a prompt. - } - if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; - const request = pending.get(message.id); - if (!request) return; - pending.delete(message.id); - if (message.error !== undefined) { state.mode = null; return; } - const result = object(message.result); - const session = request.method !== "session/set_config_option"; - const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; - if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity"); - const mode = readMode(result, session); - if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode"); - if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode"); - if (!session && request.selectsMode) selectedOnce = true; - state.sessionId = sessionId; state.mode = mode; - } catch (error) { - state.error = error instanceof Error ? error : failure("invalid mode acknowledgement"); - throw state.error; - } - }; - }, - }; - function assertNoError() { if (current?.error) throw current.error; } +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } /** Cross-check independently returned durable identity and public descriptor. */ diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index ddd5d29314b..f8b43855419 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -75,6 +75,21 @@ describe("ACPX recovery identity", () => { expect(customMode.mode).toBe("provider-custom-mode"); }); + it("binds Claude effort to recovery and rejects changed or omitted effort", async () => { + const fixture = await recoveryFixture(); + const profile = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"); + const input = { ...fixture.input, profile, requestedModel: profile.qualificationModel, claudeEffort: "high" as const }; + const high = await createAcpxRecoveryBinding(input); + const low = await createAcpxRecoveryBinding({ ...input, claudeEffort: "low" }); + expect(low.profileSessionKey).not.toBe(high.profileSessionKey); + const expected = { ...fixture.expected, profileDigest: high.commandDigest, requestedModel: high.requestedModel, effectiveModel: high.effectiveModel, claudeEffort: "high" as const }; + const saved = createAcpxIdentityRecord(expected, high); + expect(acpxProviderSessionIdentity(saved, high).claudeEffort).toBe("high"); + expect(() => verifyExpectedAcpxIdentity({ ...expected, claudeEffort: undefined }, high, saved)).toThrow("immutable"); + expect(() => verifyExpectedAcpxIdentity(expected, high, { ...saved, claudeEffort: undefined })).toThrow("persisted"); + expect(() => verifyExpectedAcpxIdentity(expected, low, saved)).toThrow("immutable"); + }); + it("binds Pi thinking mode and rejects old or mismatched warm identities", async () => { const fixture = await recoveryFixture(); const profile = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index 4439b02edcf..8393078cc5a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "./claude-effort.js"; import { parseProviderMode } from "../../contracts/provider-mode.js"; import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import { createHash } from "node:crypto"; @@ -23,6 +24,7 @@ export interface AcpxRecoveryBinding { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; profileSessionKey: string; } @@ -39,6 +41,7 @@ export interface AcpxIdentityRecord { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } @@ -51,12 +54,14 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerConfigurationDigest?: string; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; }): Promise { validateIdentity(input.normalizedSessionId, "normalized session"); const mode = parseProviderMode(input.mode); const piThinkingLevel = resolvePiThinkingLevel(input.profile.agent, input.piThinkingLevel); + const claudeEffort = resolveClaudeEffort(input.profile.agent, input.claudeEffort); if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy"); if (input.requestedModel !== input.profile.qualificationModel) { throw new Error("ACPX recovery requested model does not match its admitted profile"); @@ -104,6 +109,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -118,6 +124,7 @@ export async function createAcpxRecoveryBinding(input: { permissionMode: input.permissionMode, ...(mode ? { mode } : {}), ...(piThinkingLevel ? { piThinkingLevel } : {}), + ...(claudeEffort ? { claudeEffort } : {}), profileSessionKey, }; } @@ -140,6 +147,7 @@ export function createAcpxIdentityRecord( permissionMode: binding.permissionMode, ...(binding.mode ? { mode: binding.mode } : {}), ...(binding.piThinkingLevel ? { piThinkingLevel: binding.piThinkingLevel } : {}), + ...(binding.claudeEffort ? { claudeEffort: binding.claudeEffort } : {}), providerLifetimeFenceCandidates: Object.freeze([ ...expected.providerLifetimeFenceCandidates, ]) as readonly [number, number, number], @@ -167,6 +175,7 @@ export function acpxProviderSessionIdentity( permissionMode: record.permissionMode, ...(record.mode ? { mode: record.mode } : {}), ...(record.piThinkingLevel ? { piThinkingLevel: record.piThinkingLevel } : {}), + ...(record.claudeEffort ? { claudeEffort: record.claudeEffort } : {}), providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); @@ -193,7 +202,8 @@ export function verifyExpectedAcpxIdentity( expected.effectiveModel !== binding.effectiveModel || expected.permissionMode !== binding.permissionMode || expected.mode !== binding.mode || - expected.piThinkingLevel !== binding.piThinkingLevel + expected.piThinkingLevel !== binding.piThinkingLevel || + expected.claudeEffort !== binding.claudeEffort ) { throw new Error( "ACPX recovery identity conflicts with the immutable session configuration", @@ -214,6 +224,7 @@ export function verifyExpectedAcpxIdentity( record.permissionMode !== binding.permissionMode || record.mode !== binding.mode || record.piThinkingLevel !== binding.piThinkingLevel || + record.claudeEffort !== binding.claudeEffort || !sameFenceCandidates( record.providerLifetimeFenceCandidates, expected.providerLifetimeFenceCandidates, @@ -240,6 +251,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "permissionMode", "mode", "piThinkingLevel", + "claudeEffort", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -267,6 +279,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { throw new Error("ACPX identity permission mode is invalid"); } if (value.mode !== undefined) parseProviderMode(value.mode); + resolveClaudeEffort("claude", value.claudeEffort); if (value.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", value.piThinkingLevel); validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; @@ -297,6 +310,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { throw new Error("Expected ACPX permission mode is invalid"); } if (expected.mode !== undefined) parseProviderMode(expected.mode); + resolveClaudeEffort("claude", expected.claudeEffort); if (expected.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", expected.piThinkingLevel); validateFenceCandidates(expected.providerLifetimeFenceCandidates); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 8be42da690f..f97d92b1690 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "./claude-effort.js"; import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; @@ -83,6 +84,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -163,6 +165,7 @@ export interface AcpxRuntimePortOpenOptions { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -238,6 +241,7 @@ export interface OpenAcpxRuntimeHostOptions { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: PiThinkingLevel; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -350,6 +354,7 @@ export class AcpxRuntimeHost { permissionMode: options.permissionMode, mode: resolveAcpxProviderMode(options.agent, options.mode), piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel), + claudeEffort: resolveClaudeEffort(options.agent, options.claudeEffort), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -581,6 +586,7 @@ export class AcpxRuntimeHost { permissionMode: binding.permissionMode, mode: binding.mode, piThinkingLevel: binding.piThinkingLevel, + claudeEffort: binding.claudeEffort, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -644,6 +650,7 @@ export class AcpxRuntimeHost { if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) { throw new Error("ACPX runtime Pi thinking level conflicts with session binding"); } + if (runtimeIdentity.claudeEffort !== binding.claudeEffort) throw new Error("ACPX runtime Claude effort conflicts with session binding"); const observedIdentity: AcpxExpectedSessionIdentity = { kind: "acpx", normalizedSessionId: binding.normalizedSessionId, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index ab14aa666f3..7225231f2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -119,6 +119,20 @@ describe("ACPX runtime sandbox", () => { } }); + it("restores bound Claude effort in private settings across cold connections", async () => { + const fixture = await sandboxFixture("claude"); + for (const claudeEffort of ["high", "xhigh", "max"] as const) { + const binding = { ...fixture.binding, claudeEffort }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const sandbox = await prepareAcpxRuntimeSandbox({ binding, agent: "claude" }); + const settings = JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8")); + expect(settings.effortLevel).toBe(claudeEffort); + } + } + const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "claude" }); + expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"))).not.toHaveProperty("effortLevel"); + }); + it.each([ ["pi", "OPENROUTER_API_KEY", "pi-home"], ["claude", "ANTHROPIC_API_KEY", "claude-home"], diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 3e0e52d250d..d5ae8ce9e14 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -403,6 +403,10 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], + // Claude reloads effort from settings on a new ACP connection. Keep + // the bound choice available during model verification and resume; + // the protocol guard still requires a provider acknowledgement. + ...(input.binding.claudeEffort ? { effortLevel: input.binding.claudeEffort } : {}), ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } : {}), diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index d9ce539a9f6..717ffc97748 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -47,6 +47,7 @@ export interface AcpxSidecarOpenParams { permissionMode: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -69,6 +70,7 @@ export interface AcpxExpectedSessionIdentity { permissionMode?: NativeAcpxPermissionMode; mode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index c6644732cb7..77a7b2ae959 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -1,3 +1,4 @@ +import { resolveClaudeEffort } from "../acpx/claude-effort.js"; import { resolvePiThinkingLevel } from "../acpx/pi-thinking.js"; import { isProviderMode } from "../../contracts/provider-mode.js"; import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js"; @@ -81,6 +82,7 @@ export function parseProviderIdentity( ); } const piThinkingLevel = identity.piThinkingLevel === undefined ? undefined : resolvePiThinkingLevel("pi", identity.piThinkingLevel); + const claudeEffort = resolveClaudeEffort("claude", identity.claudeEffort); const mode = identity.mode; if (mode !== undefined && !isProviderMode(mode)) { throw new Error("ACPX provider identity contains an invalid provider mode"); @@ -112,6 +114,7 @@ export function parseProviderIdentity( ...(permissionMode === undefined ? {} : { permissionMode }), ...(mode === undefined ? {} : { mode }), ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), + ...(claudeEffort === undefined ? {} : { claudeEffort }), providerLifetimeFenceCandidates: fenceCandidates as [ number, number, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 32103f88d94..ac89da39070 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -57,6 +57,7 @@ export { type QualifiedAcpxEnvironmentProbe, } from "./drivers/acpx/codex-acpx-driver.js"; export * from "./drivers/acpx/sidecar-protocol.js"; +export { resolveClaudeEffort } from "./drivers/acpx/claude-effort.js"; export * from "./drivers/runner-tool-bridge.js"; export { createRunnerdCodexTransport, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ff7dc3e8d56..3f49e94a6a0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,5 +1,6 @@ import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { resolveClaudeEffort, admittedClaudeEffort } from "../drivers/acpx/claude-effort.js"; import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { configuredEnvironment } from "../configured-environment.js"; import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; @@ -1191,6 +1192,7 @@ export interface CapabilityRunnerdProcessEvidence { providerDriver: string | null; /** Effective native mode, populated only from the admitted Pi identity. */ piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; providerVersion: string | null; acpxAgent: QualifiedAcpxAgent | null; agentServerVersion: string | null; @@ -1215,6 +1217,7 @@ export interface CapabilityRunnerdCodexTransportOptions { acpxPermissionMode?: NativeAcpxPermissionMode; acpxMode?: string; piThinkingLevel?: "off" | "low" | "high" | "max"; + claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max"; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -3617,6 +3620,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + resolveClaudeEffort(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.claudeEffort); resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel); if (options.acpxMode !== undefined && options.provider !== "acpx") { throw new Error("acpxMode requires the ACPX provider"); @@ -4912,6 +4916,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, ...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }), ...(acpxProfile!.agent === "pi" ? { piThinkingLevel: this.options.piThinkingLevel } : {}), + ...(acpxProfile!.agent === "claude" && this.options.claudeEffort ? { claudeEffort: this.options.claudeEffort } : {}), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), @@ -6597,6 +6602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) { throw new Error("ACPX capability identity differs from the admitted profile"); } + const claudeEffort = admittedClaudeEffort(this.options.acpxAgent ?? "codex", this.options.claudeEffort, descriptor, providerIdentity); + if (claudeEffort !== undefined) this.#evidence.claudeEffort = claudeEffort; const piThinkingLevel = admittedPiThinkingLevel(this.options.acpxAgent ?? "codex", this.options.piThinkingLevel, descriptor, providerIdentity); if (piThinkingLevel !== undefined) this.#evidence.piThinkingLevel = piThinkingLevel; this.#turnControls = parseAcpxTurnControlCapabilities(descriptor.turnControls, descriptor.agent); diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts index 9d9efa46acf..4874c6c0943 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -1178,6 +1178,15 @@ export const providerDescriptorSchema = { "high", "max" ] + }, + "claudeEffort": { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] } }, "allOf": [ @@ -1201,6 +1210,26 @@ export const providerDescriptorSchema = { ] } }, + { + "if": { + "required": [ + "claudeEffort" + ] + }, + "then": { + "properties": { + "provider": { + "const": "acpx" + }, + "agent": { + "const": "claude" + } + }, + "required": [ + "agent" + ] + } + }, { "oneOf": [ { diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json new file mode 100644 index 00000000000..e344c8eb532 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 23, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "8767456c125b53b1e85fbfce84245c9b213f9ef27a58970cf73dbc2ff82005d8", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json new file mode 100644 index 00000000000..eab746e74c7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 18, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "d2777bfc3e15ee7861ce57a069ffb55d3dc266364adf7915845bfbad5aa2e402", + "semanticDirectDriverSourceSha256": "187772a17e5d4f854ec10f37abfcd294cf4f1db58a91d18e7645f4a8c617f329", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fb4a232460dc9e6c7e7221cb4ddd66d7cb82fa0b35e1b3eb04f2d1ee5ee18c86", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index b28f3b3091e..21691d1a965 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => { expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true); expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false); - expect(validateRunnerCatalog()).toHaveLength(722); + expect(validateRunnerCatalog()).toHaveLength(724); for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); @@ -189,8 +189,8 @@ describe("Pi controls catalog admission", () => { // Extended v3 states the strict file oracle's task-wide Bash limit. const pi = runnerMatrix.find(c => c.profile.qualificationCandidate === "pi")!.profile; expect(pi.modelQualification?.qualificationId).toBe("pi:0.0.33:1.0.0:openrouter"); - expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 19, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); - expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 19, + expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 23, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); + expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 23, remoteProcessIdentity: "observer-pid-startTicks-bootId", controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner" }); expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 5, piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" }); @@ -200,7 +200,7 @@ describe("Pi controls catalog admission", () => { } const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)])); expect(hashes).toEqual({ - "pi-native": "90f2e901d7f39bfb6bbadcf63bc78e410a3b071af980f3a23979747e3d5f2021", + "pi-native": "3c77ced7edfb99aa12052f451398da7580466a54abadcaf287d31ee12c16382d", "native-active-stop": "2d4fdeeb75bd531b309bd1b35cfa5680ceefdeee6b7155b068dd011ce9901980", "rich-acp-warm-continuity": "331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0", "extended-harnesses": "5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6", From 6263a47185a2760214f055ac4af2a01172674169 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:24:17 -0500 Subject: [PATCH 3/6] fix(claude): preserve permissions and connection claims at creation Retain native import settings, explicitly select Legacy in qualification, pin updated CLI fixtures, and version shared source attestations. Co-Authored-By: Paperclip --- .../claude-local/src/server/acp.test.ts | 2 +- .../src/server/execute.acp-fallback.test.ts | 2 +- .../src/server/execute.remote.test.ts | 28 +++++------ .../src/server/test.probe.test.ts | 34 ++++++------- .../src/server/test.remote.test.ts | 4 +- packages/paperclip-runner/acpx-profiles.json | 8 +-- .../runner-core/src/acpx_provider_backend.rs | 3 +- .../src/generated_acpx_profiles.rs | 4 +- .../tests/native_provider_backend.rs | 2 +- .../scripts/generate-acpx-profiles.mjs | 2 +- .../drivers/acpx/copilot-installation.test.ts | 14 ++++-- .../src/drivers/acpx/generated-profiles.ts | 8 +-- .../src/drivers/acpx/pi-installation.test.ts | 40 +++++++++------ .../pi-acp/profile-v23-identity.json | 50 +++++++++++++++++++ .../copilot-profile-v18-identity.json | 43 ++++++++++++++++ ...agent-hire-auth-inheritance-routes.test.ts | 4 +- .../agent-permissions-routes.test.ts | 4 +- .../agents-claude-oauth-binding.test.ts | 22 ++++++-- .../claude-local-adapter-environment.test.ts | 3 ++ .../__tests__/claude-local-execute.test.ts | 14 ++++-- .../src/__tests__/company-portability.test.ts | 6 +-- server/src/services/agent-credentials.ts | 3 +- .../services/agent-runner-selection.test.ts | 9 ++++ server/src/services/agent-runner-selection.ts | 19 +++++-- server/src/services/anthropic-pricing.test.ts | 2 +- server/src/services/anthropic-pricing.ts | 2 +- .../native-runtime/provider-profile.ts | 4 +- server/src/services/secrets.ts | 3 +- tests/runner-e2e/catalog.ts | 2 +- tests/runner-e2e/first-task-flow.ts | 2 +- tests/runner-e2e/pi-controls-evidence.test.ts | 6 +-- tests/runner-e2e/runner.spec.ts | 3 ++ ui/src/components/AgentConfigForm.tsx | 2 +- ui/src/components/NewAgentDialog.test.tsx | 2 +- ui/src/pages/CompanyImport.test.tsx | 33 ++++++++++++ ui/src/pages/CompanyImport.tsx | 4 +- 36 files changed, 296 insertions(+), 97 deletions(-) create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 8e16e2d31a9..304c2d6f852 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -278,7 +278,7 @@ describe("claude_local ACP lane", () => { onMeta: async (payload) => { meta.push(payload); }, })); expect(result.exitCode).toBe(0); - expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5"); + expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5-5"); }); it("keeps ACP model precedence consistent with CLI and provider overrides", () => { diff --git a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts index 64d8f04b223..6d0dc802ad4 100644 --- a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts @@ -78,7 +78,7 @@ function buildContext(config: Record = {}) { sessionDisplayId: null, taskKey: null, }, - config, + config: { model: "claude-opus-5", ...config }, context: {}, onLog: vi.fn(async () => {}), }; diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index cbb6ed55013..f8dc789a984 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -189,11 +189,11 @@ describe("claude remote execution", () => { remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/claude/mcp-config`, followSymlinks: true, })); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5"])); + expect(call?.[2]).toEqual(expect.arrayContaining(["--model", "claude-opus-5-5"])); expect(call?.[2]).toContain("--dangerously-skip-permissions"); expect(call?.[2]).not.toContain("--allowedTools"); expect(call?.[2]).toContain("--append-system-prompt-file"); @@ -255,7 +255,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -280,8 +280,8 @@ describe("claude remote execution", () => { onLog: async () => {}, }); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined; expect(call?.[2]).not.toContain("--resume"); }); @@ -303,7 +303,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] }, + config: { model: "claude-opus-5", engine: "cli", command: "claude", cwd: rootDir, paperclipRuntimeSkills: [] }, context: {}, onLog, }); @@ -345,7 +345,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -370,8 +370,8 @@ describe("claude remote execution", () => { onLog: async () => {}, }); - expect(runChildProcess).toHaveBeenCalledTimes(1); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(runChildProcess.mock.calls.filter(call => !call[2].includes("--version"))).toHaveLength(1); + const call = runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[]] | undefined; expect(call?.[2]).toContain("--resume"); expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012"); }); @@ -413,7 +413,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", }, @@ -463,7 +463,7 @@ describe("claude remote execution", () => { sessionDisplayId: null, taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", ...config, @@ -619,7 +619,7 @@ describe("claude remote execution", () => { sessionDisplayId: "12345678-1234-4abc-9def-123456789012", taskKey: null, }, - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { ANTHROPIC_API_KEY: "fixture-anthropic-key" }, @@ -633,7 +633,7 @@ describe("claude remote execution", () => { expect(result.exitCode).toBe(0); expect(runChildProcess).toHaveBeenCalledTimes(2); - const first = (runChildProcess.mock.calls[0] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; + const first = (runChildProcess.mock.calls.find(call => !call[2].includes("--version")) as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; const retry = (runChildProcess.mock.calls[1] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? ""; expect(first).toContain("## Compact assignment"); expect(first).not.toContain("Explain the next step before starting work."); diff --git a/packages/adapters/claude-local/src/server/test.probe.test.ts b/packages/adapters/claude-local/src/server/test.probe.test.ts index 7273ca6adff..42a4fcfc22d 100644 --- a/packages/adapters/claude-local/src/server/test.probe.test.ts +++ b/packages/adapters/claude-local/src/server/test.probe.test.ts @@ -139,7 +139,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -170,7 +170,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -202,7 +202,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -239,7 +239,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -269,7 +269,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -296,7 +296,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -323,7 +323,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -361,7 +361,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -401,7 +401,7 @@ describe("claude sandbox hello probe diagnostics", () => { testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }), @@ -428,7 +428,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -451,7 +451,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -471,7 +471,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -494,7 +494,7 @@ describe("claude auth mode hints", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { CLAUDE_CODE_OAUTH_TOKEN: "oauth-test-token" }, @@ -515,7 +515,7 @@ describe("claude auth mode hints", () => { it("reports an intentionally selected managed API account without a subscription warning", async () => { probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" }; const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } }, + config: { model: "claude-opus-5", engine: "cli", command: "claude", managedAiConnection: { provider: "anthropic", method: "api_key" }, env: { ANTHROPIC_API_KEY: "api-test-key" } }, executionTarget: sandboxTarget, environmentName: "Daytona", }); expect(result.checks.find(check => check.code === "claude_anthropic_api_key_overrides_subscription")).toMatchObject({ level: "info", message: "Using the selected Claude API connection." }); @@ -528,7 +528,7 @@ describe("claude auth mode hints", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "claude", env: { @@ -606,7 +606,7 @@ describe("claude CLI local hello probe hardening", () => { await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { + config: { model: "claude-opus-5", engine: "cli", command: "/tmp/evil/claude", env: { @@ -683,7 +683,7 @@ describe("claude CLI local hello probe hardening", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: null, environmentName: null, }); diff --git a/packages/adapters/claude-local/src/server/test.remote.test.ts b/packages/adapters/claude-local/src/server/test.remote.test.ts index 80ecd74cccf..94c9633155a 100644 --- a/packages/adapters/claude-local/src/server/test.remote.test.ts +++ b/packages/adapters/claude-local/src/server/test.remote.test.ts @@ -109,7 +109,7 @@ describe("claude sandbox auth-missing check", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -130,7 +130,7 @@ describe("claude sandbox auth-missing check", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { engine: "cli", command: "claude" }, + config: { model: "claude-opus-5", engine: "cli", command: "claude" }, executionTarget: sshTarget, environmentName: "Remote host", }); diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 81a3e159050..c33720fdd9f 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "requiresProviderPolicy": true }, "cursor": { @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "requiresProviderPolicy": true, "qualificationStatus": "pending" }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 434aa53e665..01665c56bbf 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3200,7 +3200,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v22-identity.json" + "../../../../test-fixtures/pi-acp/profile-v23-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3224,6 +3224,7 @@ mod tests { include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v22-identity.json"), ] { let prior: Value = serde_json::from_str(prior).unwrap(); value["commandDigest"] = prior["commandDigest"].clone(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 28f4d2a775a..f255519807c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index cc94d4834c0..4dde0516ef3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9"; + "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index afdd2013b65..d73a3af6837 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v18-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 999efa6eed2..6662f9fe2aa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,8 +87,16 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); - it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it("preserves v17 executable and policy identity while binding the shared Claude effort sources", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()) + .toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("admits the current v18 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -119,7 +127,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 3a0bf38cf33..92b3eadbcdd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 22, + "agentProfileVersion": 23, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", "permissionPolicy": "interactive" }, "cursor": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index c04f4c3f71e..f34810516f9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -88,11 +88,19 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("preserves profile 22 runtime bytes and revises only shared Claude effort source attestations", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(22), current = await read(23); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "recoveryIdentitySourceSha256", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v22-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v23-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -123,21 +131,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 22"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 22"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 23"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 23"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json new file mode 100644 index 00000000000..e344c8eb532 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:eba77e17e5e874d0dc80585abbf0266f3005c6d6fb746dda29cafad95657f6c6", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 23, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "8767456c125b53b1e85fbfce84245c9b213f9ef27a58970cf73dbc2ff82005d8", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json new file mode 100644 index 00000000000..eab746e74c7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:bc0827bd362d01b5595738515f0ca1adcd6961ec622034cbda4633b0f5ec4701", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 18, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "d2777bfc3e15ee7861ce57a069ffb55d3dc266364adf7915845bfbad5aa2e402", + "semanticDirectDriverSourceSha256": "187772a17e5d4f854ec10f37abfcd294cf4f1db58a91d18e7645f4a8c617f329", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fb4a232460dc9e6c7e7221cb4ddd66d7cb82fa0b35e1b3eb04f2d1ee5ee18c86", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts b/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts index 42db2f99c01..370894b8c4f 100644 --- a/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts +++ b/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts @@ -499,13 +499,13 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => { const parent = await seedParentAgent(companyId, "paperclip_runner", {}); await db.update(agents).set({ adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all", - env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent", + env: { PRIVATE_PARENT_TOKEN: secretRef("parent-secret") }, cwd: "/private/parent", invocationLimits: { maxIterations: 3, credentials: "never-copy" }, } }).where(eq(agents.id, parent.id)); const res = await hire(agentActor(companyId, parent.id), companyId, { name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller", }); - expect(res.status).toBe(201); + expect(res.status, JSON.stringify(res.body)).toBe(201); expect(res.body.agent.status).toBe("pending_approval"); expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined(); expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 446baf0cbb7..26733c99871 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1853,8 +1853,8 @@ describe("agent permission routes", () => { }), { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); - if (adapterCase.adapterType === "codex_local") { - expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); + if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) { + if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js"); expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); } else { diff --git a/server/src/__tests__/agents-claude-oauth-binding.test.ts b/server/src/__tests__/agents-claude-oauth-binding.test.ts index 4d645e6388e..e6d67135f03 100644 --- a/server/src/__tests__/agents-claude-oauth-binding.test.ts +++ b/server/src/__tests__/agents-claude-oauth-binding.test.ts @@ -48,6 +48,16 @@ const withEnv = (env: Record) => ({ env }); // --- The pure server-enforced binding invariant (no database) ---------------- describe("assertClaudeOAuthBindingInvariant", () => { + it.each([undefined, "claude"])("enforces the fixed binding for native Claude (agent: %s)", acpxAgent => { + const nextConfig = { provider: "acpx", ...(acpxAgent && { acpxAgent }), ...withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }) }; + expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig })) + .toEqual({ introducesBinding: true, keepsBinding: false }); + expect(() => assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { + ...nextConfig, env: { ...nextConfig.env, ANTHROPIC_API_KEY: "fixture-api-key" }, + } })).toThrow(CLAUDE_OAUTH_CREDENTIAL_CONFLICT); + expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { ...nextConfig, acpxAgent: "codex" } })) + .toEqual({ introducesBinding: false, keepsBinding: false }); + }); it("reports that a create introduces the fixed binding", () => { const decision = assertClaudeOAuthBindingInvariant({ adapterType: "claude_local", @@ -312,6 +322,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle" as const, adapterType: "claude_local", + runner: "legacy" as const, defaultEnvironmentId: scope.environmentId, adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } }, runtimeConfig: {}, @@ -359,11 +370,13 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { return rows.length; } - it("inserts the fixed binding and consumes a valid claim exactly once", async () => { + it.each([false, true])("inserts the fixed binding and consumes a valid claim exactly once (native: %s)", async native => { const scope = await seedScope(); const sessionId = await seedStoredClaim(scope, Date.now() + 60_000); + const legacyInput = createInput(scope); + const input = native ? { ...legacyInput, runner: undefined, adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", ...legacyInput.adapterConfig } } : legacyInput; - const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), { + const created = await createAgentLifecycle(db).requestHire(scope.companyId, input, { claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId }, }); @@ -377,7 +390,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { // A replay of the same claim inserts no second agent. await expect( - createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), { + createAgentLifecycle(db).requestHire(scope.companyId, input, { claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId }, }), ).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED }); @@ -616,6 +629,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, @@ -680,6 +694,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, @@ -755,6 +770,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { role: "engineer", status: "idle", adapterType: "claude_local", + runner: "legacy", defaultEnvironmentId: scope.environmentId, adapterConfig: { env: {} }, runtimeConfig: {}, diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 055e66d9ea4..69a321b05e7 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -48,6 +48,7 @@ afterEach(() => { async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const argv = process.argv.slice(2); if (argv.includes("--help")) { process.stdout.write("Usage: claude [options]\\n --print\\n --model \\n"); @@ -306,6 +307,7 @@ describe("claude_local environment diagnostics", () => { adapterType: "claude_local", config: { engine: "cli", + model: "claude-opus-5", command: "claude", }, executionTarget: { @@ -379,6 +381,7 @@ describe("claude_local environment diagnostics", () => { await fs.writeFile(path.join(sourceConfigDir, "credentials.json"), JSON.stringify({ token: "local" }), "utf8"); await fs.writeFile(path.join(remoteHome, ".claude", ".credentials.json"), JSON.stringify({ token: "remote" }), "utf8"); await fs.writeFile(commandPath, `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("fs"); const path = require("path"); const configDir = process.env.CLAUDE_CONFIG_DIR || ""; diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index adea792ca6e..0211abcb545 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -20,6 +20,7 @@ async function writeFailingClaudeCommand( const payload = JSON.stringify(options.resultEvent); const exit = options.exitCode ?? 1; const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } console.log(${JSON.stringify(payload)}); process.exit(${exit}); `; @@ -33,6 +34,7 @@ async function writeTextFailingClaudeCommand( ): Promise { const exit = options.exitCode ?? 1; const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } if (${JSON.stringify(options.stdout ?? "")}) { process.stdout.write(${JSON.stringify(options.stdout ?? "")}); } @@ -47,6 +49,7 @@ process.exit(${exit}); async function writeFakeClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -88,6 +91,7 @@ console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-811 async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -126,6 +130,7 @@ console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-833 async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const path = require("node:path"); @@ -185,6 +190,7 @@ afterEach(() => { async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -221,6 +227,7 @@ console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bb async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -251,6 +258,7 @@ process.exit(1); async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node +if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); } const fs = require("node:fs"); const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; @@ -410,9 +418,9 @@ describe("claude execute", () => { }); it.each([ - [undefined, "claude-opus-5"], - ["", "claude-opus-5"], - [" ", "claude-opus-5"], + [undefined, "claude-opus-5-5"], + ["", "claude-opus-5-5"], + [" ", "claude-opus-5-5"], ["claude-sonnet-4-5", "claude-sonnet-4-5"], ])("passes the resolved model to the CLI for %j", async (model, expected) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-")); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 2884ae55abd..6704cad5c5b 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6156,7 +6156,7 @@ describe("company portability", () => { await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } }); expect(agentSvc.create).not.toHaveBeenCalled(); instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); - await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1")) + await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-build-1.0.13" } } } }, "user-1")) .rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } }); expect(agentSvc.create).not.toHaveBeenCalled(); await portability.importBundle(input, "user-1"); @@ -6190,7 +6190,7 @@ describe("company portability", () => { adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", - adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" }, + adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, }, }, }; @@ -6222,7 +6222,7 @@ describe("company portability", () => { await portability.importBundle(request, "user-1"); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", - adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }), + adapterConfig: expect.objectContaining({ provider: "opencode" }), }), { createdByUserId: "user-1" }); await portability.importBundle({ diff --git a/server/src/services/agent-credentials.ts b/server/src/services/agent-credentials.ts index 656fc18d3bf..503f68dc0c2 100644 --- a/server/src/services/agent-credentials.ts +++ b/server/src/services/agent-credentials.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; import { agents, type Db } from "@paperclipai/db"; +import { agentHarnessType } from "@paperclipai/shared"; import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; import { logActivity } from "./activity-log.js"; import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError, @@ -166,7 +167,7 @@ export function agentCredentialService(db: Db) { if ( !parent || parent.companyId !== input.companyId || - parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE || + agentHarnessType(parent.adapterType, parent.adapterConfig as Record) !== CLAUDE_LOCAL_ADAPTER_TYPE || !claudeOAuthBindingsMatchExactly(parentBinding, childBinding) ) { throw claudeOAuthClaimRejectedError(); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index dd64b854ad2..ccdc916b0c3 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -219,6 +219,15 @@ describe("Claude runner defaults", () => { expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig }); expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom"); }); + it.each(["permissionMode", "acpPermissionMode"])("translates the legacy Claude %s without granting broader permissions", key => { + for (const mode of ["deny-all", "approve-reads", "approve-all"]) { + const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { engine: "acp", [key]: mode } }); + expect(native.adapterConfig.acpxPermissionMode).toBe(mode); + expect(native.adapterConfig[key]).toBeUndefined(); + } + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "deny-all", acpxPermissionMode: "approve-all" } })).toThrow("cannot honor"); + expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "unrecognized" } })).toThrow("Legacy runner"); + }); it("retains saved native Claude and allows an explicit compatible Legacy switch", () => { const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "custom", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } }; expect(resolveNewAgentRunner(saved)).toEqual(saved); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 7f687ba191e..f20dae2ce52 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -41,16 +41,27 @@ function incompatible(fields: string[]): never { } function resolveClaudeRunnerConfig(config: Record): Record { - const unsupported = claudeRunnerUnsupportedSettings(config); + const translated = { ...config }; + const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined; + if (legacyPermission !== undefined) { + const mode = legacyPermission.trim(); + if (!["approve-all", "approve-reads", "deny-all"].includes(mode) + || (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== mode)) incompatible(["permissionMode", "acpPermissionMode"]); + translated.acpxPermissionMode = mode; + } + for (const key of ["permissionMode", "acpPermissionMode"]) { + if (config[key] !== undefined && typeof config[key] !== "string") incompatible([key]); + delete translated[key]; + } + const unsupported = claudeRunnerUnsupportedSettings(translated); const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort; if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model"); if (unsupported.length) incompatible(unsupported); - const translated = { ...config }; - for (const key of ["engine", "command", "mode", "nonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key]; + for (const key of ["engine", "command", "mode", "acpMode", "nonInteractivePermissions", "acpNonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key]; const env = config.env && typeof config.env === "object" ? config.env as Record : {}; const model = resolveClaudeModel(config.model, env); if (!model) incompatible(["model"]); - return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: config.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" }; + return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" }; } /** Idempotent translation. Missing dependencies or credentials never select another runner. */ diff --git a/server/src/services/anthropic-pricing.test.ts b/server/src/services/anthropic-pricing.test.ts index cffcffd0dc3..1a4ba592f20 100644 --- a/server/src/services/anthropic-pricing.test.ts +++ b/server/src/services/anthropic-pricing.test.ts @@ -23,7 +23,7 @@ describe("complete Anthropic receipt pricing", () => { }); it.each([ { complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" }, - { provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, + { provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, { model: "constructor" }, { model: "toString" }, { model: "__proto__" }, { usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 }, { pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] }, { usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } }, diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts index 6a55f9e89e6..63348e36cf5 100644 --- a/server/src/services/anthropic-pricing.ts +++ b/server/src/services/anthropic-pricing.ts @@ -13,7 +13,7 @@ const RATE_CARDS: Record * claim. ACP aggregates cache writes without TTL; conservatively price them at * the documented one-hour rate rather than silently assume five-minute writes. */ export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint { - const card = receipt.model ? RATE_CARDS[receipt.model] : undefined; + const card = receipt.model && Object.hasOwn(RATE_CARDS, receipt.model) ? RATE_CARDS[receipt.model] : undefined; if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run" || receipt.provider !== "anthropic" || receipt.biller !== "anthropic" || !["api", "metered_api"].includes(receipt.billingType ?? "") diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 196b77e7c39..a1eaf65db08 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -355,12 +355,14 @@ export function assertAgentCoreProfileRecoveryBinding(input: { } export function claudeRunnerUnsupportedSettings(config: Record): string[] { - const unsupported = ["chrome", "agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun"] + const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"] .filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0)); if (config.command && config.command !== "claude") unsupported.push("command"); if (config.mode && config.mode !== "persistent") unsupported.push("mode"); + if (config.acpMode && config.acpMode !== "persistent") unsupported.push("acpMode"); if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine"); if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions"); + if (config.acpNonInteractivePermissions && config.acpNonInteractivePermissions !== "deny") unsupported.push("acpNonInteractivePermissions"); if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions"); return unsupported; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 62338a9dc4d..518cd6f9f85 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -36,6 +36,7 @@ import type { SecretVersionSelector, } from "@paperclipai/shared"; import { + agentHarnessType, CLASS3_STATIC_LEASE_ALLOWLIST, createSecretProviderConfigSchema, deriveProjectUrlKey, @@ -279,7 +280,7 @@ export interface ClaudeOAuthBindingInvariantDecision { export function assertClaudeOAuthBindingInvariant( input: ClaudeOAuthBindingInvariantInput, ): ClaudeOAuthBindingInvariantDecision { - const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE; + const isClaudeLocal = agentHarnessType(input.adapterType ?? "", asRecord(input.nextConfig) ?? {}) === CLAUDE_LOCAL_ADAPTER_TYPE; const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig); const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 3d5544627a6..51e1d17ba60 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1430,7 +1430,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ groups: ["onboarding"], profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)), environments: [localEnvironment], tasks: firstTaskTasks, expectedMatrixSize: 52, - definitionMetadata: { version: 6, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", productionInstructions: true, qualityGrading: "informational" }, + definitionMetadata: { version: 7, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", claudeDefaultTarget: "darwin-arm64", legacySetup: "explicit-advanced-runner-choice", productionInstructions: true, qualityGrading: "informational" }, }, { id: "agent-chat", label: "Persistent Agent Chat", diff --git a/tests/runner-e2e/first-task-flow.ts b/tests/runner-e2e/first-task-flow.ts index d1187512f44..556b50d7a46 100644 --- a/tests/runner-e2e/first-task-flow.ts +++ b/tests/runner-e2e/first-task-flow.ts @@ -204,7 +204,7 @@ export async function setupFirstTaskFixtures(input: { exact: true, }); const savedKey = page.getByRole("combobox", { name: "Saved API key" }); - if (execution.profile.id === "legacy-codex") { + if (["legacy-codex", "legacy-claude"].includes(execution.profile.id)) { await page.getByText("Advanced", { exact: true }).click(); await page.getByRole("button", { name: "Runner", exact: true }).click(); await page.getByRole("option", { name: "Legacy runner", exact: true }).click(); diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 08c8b8c6500..21691d1a965 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: 22, 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: 22, + expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 23, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); + expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 23, remoteProcessIdentity: "observer-pid-startTicks-bootId", controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner" }); expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 5, piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" }); @@ -200,7 +200,7 @@ describe("Pi controls catalog admission", () => { } const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)])); expect(hashes).toEqual({ - "pi-native": "f88d1648fe02ac07b9f43cd5d747131d98844add29fda73746bb1b2bceec0df9", + "pi-native": "3c77ced7edfb99aa12052f451398da7580466a54abadcaf287d31ee12c16382d", "native-active-stop": "2d4fdeeb75bd531b309bd1b35cfa5680ceefdeee6b7155b068dd011ce9901980", "rich-acp-warm-continuity": "331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0", "extended-harnesses": "5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6", diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 6799d4812d9..ac6ff667897 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -561,6 +561,9 @@ for (const execution of executions) { page, request, }, testInfo) => { + test.skip(execution.suite.id === "first-task" && execution.profile.id === "runner-acpx-claude" + && (process.platform !== "darwin" || process.arch !== "arm64"), + "Ordinary native Claude onboarding is qualified on Apple Silicon only in this delivery."); test.setTimeout( execution.task.flow === "warm_three_turn" ? deadlineMs diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 0a0452842cf..9e119f608bb 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1901,7 +1901,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners} onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />} - {isLocal && fieldsAdapterType !== "paperclip_runner" && (<> {/* + {isLocal && (!isRunnerHarness || fieldsAdapterType !== "paperclip_runner") && (<> {/* The command names a binary on the execution host, so the managed-sandbox-only policy hides it: the platform-managed image owns the binary. Hiding is presentation only. A stored diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 1734a9202f4..34ce7eee193 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -107,7 +107,7 @@ it("offers one Codex harness without a runner/provider tile", async () => { expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1); expect(document.querySelector("select")).toBeNull(); }); -it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => { +it.each(["grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => { await name(); await act(async () => (document.querySelector("summary") as HTMLElement).click()); await act(async () => document.querySelector('[aria-label="Experimental harness"]')!.click()); diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 8ac4808f7a3..87eb5bb5e9a 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1269,6 +1269,39 @@ describe("CompanyImport", () => { expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); + it.each(["deny-all", "approve-reads"])("preserves native Claude settings and saves an edited permission from %s", async permission => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildPreviewResult(); + const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: permission, timeoutSec: 180, lifecycleMode: "per_turn" }; + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.runner = "paperclip"; + preview.manifest.agents[0]!.adapterConfig = source; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + const select = container.querySelector('[aria-label="Permission mode"]'); + expect(select?.textContent).toContain(permission === "deny-all" ? "Deny all" : "Allow Paperclip reads"); + await act(async () => select!.click()); + const nextPermission = permission === "deny-all" ? "approve-reads" : "deny-all"; + const option = [...document.querySelectorAll('[role="option"]')].find(element => element.textContent === (nextPermission === "deny-all" ? "Deny all" : "Allow Paperclip reads")); + expect(option).toBeTruthy(); + await act(async () => option!.click()); + await flushReact(); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ + runner: "paperclip", + adapterConfig: { ...source, acpxPermissionMode: nextPermission }, + }); + expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source); + }); + it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => { mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]); const preview = buildPreviewResult(); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 701f743f53b..526ec210963 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1735,7 +1735,9 @@ export function CompanyImport() { if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) { throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider."); } - override.adapterConfig = getUIAdapter(configVals.adapterType).buildAdapterConfig(configVals); + const retainsNativeHarness = agent.adapterType === "paperclip_runner" + && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); + override.adapterConfig = getUIAdapter(retainsNativeHarness ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig(configVals); } overrides[agent.slug] = override; } From 342a9f4b18c8cf96403350b529149f1f389f141e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:25:31 -0500 Subject: [PATCH 4/6] docs: record Claude review fixes and protocol prerequisite Co-Authored-By: Paperclip --- doc/plans/2026-10-12-claude-runner-default.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 787446934f4..1c430c624d6 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -10,6 +10,9 @@ Local Apple Silicon API-key task and follow-up are the first gate. Keep existing ## Current state +- Review boundaries: the user approved a separate shared-protocol prerequisite to keep each PR below the repository's 100-file limit. The behavior PR is [#15993](https://github.com/paperclipai/paperclip/pull/15993); the platform branch is `codex/claude-runner-platforms` and remains gated. Protocol candidate `2e8d5e906e` has 44 files and preserves latest Codex startup changes from `a200495cc7`. Behavior candidate `12f30702d7` includes that prerequisite. +- Review corrections preserve legacy ACP permission aliases, retained native import settings and fixed Claude OAuth claims through native creation. Unknown inherited-property model names stay unpriced. Legacy onboarding cases now explicitly select Legacy; ordinary native Claude onboarding skips unqualified controller targets before provisioning. Focused import52, selection/pricing117, OAuth54, CLI fixture104, shared Pi92, Copilot28 and Pi evidence72 controls passed; final CI/review remains required. +- Shared source attestations are versioned rather than rewriting historical identities: Pi v23 and pending Copilot v18 bind the changed Claude effort sources. Prior declarations remain. Executable closures and platform qualification are unchanged; prior mismatched profiles require session reopening. These new generated bindings require a current-source daemon/package build. - Local ordinary API-key onboarding, useful task, revision, cold resume with high effort, independent artifact downloads and persistence passed. The active preview is `http://127.0.0.1:3105/CLAA/issues/CLAA-1`. - The user redirected the remaining live checks to API keys. Subscription login remains supported, but no further attended subscription qualification is required; subscription follow-up is not claimed. - Shared runner controls passed light/dark, 390-pixel mobile and keyboard opening/closing checks. The walkthrough found native Command/Extra args controls; they are now hidden for native execution. The saved Claude preservation test covers this path. Agent form 111 tests, UI TypeScript and token gates passed. From 269e802fe62e3b8dc5d3288428c592f279533e29 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:29:56 -0500 Subject: [PATCH 5/6] fix(runner): retain versioned profiles in Docker context Co-Authored-By: Paperclip --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index 30d3d533bf0..2386941de44 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,6 +44,7 @@ packages/paperclip-runner/test/** 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 packages/paperclip-runner/test-fixtures/** !packages/paperclip-runner/test-fixtures/pi-acp @@ -53,6 +54,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/test-fixtures/pi-acp/profile-v20-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json !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/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 From a950d9b16347138de21c0765109b4943764af6f2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:30:02 -0500 Subject: [PATCH 6/6] docs: link the approved protocol review boundary Co-Authored-By: Paperclip --- doc/plans/2026-10-12-claude-runner-default.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 1c430c624d6..83bdd51a77e 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -6,11 +6,11 @@ Updated 2026-10-12. Start from `058af8f1b69c5fa3d9bc974b9eab73c141b52823`; branch `codex/claude-runner-default`, checkout `/Users/dotta/.codex/worktrees/670b/paperclip`. New Claude agents eventually default to native ACPX Claude on Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Codex selection remains owned by the dependency branch. On 2026-10-12 the user explicitly approved preserving its latest Linux ARM64 default from `dcdf09a0f33536fa2b6daaf6e731d921d5711282` during reconciliation. Ordinary Claude defaults become Opus 5.5; explicit models and existing runner selections remain intact. -Local Apple Silicon API-key task and follow-up are the first gate. Keep existing connection/login tools. Complete core settings and local subscription next, then package/qualify other targets. Two reviewable PRs; no merge or deployment. Linux ARM64 covers installed hosts and SSH, not ARM cloud images. Separate $50 Claude ceiling including setup probes, unknown-charge holds and task turns. No automatic paid retry loops. Docker and expensive Rust builds run in cloud infrastructure; never build Docker images on this Mac. +Local Apple Silicon API-key task and follow-up are the first gate. Keep existing connection/login tools. Complete core settings and local subscription next, then package/qualify other targets. Two behavior/platform delivery boundaries plus the user-approved protocol prerequisite PR; no merge or deployment. Linux ARM64 covers installed hosts and SSH, not ARM cloud images. Separate $50 Claude ceiling including setup probes, unknown-charge holds and task turns. No automatic paid retry loops. Docker and expensive Rust builds run in cloud infrastructure; never build Docker images on this Mac. ## Current state -- Review boundaries: the user approved a separate shared-protocol prerequisite to keep each PR below the repository's 100-file limit. The behavior PR is [#15993](https://github.com/paperclipai/paperclip/pull/15993); the platform branch is `codex/claude-runner-platforms` and remains gated. Protocol candidate `2e8d5e906e` has 44 files and preserves latest Codex startup changes from `a200495cc7`. Behavior candidate `12f30702d7` includes that prerequisite. +- Review boundaries: the user approved a separate shared-protocol prerequisite to keep each PR below the repository's 100-file limit. The behavior PR is [#15993](https://github.com/paperclipai/paperclip/pull/15993); the platform branch is `codex/claude-runner-platforms` and remains gated. Protocol prerequisite [#15994](https://github.com/paperclipai/paperclip/pull/15994), initial candidate `2e8d5e906e`, has 44 source files and preserves latest Codex startup changes from `a200495cc7`. Behavior candidate `12f30702d7` includes that prerequisite. - Review corrections preserve legacy ACP permission aliases, retained native import settings and fixed Claude OAuth claims through native creation. Unknown inherited-property model names stay unpriced. Legacy onboarding cases now explicitly select Legacy; ordinary native Claude onboarding skips unqualified controller targets before provisioning. Focused import52, selection/pricing117, OAuth54, CLI fixture104, shared Pi92, Copilot28 and Pi evidence72 controls passed; final CI/review remains required. - Shared source attestations are versioned rather than rewriting historical identities: Pi v23 and pending Copilot v18 bind the changed Claude effort sources. Prior declarations remain. Executable closures and platform qualification are unchanged; prior mismatched profiles require session reopening. These new generated bindings require a current-source daemon/package build. - Local ordinary API-key onboarding, useful task, revision, cold resume with high effort, independent artifact downloads and persistence passed. The active preview is `http://127.0.0.1:3105/CLAA/issues/CLAA-1`. @@ -89,3 +89,8 @@ Independent downloads matched both byte oracles, and the original remained uncha Executable identities: daemon source `5f0b898e9ecef77afefe309a230e04d7fef66cde`, sha256 `228c7814c4786614c8358b205da01cf43df822ca5e6f6b74d5a1e8304d03a6de`; sidecar sha256 `daabddfc1373e6b6d2a754cb60bc4c33d7a8b6d6d5c3564ce511ebfaeb8a463d`; Claude SDK 0.3.286 executable sha256 `75e3016e9d2570767b08e43a7467d4817a4f149232c169ca295f2c95fef21433`. Behavior source is `9788c05d0a9825d825c0461eab3191da6a6dcad7`. Reconciled source `966b3d5bc294442f71c1def9406ed2027f41d191` merges Codex dependency `123a18837478d8cd0e2aff23f7e7aa86c9107de3`, retaining its GNU libc 2.39 gate and Linux ARM64 selection. Claude admission/sandbox/input and daemon Rust sources did not change during that reconciliation; the rebuilt sidecar digest is identical. Updated runner TypeScript build, server TypeScript, 95 selection controls and 15 installed-startup controls passed after reconciliation. [Corrected cloud source checks](https://github.com/paperclipai/paperclip/actions/runs/38279356267) and [corrected four-target packaging](https://github.com/paperclipai/paperclip/actions/runs/38279359427) run on frozen behavior source 9788c05. Carry forward only unchanged evidence; final source and package consumers still require passing checks. Active company caps after archiving the unused subscription-only fixture total $42: original $17, failed candidate $9, API acceptance $16. Agent limits share these allocations, and unknown-charge holds remain in place. Known API estimates now total $0.9975406; this excludes the original unpriced receipt and is not a provider invoice. + + +## Approved prerequisite split and current CI + +The behavior PR now targets the protocol branch and has 64 changed files. Protocol #15994 targets the latest Codex dependency and adds the shared v7 effort/resume contract with historical readers. Cloud Docker-context verification found that `.dockerignore` omitted the new Pi v23 and Copilot v18 identity inputs; explicit exceptions retain them without broadening the fixture context. The old 9788c05 package campaign passed all four daemon producers and Apple Silicon clean install, but the Linux ARM64 consumer failed durable-suspend settlement. Retain that failure; current-source package qualification and ARM diagnosis remain required. No local Docker or Rust compilation occurred.