Skip to content
8 changes: 4 additions & 4 deletions crates/agent/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@ The harness layers several capabilities over the bare tools + loop:
Proxy / TypedArrays / MapSet) runs it against `tools.<server>.<method>(input)` Promises. Built-in
coding tools stay direct. Nested calls honor `--exclude-tools` / `--deny-tool`. Budgets: 30s wall
clock, 64 nested calls, 256 KiB result, **4 MiB JS heap**, **128 KiB JS stack**, and **one QuickJS
runtime per process** (a `serve` daemon never holds two heaps at once). An allow-list of built-ins
runtime per process** (a `serve` daemon never holds two heaps at once). QuickJS evaluates synchronously, so evaluation runs on the blocking pool under a local executor, never on the runtime thread other sessions share: a spinning script holds a pool thread until its interrupt (deadline, cancellation, or the call being dropped — a guard trips it) fires, and the one-heap slot is released when the evaluation really ends. Nested host calls are spawned back onto the session's runtime, under the script's deadline (which so holds across a nested await, not only while JS runs), and aborted by the same guard however the run ends — cancelled or dropped — so no host tool runs on after it. An allow-list of built-ins
(`--tools read,bash`) still keeps `execute` so the deferred catalog is reachable; `--exclude-tools
execute` (or `--no-tools`) drops it. Subagents inherit the parent's flag so a child cannot
re-advertise the catalog the parent deferred. QuickJS is a cargo feature (`code-mode`) because idle
Expand Down Expand Up @@ -2104,7 +2104,7 @@ the model reads. Body/time caps are ax's: 20 MB, 30 s.
**Egress safety is the load-bearing part** (`web/ssrf.rs`). ax is a local CLI a human runs; this is a
server fetching URLs the _model_ chose. Because the tool is read-only (in `READ_ONLY_TOOLS`, so
`--approve all` never prompts on it — the operator's choice), the SSRF filter is the **sole** boundary
between the model and an internal fetch. It is deny-by-default, in two layers:
between the model and an internal fetch. So it **fails closed**: if the hardened client (resolver, timeout, no redirects) cannot be built — the builder errs or panics — the call fails and nothing is sent; there is no fallback to a default client (`a_client_that_cannot_be_built_fails_the_call_and_sends_nothing`). The MCP Events direct-HTTP client fails closed the same way. It is deny-by-default, in two layers:

- **A validating `reqwest::dns::Resolve`.** reqwest connects to exactly what the resolver returns, so
checking every resolved `SocketAddr` validates the _actual_ connection IP — on the initial request and
Expand Down Expand Up @@ -2487,7 +2487,7 @@ standing in for `serve_session` so the test decides exactly when an exiting task
`#[tokio::main]` builds one, and that's what this binary used to inherit. Session tasks now share
that process runtime (`serve_ws` `tokio::spawn`s each `serve_session`; the future is `Send`).
CPU-bound tool work (`grep`/`find`/image resize) is `spawn_blocking`, which a current-thread
runtime still has a blocking pool for. Extra workers cost per-thread stacks and mimalloc heaps, on
runtime still has a blocking pool for. So is anything that blocks the thread without using CPU: `web` builds its client and spawns and waits on its isolated parser child there, `bash`'s stale-spill sweep of the system temp directory runs there, `memory` merges and renders large search results there, and Code Mode evaluates there. `tests/tool_reactor_stall.rs` holds every tool that needs no external service to it with two probes on a `current_thread` runtime — the CPU the runtime thread spends inside the tool's polls, and the worst gap of a ticker task on the same runtime with run-queue wait (`/proc/thread-self/schedstat`) taken out, so wall-clock blocking (a sleep, blocking disk or NFS I/O, a contended `std` mutex) is caught and host load is not. Extra workers cost per-thread stacks and mimalloc heaps, on
a binary whose idle RSS in a 768 MB guest is already the density budget.

**The default now splits by mode** (`main.rs::build_runtime`): `current_thread` for a local daemon,
Expand Down Expand Up @@ -4837,7 +4837,7 @@ mcp_events_subscribe (any session) ──► owned by that session
session-bound HTTP server goes through rmcp's own connection, which carries its `Mcp-Session-Id`.
A server→client request (an elicitation, a sampling request) raised during an `events/*` request
is attributed to the session that owns it over rmcp — the same rule as during a `tools/call`
(`track_call`) — and, over direct HTTP, where nothing could route it to a session, answered at once
(`track_call`); a session starts its hub only after its elicitation and sampling gates are installed, so one raised during the very first `events/*` request reaches its client rather than the default gate's decline — and, over direct HTTP, where nothing could route it to a session, answered at once
with an error rather than left unanswered (`tests/mcp_events_nested.rs`). That answer is POSTed
with the same `Accept` (`application/json, text/event-stream`) as every events request — a
streamable-HTTP server refuses an answer without it `406` and keeps waiting (the fixture enforces
Expand Down
13 changes: 13 additions & 0 deletions crates/agent/src/bin/mcp_fixture_events_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ struct State {
/// `MCP_FIXTURE_NESTED_DURING=poll|stream`: the client's answers to the nested
/// `elicitation/create` raised during that `events/*` request.
nested_answers: Vec<Value>,
/// `MCP_FIXTURE_NESTED_ON_SIGNAL=1`: the nested request is held until `POST
/// /control/raise_nested` sets this — so a test raises it once its client is attached, rather
/// than guessing a delay.
nested_released: bool,
/// `MCP_FIXTURE_FORBID_FIRST=<n>`: the first `n` `events/poll`/`events/subscribe` requests are
/// refused with `-32012` (forbidden) — credentials that are refreshed and then work.
forbid_left: u64,
Expand Down Expand Up @@ -1058,6 +1062,10 @@ async fn control(state: &Shared, method: &str, path: &str, body: &[u8]) -> (u16,
None => (404, json!({"error": "no such event"})),
}
}
("POST", "/control/raise_nested") => {
state.lock().unwrap().nested_released = true;
(200, json!({ "ok": true }))
}
("POST", "/control/jwks") => {
let mut st = state.lock().unwrap();
st.jwks_status = req["status"].as_u64().map(|s| s as u16);
Expand Down Expand Up @@ -1593,6 +1601,11 @@ async fn nested_elicitation(state: &Shared, out: &Stdout, waiters: &Waiters, dur
if delay > 0 {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
if env_flag("MCP_FIXTURE_NESTED_ON_SIGNAL") {
while !state.lock().unwrap().nested_released {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
let id = format!("nested-{during}");
let (tx, rx) = tokio::sync::oneshot::channel();
waiters.lock().unwrap().insert(json!(id).to_string(), tx);
Expand Down
2 changes: 1 addition & 1 deletion crates/agent/src/exec_endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ impl crate::tools::fs::FsBackend for CellFs {
async fn write_if_unchanged(
&self,
path: &std::path::Path,
bytes: &[u8],
bytes: Vec<u8>,
expected: Option<std::time::SystemTime>,
) -> Result<bool, crate::tools::fs::FsError> {
self.inner()?
Expand Down
Loading
Loading