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();