From 6f8ad3d77bc9032d7a6d518eb0a9580fc607c23d Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 16 Oct 2026 10:39:38 -0700 Subject: [PATCH] Add verified session-local stdio MCP transport to Runner (#16526) ## Thinking Path > - Paperclip manages AI agent runs and their tool authority. > - Runner supplies provider sessions with assigned MCP connections. > - The current native connection binding supports an HTTP gateway only. > - Execution hosts also have private local tools with stdio MCP interfaces. > - Those tools need session-local registration and safe concurrency. > - This pull request adds verified local stdio assignments beside the existing gateway. > - Providers receive the existing tool schemas and image content. ## Linked Issues or Issue Description **Problem or motivation** Runner can supply a native provider session with an HTTP MCP gateway, but cannot also assign a verified local stdio MCP server without persistent provider configuration. Execution hosts need a session-local transport for local MCP tools, coordination for calls sharing a resource, and native schema/image delivery. The pinned native Codex client drops MCP images when `structuredContent` is also present. **Proposed solution** Add general stdio MCP transport support using host-assigned executable digests, arguments, environment and a private Unix socket identifying the shared resource. Tool implementations supply their own schemas and responses; Runner forwards their native MCP schemas and content. Register the transport for the current provider session. Serialize calls across processes, keep unknown outcomes fenced, and authorize assigned native Codex tools without a new prompt while routing explicit tool confirmations through existing PRP permission requests. Retain the HTTP gateway and applicable permission policy. **Alternatives considered** Reimplementing tools or translating their actions into provider-specific APIs would tie the transport to individual tool implementations. Global provider config would leak session assignments. Shell commands alone do not deliver native MCP images or preserve MCP approval requests. **Roadmap alignment** This completes the existing Runner transport boundary. Related: #10690 (operator config for the legacy ACPX engine), #11605 (gateway delivery of installed stdio connections), #16486 (preconfigured provider homes). This change assigns local tools on the execution host and leaves those administration paths intact. ## What Changed - Add reserved, verified stdio bindings for native Codex and through the shared ACPX runtime. ACPX clients must support stdio MCP assignments. Read-only sessions omit the binding. No home/project config is rewritten. - Run the backend with only its assigned environment through the verified executable launch path. - Serialize all calls sharing a private Unix socket across sessions. Metadata remains responsive. Queued cancellation prevents execution; active calls remain fenced until acknowledgement. Unknown outcomes require a resource restart. - Authorize host-assigned native Codex tools without a new per-call prompt, matching existing execution-host shell utilities. Preserve explicit tool deny/prompt rules. Route requested MCP tool confirmations through one-time PRP approvals, bound to the active thread and turn. Keep shell escalation disabled and existing tool deny rules effective. ACPX adapters retain their applicable ACPX permission policy. - Preserve schemas and image blocks. For native Codex 0.160/0.162, retain structured metadata as MCP text on image-bearing results to avoid the client's image-loss bug. ACPX adapters receive the original response. - Document transport authority, cancellation and recovery limits. ## Verification - `pnpm -r typecheck` and `pnpm build` passed with pnpm 9.15.4 and Rust 1.97.1 on macOS arm64. - Before Apex, full `paperclip-runner-core` Rust tests passed. After the fixes, all 379 library tests and eight subprocess tests using a generic stdio MCP fixture pass. Those subprocess tests cover schema/image passthrough, assigned-only environment, executable drift, call serialization, cancellation and unknown outcomes. The native Codex suite passed 91 tests with two intentionally ignored; `receipt_limit_rejects_the_call_and_keeps_polling_when_interrupt_fails` timed out waiting for `turn.interrupted`, including an isolated retry. The unchanged pre-fix commit `80917b301a2309f7966a466cf0c198a2112be2d5` reproduces the same timeout in isolation; this local native-suite run is not claimed as fully passing. - All 252 native controller tests pass against the final narrowed approval branch. Focused TypeScript transport/permission suites passed (298 tests); final binding, read-only host, policy and adapter checks passed (105 tests). - **Provider-client smoke coverage:** pinned Codex 0.160.0 through native Runner and Claude SDK 0.3.286 used local stdio MCP servers and loopback model API fixtures without live model credentials. These clients define the smoke-test coverage; this is not an exhaustive adapter compatibility test. Image responses reached the next model request as typed image blocks. Native Codex checks also verified unattended execution without approval requests, explicit per-tool prompt denial without backend execution, disabled-tool rules, and stale-turn approval rejection. - Two Greptile concurrency findings were reproduced with failing subprocess tests and fixed: pending cancellation under output backpressure, and a ctime-based fence reset after chmod. Initial checks exposed Codex CLI option-scope and key-quoting errors, which were corrected. The existing ordinary elicitation regression also exposed an overly broad request branch; the final branch handles explicit tool approvals only. - `pnpm test:run` was attempted. The broad local server suite hit persistent embedded PostgreSQL startup failures and unrelated local Git fixture failures; it was stopped with the failure log retained. Those local results are not claimed as passing. Hosted Linux CI gates passed on the reviewed runtime commit, as recorded below. - First Apex review found three additional defects, now fixed with regressions: keep the resource-lock socket snapshot instead of sampling the socket twice; project native permission requests with durable turn/item IDs while retaining provider IDs in details; validate provider response conversion before marking a request as settling. The retry regression failed before the fix. Focused Codex request/adapter checks pass (23 tests), server permission projection passes (16 tests), and all 379 Runner library tests pass. The broader database-backed bridge suite was attempted but its local PostgreSQL setup timed out; 29 tests did not execute. This local failure is retained, not claimed as passing. - Runtime acceptance before the documentation clarification, on `bd4fccae001157beb5bc76ff17c1920a5db3105a`: standard Greptile 5/5, then Apex 5/5 with no unresolved threads; all 60 checks complete successfully or intentionally skip. Ten CI jobs were interrupted by the same runner shutdown, including one stale job that required cancellation before retry; the same-commit retry passed. Hosted Rust tests also pass, including the native suite with the local baseline timeout noted above. - `git diff --check`, map validation, and pre-push secret/PII scan passed. ## Risks - No live model account or deployment isolation qualification was run. The loopback fixture proves the actual vendor-client request format, not model interpretation. - Stdio MCP support uses the current host-assignment contract: each binding requires a verified executable and a private Unix socket identifying the serialized resource. Other platforms retain the HTTP path. No provider version was upgraded. - A lost acknowledgement blocks subsequent calls until the host restarts the assigned resource. Direct shell calls are outside this MCP fence and retain the existing shell policy. - Preconfigured Claude remains unattended and denies interactive approval requests. Host assignment authorizes native Codex local tools; it must be supplied only for sessions already authorized to use the resource. ## Model Used OpenAI Codex, GPT-6, with reasoning and repository/tool execution. The session does not expose a more specific model identifier or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- maps/agent-access-map/gateways-profiles.md | 24 + packages/paperclip-runner/README.md | 55 ++ .../runner/crates/runner-core/Cargo.toml | 4 + .../runner-core/src/acpx_sidecar_transport.rs | 2 + .../runner-core/src/bin/fake-stdio-mcp.rs | 52 ++ .../runner-core/src/bin/paperclip-runnerd.rs | 6 + .../crates/runner-core/src/codex_provider.rs | 75 ++- .../runner-core/src/configured_environment.rs | 2 + .../runner/crates/runner-core/src/lib.rs | 1 + .../runner-core/src/process_supervisor.rs | 16 + .../runner-core/src/provider_backend.rs | 71 ++- .../crates/runner-core/src/stdio_mcp.rs | 587 ++++++++++++++++++ .../crates/runner-core/tests/stdio_mcp.rs | 366 +++++++++++ .../src/configured-environment.ts | 1 + .../durable-prp-control-plane.test.ts | 4 + .../durable-prp-control-plane.ts | 2 + .../src/drivers/acpx/codex-runtime-adapter.ts | 7 +- .../src/drivers/acpx/runtime-host.test.ts | 23 + .../src/drivers/acpx/runtime-host.ts | 13 +- ...app-server-driver.runtime-requests.test.ts | 29 + .../drivers/codex/codex-harness-session.ts | 2 +- .../codex/codex-question-adapter.test.ts | 20 + .../drivers/codex/codex-question-adapter.ts | 15 +- .../codex/codex-session-server-requests.ts | 2 +- .../src/drivers/stdio-mcp.test.ts | 64 ++ .../paperclip-runner/src/drivers/stdio-mcp.ts | 67 ++ .../src/live/runnerd-codex-transport.ts | 12 +- .../native-permission-projection.test.ts | 10 +- 28 files changed, 1509 insertions(+), 23 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-stdio-mcp.rs create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs create mode 100644 packages/paperclip-runner/runner/crates/runner-core/tests/stdio_mcp.rs create mode 100644 packages/paperclip-runner/src/drivers/stdio-mcp.test.ts create mode 100644 packages/paperclip-runner/src/drivers/stdio-mcp.ts diff --git a/maps/agent-access-map/gateways-profiles.md b/maps/agent-access-map/gateways-profiles.md index 44a8d08bb69..5e37a01040f 100644 --- a/maps/agent-access-map/gateways-profiles.md +++ b/maps/agent-access-map/gateways-profiles.md @@ -145,3 +145,27 @@ own policy and access review. Gaps: Audit management API exists but broad audit visibility is not an agent use-path guarantee. + +### Execution-host stdio assignments + +Runner registers verified, session-local stdio servers from the execution host +with native Codex and through the shared ACPX runtime. ACPX clients must support +stdio MCP assignments. The transport uses each implementation's own schemas and +responses. Each binding requires a private Unix socket to identify the shared +resource whose calls are serialized. This is a separate binding alongside the +HTTP gateway; it does not create gateway/profile administration +authority. Tasks cannot supply these reserved environment inputs. Read-only +sessions omit them. +ACPX adapters use their applicable ACPX permission policy. Host-assigned native +Codex tools run without a new per-call prompt, matching existing execution-host shell +utilities. Explicit deny/prompt rules remain effective; requested approvals use +PRP with active thread/turn checks and one-time decisions. Preconfigured Claude +rejects interactive approval requests. See the [Runner contract](../../packages/paperclip-runner/README.md#assigned-local-mcp-servers). + +Local fixture coverage: [binding validation](../../packages/paperclip-runner/src/drivers/stdio-mcp.test.ts), +[relay processes and cancellation](../../packages/paperclip-runner/runner/crates/runner-core/tests/stdio_mcp.rs), +and [approval projection](../../packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts). +Provider-client image and approval smoke tests used native Codex and Claude SDK; +that coverage is not an exhaustive adapter compatibility test. +Vendor-client evidence belongs in the PR. These checks do not +certify live external credentials, deployment isolation or gateway revocation. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 60beaad2f5f..688a6f9a526 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -785,3 +785,58 @@ The external provider history must still exist to resume a session after a remot machine is replaced. Missing history fails recovery rather than importing an isolated home. OpenCode and the remaining ACPX profiles require separate qualification before this mode can be enabled for them. + +## Assigned local MCP servers + +An execution host can assign local stdio MCP servers alongside the existing +`PAPERCLIP_NATIVE_MCP_NAME` / `URL` / `TOKEN` HTTP gateway. This is general transport +support for local MCP implementations that satisfy the executable-verification +and private Unix socket requirements below. Runner uses each server's own tool +schemas and responses. Set +`PAPERCLIP_NATIVE_MCP_STDIO` to a JSON array of bindings with these fields: + +```json +[{"name":"local_tools","command":"/opt/local-tools/bin/mcp-server","args":[],"env":{"HOME":"/home/agent"},"sha256":"sha256:<64 lowercase hex digits>","serialResource":"/home/agent/.local/state/local-tools/runtime/service.sock"}] +``` + +`serialResource` identifies the shared resource for cross-process call locking; +the MCP transport itself uses stdio. It must name an existing private Unix socket +with the ownership and path requirements below. + +Set `PAPERCLIP_NATIVE_MCP_PROXY` to the assigned Runner executable. These are +execution-host inputs, excluded from configurable task environment and provider +shell inheritance. Names use letters, digits, underscores or hyphens and cannot +replace the internal task server or gateway. Commands are verified against their +assigned digest before execution. The backend receives only its assigned environment. + +Runner supplies these session-local bindings to native Codex and through the +shared ACPX runtime. ACPX clients must support stdio MCP assignments. +User or project MCP files are never rewritten. Read-only sessions omit these +bindings. Host assignment authorizes native Codex to use these local tools without +a new per-call prompt, matching execution-host utilities available through shell. +ACPX adapters use the existing ACPX permission policy. Explicit native Codex tool +deny/prompt rules remain effective; requested approvals use the existing PRP +permission request, bound to the active thread and turn. Only one-time accept, decline or cancel +decisions are accepted. Shell escalation stays disabled. +Preconfigured Claude remains unattended and rejects approval requests. + +The relay forwards tool schemas and native MCP content. For native Codex +0.160/0.162, image-bearing results put structured metadata in a text block instead of +`structuredContent`, because those clients otherwise discard the image blocks. +ACPX adapters receive the original response unchanged. Tool schemas and actions +are forwarded without provider-specific translation. + +Provider-client smoke tests exercised native Codex 0.160.0 and Claude SDK +0.3.286 with local MCP servers and loopback model APIs. This records the clients +used to verify image delivery and approvals; it is not an exhaustive adapter +compatibility test. + +All `tools/call` requests for the same private Unix socket are serialized across +sessions and processes, including reads. Metadata requests stay responsive. +Cancelling a queued request prevents dispatch. An active request retains its fence +until the backend acknowledges it; client cancellation cannot prove daemon work +stopped. If a relay exits with an unacknowledged call, later calls fail closed until +the host restarts that resource with a new socket. Do not remove its lock marker +while daemon work might still be running. The socket and its parent must be private, +canonical paths owned by the same account. Local stdio assignment requires Unix; +other platforms retain the HTTP path. diff --git a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml index d175d14a254..08ded319dba 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml +++ b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml @@ -53,3 +53,7 @@ path = "src/bin/fake-codex-app-server.rs" [[bin]] name = "fake-acpx-sidecar" path = "src/bin/fake-acpx-sidecar.rs" + +[[bin]] +name = "fake-stdio-mcp" +path = "src/bin/fake-stdio-mcp.rs" diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index e576191d7c5..830ef41cfd7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -244,6 +244,8 @@ impl AcpxSidecarTransport { // The qualified sidecar configures the runner-owned gateway. Keep // its credential with the name/URL; unrelated secrets stay excluded. "PAPERCLIP_NATIVE_MCP_TOKEN", + "PAPERCLIP_NATIVE_MCP_STDIO", + "PAPERCLIP_NATIVE_MCP_PROXY", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-stdio-mcp.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-stdio-mcp.rs new file mode 100644 index 00000000000..371531a51d5 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-stdio-mcp.rs @@ -0,0 +1,52 @@ +use serde_json::{json, Value}; +use std::fs::OpenOptions; +use std::io::{self, BufRead, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +fn main() { + let output = Arc::new(Mutex::new(io::stdout())); + let mut workers = vec![]; + for line in io::stdin().lock().lines() { + let Ok(line) = line else { + break; + }; + let message: Value = serde_json::from_str(&line).unwrap(); + let Some(id) = message.get("id").cloned() else { + continue; + }; + let output = output.clone(); + workers.push(thread::spawn(move || { + let mut result = match message["method"].as_str().unwrap_or("") { + "initialize" => json!({"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}}), + "tools/list" => json!({"tools":[{"name":"opaque","inputSchema":{"type":"object","properties":{"original":{"type":"string"}}}}]}), + "ping" => { + if message.pointer("/params/padding") == Some(&json!(true)) { + std::fs::write(std::env::var("FIXTURE_PING_READY").unwrap(), "ready").unwrap(); + json!({"padding":"x".repeat(1024*1024)}) + } else { json!({"secretLeaked":std::env::var_os("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET").is_some()}) } + }, + "tools/call" => { + let log = std::env::var("FIXTURE_LOG").unwrap(); + let mut file = OpenOptions::new().create(true).append(true).open(log).unwrap(); + writeln!(file, "begin {id}").unwrap(); + if message.pointer("/params/arguments/block") == Some(&json!(true)) { + let gate = std::env::var("FIXTURE_GATE").unwrap(); + while !std::path::Path::new(&gate).exists() { thread::sleep(Duration::from_millis(10)); } + } + writeln!(file, "end {id}").unwrap(); + json!({"content":[{"type":"text","text":"unchanged"},{"type":"image","mimeType":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="}],"structuredContent":{"original":message["params"]["arguments"]}}) + } + _ => json!({}), + }; + if std::env::var_os("FIXTURE_NO_STRUCTURED").is_some() { result.as_object_mut().unwrap().remove("structuredContent"); } + let mut output = output.lock().unwrap(); + writeln!(output, "{}", json!({"jsonrpc":"2.0","id":id,"result":result})).unwrap(); + output.flush().unwrap(); + })); + } + for worker in workers { + let _ = worker.join(); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 046d3027b13..3cc034df5db 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -349,6 +349,12 @@ fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> { } fn run(args: &[String]) -> Result<(), LocalRunnerError> { + if args.first().map(String::as_str) == Some("--stdio-mcp") + && (args.len() == 2 || (args.len() == 3 && args[2] == "--content-images")) + { + return paperclip_runner_core::stdio_mcp::run_proxy(Path::new(&args[1]), args.len() == 3) + .map_err(|e| LocalRunnerError::invalid(e.to_string())); + } if args == ["--build-metadata"] { println!("{}", build_metadata()); return Ok(()); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index b82e547bff0..67e4ae38399 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -542,6 +542,10 @@ pub enum CodexProviderEvent { request_id: String, question_set: Value, }, + McpPermissionRequest { + request_id: String, + params: Value, + }, Exited { exit_code: Option, success: bool, @@ -665,6 +669,7 @@ enum AmbiguousTurnMessage { pub struct CodexProvider { process: SupervisedProcess, + _stdio_mcp: Option, stderr_tail: BoundedLogBuffer, config: CodexProviderConfig, authorized_tools: Vec, @@ -955,6 +960,25 @@ impl CodexProvider { .chain(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.iter().copied()) .collect::>(); let runtime_request_scope = new_runtime_request_scope()?; + let stdio_mcp = if config.provider == "codex" && !config.read_only { + crate::stdio_mcp::StdioMcpSession::create() + .map_err(|e| LocalRunnerError::invalid(e.to_string()))? + } else { + None + }; + let mut launch_args = config.args.clone(); + if let Some(session) = &stdio_mcp { + let position = launch_args + .iter() + .position(|arg| arg == "app-server") + .unwrap_or(launch_args.len()); + launch_args.splice( + position..position, + session + .codex_args() + .map_err(|e| LocalRunnerError::invalid(e.to_string()))?, + ); + } let spawn = (|| { if config.provider == "opencode" { let profile = opencode_launch_profile.ok_or_else(|| { @@ -980,7 +1004,7 @@ impl CodexProvider { } else { SupervisedProcess::spawn_in_directory_with_environment_keys( &config.command, - &config.args, + &launch_args, Duration::from_secs(2), CODEX_APP_SERVER_MAX_FRAME_BYTES, &environment_keys, @@ -1011,6 +1035,7 @@ impl CodexProvider { } let mut provider = Self { process, + _stdio_mcp: stdio_mcp, stderr_tail: BoundedLogBuffer::new( MAX_PROVIDER_STDERR_LINES, MAX_PROVIDER_STDERR_BYTES, @@ -1079,7 +1104,7 @@ impl CodexProvider { let mut params = json!({ "cwd": config.cwd, "model": config.model, - "approvalPolicy": config.approval_policy, + "approvalPolicy": if provider._stdio_mcp.is_some() { crate::stdio_mcp::codex_approval_policy() } else { json!(config.approval_policy) }, "runtimeWorkspaceRoots": [config.cwd], "dynamicTools": dynamic_tools, }); @@ -1372,7 +1397,8 @@ impl CodexProvider { )); } Some(CodexProviderEvent::ToolCall { .. }) - | Some(CodexProviderEvent::RuntimeRequest { .. }) => { + | Some(CodexProviderEvent::RuntimeRequest { .. }) + | Some(CodexProviderEvent::McpPermissionRequest { .. }) => { return Err(LocalRunnerError::invalid( "Codex warm run attachment observed a post-terminal provider request", )); @@ -2082,7 +2108,11 @@ impl CodexProvider { "runtime response belongs to another Codex turn", )); } - let result = codex_question_response(&pending, response)?; + let result = if pending.method == "mcpServer/elicitation/request" { + crate::stdio_mcp::tool_approval_response(response)? + } else { + codex_question_response(&pending, response)? + }; self.process .send(&json!({"id": pending.rpc_id, "result": result}))?; if let Some(completed) = self.pending_runtime_requests.remove(request_id) { @@ -2540,11 +2570,18 @@ impl CodexProvider { if matches!( method, "item/tool/requestUserInput" | OPENCODE_RUNTIME_REQUEST_METHOD - ) { + ) || (method == "mcpServer/elicitation/request" + && message + .pointer("/params/_meta/codex_approval_kind") + .and_then(Value::as_str) + == Some("mcp_tool_call")) + { let params = message.get("params").cloned().unwrap_or(Value::Null); - if method == "item/tool/requestUserInput" - && params.get("threadId").and_then(Value::as_str) - != Some(self.thread_id.as_str()) + if matches!( + method, + "item/tool/requestUserInput" | "mcpServer/elicitation/request" + ) && params.get("threadId").and_then(Value::as_str) + != Some(self.thread_id.as_str()) { if params .get("threadId") @@ -2579,6 +2616,20 @@ impl CodexProvider { let (provider_request_id, question_set, option_labels) = if method == OPENCODE_RUNTIME_REQUEST_METHOD { opencode_question_set(¶ms)? + } else if method == "mcpServer/elicitation/request" { + let servers = self + ._stdio_mcp + .as_ref() + .map(|session| { + session + .entries + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + }) + .unwrap_or_default(); + crate::stdio_mcp::validate_tool_approval(¶ms, &servers)?; + (rpc_id.to_string(), Value::Null, BTreeMap::new()) } else { codex_question_set(&rpc_id, ¶ms)? }; @@ -2590,6 +2641,8 @@ impl CodexProvider { &question_set, &option_labels, )?; + let mcp_params = + (method == "mcpServer/elicitation/request").then(|| params.clone()); let pending = PendingRuntimeRequest { rpc_id: rpc_id.clone(), turn_id: active_turn_id.clone(), @@ -2650,6 +2703,12 @@ impl CodexProvider { .insert(request_id.clone(), pending); self.pending_runtime_request_bytes = retained_request_bytes.expect("bounded runtime request bytes checked above"); + if let Some(params) = mcp_params { + return Ok(Some(CodexProviderEvent::McpPermissionRequest { + request_id, + params, + })); + } return Ok(Some(CodexProviderEvent::RuntimeRequest { request_id, question_set, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs index 27c0c24b004..2fed99a63e0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs @@ -55,6 +55,8 @@ const RESERVED: &[&str] = &[ "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_NATIVE_MCP_URL", + "PAPERCLIP_NATIVE_MCP_STDIO", + "PAPERCLIP_NATIVE_MCP_PROXY", "PAPERCLIP_NORMALIZED_SESSION_ID", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", "PAPERCLIP_PROVIDER_TRACE_PATH", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 6d0ad4e0ec8..19e144ed671 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod qualified_launch; pub mod question_response; pub mod replay; mod stable_identity; +pub mod stdio_mcp; use std::error::Error; use std::fmt::{self, Display, Formatter}; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index 372c96ea323..daab708003d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -454,6 +454,22 @@ impl VerifiedProcessLaunch { } } + pub(crate) fn spawn_stdio( + &self, + environment: &std::collections::BTreeMap, + ) -> Result { + let inherited = self.inherited_command()?; + Command::new(&inherited.program) + .args(&inherited.args) + .env_clear() + .envs(environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|_| LocalRunnerError::invalid("failed to spawn verified stdio MCP server")) + } + pub fn with_inherited_runtime_executable(mut self) -> Self { self.inherit_runtime_executable = true; self diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index e6c2c977dbe..4d4c5074fc0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -326,6 +326,25 @@ fn authorized_tool_set(payload: &Value) -> Result NormalizedProviderEvent { + NormalizedProviderEvent { + event_type: "runtime_request.created".to_owned(), + priority: EventPriority::P0, + payload: json!({"request":{ + "schema":"paperclip.runtime_request.v2", "requestKind":"permission_approval", "type":"permission", + "requestId":request_id, "status":"pending", "turnId":identity.turn_id, "itemId":identity.item_id, + "prompt":params["message"], + "choices":[{"key":"accept","label":"Allow once"},{"key":"decline","label":"Deny"},{"key":"cancel","label":"Cancel"}], + "details":params, + "origin":{"adapter":"codex-app-server","provider":"codex","method":"mcpServer/elicitation/request"} + }}), + } +} + fn semantic_correlation(identity: &ProviderEventIdentity) -> Value { json!({ "runId": identity.run_id, @@ -3453,9 +3472,25 @@ impl CodexCommandExecutor { "cannot resolve a {provider_name} runtime request outside an active turn" ))); } + if payload + .get("turnId") + .and_then(Value::as_str) + .is_some_and(|turn| { + self.event_identity + .as_ref() + .is_none_or(|identity| turn != identity.turn_id) + }) + { + return Err(DurableRunnerError::invalid( + "Codex runtime response belongs to a stale turn", + )); + } let response = payload .get("response") - .ok_or_else(|| DurableRunnerError::invalid("request.resolve requires response"))?; + .or_else(|| payload.get("resolution")) + .ok_or_else(|| { + DurableRunnerError::invalid("request.resolve requires response or resolution") + })?; self.ensure_provider()? .resolve_runtime_request(request_id, response) .map_err(|error| { @@ -4377,6 +4412,20 @@ impl CodexCommandExecutor { ); } } + CodexProviderEvent::McpPermissionRequest { request_id, params } => { + let identity = self.event_identity()?; + let state = self + .state + .as_mut() + .expect("Codex state remains available while polling"); + let event = mcp_permission_event(&identity, request_id, params); + if receipt_limit_terminal_poll { + state.push_receipt_limit_cleanup_event(event)?; + } else { + state.push_event(event)?; + } + self.save_state()?; + } CodexProviderEvent::RuntimeRequest { request_id, question_set, @@ -4669,6 +4718,26 @@ impl CommandExecutor for CodexCommandExecutor { #[cfg(test)] mod tests { + #[test] + fn mcp_permission_requests_use_durable_turn_and_item_identity() { + let identity = ProviderEventIdentity { + runner_instance_id: "runner".into(), + run_id: "run".into(), + normalized_session_id: "session".into(), + turn_id: "durable-turn".into(), + item_id: "durable-item".into(), + }; + let params = json!({"threadId":"provider-thread", "turnId":"provider-turn", + "itemId":"provider-item", "message":"Allow tool?", "serverName":"assigned"}); + let event = mcp_permission_event(&identity, "request-1".into(), params.clone()); + let request = &event.payload["request"]; + assert_eq!(event.event_type, "runtime_request.created"); + assert_eq!(request["requestKind"], "permission_approval"); + assert_eq!(request["turnId"], identity.turn_id); + assert_eq!(request["itemId"], identity.item_id); + assert_eq!(request["details"], params); + } + #[test] fn startup_evidence_save_failure_cannot_become_an_empty_drain_or_admission() { let directory = std::env::temp_dir().join(format!( diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs b/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs new file mode 100644 index 00000000000..7f580ea20a3 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs @@ -0,0 +1,587 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write}; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::durable::QualifiedLaunchArtifact; +use crate::local_runner::LocalRunnerError; +use crate::process_supervisor::{VerifiedProcessArgument, VerifiedProcessLaunch}; +use crate::qualified_launch::verify_executable_launch_artifact; + +pub const BINDINGS_ENV: &str = "PAPERCLIP_NATIVE_MCP_STDIO"; +pub const PROXY_ENV: &str = "PAPERCLIP_NATIVE_MCP_PROXY"; +const MAX_FRAME: usize = 64 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StdioMcpBinding { + pub name: String, + pub command: PathBuf, + pub args: Vec, + pub env: BTreeMap, + pub sha256: String, + pub serial_resource: PathBuf, +} + +type McpResult = Result>; + +fn invalid(message: impl Into) -> Box { + LocalRunnerError::invalid(message).into() +} + +#[cfg(unix)] +impl StdioMcpBinding { + pub fn validate(&self) -> McpResult<()> { + if self.name.is_empty() + || self.name.len() > 128 + || self.name == "paperclip" + || self.name.starts_with("paperclip_runner_") + || !self + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) + || !self.command.is_absolute() + || !self.serial_resource.is_absolute() + || self.args.len() > 64 + || self.args.iter().any(|s| s.len() > 4096 || s.contains('\0')) + || self.env.len() > 64 + || self.env.iter().any(|(k, v)| { + k.is_empty() + || !k.bytes().enumerate().all(|(i, b)| { + b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()) + }) + || k.starts_with("LD_") + || k.starts_with("DYLD_") + || ["NODE_OPTIONS", "NODE_PATH", "BASH_ENV", "ENV"].contains(&k.as_str()) + || v.contains('\0') + }) + { + return Err(invalid("invalid assigned stdio MCP binding")); + } + verify_executable_launch_artifact( + &QualifiedLaunchArtifact { + path: self.command.clone(), + sha256: self.sha256.clone(), + }, + "stdio MCP executable", + )?; + Ok(()) + } +} + +#[cfg(unix)] +pub fn assigned_bindings() -> McpResult> { + let Some(raw) = std::env::var_os(BINDINGS_ENV) else { + return Ok(vec![]); + }; + let raw = raw + .to_str() + .ok_or_else(|| invalid("invalid stdio MCP environment"))?; + if raw.len() > 64 * 1024 { + return Err(invalid("oversized stdio MCP binding")); + } + let bindings: Vec = + serde_json::from_str(raw).map_err(|_| invalid("invalid stdio MCP binding JSON"))?; + if bindings.len() > 8 { + return Err(invalid("too many stdio MCP bindings")); + } + let mut names = BTreeSet::new(); + if let Ok(gateway) = std::env::var("PAPERCLIP_NATIVE_MCP_NAME") { + names.insert(gateway); + } + for binding in &bindings { + binding.validate()?; + if !names.insert(binding.name.clone()) { + return Err(invalid("duplicate assigned MCP name")); + } + } + Ok(bindings) +} + +pub struct StdioMcpSession { + directory: PathBuf, + pub entries: Vec<(String, PathBuf)>, +} +impl StdioMcpSession { + #[cfg(unix)] + pub fn create() -> McpResult> { + let bindings = assigned_bindings()?; + if bindings.is_empty() { + return Ok(None); + } + let directory = + std::env::temp_dir().join(format!("paperclip-mcp-{}", uuid::Uuid::new_v4())); + fs::DirBuilder::new().mode(0o700).create(&directory)?; + let mut session = Self { + directory, + entries: vec![], + }; + for (index, binding) in bindings.iter().enumerate() { + let path = session.directory.join(format!("{index}.json")); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + serde_json::to_writer(&mut file, binding) + .map_err(|_| invalid("cannot write stdio MCP binding"))?; + session.entries.push((binding.name.clone(), path)); + } + Ok(Some(session)) + } + #[cfg(not(unix))] + pub fn create() -> McpResult> { + if std::env::var_os(BINDINGS_ENV).is_some() { + return Err(invalid("assigned stdio MCP requires a Unix desktop socket")); + } + Ok(None) + } + pub fn codex_args(&self) -> McpResult> { + let command = std::env::var(PROXY_ENV) + .map_err(|_| invalid("stdio MCP proxy executable is unassigned"))?; + if !Path::new(&command).is_absolute() { + return Err(invalid("stdio MCP proxy command must be absolute")); + } + let mut args = vec![]; + for (name, path) in &self.entries { + let prefix = format!("mcp_servers.{name}"); + let command = serde_json::to_string(&command).unwrap(); + let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); + for value in [ + format!("{prefix}.command={command}"), + format!("{prefix}.args=[\"--stdio-mcp\",{path},\"--content-images\"]"), + format!("{prefix}.env={{}}"), + format!("{prefix}.env_vars=[]"), + format!("{prefix}.default_tools_approval_mode=\"approve\""), + format!("{prefix}.required=true"), + ] { + args.extend(["-c".into(), value]); + } + } + Ok(args) + } +} +impl Drop for StdioMcpSession { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +#[cfg(unix)] +fn resource_lock(resource: &Path) -> McpResult<(File, (u64, u64))> { + let metadata = fs::symlink_metadata(resource)?; + let parent = resource + .parent() + .ok_or_else(|| invalid("missing MCP socket parent"))?; + let parent_metadata = fs::symlink_metadata(parent)?; + if !metadata.file_type().is_socket() + || metadata.mode() & 0o077 != 0 + || !parent_metadata.is_dir() + || parent_metadata.file_type().is_symlink() + || parent_metadata.mode() & 0o077 != 0 + || metadata.uid() != parent_metadata.uid() + || fs::canonicalize(resource)? != resource + { + return Err(invalid( + "stdio MCP resource must be a private canonical socket", + )); + } + let identity = format!( + "{}:{}:{}", + resource.display(), + metadata.dev(), + metadata.ino() + ); + let digest = format!("{:x}", Sha256::digest(identity)); + let path = parent.join(format!(".paperclip-mcp-{digest}.lock")); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&path)?; + if fs::symlink_metadata(&path)?.file_type().is_symlink() || file.metadata()?.mode() & 0o077 != 0 + { + return Err(invalid("invalid stdio MCP lock")); + } + Ok((file, (metadata.dev(), metadata.ino()))) +} + +enum Event { + Client(Vec), + Server(Vec), + ClientClosed, + ServerClosed, +} +fn reader(input: R, tx: mpsc::SyncSender, client: bool) { + thread::spawn(move || { + let mut input = BufReader::new(input); + loop { + let mut line = vec![]; + let result = input + .by_ref() + .take(MAX_FRAME as u64 + 1) + .read_until(b'\n', &mut line); + match result { + Ok(0) | Err(_) => break, + Ok(_) if line.len() <= MAX_FRAME && line.last() == Some(&b'\n') => { + if tx + .send(if client { + Event::Client(line) + } else { + Event::Server(line) + }) + .is_err() + { + return; + } + } + _ => break, + } + } + let _ = tx.send(if client { + Event::ClientClosed + } else { + Event::ServerClosed + }); + }); +} + +#[cfg(unix)] +pub fn run_proxy(path: &Path, content_images: bool) -> McpResult<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.mode() & 0o077 != 0 + || metadata.len() > 64 * 1024 + { + return Err(invalid("invalid private stdio MCP configuration")); + } + let binding: StdioMcpBinding = serde_json::from_slice(&fs::read(path)?) + .map_err(|_| invalid("invalid stdio MCP configuration"))?; + binding.validate()?; + let (mut lock, resource_identity) = resource_lock(&binding.serial_resource)?; + let program = verify_executable_launch_artifact( + &QualifiedLaunchArtifact { + path: binding.command.clone(), + sha256: binding.sha256.clone(), + }, + "stdio MCP executable", + )?; + let launch = VerifiedProcessLaunch::new( + program, + binding + .args + .iter() + .cloned() + .map(VerifiedProcessArgument::Literal) + .collect(), + ); + let mut child = launch.spawn_stdio(&binding.env)?; + let result: McpResult<()> = (|| { + let mut input = child.stdin.take().unwrap(); + let (tx, rx) = mpsc::sync_channel(2); + reader(io::stdin(), tx.clone(), true); + reader(child.stdout.take().unwrap(), tx, false); + let mut output = io::stdout().lock(); + let mut queue: VecDeque<(Value, Vec)> = VecDeque::new(); + let mut active: Option = None; + let mut closing = false; + let mut ids = BTreeSet::new(); + let mut deferred = None; + loop { + let ready_event = deferred.take().or_else(|| rx.try_recv().ok()); + if ready_event.is_none() && active.is_none() && !queue.is_empty() { + let current = fs::symlink_metadata(&binding.serial_resource)?; + if (current.dev(), current.ino()) != resource_identity + || current.mode() & 0o077 != 0 + { + return Err(invalid("assigned MCP socket changed; reopen the session")); + } + match lock.try_lock() { + Ok(()) => { + if let Ok(event) = rx.try_recv() { + lock.unlock()?; + deferred = Some(event); + continue; + } + if lock.metadata()?.len() != 0 { + return Err(invalid("previous MCP operation has unknown outcome; restart the assigned resource before retrying")); + } + let (id, line) = queue.pop_front().unwrap(); + lock.seek(SeekFrom::Start(0))?; + lock.write_all(b"pending")?; + lock.sync_data()?; + active = Some(id); + input.write_all(&line)?; + input.flush()?; + } + Err(fs::TryLockError::WouldBlock) => {} + Err(fs::TryLockError::Error(error)) => return Err(error.into()), + } + } + if closing && active.is_none() { + return Ok(()); + } + let event = match ready_event { + Some(event) => event, + None => match rx.recv_timeout(Duration::from_millis(20)) { + Ok(event) => event, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(_) => return Err(invalid("stdio MCP transport disconnected")), + }, + }; + match event { + Event::Client(line) => { + let message: Value = + serde_json::from_slice(&line).map_err(|_| invalid("invalid MCP JSON"))?; + if message.get("method").is_some() { + if let Some(id) = message.get("id") { + if id.to_string().len() > 256 + || ids.len() >= 128 + || !ids.insert(id.to_string()) + { + return Err(invalid("duplicate pending MCP request id")); + } + } + } + if message["method"] == "notifications/cancelled" { + let cancelled = &message["params"]["requestId"]; + if let Some(index) = queue.iter().position(|(id, _)| id == cancelled) { + let (id, _) = queue.remove(index).unwrap(); + ids.remove(&id.to_string()); + writeln!( + output, + "{}", + serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":-32800,"message":"MCP call cancelled before dispatch"}}) + )?; + output.flush()?; + continue; + } + if active.as_ref() == Some(cancelled) { + continue; + } + } + if message["method"] == "tools/call" { + let id = message + .get("id") + .filter(|id| id.is_string() || id.is_number()) + .ok_or_else(|| invalid("MCP tool call requires an id"))?; + if queue.len() >= 64 + || queue.iter().map(|(_, line)| line.len()).sum::() + line.len() + > 8 * 1024 * 1024 + { + return Err(invalid("stdio MCP call queue is full")); + } + queue.push_back((id.clone(), line)); + } else { + input.write_all(&line)?; + input.flush()?; + } + } + Event::Server(mut line) => { + let mut message: Value = serde_json::from_slice(&line) + .map_err(|_| invalid("invalid MCP server JSON"))?; + if active + .as_ref() + .is_some_and(|id| message.get("id") == Some(id)) + && (message.get("result").is_some() || message.get("error").is_some()) + { + lock.set_len(0)?; + lock.sync_data()?; + lock.unlock()?; + active = None; + } + if message.get("result").is_some() || message.get("error").is_some() { + if let Some(id) = message.get("id") { + ids.remove(&id.to_string()); + } + } + if content_images { + if let Some(result) = + message.get_mut("result").and_then(Value::as_object_mut) + { + let has_image = + result.get("content").and_then(Value::as_array).is_some_and( + |content| content.iter().any(|item| item["type"] == "image"), + ); + if has_image { + if let Some(structured) = result.remove("structuredContent") { + let content = result + .get_mut("content") + .and_then(Value::as_array_mut) + .unwrap(); + let already_present = content.iter().any(|item| { + item["type"] == "text" + && item["text"] + .as_str() + .and_then(|text| { + serde_json::from_str::(text).ok() + }) + .as_ref() + == Some(&structured) + }); + if !already_present { + content.push(serde_json::json!({"type":"text","text":structured.to_string()})); + } + line = serde_json::to_vec(&message)?; + line.push(b'\n'); + } + } + } + } + output.write_all(&line)?; + output.flush()?; + } + Event::ClientClosed => { + closing = true; + queue.clear(); + } + Event::ServerClosed => return Err(invalid("stdio MCP server exited")), + } + } + })(); + for _ in 0..100 { + if child.try_wait()?.is_some() { + return result; + } + thread::sleep(Duration::from_millis(10)); + } + let _ = child.kill(); + let _ = child.wait(); + result +} + +pub(crate) fn validate_tool_approval( + params: &Value, + assigned_servers: &[&str], +) -> Result<(), LocalRunnerError> { + let server = params + .get("serverName") + .and_then(Value::as_str) + .unwrap_or(""); + if !assigned_servers.contains(&server) + || params.get("mode").and_then(Value::as_str) != Some("form") + || params + .pointer("/_meta/codex_approval_kind") + .and_then(Value::as_str) + != Some("mcp_tool_call") + || params.get("requestedSchema") + != Some(&serde_json::json!({"type":"object","properties":{}})) + || params + .get("message") + .and_then(Value::as_str) + .is_none_or(|s| s.is_empty() || s.chars().count() > 4000) + || serde_json::to_vec(params) + .map_err(|_| LocalRunnerError::invalid("invalid MCP approval"))? + .len() + > 64 * 1024 + { + return Err(LocalRunnerError::invalid( + "MCP approval is not bound to an assigned stdio server", + )); + } + Ok(()) +} + +pub(crate) fn tool_approval_response(resolution: &Value) -> Result { + if resolution.as_object().is_none_or(|v| v.len() != 1) + || !matches!( + resolution.get("action").and_then(Value::as_str), + Some("accept" | "decline" | "cancel") + ) + { + return Err(LocalRunnerError::invalid( + "MCP approval requires an explicit one-time accept, decline or cancel", + )); + } + Ok(serde_json::json!({"action":resolution["action"],"content":null,"_meta":null})) +} + +#[cfg(test)] +mod approval_tests { + use super::*; + use serde_json::json; + #[test] + #[cfg(unix)] + fn resource_lock_retains_the_socket_identity_it_locked() { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + let root = + std::env::temp_dir().join(format!("lock-{}", &uuid::Uuid::new_v4().to_string()[..8])); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + let root = fs::canonicalize(root).unwrap(); + let socket = root.join("resource.sock"); + let original = UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + let before = fs::symlink_metadata(&socket).unwrap(); + let (old_lock, old_identity) = resource_lock(&socket).unwrap(); + fs::remove_file(&socket).unwrap(); + let replacement = UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + let (new_lock, new_identity) = resource_lock(&socket).unwrap(); + assert_eq!(old_identity, (before.dev(), before.ino())); + assert_ne!(old_identity, new_identity); + old_lock.lock().unwrap(); + new_lock.try_lock().unwrap(); + drop((old_lock, new_lock, original, replacement)); + fs::remove_dir_all(root).unwrap(); + } + fn request() -> Value { + json!({"serverName":"assigned", "mode":"form", "message":"Allow input?", "_meta":{"codex_approval_kind":"mcp_tool_call"},"requestedSchema":{"type":"object","properties":{}}}) + } + #[test] + fn approval_metadata_cannot_widen_server_authority() { + assert!(validate_tool_approval(&request(), &["assigned"]).is_ok()); + assert!(validate_tool_approval(&request(), &["other"]).is_err()); + for (field, value) in [ + ("mode", json!("url")), + ( + "requestedSchema", + json!({"type":"object","properties":{"password":{"type":"string"}}}), + ), + ("_meta", json!({})), + ("message", json!("")), + ] { + let mut request = request(); + request[field] = value; + assert!(validate_tool_approval(&request, &["assigned"]).is_err()); + } + } + #[test] + fn approval_requires_a_one_time_explicit_decision() { + for action in ["accept", "decline", "cancel"] { + assert_eq!( + tool_approval_response(&json!({"action":action})).unwrap(), + json!({"action":action,"content":null,"_meta":null}) + ); + } + for invalid in [ + json!({}), + json!({"action":"accept_for_session"}), + json!({"action":"submit","content":{}}), + json!({"action":"accept","permissions":{"all":true}}), + ] { + assert!(tool_approval_response(&invalid).is_err()); + } + } +} + +pub(crate) fn codex_approval_policy() -> Value { + serde_json::json!({"granular":{"sandbox_approval":false,"rules":false,"mcp_elicitations":true,"request_permissions":false,"skill_approval":false}}) +} + +#[cfg(not(unix))] +pub fn run_proxy(_path: &Path, _content_images: bool) -> McpResult<()> { + Err(invalid("assigned stdio MCP requires a Unix desktop socket")) +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/stdio_mcp.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/stdio_mcp.rs new file mode 100644 index 00000000000..dbb0e8f99cd --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/stdio_mcp.rs @@ -0,0 +1,366 @@ +#![cfg(unix)] +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, +}; +use std::thread; +use std::time::{Duration, Instant}; + +struct Fixture { + root: PathBuf, + _socket: UnixListener, +} +impl Fixture { + fn new() -> Self { + let root = + std::env::temp_dir().join(format!("m{}", &uuid::Uuid::new_v4().to_string()[..8])); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + let root = fs::canonicalize(root).unwrap(); + let socket = UnixListener::bind(root.join("resource.sock")).unwrap(); + fs::set_permissions( + root.join("resource.sock"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + Self { + root, + _socket: socket, + } + } + fn config(&self) -> Value { + let command = fs::canonicalize(env!("CARGO_BIN_EXE_fake-stdio-mcp")).unwrap(); + let sha = format!("sha256:{:x}", Sha256::digest(fs::read(&command).unwrap())); + json!({"name":"fixture","command":command,"args":[],"env":{ + "FIXTURE_LOG":self.root.join("calls"),"FIXTURE_GATE":self.root.join("release"),"FIXTURE_PING_READY":self.root.join("ping-ready") + },"sha256":sha,"serialResource":self.root.join("resource.sock")}) + } + fn spawn(&self, config: Value) -> Peer { + self.spawn_options(config, false) + } + fn spawn_options(&self, config: Value, content_images: bool) -> Peer { + self.spawn_output_gate(config, content_images, Arc::new(AtomicBool::new(true))) + } + fn spawn_output_gate( + &self, + config: Value, + content_images: bool, + gate: Arc, + ) -> Peer { + let path = self.root.join(format!("{}.json", uuid::Uuid::new_v4())); + fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_paperclip-runnerd")); + command.args(["--stdio-mcp", path.to_str().unwrap()]); + if content_images { + command.arg("--content-images"); + } + let mut child = command + .env("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET", "must-not-leak") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let (tx, rx) = mpsc::channel(); + let stdout = child.stdout.take().unwrap(); + thread::spawn(move || { + while !gate.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(5)); + } + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { + break; + }; + if tx.send(serde_json::from_str(&line).unwrap()).is_err() { + break; + } + } + }); + Peer { child, rx } + } + fn calls(&self) -> String { + fs::read_to_string(self.root.join("calls")).unwrap_or_default() + } + fn wait_begin(&self) { + let deadline = Instant::now() + Duration::from_secs(5); + while !self.calls().contains("begin") { + assert!(Instant::now() < deadline, "peer never received call"); + thread::sleep(Duration::from_millis(10)); + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} +struct Peer { + child: Child, + rx: mpsc::Receiver, +} +impl Peer { + fn send(&mut self, id: u64, method: &str, args: Value) { + writeln!( + self.child.stdin.as_mut().unwrap(), + "{}", + json!({"jsonrpc":"2.0","id":id,"method":method,"params":args}) + ) + .unwrap(); + } + fn wait_exit(&mut self) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + return status; + } + assert!( + Instant::now() < deadline, + "relay did not reject invalid operation" + ); + thread::sleep(Duration::from_millis(10)); + } + } + fn response(&self, id: u64) -> Value { + let value = self + .rx + .recv_timeout(Duration::from_secs(5)) + .expect("MCP response timeout"); + assert_eq!(value["id"], id, "unexpected response: {value}"); + value["result"].clone() + } +} +impl Drop for Peer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn preserves_native_schemas_images_and_assigned_environment() { + let fixture = Fixture::new(); + let mut peer = fixture.spawn(fixture.config()); + peer.send(1, "tools/list", json!({})); + assert_eq!( + peer.response(1)["tools"][0]["inputSchema"], + json!({"type":"object","properties":{"original":{"type":"string"}}}) + ); + peer.send(2, "ping", json!({})); + assert_eq!(peer.response(2)["secretLeaked"], false); + peer.send( + 3, + "tools/call", + json!({"name":"opaque","arguments":{"original":"literal $HOME `echo nope` ✓"}}), + ); + let response = peer.response(3); + assert_eq!( + response["content"][0], + json!({"type":"text","text":"unchanged"}) + ); + assert_eq!(response["content"][1]["type"], "image"); + assert_eq!(response["content"][1]["mimeType"], "image/png"); + assert!(response["content"][1]["data"] + .as_str() + .unwrap() + .starts_with("iVBOR")); + assert_eq!( + response["structuredContent"]["original"]["original"], + "literal $HOME `echo nope` ✓" + ); +} + +#[test] +fn serializes_calls_across_connections_while_metadata_stays_responsive() { + let fixture = Fixture::new(); + let mut first = fixture.spawn(fixture.config()); + let mut second = fixture.spawn(fixture.config()); + first.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"block":true}}), + ); + fixture.wait_begin(); + first.send(3, "ping", json!({})); + assert_eq!(first.response(3)["secretLeaked"], false); + second.send(2, "tools/call", json!({"name":"opaque","arguments":{}})); + second.send(4, "ping", json!({})); + assert_eq!(second.response(4)["secretLeaked"], false); + assert_eq!(fixture.calls(), "begin 1\n"); + fs::write(fixture.root.join("release"), "ready").unwrap(); + first.response(1); + second.response(2); + assert_eq!(fixture.calls(), "begin 1\nend 1\nbegin 2\nend 2\n"); +} + +#[test] +fn rejects_hash_drift_before_starting_the_server() { + let fixture = Fixture::new(); + let mut config = fixture.config(); + config["sha256"] = json!(format!("sha256:{}", "0".repeat(64))); + let mut peer = fixture.spawn(config); + assert!(!peer.wait_exit().success()); + assert_eq!(fixture.calls(), ""); +} + +#[test] +fn lost_acknowledgement_blocks_calls_until_socket_replacement() { + let fixture = Fixture::new(); + let mut first = fixture.spawn(fixture.config()); + first.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"block":true}}), + ); + fixture.wait_begin(); + first.send(1, "ping", json!({})); + assert!(!first.wait_exit().success()); + assert!( + fs::read_dir(&fixture.root).unwrap().any(|e| { + let e = e.unwrap(); + e.file_name() + .to_string_lossy() + .starts_with(".paperclip-mcp-") + && fs::read(e.path()).unwrap() == b"pending" + }), + "unfinished marker missing" + ); + let mut second = fixture.spawn(fixture.config()); + second.send(2, "tools/call", json!({"name":"opaque","arguments":{}})); + assert!(!second.wait_exit().success()); + assert_eq!(fixture.calls(), "begin 1\n"); + fs::remove_file(fixture.root.join("resource.sock")).unwrap(); + let _next = UnixListener::bind(fixture.root.join("resource.sock")).unwrap(); + fs::set_permissions( + fixture.root.join("resource.sock"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let mut third = fixture.spawn(fixture.config()); + third.send(3, "tools/call", json!({"name":"opaque","arguments":{}})); + third.response(3); + assert!(fixture.calls().ends_with("begin 3\nend 3\n")); +} + +#[test] +fn image_compatibility_keeps_pixels_and_structured_metadata() { + let fixture = Fixture::new(); + let mut peer = fixture.spawn_options(fixture.config(), true); + peer.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"original":"metadata"}}), + ); + let response = peer.response(1); + assert!(response.get("structuredContent").is_none()); + assert_eq!(response["content"][1]["type"], "image"); + assert_eq!(response["content"][1]["mimeType"], "image/png"); + let metadata: Value = + serde_json::from_str(response["content"][2]["text"].as_str().unwrap()).unwrap(); + assert_eq!(metadata, json!({"original":{"original":"metadata"}})); +} + +#[test] +fn cancellation_removes_queued_calls_and_fences_active_calls_until_ack() { + let fixture = Fixture::new(); + let mut first = fixture.spawn(fixture.config()); + let mut second = fixture.spawn(fixture.config()); + first.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"block":true}}), + ); + fixture.wait_begin(); + second.send(2, "tools/call", json!({"name":"opaque","arguments":{}})); + writeln!( + second.child.stdin.as_mut().unwrap(), + "{}", + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2}}) + ) + .unwrap(); + let cancelled = second.rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(cancelled["error"]["code"], -32800); + writeln!( + first.child.stdin.as_mut().unwrap(), + "{}", + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}) + ) + .unwrap(); + second.send(3, "tools/call", json!({"name":"opaque","arguments":{}})); + second.send(4, "ping", json!({})); + second.response(4); + assert_eq!(fixture.calls(), "begin 1\n"); + fs::write(fixture.root.join("release"), "ready").unwrap(); + first.response(1); + second.response(3); + assert_eq!(fixture.calls(), "begin 1\nend 1\nbegin 3\nend 3\n"); +} + +#[test] +fn cancellation_waiting_behind_output_is_handled_before_new_dispatch() { + let fixture = Fixture::new(); + let mut first = fixture.spawn(fixture.config()); + first.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"block":true}}), + ); + fixture.wait_begin(); + let gate = Arc::new(AtomicBool::new(false)); + let mut second = fixture.spawn_output_gate(fixture.config(), false, gate.clone()); + second.send(4, "ping", json!({"padding":true})); + let deadline = Instant::now() + Duration::from_secs(5); + while !fixture.root.join("ping-ready").exists() { + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(5)); + } + thread::sleep(Duration::from_millis(50)); + second.send(2, "tools/call", json!({"name":"opaque","arguments":{}})); + writeln!( + second.child.stdin.as_mut().unwrap(), + "{}", + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2}}) + ) + .unwrap(); + fs::write(fixture.root.join("release"), "ready").unwrap(); + first.response(1); + thread::sleep(Duration::from_millis(50)); + gate.store(true, Ordering::SeqCst); + second.response(4); + let response = second.rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!( + response["error"]["code"], -32800, + "cancelled call was dispatched: {response}" + ); + assert_eq!(fixture.calls(), "begin 1\nend 1\n"); +} + +#[test] +fn metadata_only_socket_changes_do_not_bypass_unknown_outcome() { + let fixture = Fixture::new(); + let mut first = fixture.spawn(fixture.config()); + first.send( + 1, + "tools/call", + json!({"name":"opaque","arguments":{"block":true}}), + ); + fixture.wait_begin(); + first.send(1, "ping", json!({})); + assert!(!first.wait_exit().success()); + fs::set_permissions( + fixture.root.join("resource.sock"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let mut second = fixture.spawn(fixture.config()); + second.send(2, "tools/call", json!({"name":"opaque","arguments":{}})); + assert!(!second.wait_exit().success()); + assert_eq!(fixture.calls(), "begin 1\n"); +} diff --git a/packages/paperclip-runner/src/configured-environment.ts b/packages/paperclip-runner/src/configured-environment.ts index bd10a7e7813..49a9be538f3 100644 --- a/packages/paperclip-runner/src/configured-environment.ts +++ b/packages/paperclip-runner/src/configured-environment.ts @@ -15,6 +15,7 @@ export const GENERATED_RUNTIME_ENVIRONMENT_KEYS: ReadonlySet = new Set([ "PAPERCLIP_WORKSPACE_WORKTREE_PATH", "PAPERCLIP_WORKSPACE_REALIZATION_MODE", "PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT", "PAPERCLIP_RUNTIME_PRIMARY_URL", "PAPERCLIP_RUNTIME_SERVICES_JSON", "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON", "PAPERCLIP_AGENT_MESSAGE_KEY", "PAPERCLIP_HARNESS_CHECKOUT_KEY", "PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY", + "PAPERCLIP_NATIVE_MCP_STDIO", "PAPERCLIP_NATIVE_MCP_PROXY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_PROVIDER_TRACE_PATH", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", "PAPERCLIP_ACPX_CREDENTIAL_BINDING", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 8dab79ac10d..4d44db5c05b 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -526,6 +526,8 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" ANTHROPIC_BASE_URL: "https://gateway.example", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", + PAPERCLIP_NATIVE_MCP_STDIO: "[]", + PAPERCLIP_NATIVE_MCP_PROXY: "/runner/paperclip-runnerd", DATABASE_URL: "must-not-reach-runnerd", PAPERCLIP_API_KEY: "must-not-reach-runnerd", NODE_OPTIONS: "--require=/untrusted/bootstrap.cjs", @@ -561,6 +563,8 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" ANTHROPIC_BASE_URL: "https://gateway.example", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", + PAPERCLIP_NATIVE_MCP_STDIO: "[]", + PAPERCLIP_NATIVE_MCP_PROXY: "/runner/paperclip-runnerd", }); expect(launches[0]!.environment.DATABASE_URL).toBeUndefined(); expect(launches[0]!.environment.PAPERCLIP_API_KEY).toBeUndefined(); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index bf0446f25d9..c671e5b778e 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3443,6 +3443,8 @@ const runnerExplicitProviderEnvironmentKeys = [ "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN", + "PAPERCLIP_NATIVE_MCP_STDIO", + "PAPERCLIP_NATIVE_MCP_PROXY", "PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH", "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", "PAPERCLIP_RUNNER_HOME_MODE", 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 ea262104f3e..17567489fbf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -368,7 +368,10 @@ export async function openQualifiedAcpxRuntime( ? [...options.permissionPolicy.escalate] : undefined, }, - mcpServers: options.mcpServers.map((server) => ({ + mcpServers: options.mcpServers.map((server) => "command" in server ? { + name: server.name, command: server.command, args: server.args, + env: Object.entries(server.env).map(([name, value]) => ({ name, value })), + } : ({ type: "http" as const, name: server.name, url: server.url, @@ -443,7 +446,7 @@ export async function openQualifiedAcpxRuntime( // This URL comes from the runner-owned authenticated tool bridge, // never provider-supplied permission-request metadata. PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: options.mcpServers.find( - (server) => server.runnerOwned && server.name === "paperclip", + (server): server is Extract => "url" in server && server.runnerOwned && server.name === "paperclip", )?.url ?? "", } : {}), 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 1fc811be164..c7f7da9e596 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { createPiLaunchSpec } from "./pi-acp-runtime.js"; import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; @@ -338,6 +339,28 @@ describe("ACPX runtime host", () => { } finally { await host.close({ reason: "gateway test complete" }); } }); + it.each([false, true])("keeps stdio registration session-local and omits it for readOnly=%s", async readOnly => { + const fixture = await hostFixture(); + const command = join(fixture.root, "desktop-server"); + await writeFile(command, "#!/bin/sh\nexit 0\n", { mode: 0o555 }); + const binding = { name: "desktop", command: await realpath(command), args: ["mcp"], env: { HOME: fixture.root }, + sha256: `sha256:${createHash("sha256").update(await readFile(command)).digest("hex")}`, + serialResource: join(fixture.root, "cua.sock") }; + let servers: AcpxRuntimePortOpenOptions["mcpServers"] = []; + const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "claude", model: "claude-sonnet-5", + permissionMode: "approve-reads", providerPolicy: { readOnly }, + environment: { ...fixture.options.environment, PAPERCLIP_NATIVE_MCP_STDIO: JSON.stringify([binding]), PAPERCLIP_NATIVE_MCP_PROXY: "/verified/runnerd" }, + }, fixture.dependencies({ openRuntime: async options => { + servers = options.mcpServers; + return runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }); + } })); + try { + const desktop = servers.find(server => server.name === "desktop"); + if (readOnly) expect(desktop).toBeUndefined(); + else expect(desktop).toMatchObject({ name: "desktop", command: "/verified/runnerd", runnerOwned: false, env: {} }); + } finally { await host.close({ reason: "stdio policy verified" }); } + }); + it.each([ ["approve-reads", ["mcp__paperclip__get_task_context"]], ["approve-paperclip", ["mcp__paperclip__create_task", "mcp__paperclip__get_task_context", "mcp__paperclip__reassign_task", "mcp__paperclip__write_document"]], diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index cef70e38692..61d3cbf950b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -9,6 +9,7 @@ import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxP import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js"; import { stageManagedGrokCredential } from "./grok-credentials.js"; import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; +import { prepareStdioMcpBindings } from "../stdio-mcp.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; import type { @@ -184,12 +185,11 @@ export interface AcpxRuntimePortOpenOptions { retainFailedAdmissionCleanup(cleanup: Promise): void; } -export interface AcpxMcpServerBinding { - name: string; - url: string; - bearerToken: string; - runnerOwned: boolean; -} +export type AcpxMcpServerBinding = { + name: string; url: string; bearerToken: string; runnerOwned: boolean; +} | { + name: string; command: string; args: string[]; env: Record; runnerOwned: false; +}; export type AcpxSemanticToolSession = Omit; @@ -606,6 +606,7 @@ export class AcpxRuntimeHost { : { assertWorkspaceHeld: () => { options.assertWorkspaceHeld?.(); agentFiles?.assertHeld(); } }), ...(options.signal === undefined ? {} : { signal: options.signal }), mcpServers: [ + ...(options.providerPolicy?.readOnly ? [] : prepareStdioMcpBindings(options.environment ?? {}, sandbox.root)), ...(toolBridge ? [{ name: preconfigured ? `paperclip_runner_${binding.profileSessionKey.slice(-16)}` : "paperclip", url: toolBridge.url, bearerToken: toolBridge.secret, runnerOwned: true }] : []), // This is Paperclip's authenticated gateway, not a direct upstream diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts index fb7eaae47df..f7a5dba8bd1 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.runtime-requests.test.ts @@ -44,6 +44,35 @@ import { } from "./codex-app-server-driver.test-support.js"; describe("Codex app-server Codex driver", () => { + it("keeps a prompted MCP tool answerable after rejecting a session-wide decision", async () => { + const transport = new FakeCodexTransport(); + const session = await makeDriver([transport]).openSession({ + runId: "run-mcp-retry", + normalizedSessionId: "normalized-mcp-retry", + workingDirectory: WORKSPACE, + }); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Inspect the desktop." } }); + const response = transport.invoke({ + id: "desktop-approval", method: "mcpServer/elicitation/request", + params: { + threadId: "thread-1", turnId, itemId: "desktop-item", serverName: "assigned", + mode: "form", message: "Allow the tool?", _meta: { codex_approval_kind: "mcp_tool_call" }, + requestedSchema: { type: "object", properties: {} }, + }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + await expect(session.resolveRuntimeRequest?.({ + requestId: "desktop-approval", turnId, resolution: { action: "accept_for_session" }, + })).rejects.toThrow(/one-time/); + expect(session.pendingRuntimeRequests?.()).toHaveLength(1); + await session.resolveRuntimeRequest?.({ + requestId: "desktop-approval", turnId, resolution: { action: "accept" }, + }); + expect(await response).toEqual({ action: "accept", content: null, _meta: null }); + expect(session.pendingRuntimeRequests?.()).toHaveLength(0); + await session.close(); + }); + it("validates runtime request resolutions against the kind of request they answer", async () => { const transport = new FakeCodexTransport(); const session = await makeDriver([transport]).openSession({ diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 983d9322eee..47270fe5bde 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -485,12 +485,12 @@ export class CodexHarnessSession `request ${input.requestId} is already settling`, ); } - pending.settlingResolution = structuredClone(resolution); const response = runtimeRequestResponse( pending.request, resolution, pending.responseContext, ); + pending.settlingResolution = structuredClone(resolution); try { await this.transport.resolveRuntimeRequest?.({ requestId: input.requestId, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts index afdca595faa..9297dbd2c0f 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts @@ -541,3 +541,23 @@ describe("Codex structured question adapter", () => { }); }); }); + + +describe("Codex MCP tool approval", () => { + const params = { serverName: "assigned", mode: "form", message: "Allow the tool?", _meta: { codex_approval_kind: "mcp_tool_call" }, requestedSchema: { type: "object", properties: {} }, choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }] }; + const request: HarnessRuntimeRequest = { + requestId: "scoped-request", requestKind: "permission_approval", method: "mcpServer/elicitation/request", + turnId: "turn", itemId: "item", status: "pending", prompt: params.message, details: params, + origin: { adapter: "codex-app-server", provider: "codex", method: "mcpServer/elicitation/request" }, + }; + it("keeps confirmations on the permission path and ordinary forms on the input path", () => { + expect(runtimeRequestKind(request.method, params)).toBe("permission_approval"); + expect(runtimeRequestKind(request.method, { ...params, requestedSchema: { type: "object", properties: { password: { type: "string" } } } })).toBe("elicitation"); + expect(runtimeRequestProtocolPayload(request)).toMatchObject({ schema: "paperclip.runtime_request.v2", type: "permission", choices: params.choices }); + expect(runtimeRequestProtocolPayload(request)).not.toHaveProperty("method"); + }); + it("returns only explicit one-time decisions, without granting session authority", () => { + for (const action of ["accept", "decline", "cancel"] as const) expect(runtimeRequestResponse(request, { action })).toEqual({ action, content: null, _meta: null }); + expect(() => runtimeRequestResponse(request, { action: "accept_for_session" })).toThrow(/one-time/); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts index d75f709caf0..b3c69c21773 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts @@ -58,7 +58,11 @@ function boundedText( : `${candidate.slice(0, maxCharacters)}...[truncated]`; } -export function runtimeRequestKind(method: string): HarnessRuntimeRequestKind | null { +export function runtimeRequestKind(method: string, params?: Record): HarnessRuntimeRequestKind | null { + if (method === "mcpServer/elicitation/request" && record(params?._meta).codex_approval_kind === "mcp_tool_call" + && params?.mode === "form" && record(params.requestedSchema).type === "object" + && Object.keys(record(record(params.requestedSchema).properties)).length === 0 + && Object.keys(record(params.requestedSchema)).sort().join(",") === "properties,type") return "permission_approval"; if ( method === "item/commandExecution/requestApproval" || method === "execCommandApproval" @@ -451,6 +455,11 @@ export function runtimeRequestProtocolPayload(request: HarnessRuntimeRequest): R if (request.method === "session/request_permission") { return { ...request, schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, type: "permission", choices: request.details.choices }; } + if (request.requestKind === "permission_approval" && request.method === "mcpServer/elicitation/request") { + return { schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, requestKind: "permission_approval", requestId: request.requestId, + type: "permission", status: request.status, prompt: request.prompt, choices: request.details.choices, + details: request.details, origin: request.origin, turnId: request.turnId, itemId: request.itemId }; + } if (request.input !== undefined) { return { schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, @@ -576,6 +585,10 @@ export function runtimeRequestResponse( } as const; return { decision: decisions[resolution.action] }; } + if (request.requestKind === "permission_approval" && request.method === "mcpServer/elicitation/request") { + if (!["accept", "decline", "cancel"].includes(resolution.action)) throw new Error("MCP tool approval requires a one-time decision"); + return { action: resolution.action, content: null, _meta: null }; + } if (request.requestKind === "permission_approval") { if (resolution.action === "submit") { throw new Error( diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts index 0eb5ccf2579..09be371e7d7 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts @@ -236,7 +236,7 @@ async function handleServerRequestBody( }; } - const requestKind = runtimeRequestKind(request.method); + const requestKind = runtimeRequestKind(request.method, request.params); if (requestKind === null) return safeRequestResponse(request.method); const requestTurnId = text(request.params.turnId); if ( diff --git a/packages/paperclip-runner/src/drivers/stdio-mcp.test.ts b/packages/paperclip-runner/src/drivers/stdio-mcp.test.ts new file mode 100644 index 00000000000..e32bb211d38 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/stdio-mcp.test.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareStdioMcpBindings, stdioMcpLaunchBindings, STDIO_MCP_BINDINGS_ENV } from "./stdio-mcp.js"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); }); +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "stdio-binding-"))); + roots.push(root); + const command = join(root, "server"); + writeFileSync(command, "#!/bin/sh\nexit 0\n", { mode: 0o555 }); + const binding = { name: "assigned-desktop", command, args: ["mcp", "--socket", join(root, "cua.sock")], env: { DESKTOP_STATE_DIR: root }, sha256: `sha256:${createHash("sha256").update(readFileSync(command)).digest("hex")}`, serialResource: join(root, "cua.sock") }; + const environment = { [STDIO_MCP_BINDINGS_ENV]: JSON.stringify([binding]), PAPERCLIP_NATIVE_MCP_PROXY: "/verified/runnerd" }; + return { root, binding, environment }; +} + +describe("assigned stdio MCP bindings", () => { + it("uses only assigned bindings and creates private session-local config without bypassing approval", () => { + const { root, binding, environment } = fixture(); + expect(stdioMcpLaunchBindings({})).toEqual([]); + expect(stdioMcpLaunchBindings(environment)).toEqual([binding]); + const servers = prepareStdioMcpBindings(environment, root); + expect(servers).toEqual([{ name: binding.name, command: "/verified/runnerd", args: ["--stdio-mcp", join(root, "stdio-mcp/0.json")], env: {}, runnerOwned: false }]); + expect(JSON.parse(readFileSync(servers[0]!.args[1]!, "utf8"))).toEqual(binding); + expect(statSync(servers[0]!.args[1]!).mode & 0o777).toBe(0o600); + expect(statSync(join(root, "stdio-mcp")).mode & 0o777).toBe(0o700); + prepareStdioMcpBindings({}, root); + expect(() => statSync(join(root, "stdio-mcp/0.json"))).toThrow(); + }); + it.each([ + ["missing digest", (b: Record) => { delete b.sha256; }], + ["dotted server name", (b: Record) => { b.name = "nested.server"; }], + ["loader override", (b: Record) => { b.env = { NODE_OPTIONS: "--require=evil" }; }], + ["relative command", (b: Record) => { b.command = "cua-driver"; }], + ["invalid arguments", (b: Record) => { b.args = ["mcp", 1]; }], + ["invalid environment", (b: Record) => { b.env = { HOME: "bad\0value" }; }], + ["reserved task bridge", (b: Record) => { b.name = "paperclip"; }], + ["reserved preconfigured bridge", (b: Record) => { b.name = "paperclip_runner_forged"; }], + ["unknown fields", (b: Record) => { b.autoApprove = true; }], + ])("rejects %s", (_name, mutate) => { + const { binding, environment } = fixture(); + mutate(binding); + expect(() => stdioMcpLaunchBindings({ ...environment, [STDIO_MCP_BINDINGS_ENV]: JSON.stringify([binding]) })).toThrow(); + }); + it("rejects executable drift, symlinks, and writable-by-others executables", () => { + for (const mutation of [ + (command: string) => { chmodSync(command, 0o755); writeFileSync(command, "changed"); }, + (command: string) => { rmSync(command); symlinkSync(process.execPath, command); }, + (command: string) => chmodSync(command, 0o777), + ]) { + const { binding, environment } = fixture(); + mutation(binding.command); + expect(() => stdioMcpLaunchBindings(environment)).toThrow(); + } + }); + it("rejects duplicate names and collisions with the existing HTTP gateway", () => { + const { binding, environment } = fixture(); + expect(() => stdioMcpLaunchBindings({ ...environment, [STDIO_MCP_BINDINGS_ENV]: JSON.stringify([binding, binding]) })).toThrow(); + expect(() => stdioMcpLaunchBindings({ ...environment, PAPERCLIP_NATIVE_MCP_NAME: binding.name })).toThrow(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/stdio-mcp.ts b/packages/paperclip-runner/src/drivers/stdio-mcp.ts new file mode 100644 index 00000000000..8df8182b3b5 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/stdio-mcp.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; +import { lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +export interface StdioMcpLaunchBinding { + name: string; + command: string; + args: string[]; + env: Record; + sha256: string; + serialResource: string; +} + +export const STDIO_MCP_BINDINGS_ENV = "PAPERCLIP_NATIVE_MCP_STDIO"; + +export function stdioMcpLaunchBindings(environment: NodeJS.ProcessEnv): StdioMcpLaunchBinding[] { + const raw = environment[STDIO_MCP_BINDINGS_ENV]; + if (raw === undefined) return []; + const invalid = () => new Error("assigned stdio MCP launch binding is invalid"); + if (Buffer.byteLength(raw) > 64 * 1024) throw invalid(); + let values: unknown; + try { values = JSON.parse(raw); } catch { throw invalid(); } + if (!Array.isArray(values) || values.length > 8) throw invalid(); + const names = new Set(["paperclip", environment.PAPERCLIP_NATIVE_MCP_NAME ?? ""]); + return values.map((value: unknown) => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); + const binding = value as Record; + if (Object.keys(binding).sort().join(",") !== "args,command,env,name,serialResource,sha256" + || typeof binding.name !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(binding.name) + || names.has(binding.name) || binding.name.startsWith("paperclip_runner_") + || typeof binding.command !== "string" || !isAbsolute(binding.command) + || binding.command.includes("\0") || binding.command.length > 4096 + || typeof binding.serialResource !== "string" || !isAbsolute(binding.serialResource) || binding.serialResource.includes("\0") + || typeof binding.sha256 !== "string" || !/^sha256:[a-f0-9]{64}$/.test(binding.sha256) + || !Array.isArray(binding.args) || binding.args.length > 64 + || binding.args.some(arg => typeof arg !== "string" || arg.includes("\0") || arg.length > 4096) + || !binding.env || typeof binding.env !== "object" || Array.isArray(binding.env)) throw invalid(); + const env = binding.env as Record; + if (Object.keys(env).length > 64 || Object.entries(env).some(([key, value]) => + !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) || /^(?:LD_|DYLD_)/.test(key) || ["NODE_OPTIONS", "NODE_PATH", "BASH_ENV", "ENV"].includes(key) || typeof value !== "string" || value.includes("\0") + )) throw invalid(); + const metadata = lstatSync(binding.command); + if (!metadata.isFile() || metadata.isSymbolicLink() || !(metadata.mode & 0o111) + || (metadata.mode & 0o022) || realpathSync(binding.command) !== binding.command) throw invalid(); + const digest = `sha256:${createHash("sha256").update(readFileSync(binding.command)).digest("hex")}`; + if (digest !== binding.sha256) throw new Error("assigned stdio MCP executable digest mismatch"); + names.add(binding.name); + return { name: binding.name, command: binding.command, args: binding.args as string[], env: env as Record, sha256: binding.sha256, serialResource: binding.serialResource }; + }); +} + +export function prepareStdioMcpBindings(environment: NodeJS.ProcessEnv, root: string) { + const bindings = stdioMcpLaunchBindings(environment); + const directory = join(root, "stdio-mcp"); + const proxy = environment.PAPERCLIP_NATIVE_MCP_PROXY; + if (bindings.length && (!proxy || !isAbsolute(proxy) || proxy.includes("\0"))) { + throw new Error("assigned stdio MCP proxy is unavailable"); + } + rmSync(directory, { force: true, recursive: true }); + if (!bindings.length) return []; + mkdirSync(directory, { mode: 0o700 }); + return bindings.map((binding, index) => { + const path = join(directory, `${index}.json`); + writeFileSync(path, JSON.stringify(binding), { mode: 0o600, flag: "wx" }); + return { name: binding.name, command: proxy!, args: ["--stdio-mcp", path], env: {}, runnerOwned: false as const }; + }); +} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 10994c25002..e65db3b7ad4 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -970,7 +970,12 @@ export function bridgedAcpxPermissionParams( function acpxPermissionRequestParams(request: Record, threadId: string, turnId: string): Record | null { if (request.type !== "permission" || request.requestKind !== "permission_approval" - || record(request.origin).method !== "session/request_permission") return null; + || !["session/request_permission", "mcpServer/elicitation/request"].includes(String(record(request.origin).method))) return null; + if (record(request.origin).method === "mcpServer/elicitation/request") { + const details = record(request.details); + return { threadId, turnId, message: request.prompt, choices: request.choices, + serverName: details.serverName, mode: details.mode, _meta: details._meta, requestedSchema: details.requestedSchema, origin: request.origin }; + } const toolCallId = record(request.details).toolCallId; // Match the permission adapter's 240-character bound. Never truncate, trim, // hash, or substitute itemId: any of those would change the correlation key. @@ -3251,6 +3256,8 @@ function withRunnerdProviderTrace( for (const key of [ "PAPERCLIP_PROVIDER_TRACE_PATH", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", + "PAPERCLIP_NATIVE_MCP_STDIO", + "PAPERCLIP_NATIVE_MCP_PROXY", ] as const) { const value = source?.[key]; if (value !== undefined) result[key] = value; @@ -4221,6 +4228,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error( `PRP runtime request ${input.requestId} is no longer pending`, ); + if (input.turnId !== this.#turnId) throw new Error("native_runner_runtime_response_stale_turn"); if (!pending.permission && !("response" in input.resolution) && input.resolution.action !== "cancel" && input.resolution.action !== "decline") { throw new Error( "runnerd-native runtime requests require a canonical question response", @@ -6440,7 +6448,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const origin = record(normalizedRequest.origin); const method = typeof origin.method === "string" ? origin.method : ""; const permission = normalizedRequest.type === "permission" && normalizedRequest.requestKind === "permission_approval" - && method === "session/request_permission"; + && ["session/request_permission", "mcpServer/elicitation/request"].includes(method); const params = permission ? bridgedAcpxPermissionParams( event, this.#threadId, diff --git a/server/src/services/native-runtime/native-permission-projection.test.ts b/server/src/services/native-runtime/native-permission-projection.test.ts index 718a57ba0a5..4fb9d778a40 100644 --- a/server/src/services/native-runtime/native-permission-projection.test.ts +++ b/server/src/services/native-runtime/native-permission-projection.test.ts @@ -26,8 +26,16 @@ function permissionEvent(): PrpEvent { } describe("native permission projection", () => { - it("admits the existing privileged runtime card without a human-only question mutation", async () => { + it.each(["acpx", "codex"])("admits %s permissions without a human-only question mutation", async (provider) => { const event = permissionEvent(); + if (provider === "codex") { + Object.assign(event.payload.request as object, { + details: { threadId: "provider-thread", turnId: "provider-turn", itemId: "provider-item", + serverName: "assigned", mode: "form", message: "Allow tool?", + _meta: { codex_approval_kind: "mcp_tool_call" }, requestedSchema: { type: "object", properties: {} } }, + origin: { adapter: "codex-app-server", provider: "codex", method: "mcpServer/elicitation/request" }, + }); + } const original = structuredClone(event); const select = vi.fn(); await expect(projectNativeRuntimeRequest({ db: { select } as unknown as Db, binding, event })).resolves.toBeNull();