diff --git a/package.json b/package.json index 307a31513b..67c699d3b2 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch", "acpx@0.12.0": "patches/acpx@0.12.0.patch", "acpx@0.13.1": "patches/acpx@0.13.1.patch", + "@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", "@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch" }, "overrides": { diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 0d8fb43da6..e01fb84597 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -20,15 +20,15 @@ The package also publishes the canonical semantic action declarations and their input and output schemas. Its package-local dispatcher projects only bound, run-authorized actions and emits redacted semantic receipts. -The production-selected provider remains Codex. The package also contains the -qualified OpenCode 1.18.17 driver, authenticated loopback MCP bridge, and -runnerd-compatible proxy, but this slice does not authorize the server to -select OpenCode for a fresh native run. Dynamic semantic tools remain -undiscoverable unless the hidden server coordinator projects one of the five -same-task read bindings for an already persisted native Codex run. Catalog -membership alone does not grant authority. The server can now create and start -a Codex-backed native run only through the default-off `paperclip_runner` -adapter. See +The first and only provider selected directly by runnerd remains Codex. The +package also contains the qualified OpenCode 1.18.17 proxy and the bounded ACPX +sidecar for Codex and Claude, but this slice does not authorize the server +to select those additional providers for a fresh native run. Dynamic semantic +tools remain undiscoverable unless the hidden server coordinator projects one +of the five same-task read bindings for an already persisted native Codex run. +Catalog membership alone does not grant authority. The server can now create +and start a Codex-backed native run only through the default-off +`paperclip_runner` adapter. See [`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary. The package has two initial public surfaces: @@ -60,10 +60,13 @@ and preserves exact provider session identity for recovery. The backend factory requires an explicit runtime directory before constructing this provider. The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2 -stdin/stdout bridge admits the qualified Codex ACPX profile only. It validates -the exact model, session identity, tool catalog, structured input, and terminal -settlement at the process boundary. Runnerd and the server do not select this -sidecar in this slice. Other ACPX agents remain unavailable. +stdin/stdout bridge admits the closed Claude and Codex ACPX profiles. It +validates each agent's exact package/model pair, session identity, tool catalog, +structured input, and terminal settlement at the process boundary. Claude runs +without ambient project or local settings. Pi remains unavailable until its +separately spawned runtime can use the same descriptor-confined verified launch +boundary as the ACP server. Runnerd and the server do not select this sidecar +in this slice. The Rust core includes a bounded client for this sidecar protocol. It enforces request identity, event order, frame and queue limits, timeouts, redacted @@ -100,7 +103,7 @@ unresolved turn-scoped requests. This reducer still does not select ACPX in runnerd. The package-local session bootstrap starts the bounded sidecar transport, -verifies the Codex-only capability handshake and effective model, opens one +verifies the selected qualified capability handshake and effective model, opens one identity-bound session, and confirms its run attachment. Any failed bootstrap terminates the process; session shutdown preserves persistent provider state. The session can then start one immutable-workspace turn, request interruption, @@ -118,9 +121,8 @@ against the exact persisted question IDs, answer modes, options, required answers, custom-answer policy, and text constraints before provider delivery. Tool results and structured question responses then use two-phase resolution: validate retained identity and schema, require the exact sidecar -acknowledgement, and only then clear pending local state. Codex permission -requests violate its pinned sidecar policy and terminate the session fail -closed. +acknowledgement, and only then clear pending local state. Permission events that +escape the pinned sidecar policy terminate the session fail closed. Safe suspension is available only with no active turn or pending request. The sidecar must return the exact persistent session identity before runnerd terminates the local process. diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index d844a3976e..6d67ed90f2 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -56,6 +56,7 @@ "trace:conformance:rust": "cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin conformance-tracer" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "0.70.0", "@agentclientprotocol/codex-acp": "1.6.2", "acpx": "0.13.1", "ajv": "^8.20.0", 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 359b921f6f..03da74667c 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 @@ -69,10 +69,20 @@ pub struct AcpxProviderSessionConfig { impl AcpxProviderSessionConfig { pub fn validate(&self) -> Result<(), LocalRunnerError> { self.transport.validate()?; - if self.agent != "codex" { - return Err(LocalRunnerError::invalid( - "the initial ACPX provider session supports Codex only", - )); + let qualified_model = match self.agent.as_str() { + "claude" => "claude-sonnet-5", + "codex" => "gpt-5.6-sol", + _ => { + return Err(LocalRunnerError::invalid( + "ACPX agent must be claude or codex", + )) + } + }; + if self.model != qualified_model { + return Err(LocalRunnerError::invalid(format!( + "ACPX {} profile requires exact model {qualified_model}", + self.agent + ))); } validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; validate_stable_id(&self.run_id, SHORT_STABLE_ID_CHARS, "ACPX run id")?; @@ -513,7 +523,7 @@ impl AcpxProviderSession { } AcpxProviderStateEvent::PermissionRequest { .. } => { return Err(self.fail_closed(LocalRunnerError::invalid( - "ACPX Codex permission request violated the pinned runner policy", + "ACPX permission request violated the pinned runner policy", ))); } _ => {} 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 3da5419b7f..688053d674 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 @@ -135,7 +135,8 @@ fn admits_recovery_only_for_the_exact_run_catalog_and_provider_identity() { operations, }; let mut changed_model = config.clone(); - changed_model.model = "gpt-5.6-sol-mini".to_owned(); + changed_model.agent = "claude".to_owned(); + changed_model.model = "claude-sonnet-5".to_owned(); let mut changed_permission = config.clone(); changed_permission.permission_mode = AcpxPermissionMode::ApproveAll; let mut changed_expected_identity = config.clone(); 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 e9067210d7..a57168370d 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 @@ -87,10 +87,10 @@ fn bootstraps_a_codex_session_and_confirms_run_identity() { } #[test] -fn validates_codex_policy_and_tool_catalog_before_spawning() { +fn validates_qualified_policy_and_tool_catalog_before_spawning() { let mut invalid_agent = config("bootstrap"); - invalid_agent.agent = "opencode".to_owned(); - assert!(start_error(&invalid_agent).contains("Codex only")); + invalid_agent.agent = "pi".to_owned(); + assert!(start_error(&invalid_agent).contains("claude or codex")); let mut unpinned = config("bootstrap"); unpinned.permission_mode_pinned = false; @@ -101,6 +101,24 @@ fn validates_codex_policy_and_tool_catalog_before_spawning() { assert!(start_error(&invalid_tools).contains("authorized tools")); } +#[test] +fn admits_each_exact_qualified_agent_model_pair() { + for (agent, model) in [("codex", "gpt-5.6-sol"), ("claude", "claude-sonnet-5")] { + let mut qualified = config("bootstrap"); + qualified.agent = agent.to_owned(); + qualified.model = model.to_owned(); + qualified.validate().unwrap(); + } + + let mut drifted = config("bootstrap"); + drifted.agent = "claude".to_owned(); + assert!(drifted + .validate() + .unwrap_err() + .to_string() + .contains("exact model")); +} + #[test] fn rejects_a_sidecar_that_reports_another_effective_model() { let error = start_error(&config("bootstrap-wrong-model")); 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 caedc8996e..ee3cd71e0e 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -16,21 +16,27 @@ export interface CodexAcpxNativeSessionBackendOptions extends Omit< "model" | "permissionMode" | "systemInstructions" > {} +export type AcpxNativeSessionBackendOptions = + CodexAcpxNativeSessionBackendOptions; + /** - * Constructs the qualified Codex ACPX backend. Other ACPX agents remain - * unavailable until their runtime, policy, and conformance slices ship. + * Constructs a backend only after the persisted ACPX snapshot matches the + * closed, package-owned qualification profile for its selected agent. */ -export function createCodexAcpxNativeSessionBackend( +export function createAcpxNativeSessionBackend( input: NativeExecutionInput, - options: CodexAcpxNativeSessionBackendOptions, + options: AcpxNativeSessionBackendOptions, ): NativeSessionBackend { - if (input.provider.kind !== "acpx" || input.provider.agent !== "codex") { + if (input.provider.kind !== "acpx") { + throw new Error("ACPX backend requires provider kind acpx"); + } + if (input.provider.agent === "pi") { throw new Error( - "Codex ACPX backend requires provider kind acpx with agent codex", + "Pi ACPX backend is unavailable until descriptor-confined verified launch is implemented", ); } const qualifiedProfile = resolveQualifiedAcpxProfile( - "codex", + input.provider.agent, input.provider.model, ); for (const field of [ @@ -47,7 +53,7 @@ export function createCodexAcpxNativeSessionBackend( ] as const) { if (input.provider.profile[field] !== qualifiedProfile[field]) { throw new Error( - `Persisted Codex ACPX profile does not match the qualified ${field}`, + `Persisted ${input.provider.agent} ACPX profile does not match the qualified ${field}`, ); } } @@ -63,9 +69,23 @@ export function createCodexAcpxNativeSessionBackend( return new HarnessDriverBackend( new CodexAcpxDriver({ ...options, + agent: input.provider.agent, model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", systemInstructions, }), ); } + +/** Backward-compatible Codex-specific constructor. */ +export function createCodexAcpxNativeSessionBackend( + input: NativeExecutionInput, + options: CodexAcpxNativeSessionBackendOptions, +): NativeSessionBackend { + if (input.provider.kind !== "acpx" || input.provider.agent !== "codex") { + throw new Error( + "Codex ACPX backend requires provider kind acpx with agent codex", + ); + } + return createAcpxNativeSessionBackend(input, options); +} diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 79c0e4db6a..2791c6602d 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -55,7 +55,9 @@ function execution( }; } -function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput { +function acpxExecution( + agent: "codex" | "pi" | "claude" = "codex", +): NativeExecutionInput { return { ...execution(), session: { @@ -70,7 +72,9 @@ function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput { model: agent === "codex" ? "gpt-5.6-sol" - : "openrouter/deepseek/deepseek-v4-flash-0731", + : agent === "pi" + ? "openrouter/deepseek/deepseek-v4-flash-0731" + : "claude-sonnet-5", permissionPolicy: "interactive", profile: { driverKind: "acpx_runtime", @@ -79,15 +83,22 @@ function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput { agent, agentProfileVersion: 1, agentServerPackage: - agent === "codex" ? "@agentclientprotocol/codex-acp" : "pi-acp", - agentServerVersion: agent === "codex" ? "1.6.2" : "0.0.33", + agent === "codex" + ? "@agentclientprotocol/codex-acp" + : agent === "pi" + ? "pi-acp" + : "@agentclientprotocol/claude-agent-acp", + agentServerVersion: + agent === "codex" ? "1.6.2" : agent === "pi" ? "0.0.33" : "0.70.0", agentRuntimePackage: - agent === "codex" ? null : "@earendil-works/pi-coding-agent", - agentRuntimeVersion: agent === "codex" ? null : "0.84.2", + agent === "pi" ? "@earendil-works/pi-coding-agent" : null, + agentRuntimeVersion: agent === "pi" ? "0.84.2" : null, commandDigest: agent === "codex" ? "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79" - : "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + : agent === "pi" + ? "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f" + : "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", }, }, }; @@ -168,15 +179,32 @@ describe("native backend factory", () => { }); }); - it("requires an explicit runtime root and keeps other ACPX agents disabled", () => { + it("requires an explicit runtime root", () => { expect(() => createNativeSessionBackend(acpxExecution())).toThrow( "requires an instance runtime directory", ); + }); + + it.each(["claude" as const])( + "constructs the qualified %s ACPX backend", + async (agent) => { + const backend = createNativeSessionBackend(acpxExecution(agent), { + acpxRuntimeDirectory: "/runtime", + }); + + await expect(backend.descriptor()).resolves.toMatchObject({ + name: "acpx_runtime", + version: "0.13.1", + }); + }, + ); + + it("rejects Pi before constructing an ACPX backend", () => { expect(() => createNativeSessionBackend(acpxExecution("pi"), { acpxRuntimeDirectory: "/runtime", }), - ).toThrow("ACPX backend for pi is not included"); + ).toThrow("descriptor-confined verified launch"); }); it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 76003ef48d..75b24edca4 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -9,7 +9,7 @@ import { type CodexNativeSessionBackendOptions, } from "./codex-native-backend.js"; import { - createCodexAcpxNativeSessionBackend, + createAcpxNativeSessionBackend, type CodexAcpxNativeSessionBackendOptions, } from "./codex-acpx-native-backend.js"; import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js"; @@ -56,21 +56,23 @@ export function createNativeSessionBackend( }); } if (input.provider.kind === "acpx") { - if (input.provider.agent !== "codex") { + if (input.provider.agent === "pi") { throw new Error( - `Native ACPX backend for ${input.provider.agent} is not included in the Codex-first runner`, + "Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented", ); } if (!options.acpxRuntimeDirectory?.trim()) { - throw new Error( - "Codex ACPX backend requires an instance runtime directory", - ); + throw new Error("ACPX backend requires an instance runtime directory"); } - return createCodexAcpxNativeSessionBackend(input, { + return createAcpxNativeSessionBackend(input, { runtimeDirectory: options.acpxRuntimeDirectory, environment: options.acpxEnvironment, - managedCodexCredentialSourcePath: - options.acpxManagedCodexCredentialSourcePath, + ...(input.provider.agent === "codex" + ? { + managedCodexCredentialSourcePath: + options.acpxManagedCodexCredentialSourcePath, + } + : {}), dynamicTools: options.dynamicTools, dynamicToolHandler: options.acpxDynamicToolHandler, }); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index a0a9a0aefa..24bc74d381 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -28,7 +28,7 @@ afterEach(async () => { children.clear(); }); -describe("Codex ACPX runtime sidecar", () => { +describe("qualified ACPX runtime sidecar", () => { it("keeps session admission closed while any cleanup owner remains", () => { const cleanup = Promise.resolve(); @@ -428,9 +428,31 @@ describe("Codex ACPX runtime sidecar", () => { }); }); + it.each([["claude", "claude-sonnet-5"]])( + "reports the qualified %s profile", + async (agent, model) => { + const sidecar = startSidecar(); + sidecar.write(initializeRequest(1, agent, model)); + + await expect( + sidecar.next((frame) => frame.id === 1), + ).resolves.toMatchObject({ + id: 1, + ok: true, + result: { profile: { agent, qualificationModel: model } }, + }); + }, + ); + it("fails closed after an unsupported provider bootstrap", async () => { const sidecar = startSidecar(); - sidecar.write(initializeRequest(1, "pi")); + sidecar.write( + initializeRequest( + 1, + "pi", + "openrouter/deepseek/deepseek-v4-flash-0731", + ), + ); await expect( sidecar.next((frame) => frame.id === 1), @@ -439,7 +461,7 @@ describe("Codex ACPX runtime sidecar", () => { ok: false, error: { code: "acpx_sidecar_command_failed", - message: "This production ACPX sidecar supports Codex only", + message: "ACPX agent must be claude or codex", retryable: false, }, }); @@ -461,12 +483,16 @@ describe("Codex ACPX runtime sidecar", () => { }); }); -function initializeRequest(id: number, agent: string): Record { +function initializeRequest( + id: number, + agent: string, + model = "gpt-5.6-sol", +): Record { return { protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id, command: "initialize", - params: { agent, model: "gpt-5.6-sol" }, + params: { agent, model }, }; } diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index ddc5fba004..79dfb933ed 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -22,7 +22,10 @@ import { type NormalizedAcpForm, } from "../drivers/acpx/acp-question-adapter.js"; import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js"; -import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; +import { + resolveQualifiedAcpxProfile, + type QualifiedAcpxAgent, +} from "../drivers/acpx/qualified-profiles.js"; import { AcpxRuntimeHost, type AcpxRetainedCleanupFailure, @@ -114,6 +117,7 @@ let closing = false; let shutdownRequested = false; let pendingInput = Promise.resolve(); let bootstrapFailure: Error | null = null; +let initializedAgent: QualifiedAcpxAgent | null = null; let initializedModel: string | null = null; const tools = new Map(); const inputs = new Map(); @@ -194,9 +198,10 @@ async function dispatch( if (request.command === "initialize") { if (initializedModel) throw new Error("ACPX sidecar is already initialized"); - requireCodexAgent(request.params.agent); + const agent = requireQualifiedAgent(request.params.agent); const model = requiredText(request.params.model, "model"); - const profile = resolveQualifiedAcpxProfile("codex", model); + const profile = resolveQualifiedAcpxProfile(agent, model); + initializedAgent = agent; initializedModel = model; return { protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, @@ -223,15 +228,15 @@ async function dispatch( } if (!initializedModel) throw new Error("initialize the ACPX sidecar first"); const params = parseOpenParams(request.params); - if (params.model !== initializedModel) { - throw new Error("ACPX session model differs from its initialization"); + if (params.agent !== initializedAgent || params.model !== initializedModel) { + throw new Error("ACPX session profile differs from its initialization"); } const openedHost = await AcpxRuntimeHost.open( { runtimeDirectory: params.runtimeDirectory, normalizedSessionId: params.normalizedSessionId, workingDirectory: params.workingDirectory, - agent: "codex", + agent: params.agent, model: params.model, permissionMode: params.permissionMode, systemInstructions: params.systemInstructions, @@ -322,7 +327,7 @@ async function dispatch( } if (request.command === "permission.resolve") { throw new Error( - "Codex ACPX permissions are resolved by the admitted runner policy", + "ACPX permissions are resolved by the admitted runner policy", ); } if (request.command === "input.resolve") { @@ -424,7 +429,7 @@ async function dispatch( if (request.command === "session.close") { if (request.params.discardPersistentState === true) { throw new Error( - "Codex ACPX persistent state cannot be discarded by this sidecar", + "ACPX persistent state cannot be discarded by this sidecar", ); } const closingTurnId = turnId; @@ -577,7 +582,7 @@ async function waitForInput( if (!normalized) { diagnostic( "runtime_input_unsupported", - "The Codex ACPX provider requested an unsupported input mode.", + "The ACPX provider requested an unsupported input mode.", ); return { action: "cancel" }; } @@ -604,7 +609,7 @@ async function waitForInput( questionSet: normalized.questionSet, origin: { adapter: "acpx-runtime-sidecar", - provider: "codex", + provider: openParams?.agent ?? initializedAgent ?? "unknown", method: "elicitation/create", }, }, @@ -848,12 +853,12 @@ function safeOutput(value: unknown): Record { function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { - requireCodexAgent(value.agent); + const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); - resolveQualifiedAcpxProfile("codex", model); + resolveQualifiedAcpxProfile(agent, model); if (value.runtimeContext !== undefined && value.runtimeContext !== null) { throw new Error( - "Codex ACPX sidecar runtime context must be pre-materialized", + "ACPX sidecar runtime context must be pre-materialized", ); } if ( @@ -861,7 +866,7 @@ function parseOpenParams( value.providerSessionKey !== null ) { throw new Error( - "Codex ACPX replacement provider sessions are not available in this release", + "ACPX replacement provider sessions are not available in this release", ); } return { @@ -871,7 +876,7 @@ function parseOpenParams( "normalizedSessionId", ), workingDirectory: requiredText(value.workingDirectory, "workingDirectory"), - agent: "codex", + agent, model, permissionMode: requiredPermissionMode(value.permissionMode), permissionModePinned: value.permissionModePinned === true, @@ -1004,10 +1009,11 @@ function requireHost( return requireSidecarCommandHost(host, activeHostCleanup, options); } -function requireCodexAgent(value: unknown): void { - if (value !== "codex") { - throw new Error("This production ACPX sidecar supports Codex only"); +function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent { + if (value !== "codex" && value !== "claude") { + throw new Error("ACPX agent must be claude or codex"); } + return value; } function requiredText(value: unknown, field: string): string { diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 163184d1a9..34ca05c741 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -130,7 +130,7 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "construction cleanup recovered" }); }); - it("advertises only the implemented Codex production surface", async () => { + it("binds validation to the configured qualified agent", async () => { const fixture = driverFixture(); const descriptor = await fixture.driver.descriptor(); @@ -162,6 +162,38 @@ describe("Codex ACPX harness driver", () => { }); }); + it("opens the qualified Claude profile through the shared driver", async () => { + const fixture = driverFixture({ + agent: "claude", + model: "claude-sonnet-5", + }); + + await expect(fixture.driver.descriptor()).resolves.toMatchObject({ + displayName: "Claude via ACPX", + }); + await expect( + fixture.driver.validateConfig({ + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "approve-reads", + }), + ).resolves.toMatchObject({ ok: true }); + + const session = await fixture.driver.openSession({ + runId: "run-claude", + normalizedSessionId: "session-1", + workingDirectory: "/workspace", + }); + expect(fixture.hostOptions).toMatchObject({ + agent: "claude", + model: "claude-sonnet-5", + }); + expect( + fixture.hostOptions?.managedCodexCredentialSourcePath, + ).toBeUndefined(); + await session.close({ reason: "qualified Claude driver verified" }); + }); + it("maps one turn, dispatches tools, and commits one semantic result", async () => { const dynamicToolHandler = vi.fn(async () => ({ title: "Document" })); const fixture = driverFixture({ dynamicToolHandler }); 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 7231259638..c1f926a0b6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -59,6 +59,7 @@ import { acpxDriverDescriptor, validateAcpxDriverConfig, } from "./driver-profile.js"; +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; import { ACPX_TURN_CANCELLATION_SHUTDOWN_BOUND_MS, AcpxRuntimeHost, @@ -120,6 +121,8 @@ export interface CodexAcpxDynamicToolCall { } export interface CodexAcpxDriverOptions { + /** Defaults to Codex for backward compatibility. */ + agent?: QualifiedAcpxAgent; runtimeDirectory: string; model: string; permissionMode?: NativeAcpxPermissionMode; @@ -166,7 +169,7 @@ export interface CodexAcpxDriverDependencies { }) => Promise; } -/** Codex-only HarnessDriver backed by the admitted ACPX runtime host. */ +/** Qualified HarnessDriver backed by the admitted ACPX runtime host. */ export class CodexAcpxDriver implements HarnessDriver { readonly #options: CodexAcpxDriverOptions; readonly #openHost: NonNullable; @@ -183,8 +186,14 @@ export class CodexAcpxDriver implements HarnessDriver { options: CodexAcpxDriverOptions, dependencies: CodexAcpxDriverDependencies = {}, ) { + if (options.agent === "pi") { + throw new Error( + "Pi ACPX driver is unavailable until descriptor-confined verified launch is implemented", + ); + } this.#options = { ...options, + agent: options.agent ?? "codex", ...(options.environment ? { environment: { ...options.environment } } : {}), @@ -214,10 +223,9 @@ export class CodexAcpxDriver implements HarnessDriver { } async descriptor(): Promise { - const descriptor = acpxDriverDescriptor("codex"); + const descriptor = acpxDriverDescriptor(this.#options.agent ?? "codex"); return { ...descriptor, - displayName: "Codex via ACPX", runtimeContextCapabilities: { instructions: "native", skills: "unsupported", @@ -235,7 +243,10 @@ export class CodexAcpxDriver implements HarnessDriver { async validateConfig(value: unknown): Promise { const validation = validateAcpxDriverConfig(value); - if (!validation.ok || validation.config.agent === "codex") + if ( + !validation.ok || + validation.config.agent === (this.#options.agent ?? "codex") + ) return validation; return { ok: false, @@ -244,7 +255,7 @@ export class CodexAcpxDriver implements HarnessDriver { { path: "agent", code: "unsupported_agent", - message: "The production ACPX driver currently supports Codex only.", + message: `This ACPX driver is bound to ${this.#options.agent ?? "codex"}.`, }, ], }; @@ -355,7 +366,7 @@ export class CodexAcpxDriver implements HarnessDriver { runtimeDirectory: this.#options.runtimeDirectory, normalizedSessionId: input.normalizedSessionId, workingDirectory: input.workingDirectory, - agent: "codex", + agent: this.#options.agent ?? "codex", model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-reads", systemInstructions: this.#options.systemInstructions, @@ -383,6 +394,7 @@ export class CodexAcpxDriver implements HarnessDriver { input.signal?.throwIfAborted(); session = new CodexAcpxSession({ host, + agent: this.#options.agent ?? "codex", input, dynamicToolHandler: this.#options.dynamicToolHandler, now: this.#options.now ?? (() => new Date()), @@ -625,6 +637,7 @@ export class CodexAcpxDriver implements HarnessDriver { class CodexAcpxSession implements HarnessSession { readonly #host: CodexAcpxHost; + readonly #agent: QualifiedAcpxAgent; readonly #input: OpenHarnessSessionInput; readonly #dynamicToolHandler?: CodexAcpxDriverOptions["dynamicToolHandler"]; readonly #now: () => Date; @@ -680,6 +693,7 @@ class CodexAcpxSession implements HarnessSession { constructor(input: { host: CodexAcpxHost; + agent: QualifiedAcpxAgent; input: OpenHarnessSessionInput; dynamicToolHandler?: CodexAcpxDriverOptions["dynamicToolHandler"]; now: () => Date; @@ -695,6 +709,7 @@ class CodexAcpxSession implements HarnessSession { throw new Error("Codex ACPX host returned a different session identity"); } this.#host = input.host; + this.#agent = input.agent; this.#input = structuredClone(input.input); this.#dynamicToolHandler = input.dynamicToolHandler; this.#now = input.now; @@ -1545,7 +1560,7 @@ class CodexAcpxSession implements HarnessSession { input: structuredClone(normalized.questionSet), origin: { adapter: "acpx-runtime", - provider: "codex", + provider: this.#agent, method: "elicitation/create", }, }; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts index 4f343dc077..452ecd7352 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts @@ -111,35 +111,72 @@ let nextCredentialLeaseGeneration = 0; export type ManagedCodexCredentialMode = "api_key" | "inline_json" | "managed_file"; -export interface ManagedCodexCredentialLease { - readonly path: string; - readonly mode: ManagedCodexCredentialMode; +export interface AcpxProviderLifetimeLease { /** Duplicate both quorum listeners into the provider lifetime sentinel. */ readonly lifetimeFenceFds: readonly [number, number]; - /** Validate the guardian while the credential quorum is still held. */ + /** Validate the guardian while the provider-lifetime quorum is still held. */ activateLifetimeOwner(pid: number): Promise; close(): Promise; } +export interface ManagedCodexCredentialLease + extends AcpxProviderLifetimeLease { + readonly path: string; + readonly mode: ManagedCodexCredentialMode; +} + +/** + * Acquire the same kernel-backed provider lifetime ownership used by Codex + * when an ACPX agent has no staged credential home of its own. + */ +export async function acquireAcpxProviderLifetimeLease(input: { + agentHomeDirectory: string; +}): Promise { + const home = await resolvePrivateAgentHome(input.agentHomeDirectory); + const lock = await acquireCredentialHomeLock(home); + let closed = false; + let closeAttempt: Promise | null = null; + let lifetimeOwnerAttempt: Promise | null = null; + return Object.freeze({ + lifetimeFenceFds: lock.inheritanceFds(), + async activateLifetimeOwner(pid: number): Promise { + if (closed || closeAttempt !== null) { + throw new Error("ACPX provider lifetime lease is closing"); + } + if (lifetimeOwnerAttempt !== null) return await lifetimeOwnerAttempt; + const attempt = lock.activateLifetimeOwner(pid); + lifetimeOwnerAttempt = attempt; + try { + await attempt; + } finally { + if (lifetimeOwnerAttempt === attempt) lifetimeOwnerAttempt = null; + } + }, + async close(): Promise { + if (closed) return; + if (closeAttempt !== null) return await closeAttempt; + const attempt = (async () => { + await lifetimeOwnerAttempt?.catch(() => undefined); + await lock.release(); + closed = true; + })(); + closeAttempt = attempt; + try { + await attempt; + } finally { + if (closeAttempt === attempt) closeAttempt = null; + } + }, + }); +} + /** Stage one explicit Codex authentication source in its isolated runtime home. */ export async function stageManagedCodexCredential(input: { agentHomeDirectory: string; environment?: NodeJS.ProcessEnv; sourcePath?: string; }): Promise { - const home = await realpath(input.agentHomeDirectory); - const homeMetadata = await lstat(home); - if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) { - throw new Error("Managed Codex credential home must be a real directory"); - } - if ( - process.platform !== "win32" && - ((homeMetadata.mode & 0o077) !== 0 || - (typeof process.getuid === "function" && - homeMetadata.uid !== process.getuid())) - ) { - throw new Error("Managed Codex credential home permissions are unsafe"); - } + const home = await resolvePrivateAgentHome(input.agentHomeDirectory); // Join an older failed close before claiming the next generation. This // keeps quarantine recovery authoritative over the shared paths without // mistaking a waiting admission for an already-active successor. @@ -166,6 +203,23 @@ export async function stageManagedCodexCredential(input: { } } +async function resolvePrivateAgentHome(directory: string): Promise { + const home = await realpath(directory); + const homeMetadata = await lstat(home); + if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) { + throw new Error("ACPX agent home must be a real directory"); + } + if ( + process.platform !== "win32" && + ((homeMetadata.mode & 0o077) !== 0 || + (typeof process.getuid === "function" && + homeMetadata.uid !== process.getuid())) + ) { + throw new Error("ACPX agent home permissions are unsafe"); + } + return home; +} + async function stageClaimedManagedCodexCredential( input: { environment?: NodeJS.ProcessEnv; 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 a29bbb1a98..f488196254 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 @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js"; const HANDLE: AcpRuntimeHandle = { @@ -100,6 +101,35 @@ describe("Codex ACPX runtime adapter", () => { }); }); + it.each([["claude" as const, "claude-sonnet-5"]])( + "opens the qualified %s session through the verified lease", + async (agent, model) => { + const runtime = fakeRuntime(); + const command = fakeCommand(); + const options = openOptions(command); + options.profile = resolveQualifiedAcpxProfile(agent, model); + options.launchEnvironment = { PATH: "/verified/bin" }; + + await openCodexAcpxRuntime(options, { + createRegistry: ({ overrides }) => { + expect(overrides).toEqual({ + [agent]: ["paperclip-verified-acpx-command"], + }); + return registry(); + }, + createStore: () => store(), + createRuntime: () => runtime, + }); + + expect(runtime.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + agent, + sessionOptions: expect.objectContaining({ model }), + }), + ); + }, + ); + it("launches only through the verified command lease", async () => { const runtime = fakeRuntime(); const command = fakeCommand(); @@ -1137,7 +1167,7 @@ describe("Codex ACPX runtime adapter", () => { { createRuntime }, ), ).rejects.toThrow( - "The production ACPX runtime requires an inherited credential-home fence", + "The production ACPX runtime requires an inherited provider-lifetime fence", ); expect(createRuntime).not.toHaveBeenCalled(); @@ -1157,7 +1187,7 @@ describe("Codex ACPX runtime adapter", () => { { createRuntime }, ), ).rejects.toThrow( - "The production ACPX runtime requires an inherited credential-home fence", + "The production ACPX runtime requires an inherited provider-lifetime fence", ); expect(createRuntime).not.toHaveBeenCalled(); @@ -2397,22 +2427,6 @@ describe("Codex ACPX runtime adapter", () => { await retainCleanup.mock.calls[3]?.[0]; }); - it("rejects non-Codex profiles before constructing ACPX", async () => { - const createRuntime = vi.fn(); - await expect( - openCodexAcpxRuntime( - { - ...openOptions(fakeCommand()), - profile: { - ...openOptions(fakeCommand()).profile, - agent: "claude", - }, - }, - { createRuntime }, - ), - ).rejects.toThrow("currently supports Codex only"); - expect(createRuntime).not.toHaveBeenCalled(); - }); }); function openOptions( 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 3884605a6e..223f9ae74c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -73,7 +73,7 @@ class AcpxSessionHandshakeTimeoutError extends Error { } } -export interface CodexAcpxRuntimeDependencies { +export interface QualifiedAcpxRuntimeDependencies { createRuntime?: (options: AcpRuntimeOptions) => AcpRuntime; createRegistry?: (input: { overrides: Record; @@ -92,14 +92,17 @@ export interface CodexAcpxRuntimeDependencies { platform?: NodeJS.Platform; } +/** @deprecated Use QualifiedAcpxRuntimeDependencies for provider-neutral ACPX runtimes. */ +export type CodexAcpxRuntimeDependencies = QualifiedAcpxRuntimeDependencies; + /** * Adapt the pinned ACPX library to Paperclip's admitted runtime port. The * executable, launch environment, and spawn cwd stay host-owned and are never * persisted in ACPX's session options. */ -export async function openCodexAcpxRuntime( +export async function openQualifiedAcpxRuntime( options: AcpxRuntimePortOpenOptions, - dependencies: CodexAcpxRuntimeDependencies = {}, + dependencies: QualifiedAcpxRuntimeDependencies = {}, ): Promise { if (options.signal?.aborted) { // The host may have already transferred its staged credential to this @@ -116,11 +119,6 @@ export async function openCodexAcpxRuntime( // bounded sidecar exit and retained ownership of an unresponsive process // tree when Node cannot safely signal a verified provider process group. assertVerifiedAcpxProviderPlatform(dependencies.platform ?? process.platform); - if (options.profile.agent !== "codex") { - throw new Error( - "The production ACPX runtime currently supports Codex only", - ); - } options.signal?.throwIfAborted(); const credentialFenceFds = options.credentialFenceFds; if ( @@ -133,7 +131,7 @@ export async function openCodexAcpxRuntime( typeof options.activateCredentialFenceOwner !== "function" ) { throw new Error( - "The production ACPX runtime requires an inherited credential-home fence", + "The production ACPX runtime requires an inherited provider-lifetime fence", ); } @@ -174,7 +172,7 @@ export async function openCodexAcpxRuntime( backend: "acpx", runtimeSessionName: encodeAcpxRuntimeHandleState({ name: runtimeSessionName, - agent: "codex", + agent: options.profile.agent, cwd: record.cwd, mode: "persistent", acpxRecordId: record.acpxRecordId, @@ -221,7 +219,7 @@ export async function openCodexAcpxRuntime( cwd: options.cwd, sessionStore, agentRegistry: createRegistry({ - overrides: { codex: [VERIFIED_COMMAND_SENTINEL] }, + overrides: { [options.profile.agent]: [VERIFIED_COMMAND_SENTINEL] }, }), permissionMode: options.permissionMode, elicitationModes: ["form"], @@ -282,7 +280,7 @@ export async function openCodexAcpxRuntime( const handshake = Promise.resolve().then(() => runtime.ensureSession({ sessionKey: options.providerSessionKey, - agent: "codex", + agent: options.profile.agent, mode: "persistent", cwd: options.cwd, sessionOptions: { @@ -380,6 +378,9 @@ export async function openCodexAcpxRuntime( } } +/** Backward-compatible name retained for existing Codex-only consumers. */ +export const openCodexAcpxRuntime = openQualifiedAcpxRuntime; + function raceRuntimeHandshakeWithAbort( handshake: Promise, signal: AbortSignal, diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index 44c3ded2b2..acf5efce76 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -46,7 +46,6 @@ describe("ACPX driver profile", () => { }); it.each([ - ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], ["claude", "claude-sonnet-5"], ["codex", "gpt-5.6-sol"], ] as const)("accepts the exact qualified %s model", (agent, model) => { @@ -78,6 +77,15 @@ describe("ACPX driver profile", () => { ok: false, issues: [{ path: "model", code: "invalid_model" }], }); + expect( + validateAcpxDriverConfig({ + agent: "pi", + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "agent", code: "invalid_agent" }], + }); expect( validateAcpxDriverConfig({ agent: "claude", diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index 98abd458dc..97b1880550 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -12,7 +12,7 @@ import { type QualifiedAcpxAgent, } from "./qualified-profiles.js"; -const ACPX_AGENTS = ["pi", "claude", "codex"] as const; +const ACPX_AGENTS = ["claude", "codex"] as const; const ACPX_PERMISSION_MODES = [ "approve-all", "approve-reads", @@ -95,7 +95,7 @@ export function validateAcpxDriverConfig( return invalid( "agent", "invalid_agent", - "ACPX agent must be pi, claude, or codex.", + "ACPX agent must be claude or codex.", ); } const model = text(config.model); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 9653979079..f5eb29f0bf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -275,6 +275,29 @@ describe("ACPX runtime host", () => { ).rejects.toThrow(); }); + it("rejects Pi before installation or runtime launch", async () => { + const fixture = await hostFixture(); + const verifyInstallation = vi.fn(); + const openRuntime = vi.fn(); + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "pi" as never, + model: "openrouter/deepseek/deepseek-v4-flash-0731", + permissionMode: "approve-reads", + }, + { + verifyInstallation, + openRuntime, + reportRetainedCleanupFailure: vi.fn(), + }, + ), + ).rejects.toThrow("descriptor-confined verified launch"); + expect(verifyInstallation).not.toHaveBeenCalled(); + expect(openRuntime).not.toHaveBeenCalled(); + }); + it("selects and verifies Claude's qualified reported model", async () => { const fixture = await hostFixture(); let selected = false; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index bdc061469c..c568b613b1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -11,8 +11,9 @@ import { type RunnerToolBridgeOptions, } from "../runner-tool-bridge.js"; import { + acquireAcpxProviderLifetimeLease, stageManagedCodexCredential, - type ManagedCodexCredentialLease, + type AcpxProviderLifetimeLease, } from "./codex-credentials.js"; import { verifyQualifiedAcpxInstallation, @@ -124,7 +125,12 @@ export interface AcpxMcpServerBinding { export type AcpxSemanticToolSession = Omit; export interface AcpxRetainedCleanupFailure { - resource: "credential" | "command" | "runtime" | "tool_bridge"; + resource: + | "credential" + | "provider_lifetime" + | "command" + | "runtime" + | "tool_bridge"; attempt: number; error: unknown; } @@ -175,7 +181,7 @@ const RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS = 10; const RETAINED_CLEANUP_RETRY_MAX_DELAY_MS = 1_000; interface RetainedRejectedRuntimeAdmission { - readonly credential: ManagedCodexCredentialLease; + readonly credential: AcpxProviderLifetimeLease; cleanup: Promise; } @@ -189,7 +195,7 @@ const retainedRejectedRuntimeAdmissions = interface RetainedAcpxAdmissionCleanup { readonly runtime: AcpxRuntimePort | null; readonly toolBridge: RunnerToolBridge | null; - readonly credential: ManagedCodexCredentialLease | null; + readonly credential: AcpxProviderLifetimeLease | null; readonly command: VerifiedAcpxCommandLease | null; readonly reason: string; recovery: Promise | null; @@ -208,7 +214,7 @@ export class AcpxRuntimeHost { readonly #binding: AcpxRecoveryBinding; readonly #identity: AcpxIdentityRecord; readonly #sandbox: AcpxRuntimeSandbox; - readonly #credential: ManagedCodexCredentialLease | null; + readonly #credential: AcpxProviderLifetimeLease | null; readonly #command: VerifiedAcpxCommandLease; readonly #toolBridge: RunnerToolBridge | null; #activeTurn: AcpxRuntimeTurn | null = null; @@ -221,7 +227,7 @@ export class AcpxRuntimeHost { binding: AcpxRecoveryBinding; identity: AcpxIdentityRecord; sandbox: AcpxRuntimeSandbox; - credential: ManagedCodexCredentialLease | null; + credential: AcpxProviderLifetimeLease | null; command: VerifiedAcpxCommandLease; toolBridge: RunnerToolBridge | null; }) { @@ -239,6 +245,11 @@ export class AcpxRuntimeHost { dependencies: AcpxRuntimeHostDependencies, ): Promise { options.signal?.throwIfAborted(); + if (options.agent === "pi") { + throw new Error( + "ACPX pi is unavailable until its runtime has descriptor-confined verified launch", + ); + } const profile = resolveQualifiedAcpxProfile(options.agent, options.model); const binding = await runAbortableAdmissionStage(options.signal, () => createAcpxRecoveryBinding({ @@ -272,7 +283,7 @@ export class AcpxRuntimeHost { throw new Error("Verified ACPX installation does not match its profile"); } let command: VerifiedAcpxCommandLease | null = null; - let credential: ManagedCodexCredentialLease | null = null; + let credential: AcpxProviderLifetimeLease | null = null; let toolBridge: RunnerToolBridge | null = null; let runtime: AcpxRuntimePort | null = null; let pendingRuntimeOwnsCredential = false; @@ -334,6 +345,18 @@ export class AcpxRuntimeHost { reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), }); + } else { + credential = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => + acquireAcpxProviderLifetimeLease({ + agentHomeDirectory: sandbox.agentHomeDirectory, + }), + resource: "provider_lifetime", + releaseLate: (lateLifetime) => lateLifetime.close(), + reportFailure: (failure) => + dependencies.reportRetainedCleanupFailure(failure), + }); } command = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -353,6 +376,10 @@ export class AcpxRuntimeHost { dependencies.reportRetainedCleanupFailure(failure), }) : null; + const admittedLifetime = credential; + if (admittedLifetime === null) { + throw new Error("ACPX provider lifetime lease is unavailable"); + } runtime = await acquireAbortableAdmissionResource({ signal: options.signal, acquire: () => { @@ -368,11 +395,9 @@ export class AcpxRuntimeHost { binding.permissionMode, ), launchEnvironment: sandbox.launchEnvironment, - credentialFenceFds: credential?.lifetimeFenceFds ?? null, + credentialFenceFds: admittedLifetime.lifetimeFenceFds, activateCredentialFenceOwner: - typeof credential?.activateLifetimeOwner === "function" - ? credential.activateLifetimeOwner.bind(credential) - : null, + admittedLifetime.activateLifetimeOwner.bind(admittedLifetime), systemInstructions: boundedInstructions(options.systemInstructions), ...(options.assertWorkspaceHeld === undefined ? {} @@ -674,7 +699,7 @@ function raceAdmissionWithAbort( function retainAbortedRuntimeAdmissionCleanup(input: { pendingRuntime: Promise; - credential: ManagedCodexCredentialLease | null; + credential: AcpxProviderLifetimeLease | null; reason: string; failedAdmissionCleanupTransfer: Promise; }): void { @@ -688,7 +713,7 @@ function retainAbortedRuntimeAdmissionCleanup(input: { async function cleanupAbortedRuntimeAdmission( runtime: AcpxRuntimePort | null, - credential: ManagedCodexCredentialLease | null, + credential: AcpxProviderLifetimeLease | null, reason: string, ): Promise { const cleanupError = await cleanupRuntimeResources( @@ -711,7 +736,7 @@ async function cleanupAbortedRuntimeAdmission( function retainFailedAcpxAdmissionCleanup(input: { runtime: AcpxRuntimePort | null; toolBridge: RunnerToolBridge | null; - credential: ManagedCodexCredentialLease | null; + credential: AcpxProviderLifetimeLease | null; command: VerifiedAcpxCommandLease | null; reason: string; }): void { @@ -913,7 +938,7 @@ async function boundedCancellation( async function cleanupRuntimeResources( runtime: AcpxRuntimePort | null, toolBridge: RunnerToolBridge | null, - credential: ManagedCodexCredentialLease | null, + credential: AcpxProviderLifetimeLease | null, command: VerifiedAcpxCommandLease | null, reason: string, ): Promise { diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index 9dbcf8f5b0..9ce9d0e2cb 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -23,17 +23,23 @@ const codexPatch = await readFile( ), "utf8", ); +const claudePatch = await readFile( + new URL( + "../../../patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", + import.meta.url, + ), + "utf8", +); -test("the runner pins only the Codex ACPX production dependencies", () => { +test("the runner pins every qualified ACPX production dependency", () => { assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); assert.equal( runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2", ); - assert.equal(runnerPackage.dependencies["pi-acp"], undefined); assert.equal( runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], - undefined, + "0.70.0", ); }); @@ -50,6 +56,12 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch", ); + assert.equal( + rootPackage.pnpm.patchedDependencies[ + "@agentclientprotocol/claude-agent-acp@0.70.0" + ], + "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", + ); assert.equal( rootPackage.pnpm.patchedDependencies[ "@agentclientprotocol/codex-acp@1.6.2" @@ -61,6 +73,10 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = workspace, /codex-acp@1\.6\.2': patches\/@agentclientprotocol__codex-acp@1\.6\.2\.patch/, ); + assert.match( + workspace, + /claude-agent-acp@0\.70\.0': patches\/@agentclientprotocol__claude-agent-acp@0\.70\.0\.patch/, + ); }); test("the ACPX patch preserves launch-only state and verified spawning", () => { @@ -110,3 +126,16 @@ test("the Codex patch enforces isolated instructions, tools, and skills", () => ); } }); + +test("the Claude patch removes ambient project and local configuration", () => { + for (const token of [ + "PAPERCLIP_ACPX_ISOLATED_CONTEXT", + 'settingSources: ["user"]', + "userProvidedOptions?.mcpServers", + ]) { + assert.match( + claudePatch, + new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + } +}); diff --git a/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch b/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch new file mode 100644 index 0000000000..dcdcd3bcdd --- /dev/null +++ b/patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch @@ -0,0 +1,51 @@ +diff --git a/dist/acp-agent.js b/dist/acp-agent.js +--- a/dist/acp-agent.js ++++ b/dist/acp-agent.js +@@ -2681,10 +2681,20 @@ + cost: { + amount: message.total_cost_usd, + currency: "USD", + }, +- ...(message.origin && { +- _meta: { "_claude/origin": message.origin }, +- }), ++ _meta: { ++ ...(message.origin && { "_claude/origin": message.origin }), ++ // ACP's core Usage shape exposes context occupancy and ++ // cost, but not the billable input/output split. Keep the ++ // SDK's bounded aggregate in extension metadata so ACPX can ++ // normalize it without persisting a raw provider message. ++ usage: { ++ input_tokens: message.usage.input_tokens, ++ output_tokens: message.usage.output_tokens, ++ cache_read_input_tokens: message.usage.cache_read_input_tokens, ++ cache_creation_input_tokens: message.usage.cache_creation_input_tokens, ++ }, ++ }, + }, + }); + } +@@ -4866,8 +4876,12 @@ + const options = { + systemPrompt, + settingSources: ["user", "project", "local"], + ...(thinking !== undefined && { thinking }), + ...userProvidedOptions, ++ // Paperclip Runner owns the complete session context. Its isolated ++ // user root contains only assigned skills; project/local settings ++ // would reintroduce host prompts, plugins, skills, and MCP servers. ++ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }), + ...(settings && { settings }), + env, + // Override certain fields that must be controlled by ACP +@@ -4875,7 +4889,9 @@ + includePartialMessages: true, + forwardSubagentText, + mcpServers: { +- ...(userProvidedOptions?.mcpServers || {}), ++ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" ++ ? {} ++ : (userProvidedOptions?.mcpServers || {})), + ...mcpServers, + ...(fileChangeAuditSupport + ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7262ab6ace..bef4999a34 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,4 +19,5 @@ patchedDependencies: embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch acpx@0.12.0: patches/acpx@0.12.0.patch acpx@0.13.1: patches/acpx@0.13.1.patch + '@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch '@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch