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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-16 10:39:38 -07:00
1 parent ce0eff7d1f
commit 6f8ad3d77b
28 files changed
+1509 -23

No files matched your search

@@ -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.
+55
View File
@@ -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.
@@ -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"
@@ -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",
@@ -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();
}
}
@@ -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(());
@@ -542,6 +542,10 @@ pub enum CodexProviderEvent {
request_id: String,
question_set: Value,
},
McpPermissionRequest {
request_id: String,
params: Value,
},
Exited {
exit_code: Option<i32>,
success: bool,
@@ -665,6 +669,7 @@ enum AmbiguousTurnMessage {
pub struct CodexProvider {
process: SupervisedProcess,
_stdio_mcp: Option<crate::stdio_mcp::StdioMcpSession>,
stderr_tail: BoundedLogBuffer,
config: CodexProviderConfig,
authorized_tools: Vec<AuthorizedTool>,
@@ -955,6 +960,25 @@ impl CodexProvider {
.chain(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.iter().copied())
.collect::<Vec<_>>();
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(&params)?
} else if method == "mcpServer/elicitation/request" {
let servers = self
._stdio_mcp
.as_ref()
.map(|session| {
session
.entries
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
crate::stdio_mcp::validate_tool_approval(&params, &servers)?;
(rpc_id.to_string(), Value::Null, BTreeMap::new())
} else {
codex_question_set(&rpc_id, &params)?
};
@@ -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,
@@ -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",
@@ -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};
@@ -454,6 +454,22 @@ impl VerifiedProcessLaunch {
}
}
pub(crate) fn spawn_stdio(
&self,
environment: &std::collections::BTreeMap<String, String>,
) -> Result<Child, LocalRunnerError> {
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
@@ -326,6 +326,25 @@ fn authorized_tool_set(payload: &Value) -> Result<AuthorizedToolSet, DurableRunn
})
}
fn mcp_permission_event(
identity: &ProviderEventIdentity,
request_id: String,
params: Value,
) -> 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!(
@@ -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<String>,
pub env: BTreeMap<String, String>,
pub sha256: String,
pub serial_resource: PathBuf,
}
type McpResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
fn invalid(message: impl Into<String>) -> Box<dyn std::error::Error + Send + Sync> {
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<Vec<StdioMcpBinding>> {
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<StdioMcpBinding> =
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<Option<Self>> {
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<Option<Self>> {
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<Vec<String>> {
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<u8>),
Server(Vec<u8>),
ClientClosed,
ServerClosed,
}
fn reader<R: io::Read + Send + 'static>(input: R, tx: mpsc::SyncSender<Event>, 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<u8>)> = VecDeque::new();
let mut active: Option<Value> = 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::<usize>() + 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::<Value>(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<Value, LocalRunnerError> {
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"))
}
@@ -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<AtomicBool>,
) -> 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<Value>,
}
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");
}
@@ -15,6 +15,7 @@ export const GENERATED_RUNTIME_ENVIRONMENT_KEYS: ReadonlySet<string> = 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",
@@ -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();
@@ -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",
@@ -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<typeof server, { url: string }> => "url" in server && server.runnerOwned && server.name === "paperclip",
)?.url ?? "",
}
: {}),
@@ -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"]],
@@ -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>): 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<string, string>; runnerOwned: false;
};
export type AcpxSemanticToolSession = Omit<RunnerToolBridgeOptions, "secret">;
@@ -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
@@ -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({
@@ -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,
@@ -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/);
});
});
@@ -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<string, unknown>): 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(
@@ -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 (
@@ -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<string, unknown>) => { delete b.sha256; }],
["dotted server name", (b: Record<string, unknown>) => { b.name = "nested.server"; }],
["loader override", (b: Record<string, unknown>) => { b.env = { NODE_OPTIONS: "--require=evil" }; }],
["relative command", (b: Record<string, unknown>) => { b.command = "cua-driver"; }],
["invalid arguments", (b: Record<string, unknown>) => { b.args = ["mcp", 1]; }],
["invalid environment", (b: Record<string, unknown>) => { b.env = { HOME: "bad\0value" }; }],
["reserved task bridge", (b: Record<string, unknown>) => { b.name = "paperclip"; }],
["reserved preconfigured bridge", (b: Record<string, unknown>) => { b.name = "paperclip_runner_forged"; }],
["unknown fields", (b: Record<string, unknown>) => { 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();
});
});
@@ -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<string, string>;
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<string>(["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<string, unknown>;
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<string, unknown>;
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<string, string>, 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 };
});
}
@@ -970,7 +970,12 @@ export function bridgedAcpxPermissionParams(
function acpxPermissionRequestParams(request: Record<string, unknown>, threadId: string, turnId: string): Record<string, unknown> | 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,
@@ -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();