From ecf826cd583f9ac189f08798ed07a912e535c727 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 21:08:05 +0800 Subject: [PATCH 001/138] =?UTF-8?q?docs(acp):=20add=20MCP-over-ACP=20brows?= =?UTF-8?q?er-control=20implementation=20blueprint=20(=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the north-star (LLM operating the browser via MCP over /acp) into T0–T7 with sub-tasks, an OpenAB-side vs extension-side ownership split meeting at the MCP-over-ACP wire contract (T4), and the key findings that reshape the work: the agent→client request direction already exists on the downstream hop (request_permission is auto-replied in openab-core, so T1 is a relay not green-field), and mcpServers is currently [] (T5 injects a core proxy). Suggested order + which items are heavy. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 84 ++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 7757cde7d..99169f6c3 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -102,3 +102,87 @@ perceive→act tool loop), but generalized: (a) targets the **user's real Chrome logged-in), not a sandbox; (b) action surface is **extension-defined MCP tools** (DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any MCP-capable agent can use it. + +## 7. Implementation blueprint (task breakdown) + +North-star = the agent's LLM autonomously operating the user's browser via MCP tools +tunnelled over `/acp`. The base (PR that revives #1260) ships the 1:1 chat surface and the +**generated v1 wire types** (`acp_schema`, already committed) — one of the four critical- +path items is therefore done. What remains splits cleanly into an **OpenAB (server) side** +and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) +so the two can proceed largely in parallel once that contract is fixed. + +### Findings that reshape the work +- The **agent→client REQUEST direction already exists on the downstream hop**: + `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent + and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* + those downstream requests up to the `/acp` client (and the response back) instead of + auto-answering them. +- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784). + Giving the agent browser tools = **injecting a core-side proxy MCP server** into that list + (T5) that tunnels `tools/*` to the extension. + +### Ownership +- **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. +- **Extension side** (katashiro): T6; plus the client halves of T3 (respond to + `request_permission`) and T4 (serve MCP over the tunnel). +- **Both**: T7. + +### Tasks + +**T0 — Spike (do first; de-risks everything).** PoC: give `cursor-agent` a non-empty +`mcpServers` pointing at a mock MCP server and confirm the LLM actually discovers +(`tools/list`) and calls (`tools/call`) a tool; confirm a downstream agent→client request +can be relayed and answered. If this doesn't hold, the browser goal needs a different path. + +**T1 — agent→client REQUEST direction (relay).** +- 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` + client instead of auto-replying; enumerate the relayed methods. +- 1.2 Gateway outbound request path: `acp_server` sends an agent-initiated REQUEST + (method + id) to the client and keeps a pending-response map (`id → oneshot`). +- 1.3 Read loop distinguishes an inbound **client response** (`id` + `result`/`error`, no + `method`) from a client request, and routes responses to the pending map. +- 1.4 core↔gateway bridge: relay the downstream request up + the client's response back + down to the agent. +- 1.5 Round-trip tests (agent request → client → response → agent). + +**T2 — migrate `acp_server` to generated typed wire (bidirectional surface).** +- 2.1 Construct response payloads from `acp_schema` types (the deferred construction + migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). + 2.3 Round-trip validate against real traffic (ACP trace mode). + +**T3 — `session/request_permission` end-to-end.** Largely the first concrete case of T1 +(relay the request, extension consent UX, relay the response) — folds into T1, not a +separate large task. + +**T4 — MCP-over-ACP tunnel framing.** +- 4.1 Fix the **wire contract**: how MCP JSON-RPC (`tools/list` / `tools/call` / results) + is multiplexed over `/acp` (method namespace, e.g. `_mcp/*`; request/response + correlation; framing). 4.2 Gateway routes MCP-namespaced messages between the `/acp` + client and core. 4.3 Contract doc (the spec the extension implements). 4.4 Mock-MCP- + client-over-tunnel tests. + +**T5 — OpenAB core = MCP proxy/aggregator.** +- 5.1 A core-side local MCP server (proxy) the agent connects to via `mcpServers`. +- 5.2 Inject that proxy into the downstream `mcpServers` (currently `[]`). +- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel. +- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent). +- 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. + +**T6 — extension = MCP server + browser tools** (katashiro). +- 6.1 MCP server role over the outbound `/acp` WS (`tools/list` / `tools/call`). +- 6.2 DOM-semantic tools: `click(selector)` / `read_dom`(snapshot) / `navigate` / + `screenshot` / `type(selector, text)`. 6.3 Execute in the active tab + (`chrome.scripting` / content script + permissions). 6.4 Consent UX for + `request_permission`. 6.5 Tests. + +**T7 — integration + e2e + deploy.** +- 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → + LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). + 7.3 Rebuild + redeploy Falcon. 7.4 Finalize this ADR. + +### Suggested order +T0 spike → T1 (+ T3 as its first case) → T2 → **T4 (fix the wire contract)** → T5 → then +T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 +(tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is +parallel and non-blocking. From 21cfbb514c641bd14679cb1c297a8cc72759471c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 22:28:02 +0800 Subject: [PATCH 002/138] docs(adr): resolve MCP-over-ACP browser-control design (D1-D4) + flow diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the resolved design decisions into the browser-control ADR §7: - D1: auto-approve all browser tool permissions (core keeps auto-replying request_permission); fine-grained control deferred. Drops the dedicated request_permission-relay task; T1's server->client machinery stays (needed by the upstream MCP tunnel). - D2: inject the proxy via each agent's native MCP config (Cursor -> .cursor/mcp.json), not ACP session/new mcpServers (Cursor ignores those; cf. zed-industries/zed#50924). Content (HTTP url+headers) is portable; no universal config location exists. - D3: downstream (agent<->core) is a normal in-process Streamable-HTTP MCP server on loopback (via rmcp), NOT an on-ACP-stream tunnel (the ACP maintainer backed off on-stream MCP; cf. discussion #58). Upstream (core/gateway<->extension) is the one legitimate tunnel and adopts the official MCP-over-ACP RFD framing (mcp/connect + mcp/message); the RFD's "type":"acp" downstream injection is unused (Cursor unsupported). - D4: core's HTTP MCP server is always-on and decoupled from the extension WS, so the WS can attach after session start; core static-advertises the browser toolset and emits notifications/tools/list_changed on attach/detach. Also adds a TL;DR flow, an as-designed execution flow, a detailed message-level runtime sequence, and the T0 spike checklist; updates Findings/Tasks/Ownership accordingly (T3 dropped, T4 = RFD framing, T5 = HTTP MCP server + per-adapter config injection). Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 218 ++++++++++++++++--- 1 file changed, 192 insertions(+), 26 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 99169f6c3..9e4698749 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -65,6 +65,12 @@ The single `/acp` WS carries BOTH the ACP chat session (initialize / session.pro session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), distinguished by ACP method namespace. No second connection. +> **Refinement (see §7 "Design decisions"):** this multiplexing applies to the **upstream** +> hop (extension ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The +> **downstream** hop (core ↔ agent) is *not* tunnelled over ACP — core hosts a normal +> in-process HTTP MCP server the agent connects to. Only the extension, which cannot open a +> listening socket, needs MCP tunnelled over its `/acp` WS. + ## 3. Protocol gap to close first The base does only client→agent (prompt) and agent→client **notifications** (streaming @@ -112,28 +118,185 @@ path items is therefore done. What remains splits cleanly into an **OpenAB (serv and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) so the two can proceed largely in parallel once that contract is fixed. +### TL;DR — how one browser action flows + +``` + [ LLM ] ────▶ [ OpenAB (core) ] ────▶ [ browser extension ] + wants to act middle-man / relay operates the real tab + ────────────────────────────────────────────────────────────────────── + inside the server pod in the user's browser (remote) + + Request 1. LLM decides "click the Submit button" + 2. OpenAB relays the action to the extension in the user's browser + 3. the extension actually clicks it in the active tab + Result 4. the extension reports "clicked; page went to /thanks" + 5. OpenAB hands the result back to the LLM + 6. the LLM continues → the user sees its narration in the side panel + + The LLM thinks it is calling an ordinary set of tools; in reality OpenAB is a + middle-man relaying every action to the real remote browser (and relaying the + tool list the other way). Only the OpenAB↔browser leg leaves the server; the + LLM↔OpenAB legs stay in-pod. The detailed message-level sequence is below. +``` + +### Design decisions (resolved 2026-07-19) + +Four decisions were worked through and locked; they refine §2 and the tasks below. + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core + keeps auto-replying `session/request_permission` with OK (existing + `connection.rs` behaviour); fine-grained control is deferred. Consequence: a dedicated + `request_permission`-relay task (was T3) is **dropped**, but T1's server→client request + machinery is still required — the **upstream MCP tunnel** needs it. + +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` + parameter is **not** reliable for this: Cursor's CLI ignores ACP-passed MCP servers and + only loads MCP from its **own config** (`.cursor/mcp.json`) — see + [zed-industries/zed#50924](https://github.com/zed-industries/zed/issues/50924). So the + proxy is registered **per-agent, in that agent's native MCP config** (Cursor → + `.cursor/mcp.json`; others via their own file/format — there is no universal location: + VS Code uses the `servers` key, Codex uses TOML). The **content** (an HTTP MCP entry: + `url` + `headers`) is portable across vendors, so "as long as it loads, we're fine". + +- **D3 — where MCP is tunnelled.** **Downstream (agent ↔ core) is a *normal* MCP server, + not an on-ACP-stream tunnel.** The ACP maintainer prototyped on-stream MCP-over-ACP and + backed off — agents already connect to MCP servers well, and a special on-stream MCP type + is invasive + ([discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). So core + hosts a **Streamable-HTTP MCP server in-process** on `127.0.0.1:` (loopback + bearer, + via `rmcp`); the agent connects to it like any other MCP server. The **upstream** + (core/gateway ↔ extension) is the one legitimate tunnel (an MV3 extension cannot listen), + and it adopts the **official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp)** + framing (`mcp/connect` → `connectionId`, then `mcp/message`), *not* a hand-rolled envelope. + The RFD's own `"type":"acp"` downstream-injection path is **not** used (Cursor doesn't + support it; see D2). + +- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is + **always-on and decoupled** from the extension WS; the extension connecting/disconnecting + only changes *backend availability*. To let browser tools appear on a session whose WS + attaches late (reconnect, or attach-to-running-session), core does **both**: (a) **static- + advertise** the fixed browser toolset regardless of WS state — a `tools/call` while no + extension is attached returns an MCP error ("browser not connected"), which decouples WS + timing from session start with no client dependency; **and** (b) emit + `notifications/tools/list_changed` when the extension attaches/detaches, so agents that + re-query pick up extension-defined extras and fresh schema. + +### Execution flow (as designed) + +``` +Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) + [C] = MCP client [S] = MCP server + + OPENAB POD (`openab run`) REMOTE (user browser) + ┌───────────┐ ┌──────────────────────────────────┐ ┌────────────────┐ + │ agent CLI │===│ gateway core │ │ browser ext. │ + │ (Cursor) │ │ /acp srv MCP proxy + │ │ (katashiro) │ + │ LLM [C] │-┐ │ HTTP MCP srv :PORT │<==WS==> │ [S] browser │ + └───────────┘ │ └──────────────────────────────────┘ └────────────────┘ + └── http 127.0.0.1:PORT ──┘ + +Bootstrap + B1 core starts in-process HTTP MCP server @127.0.0.1:PORT (loopback + bearer) + B2 core [per-agent adapter] writes the HTTP MCP entry into the agent's native config + (Cursor → .cursor/mcp.json) BEFORE the agent boots + B3 extension opens /acp WS <=> gateway; ACP initialize; session/new declares its browser + MCP server ("type":"acp") + B4 gateway --mcp/connect--> extension → connectionId (upstream tunnel established) + B5 agent boots, reads config → HTTP-connects to core's MCP server → MCP initialize + +Discovery (tools/list) + D1 agent --http tools/list--> core proxy + D2 core --mcp/message: tools/list--> gateway <=> extension (or served from static set, D4) + D3 extension returns [click, read_dom, navigate, type, screenshot] + D4 core returns the list --http--> agent → the LLM now sees browser tools + +Runtime (LLM clicks a button) + 1 LLM decides browser.click{selector} + 2 agent ==session/request_permission==> core → core auto-approves (D1) ==OK==> agent + 3 agent --http tools/call browser.click--> core proxy [in-pod] + 4 core --mcp/message: tools/call--> gateway + 5 gateway ==server→client request==> <=> extension (leaves pod) + 6 extension runs chrome.scripting click in the active tab + 7 extension ==result==> <=> gateway (back in pod) + 8 gateway → core (match pending id) --http result--> agent → LLM continues + +Only steps 5/7 leave the pod. Outer tunnel ids are paired by the gateway pending-map; the +inner MCP ids are the MCP layer's own bookkeeping and are never inspected by the gateway. +``` + +### Runtime sequence (detailed) — one `browser.click` round-trip + +``` +Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) + +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) + +Precondition: session open, extension WS attached, tools/list already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + payload=[ mcp#7 tools/call ] id=acp#55 + 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response payload=[ mcp#7 result:{ok,url:"/thanks"} ] outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> extracts inner mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, carried verbatim agent<->core<->extension (steps 3->5->7->9) + - acp#55 = outer ACP-envelope id on the upstream tunnel; only the gateway pending-map + tracks it (steps 4<->8) + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport +(3, 9) stay in-pod on loopback. If the extension is not attached at step 5, core returns an +MCP error "browser not connected" (D4 static-advertise: calls fail gracefully, no crash). +``` + +### T0 spike checklist (what the live PoC must confirm) + +1. Cursor loads an **HTTP** MCP server registered in `.cursor/mcp.json` (auto, or needs a + one-time `cursor-agent mcp enable`). +2. Cursor honours `notifications/tools/list_changed` and re-fetches mid-session (validates + D4(b); if not, D4(a) static-advertise carries it). +3. Cursor handles a `tools/call` error ("browser not connected") gracefully. + ### Findings that reshape the work - The **agent→client REQUEST direction already exists on the downstream hop**: `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* those downstream requests up to the `/acp` client (and the response back) instead of auto-answering them. -- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784). - Giving the agent browser tools = **injecting a core-side proxy MCP server** into that list - (T5) that tunnels `tools/*` to the extension. +- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784), + but that path is **not** how the agent gets the browser tools (see D2 — Cursor ignores + ACP-passed MCP servers). Giving the agent browser tools = core **hosts a proxy HTTP MCP + server** and registers it in the agent's **native** MCP config (T5); the proxy tunnels + `tools/*` to the extension over the upstream MCP-over-ACP link. ### Ownership - **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. -- **Extension side** (katashiro): T6; plus the client halves of T3 (respond to - `request_permission`) and T4 (serve MCP over the tunnel). +- **Extension side** (katashiro): T6; plus the client half of T4 (serve MCP over the + tunnel). (Permission is auto-approved by core per D1, so no consent UX is needed yet.) - **Both**: T7. ### Tasks -**T0 — Spike (do first; de-risks everything).** PoC: give `cursor-agent` a non-empty -`mcpServers` pointing at a mock MCP server and confirm the LLM actually discovers -(`tools/list`) and calls (`tools/call`) a tool; confirm a downstream agent→client request -can be relayed and answered. If this doesn't hold, the browser goal needs a different path. +**T0 — Spike (do first; de-risks everything).** PoC per the **T0 spike checklist** above: +register a mock **HTTP** MCP server in Cursor's `.cursor/mcp.json` and confirm the LLM +discovers (`tools/list`) and calls (`tools/call`) a tool, honours `tools/list_changed`, and +handles a call error gracefully. If this doesn't hold, the browser goal needs a different +path. (The agent→client request direction it depends on already exists downstream — see +Findings.) **T1 — agent→client REQUEST direction (relay).** - 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` @@ -151,22 +314,25 @@ can be relayed and answered. If this doesn't hold, the browser goal needs a diff migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). 2.3 Round-trip validate against real traffic (ACP trace mode). -**T3 — `session/request_permission` end-to-end.** Largely the first concrete case of T1 -(relay the request, extension consent UX, relay the response) — folds into T1, not a -separate large task. +**T3 — `session/request_permission`.** **Dropped** per D1 (auto-approve; core keeps +auto-replying). Fine-grained permission control is a later, separate effort. -**T4 — MCP-over-ACP tunnel framing.** -- 4.1 Fix the **wire contract**: how MCP JSON-RPC (`tools/list` / `tools/call` / results) - is multiplexed over `/acp` (method namespace, e.g. `_mcp/*`; request/response - correlation; framing). 4.2 Gateway routes MCP-namespaced messages between the `/acp` - client and core. 4.3 Contract doc (the spec the extension implements). 4.4 Mock-MCP- - client-over-tunnel tests. +**T4 — MCP-over-ACP tunnel framing (upstream only).** Adopt the official +[MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) rather than a +hand-rolled envelope (D3). +- 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; + outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes + these between the `/acp` client (extension) and core. 4.3 Contract doc (the spec the + extension implements). 4.4 Mock-MCP-over-tunnel tests. **T5 — OpenAB core = MCP proxy/aggregator.** -- 5.1 A core-side local MCP server (proxy) the agent connects to via `mcpServers`. -- 5.2 Inject that proxy into the downstream `mcpServers` (currently `[]`). -- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel. -- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent). +- 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` + (loopback + bearer) that the agent connects to (D3). +- 5.2 **Per-agent adapter** registers that server in the agent's native MCP config (Cursor → + `.cursor/mcp.json`) before boot, *not* via ACP `session/new mcpServers` (D2). +- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel (T4). +- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent); static- + advertise the browser toolset + emit `tools/list_changed` on attach/detach (D4). - 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. **T6 — extension = MCP server + browser tools** (katashiro). @@ -179,10 +345,10 @@ separate large task. **T7 — integration + e2e + deploy.** - 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). - 7.3 Rebuild + redeploy Falcon. 7.4 Finalize this ADR. + 7.3 Rebuild + redeploy the deployed Cursor agent. 7.4 Finalize this ADR. ### Suggested order -T0 spike → T1 (+ T3 as its first case) → T2 → **T4 (fix the wire contract)** → T5 → then -T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 -(tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is +T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the RFD framing)** → T5 +→ then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), +T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is parallel and non-blocking. From b14552ae0d3da569c265cf49858808f14e80bca2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 22:50:59 +0800 Subject: [PATCH 003/138] feat(gateway/acp): server-initiated request direction (T1.2/1.3) Add the agent->client REQUEST direction to the ACP WebSocket server, the plumbing the MCP-over-ACP tunnel needs. The base only had client->server requests plus server->client notifications; this adds: - route_client_response(): the read loop now recognises an inbound client *response* (id present, no `method`, carries result/error) and routes it to the waiting request via a per-connection pending map, instead of answering it with -32600. Gated on !is_notification so notification/request handling is untouched. - pending_requests: Arc>>> per connection, drained on disconnect so in-flight awaiters unblock with "connection closed" rather than hanging until timeout. - send_request() + JsonRpcRequestOut: mint an id, register the oneshot, send the frame over the existing outbound channel, timeout-await the correlated response. Landed with #[allow(dead_code)] as ready infrastructure; its caller arrives with T1.4 (the core<->gateway bridge). Mirrors the existing client-side pattern in openab-core/src/acp/connection.rs. Adds an acp_requests test module (route + send_request round-trip, id minting, request/notification rejection, unmatched id). Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 18c2b2cca..e6965bf80 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -21,8 +21,9 @@ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -324,6 +325,19 @@ struct JsonRpcNotification { params: Value, } +/// A SERVER-INITIATED JSON-RPC request (the agent→client REQUEST direction, T1). The base +/// only ever *received* requests, so `JsonRpcRequest` is deserialize-only; this is the +/// outbound counterpart used by `send_request`. Wired to a caller by T1.4 (core↔gateway +/// bridge) / the MCP-over-ACP tunnel; landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct JsonRpcRequestOut { + jsonrpc: &'static str, + id: u64, + method: String, + params: Value, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -406,6 +420,67 @@ pub async fn ws_upgrade( // ACP Connection handler // --------------------------------------------------------------------------- +/// Route an inbound client *response* (an id-bearing frame with `result`/`error` and NO +/// `method`) to the `send_request` awaiter registered under its id. Returns `true` when the +/// frame was a response we consumed (so the caller must stop dispatching it as a request). +/// Mirrors the client-side correlation in `openab-core/src/acp/connection.rs`. +async fn route_client_response( + pending: &Arc>>>, + raw: &Value, +) -> bool { + let has_method = raw.get("method").is_some(); + let looks_like_response = raw.get("result").is_some() || raw.get("error").is_some(); + if has_method || !looks_like_response { + return false; + } + let Some(id) = raw.get("id").and_then(Value::as_u64) else { + return false; + }; + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(raw.clone()); + } else { + warn!(id, "acp: client response with no matching pending request"); + } + true +} + +/// Send a server-initiated JSON-RPC request to the connected ACP client and await its +/// response (the agent→client REQUEST direction, T1). Mints an id, registers a oneshot in +/// `pending`, writes the frame via the outbound channel, then awaits the correlated response +/// (resolved by `route_client_response`) with a timeout. Wired to a caller by T1.4 (the +/// core↔gateway MCP-over-ACP bridge); landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +async fn send_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + method: impl Into, + params: Value, + timeout_secs: u64, +) -> Result { + let id = next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(id, tx); + let frame = JsonRpcRequestOut { + jsonrpc: "2.0", + id, + method: method.into(), + params, + }; + if out_tx.send(serde_json::to_string(&frame).unwrap()).is_err() { + pending.lock().await.remove(&id); + return Err("connection closed".into()); + } + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await { + Ok(Ok(v)) => Ok(v), + Ok(Err(_)) => Err("connection closed before response".into()), + Err(_) => { + pending.lock().await.remove(&id); + Err("request timed out".into()) + } + } +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -418,6 +493,12 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // Session state for this connection let sessions: Arc>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Pending server-initiated requests (T1): id → oneshot for the client's response. The + // base had only client→server requests + server→client notifications; the MCP-over-ACP + // tunnel adds the server→client REQUEST direction, correlated through this map by + // `route_client_response` (inbound) and `send_request` (outbound, wired in T1.4). + let pending_requests: Arc>>> = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); let mut initialized = false; // Track spawned prompt tasks so we can abort on disconnect @@ -494,6 +575,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } + // A client *response* to a server-initiated request (T1): id present, no `method`, + // carries `result`/`error`. Route it to the waiting `send_request` and stop — it is + // neither a client request nor a notification. Gated on `!is_notification` (a + // notification never carries an id, so it can never be a response), keeping the + // existing notification/request handling below untouched. + if !is_notification && route_client_response(&pending_requests, &raw).await { + continue; + } + let req: JsonRpcRequest = match serde_json::from_value(raw) { Ok(r) => r, Err(e) => { @@ -699,6 +789,13 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { prompt_tasks.retain(|h| !h.is_finished()); } + // Drain any in-flight server-initiated requests: dropping each oneshot sender makes the + // corresponding `send_request` awaiter resolve to "connection closed before response" + // rather than hang until timeout. Mirrors the close-drain in openab-core connection.rs. + for (_id, tx) in pending_requests.lock().await.drain() { + drop(tx); + } + // --- Disconnect cleanup --- // Abort any in-flight prompt tasks to prevent registry leaks for handle in prompt_tasks { @@ -1585,6 +1682,96 @@ mod acp_streaming { // Handler-level tests — call the real handlers (not just literal round-trips) and // assert their actual output + side effects. // --------------------------------------------------------------------------- +#[cfg(test)] +mod acp_requests { + //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an + //! id, awaits the correlated response) and inbound `route_client_response`. + use super::{route_client_response, send_request}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + use tokio::sync::{mpsc, oneshot}; + + fn new_pending( + ) -> Arc>>> { + Arc::new(tokio::sync::Mutex::new(HashMap::new())) + } + + #[tokio::test] + async fn route_client_response_resolves_pending() { + let pending = new_pending(); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(5, tx); + let consumed = + route_client_response(&pending, &json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}})) + .await; + assert!(consumed, "an id+result frame is a response we consume"); + assert_eq!(rx.await.unwrap()["result"]["ok"], json!(true)); + assert!( + pending.lock().await.is_empty(), + "the pending entry must be removed" + ); + } + + #[tokio::test] + async fn route_client_response_ignores_requests_and_notifications() { + let pending = new_pending(); + // has `method` → a request, not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":1,"method":"foo"})).await); + // notification-shaped, no result/error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","method":"bar"})).await); + // id present but neither result nor error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":2})).await); + } + + #[tokio::test] + async fn route_client_response_unknown_id_consumes_without_panic() { + let pending = new_pending(); + let consumed = route_client_response( + &pending, + &json!({"jsonrpc":"2.0","id":99,"error":{"code":-1,"message":"x"}}), + ) + .await; + assert!(consumed, "an unmatched response is still consumed (logged, no panic)"); + } + + #[tokio::test] + async fn send_request_mints_incrementing_ids_and_returns_response() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Driver: read the emitted request frame, assert its shape, feed a matching response. + let pending2 = pending.clone(); + let driver = tokio::spawn(async move { + let frame = out_rx.recv().await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["jsonrpc"], json!("2.0")); + assert_eq!(v["id"], json!(1)); + assert_eq!(v["method"], json!("mcp/message")); + assert_eq!(v["params"]["connectionId"], json!("conn-1")); + let id = v["id"].as_u64().unwrap(); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":id,"result":{"pong":true}})) + .await; + }); + + let resp = send_request( + &out_tx, + &pending, + &next_id, + "mcp/message", + json!({"connectionId":"conn-1"}), + 5, + ) + .await + .unwrap(); + assert_eq!(resp["result"]["pong"], json!(true)); + driver.await.unwrap(); + assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); + } +} + #[cfg(test)] mod acp_handlers { use super::{ From 4d6055d3707eb59f3dd50da5464570ea1d7771b2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 23:09:46 +0800 Subject: [PATCH 004/138] refactor(gateway/acp): construct trivial responses from generated types (T2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the three trivial outbound response payloads from hand-rolled `json!` to the generated `acp_schema` types, so the wire shape is type-checked: - session/new → NewSessionResponse { session_id: SessionId(..) } - session/resume → ResumeSessionResponse::default() (serializes to {}) - prompt final → PromptResponse { stop_reason }, with `stop_reason` now a typed StopReason enum (EndTurn / Cancelled) instead of a &str literal. rename + skip_serializing_if make the emitted wire byte-identical to the prior `json!`, so the existing conforms:: round-trip tests and handler behaviour tests are unchanged (292 pass). handle_initialize stays hand-rolled for now (nested agentCapabilities/agentInfo; low value, per base ADR §7 the trivial chat subset does not require typed construction). The remaining T2.2 (typing the mcp/connect + mcp/message bidirectional frames) lands with T4. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index e6965bf80..c76db00b1 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -902,8 +902,16 @@ async fn handle_session_new( // Downgraded from info! — sessionId is a resume capability; keep it out of normal logs (F12). debug!(session = %session_id, "ACP session created"); - // ACP session/new response is just { sessionId }. - JsonRpcResponse::success(id, json!({ "sessionId": session_id })) + // ACP session/new response is just { sessionId }. Constructed from the generated + // NewSessionResponse (T2.1) so the wire shape is type-checked against acp_schema; the + // optional fields skip-serialize, giving the same { "sessionId": ... } wire. + let resp = crate::adapters::acp_schema::NewSessionResponse { + session_id: crate::adapters::acp_schema::SessionId(session_id), + config_options: None, + meta: None, + modes: None, + }; + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) } /// `session/resume` — re-attach to a session the client persisted, WITHOUT @@ -977,8 +985,10 @@ async fn handle_session_resume( debug!(session = %session_id, "ACP session resumed"); - // ACP session/resume response is an empty object (no history replay). - JsonRpcResponse::success(id, json!({})) + // ACP session/resume response is an empty object (no history replay) — the generated + // ResumeSessionResponse default serializes to {} (T2.1, type-checked against acp_schema). + let resp = crate::adapters::acp_schema::ResumeSessionResponse::default(); + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) } /// Handle `session/cancel`. Per ACP it is a one-way NOTIFICATION: the notification form @@ -1131,14 +1141,15 @@ async fn handle_session_prompt( // Stream replies back as ACP `session/update` notifications. let mut sent_len = 0usize; let timeout = tokio::time::Duration::from_secs(180); - let mut stop_reason = "end_turn"; + // Typed StopReason (T2.1) so the final PromptResponse is constructed from acp_schema. + let mut stop_reason = crate::adapters::acp_schema::StopReason::EndTurn; let mut timed_out = false; loop { tokio::select! { // session/cancel fired — stop gracefully. _ = cancel.notified() => { - stop_reason = "cancelled"; + stop_reason = crate::adapters::acp_schema::StopReason::Cancelled; break; } recv = tokio::time::timeout(timeout, reply_rx.recv()) => { @@ -1196,7 +1207,12 @@ async fn handle_session_prompt( let resp = if timed_out { JsonRpcResponse::error(id, -32603, "Timed out waiting for agent backend") } else { - JsonRpcResponse::success(id, json!({ "stopReason": stop_reason })) + // T2.1: construct the typed PromptResponse; serializes to { "stopReason": ... }. + let pr = crate::adapters::acp_schema::PromptResponse { + stop_reason, + meta: None, + }; + JsonRpcResponse::success(id, serde_json::to_value(&pr).unwrap()) }; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); } From 54ac76198facc7828c3a050bf698b9851613d587 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 19 Jul 2026 23:23:18 +0800 Subject: [PATCH 005/138] feat(gateway/acp): MCP-over-ACP tunnel frame API (T4.1) Add the gateway-side helpers for the official MCP-over-ACP RFD tunnel (agentclientprotocol.com/rfds/mcp-over-acp), built on the T1 send_request machinery (its first real callers): - mcp_connect(acpId) -> connectionId - mcp_message_request(connectionId, method, params) -> inner MCP result - mcp_disconnect(connectionId) - McpConnectParams / McpConnectResult / McpMessageParams / McpDisconnectParams (hand-rolled: these RFC methods are not in the generated acp_schema, which only has the session/new McpServer* declaration types + McpCapabilities), plus frame_result() to unwrap a response frame's result / surface its error. Per the RFD, mcp/message flattens the inner MCP method/params into the params object WITHOUT the inner MCP id; correlation is purely by the outer ACP id, and the response result is the inner MCP result payload. Adds a mock-tunnel round-trip test (mcp/connect -> connectionId, mcp/message tools/list -> result). Helpers carry #[allow(dead_code)] until T5 wires them to the core MCP proxy (their real caller). Remaining T4: session/new "type":"acp" parsing + advertise mcpCapabilities.acp, gateway<->core routing (with T5), contract doc. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index c76db00b1..3cf923fc4 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -338,6 +338,52 @@ struct JsonRpcRequestOut { params: Value, } +// --- MCP-over-ACP tunnel frames (T4) ------------------------------------------ +// The official MCP-over-ACP RFD (agentclientprotocol.com/rfds/mcp-over-acp) tunnels MCP +// over the /acp WS with three methods: mcp/connect → connectionId, then mcp/message (the +// inner MCP method/params FLATTENED into the params, correlated by the OUTER ACP id — the +// inner MCP id is not carried), and mcp/disconnect. These types are NOT in the generated +// `acp_schema` (the RFD is a proposal, not the stable v1 schema), so they are hand-rolled +// here. The extension is the MCP server and assigns `connectionId`; the gateway (agent side +// of the upstream hop) is the connector and issues connect/message/disconnect. + +/// `mcp/connect` params — `acpId` matches the `id` of the client's `session/new` +/// `mcpServers` entry with `"type":"acp"`. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpConnectParams { + #[serde(rename = "acpId")] + acp_id: String, +} + +/// `mcp/connect` result — the client-assigned connection handle. +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct McpConnectResult { + #[serde(rename = "connectionId")] + connection_id: String, +} + +/// `mcp/message` params — the inner MCP `method`/`params` are flattened in (no inner id); +/// `connectionId` selects the tunnelled MCP connection. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpMessageParams { + #[serde(rename = "connectionId")] + connection_id: String, + method: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +/// `mcp/disconnect` params. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpDisconnectParams { + #[serde(rename = "connectionId")] + connection_id: String, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -481,6 +527,77 @@ async fn send_request( } } +/// Extract the `result` from a JSON-RPC response frame, mapping an `error` member to `Err`. +/// `send_request` yields the whole response frame; the tunnel helpers want just the payload. +#[allow(dead_code)] +fn frame_result(frame: Value) -> Result { + if let Some(err) = frame.get("error") { + return Err(format!("remote error: {err}")); + } + Ok(frame.get("result").cloned().unwrap_or(Value::Null)) +} + +/// `mcp/connect` (T4): open a tunnelled MCP connection to the client-provided (`"type":"acp"`) +/// MCP server identified by `acp_id`; returns the client-assigned `connectionId`. +#[allow(dead_code)] +async fn mcp_connect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + acp_id: &str, + timeout_secs: u64, +) -> Result { + let params = serde_json::to_value(McpConnectParams { + acp_id: acp_id.to_string(), + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/connect", params, timeout_secs).await?; + let result: McpConnectResult = serde_json::from_value(frame_result(frame)?) + .map_err(|e| format!("mcp/connect: malformed result: {e}"))?; + Ok(result.connection_id) +} + +/// `mcp/message` REQUEST (T4): tunnel an inner MCP request over `connection_id`; returns the +/// inner MCP result payload (the outer ACP id does the correlation; the inner MCP id is not +/// carried on the wire). +#[allow(dead_code)] +async fn mcp_message_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + method: &str, + params: Option, + timeout_secs: u64, +) -> Result { + let msg = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: method.to_string(), + params, + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/message", msg, timeout_secs).await?; + frame_result(frame) +} + +/// `mcp/disconnect` (T4): close a tunnelled MCP connection. +#[allow(dead_code)] +async fn mcp_disconnect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + timeout_secs: u64, +) -> Result<(), String> { + let params = serde_json::to_value(McpDisconnectParams { + connection_id: connection_id.to_string(), + }) + .unwrap(); + send_request(out_tx, pending, next_id, "mcp/disconnect", params, timeout_secs) + .await + .map(|_| ()) +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -1702,7 +1819,7 @@ mod acp_streaming { mod acp_requests { //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an //! id, awaits the correlated response) and inbound `route_client_response`. - use super::{route_client_response, send_request}; + use super::{mcp_connect, mcp_message_request, route_client_response, send_request}; use serde_json::json; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1786,6 +1903,47 @@ mod acp_requests { driver.await.unwrap(); assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); } + + #[tokio::test] + async fn mcp_tunnel_connect_and_message_roundtrip() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Mock extension: answer mcp/connect with a connectionId, then turn an mcp/message + // tools/list into an inner result, routing each reply by the frame's own outer id. + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f1: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f1["method"], json!("mcp/connect")); + assert_eq!(f1["params"]["acpId"], json!("srv-1")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f1["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; + + let f2: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f2["method"], json!("mcp/message")); + assert_eq!(f2["params"]["connectionId"], json!("conn-9")); + assert_eq!(f2["params"]["method"], json!("tools/list")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f2["id"],"result":{"tools":[{"name":"browser.click"}]}}), + ) + .await; + }); + + let conn = mcp_connect(&out_tx, &pending, &next_id, "srv-1", 5) + .await + .unwrap(); + assert_eq!(conn, "conn-9"); + let result = mcp_message_request(&out_tx, &pending, &next_id, &conn, "tools/list", None, 5) + .await + .unwrap(); + assert_eq!(result["tools"][0]["name"], json!("browser.click")); + ext.await.unwrap(); + } } #[cfg(test)] From f6c92940814a81fb2f2e4e131e774a7be1e416ca Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:05:01 +0800 Subject: [PATCH 006/138] feat(core): scaffold MCP proxy server deps + static browser toolset (T5.1a) Introduce the core-hosted MCP proxy for MCP-over-ACP browser control (D3/D4), starting with the dependency integration + the static tool set: - openab-core gains optional rmcp (server + transport-streamable-http-server), axum 0.8 (matches the gateway, one axum in the workspace), and tokio-util, gated behind a new `acp-mcp` feature so non-acp builds don't pull them. The root `acp` feature (included by `unified`) now enables `openab-core/acp-mcp`. This is the workspace's first rmcp server-side usage; it resolves + compiles cleanly alongside openab-agent's rmcp client features. - New `mcp_proxy` module (feature-gated) with `browser_tools()`: the fixed DOM-semantic tool set (click / read_dom / navigate / type / screenshot) that, per D4, core static-advertises regardless of whether an extension is attached. Built from rmcp `Tool::new` + typed input schemas; unit-tested. `browser_tools()` carries #[allow(dead_code)] until the ServerHandler wires it. Next (T5.1b): ServerHandler impl + spawn_mcp_server (loopback + bearer axum listener); then T5.2 config injection and T5.3 tunnel wiring. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 207 +++++++++++++++++++++++++--- Cargo.toml | 2 +- crates/openab-core/Cargo.toml | 8 ++ crates/openab-core/src/lib.rs | 2 + crates/openab-core/src/mcp_proxy.rs | 100 ++++++++++++++ 5 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 crates/openab-core/src/mcp_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 85341ca9b..970462b42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -593,7 +593,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -846,6 +846,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -911,7 +922,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1136,7 +1147,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1171,7 +1182,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1180,6 +1191,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1379,7 +1396,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1458,6 +1475,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -2221,6 +2239,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", + "axum", "base64", "bytes", "chrono", @@ -2238,6 +2257,7 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest", + "rmcp", "rpassword", "rustls 0.22.4", "serde", @@ -2249,6 +2269,7 @@ dependencies = [ "tokio", "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", + "tokio-util", "toml", "toml_edit", "tracing", @@ -2340,6 +2361,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2413,7 +2440,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2540,7 +2567,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2672,6 +2699,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2710,6 +2748,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2719,6 +2763,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "regex" version = "1.12.4" @@ -2821,6 +2885,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -2987,6 +3080,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3092,7 +3211,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -3322,6 +3452,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3351,6 +3494,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3368,7 +3522,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3420,7 +3574,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3431,7 +3585,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3523,7 +3677,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3557,6 +3711,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -3705,7 +3870,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3977,7 +4142,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -4068,7 +4233,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4079,7 +4244,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4340,7 +4505,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -4361,7 +4526,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4381,7 +4546,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -4421,7 +4586,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7e86b6178..282bca1b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"] googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"] wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"] teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"] -acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"] +acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp", "openab-core/acp-mcp"] [dev-dependencies] tempfile = "3.27.0" diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 9d8b7389d..91c24f94b 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -49,6 +49,12 @@ aws-credential-types = { version = "1", optional = true } urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } +# MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process +# Streamable-HTTP MCP server the colocated agent connects to (D3). rmcp server + a +# self-owned loopback axum listener; kept optional so non-acp builds don't pull them. +rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } +axum = { version = "0.8", optional = true } +tokio-util = { version = "0.7", optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -65,3 +71,5 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"] pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"] filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] +# Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). +acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util"] diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 7e50ce4ce..4e26a641d 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -1,5 +1,7 @@ pub mod acp; pub mod adapter; +#[cfg(feature = "acp-mcp")] +pub mod mcp_proxy; pub mod bot_turns; pub mod config; pub mod cron; diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs new file mode 100644 index 000000000..465a8f946 --- /dev/null +++ b/crates/openab-core/src/mcp_proxy.rs @@ -0,0 +1,100 @@ +//! Core-hosted MCP proxy server for MCP-over-ACP browser control (feature `acp-mcp`). +//! +//! Per ADR §7 (D3), OpenAB **core** hosts an in-process Streamable-HTTP MCP server on +//! loopback that the colocated agent CLI connects to as a normal MCP client. The server is a +//! proxy: its tool list + tool execution are backed by the remote browser extension over the +//! `/acp` MCP-over-ACP tunnel (wired in T5.3). Per D4 the browser tool set is +//! **static-advertised** regardless of whether an extension is currently attached — a call +//! while disconnected returns a "browser not connected" error rather than hiding the tools. +//! +//! This module currently provides the static tool set; the `ServerHandler` + loopback +//! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. + +use rmcp::model::{object, Tool}; +use serde_json::json; + +/// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- +/// semantic actions the extension executes in the user's active tab; model-agnostic. +/// Consumed by the `ServerHandler::list_tools` impl wired in the next T5 sub-tick. +#[allow(dead_code)] +pub(crate) fn browser_tools() -> Vec { + vec![ + Tool::new( + "browser.click", + "Click the element matching a CSS selector in the active browser tab.", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "CSS selector" } }, + "required": ["selector"] + })), + ), + Tool::new( + "browser.read_dom", + "Read a snapshot of the active tab's DOM (optionally scoped to a selector).", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "optional CSS selector to scope the snapshot" } } + })), + ), + Tool::new( + "browser.navigate", + "Navigate the active browser tab to a URL.", + object(json!({ + "type": "object", + "properties": { "url": { "type": "string", "description": "absolute URL" } }, + "required": ["url"] + })), + ), + Tool::new( + "browser.type", + "Type text into the element matching a CSS selector in the active tab.", + object(json!({ + "type": "object", + "properties": { + "selector": { "type": "string", "description": "CSS selector" }, + "text": { "type": "string", "description": "text to type" } + }, + "required": ["selector", "text"] + })), + ), + Tool::new( + "browser.screenshot", + "Capture a screenshot of the active browser tab.", + object(json!({ "type": "object", "properties": {} })), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::browser_tools; + + #[test] + fn browser_tools_advertises_the_fixed_set() { + let tools = browser_tools(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + [ + "browser.click", + "browser.read_dom", + "browser.navigate", + "browser.type", + "browser.screenshot" + ] + ); + } + + #[test] + fn every_browser_tool_has_an_object_input_schema() { + for t in browser_tools() { + assert_eq!( + t.input_schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "tool {} must have an object input schema", + t.name + ); + assert!(t.description.is_some(), "tool {} needs a description", t.name); + } + } +} From b8a01cb3eec404e842dbe335e2b7adb02d0747ce Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:29:37 +0800 Subject: [PATCH 007/138] feat(core): MCP proxy ServerHandler + loopback server (T5.1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the core-hosted MCP server the colocated agent connects to (D3): - ProxyHandler impls rmcp ServerHandler: get_info advertises the tools capability; list_tools returns the static browser tool set (D4 static- advertise); call_tool returns "browser not connected" until the tunnel is wired (T5.3) — failing gracefully rather than hiding the tools (D4). - spawn_mcp_server binds an OS-assigned 127.0.0.1 port with its own axum listener (StreamableHttpService, stateless + JSON responses), graceful shutdown via a CancellationToken. The caller hands the port to the agent's native MCP config in T5.2. An HTTP integration test spawns the server, confirms it binds loopback, and that an MCP initialize returns a result advertising the tools capability. Bearer auth on the listener is added in T5.2 (the token is minted alongside the .cursor/mcp.json injection). Tunnel wiring (RemoteExtensionChannel -> mcp_connect /mcp_message) is T5.3. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 111 +++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 465a8f946..f39ed8059 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -10,13 +10,19 @@ //! This module currently provides the static tool set; the `ServerHandler` + loopback //! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. -use rmcp::model::{object, Tool}; +use rmcp::model::{ + object, CallToolRequestParams, CallToolResult, ErrorData as McpError, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::ServerHandler; use serde_json::json; /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. -/// Consumed by the `ServerHandler::list_tools` impl wired in the next T5 sub-tick. -#[allow(dead_code)] pub(crate) fn browser_tools() -> Vec { vec![ Tool::new( @@ -65,9 +71,78 @@ pub(crate) fn browser_tools() -> Vec { ] } +/// The core-hosted MCP server the colocated agent connects to (D3). A proxy: it advertises +/// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension +/// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not +/// connected" on call. +#[derive(Clone, Default)] +pub struct ProxyHandler {} + +impl ServerHandler for ProxyHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "OpenAB browser-control proxy: DOM-semantic tools executed in the user's browser \ + via MCP-over-ACP.", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + // D4 static-advertise: expose the browser tools regardless of extension state. + Ok(ListToolsResult { + tools: browser_tools(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + // No extension wired yet (T5.3). Per D4, fail gracefully rather than hide the tool. + Err(McpError::internal_error( + "browser not connected: open the OpenAB side panel in your browser", + None, + )) + } +} + +/// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port +/// (D3). Returns the bound address; the caller hands `addr.port()` to the colocated agent's +/// native MCP config (T5.2). Shuts down when `ct` is cancelled. A bearer gate is added in +/// T5.2 (the token is minted alongside the config injection). +pub async fn spawn_mcp_server( + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result { + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()); + let service: StreamableHttpService = + StreamableHttpService::new( + || Ok(ProxyHandler::default()), + Default::default(), + config, + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + }); + Ok(addr) +} + #[cfg(test)] mod tests { - use super::browser_tools; + use super::{browser_tools, spawn_mcp_server}; #[test] fn browser_tools_advertises_the_fixed_set() { @@ -97,4 +172,32 @@ mod tests { assert!(t.description.is_some(), "tool {} needs a description", t.name); } } + + #[tokio::test] + async fn mcp_server_binds_loopback_and_initializes() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server(ct.clone()).await.unwrap(); + assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); + + let url = format!("http://{addr}/mcp"); + let resp = reqwest::Client::new() + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#, + ) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["jsonrpc"], "2.0"); + assert!(body["result"].is_object(), "initialize must return a result"); + assert!( + body["result"]["capabilities"]["tools"].is_object(), + "server must advertise the tools capability" + ); + ct.cancel(); + } } From cc9dbf7faf8a23bf31d39993dab2bb3b1c8d2e03 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 00:49:08 +0800 Subject: [PATCH 008/138] feat(core): bearer gate on the MCP proxy server (T5.2 part 1) Even bound to loopback, the core MCP server now requires the token the agent's MCP config carries (D3), so another local process on the host can't reach the browser tools. spawn_mcp_server takes a `bearer` and layers an axum middleware that returns 401 when Authorization: Bearer is absent or wrong; the caller mints the token and shares it with the agent config. Tests: authed initialize -> 200 + tools capability; missing / wrong token -> 401. Remaining T5.2: the per-agent adapter writes { url: 127.0.0.1:, headers: Authorization Bearer } into the agent's native MCP config (Cursor -> .cursor/mcp.json) before boot, and wires spawn_mcp_server into openab startup. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 85 ++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index f39ed8059..ec537eedc 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -19,7 +19,9 @@ use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; use rmcp::ServerHandler; +use axum::response::IntoResponse; use serde_json::json; +use std::sync::Arc; /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. @@ -111,11 +113,34 @@ impl ServerHandler for ProxyHandler { } } +/// Loopback bearer gate for the MCP server (D3): even bound to 127.0.0.1, require the token +/// the agent's MCP config carries, so another local process on the host can't reach the +/// browser tools. Returns 401 when the `Authorization: Bearer ` header is absent or +/// wrong. +async fn require_bearer( + axum::extract::State(expected): axum::extract::State>, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let authed = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .is_some_and(|t| t == &*expected); + if authed { + next.run(req).await + } else { + axum::http::StatusCode::UNAUTHORIZED.into_response() + } +} + /// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port -/// (D3). Returns the bound address; the caller hands `addr.port()` to the colocated agent's -/// native MCP config (T5.2). Shuts down when `ct` is cancelled. A bearer gate is added in -/// T5.2 (the token is minted alongside the config injection). +/// (D3), gated by `bearer`. Returns the bound address; the caller hands `addr.port()` + the +/// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is +/// cancelled. pub async fn spawn_mcp_server( + bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { let config = StreamableHttpServerConfig::default() @@ -129,7 +154,12 @@ pub async fn spawn_mcp_server( Default::default(), config, ); - let router = axum::Router::new().nest_service("/mcp", service); + let router = axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn_with_state( + Arc::::from(bearer), + require_bearer, + )); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; tokio::spawn(async move { @@ -173,20 +203,23 @@ mod tests { } } + const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; + #[tokio::test] - async fn mcp_server_binds_loopback_and_initializes() { + async fn mcp_server_binds_loopback_and_initializes_with_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server(ct.clone()).await.unwrap(); + let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + .await + .unwrap(); assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); let url = format!("http://{addr}/mcp"); let resp = reqwest::Client::new() .post(&url) + .header("Authorization", "Bearer secret-token") .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") - .body( - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#, - ) + .body(INIT_BODY) .send() .await .unwrap(); @@ -200,4 +233,38 @@ mod tests { ); ct.cancel(); } + + #[tokio::test] + async fn mcp_server_rejects_missing_or_wrong_bearer() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + .await + .unwrap(); + let url = format!("http://{addr}/mcp"); + let client = reqwest::Client::new(); + + // no Authorization header -> 401 + let no_auth = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(no_auth.status(), 401); + + // wrong token -> 401 + let wrong = client + .post(&url) + .header("Authorization", "Bearer nope") + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(wrong.status(), 401); + ct.cancel(); + } } From 63d65661cebaebbc992c2d6b65199269d96aa196 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:16:31 +0800 Subject: [PATCH 009/138] =?UTF-8?q?docs(adr):=20correct=20runtime=20diagra?= =?UTF-8?q?m=20=E2=80=94=20mcp/message=20flattens,=20no=20inner=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP-over-ACP RFD flattens the inner MCP method/params into the mcp/message params and does NOT carry an inner MCP id; correlation on the upstream tunnel is by the outer ACP id alone, and the response result IS the inner MCP result payload. Fix the detailed runtime sequence + the id-space note: mcp#7 lives only on the agent<->core HTTP hop; the core proxy maps its downstream mcp#7 <-> the upstream acp#55. (Was: "carried verbatim agent<->core<->extension".) Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 9e4698749..c1d8cc08c 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -241,11 +241,11 @@ Precondition: session open, extension WS attached, tools/list already discovered .............................................................................. 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 - payload=[ mcp#7 tools/call ] id=acp#55 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks - 7 G <==WS== E response payload=[ mcp#7 result:{ok,url:"/thanks"} ] outer id=acp#55 <-on-pod - 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> extracts inner mcp#7 + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 .............................................................................. 10 A LLM consumes the tool result, keeps reasoning @@ -253,9 +253,12 @@ Precondition: session open, extension WS attached, tools/list already discovered 12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod -------------------------------------------------------------------------------- Two id spaces (never mixed) - - mcp#7 = MCP-layer id, carried verbatim agent<->core<->extension (steps 3->5->7->9) - - acp#55 = outer ACP-envelope id on the upstream tunnel; only the gateway pending-map - tracks it (steps 4<->8) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the + MCP-over-ACP RFD, mcp/message FLATTENS the inner method/params and does NOT + carry an inner MCP id, so mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id that correlates the whole upstream tunnel round-trip + (steps 4<->8); the response result IS the inner MCP result payload. The core + proxy maps its downstream mcp#7 <-> the upstream acp#55. - acp#1 = downstream ACP permission id; unrelated to the two above Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport From d91814e5737a485f21e6eafbb1badb4089f71f1a Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:41:14 +0800 Subject: [PATCH 010/138] feat(gateway/acp): parse + record client-declared type:acp mcpServers (T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser extension declares its MCP-over-ACP server in session/new via the RFD's mcpServers entry {"type":"acp","id":...,"name":...}. Parse those (raw, since the "acp" transport is an RFD proposal not in the generated schema) and record them per session (AcpSession.acp_mcp_servers), so the gateway can later mcp/connect to them (T5.3). session/resume re-records them since the client re-presents mcpServers. http/sse/stdio servers are ignored (the agent connects to those itself). D5-agnostic: needed regardless of the core MCP server topology. Field is #[allow(dead_code)] until the mcp/connect wiring consumes it. Tests: parse keeps only acp entries (+ empty cases); session/new records them. Not done here: advertising mcpCapabilities.acp in initialize — the generated McpCapabilities has only http/sse (the RFD acp flag isn't in stable v1), and since we own both ends the extension can declare type:acp unconditionally; left as a follow-up. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 3cf923fc4..3baff8fd2 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -259,6 +259,41 @@ struct AcpSession { /// "cancelled"` to the prompt's own request id (rather than hard-aborting /// the task and orphaning that id). cancel: Option>, + /// Client-declared MCP-over-ACP servers (the RFD `{"type":"acp"}` mcpServers entries): + /// the browser extension serves its MCP tools over this same /acp WS. Recorded so the + /// gateway can later `mcp/connect` to them (T5.3). Unused until that wiring lands. + #[allow(dead_code)] + acp_mcp_servers: Vec, +} + +/// A client-declared MCP-over-ACP server (the RFD `"type":"acp"` `mcpServers` entry). Not in +/// the generated schema (the RFD is a proposal), so parsed from raw params. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +struct AcpMcpServer { + id: String, + name: String, +} + +/// Extract the `"type":"acp"` entries from a `session/new` / `session/resume` params +/// `mcpServers` array. http/sse/stdio servers are ignored here — the agent connects to those +/// itself; only the `acp`-transport ones are tunnelled over this WS. +fn parse_acp_mcp_servers(params: Option<&Value>) -> Vec { + params + .and_then(|p| p.get("mcpServers")) + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("acp")) + .filter_map(|e| { + Some(AcpMcpServer { + id: e.get("id").and_then(Value::as_str)?.to_string(), + name: e.get("name").and_then(Value::as_str)?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() } pub enum ReplyChunk { @@ -774,7 +809,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let resp = handle_session_new(&sessions, id.clone()).await; + let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); + let resp = handle_session_new(&sessions, id.clone(), acp_mcp_servers).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); } "session/resume" => { @@ -1000,6 +1036,7 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { async fn handle_session_new( sessions: &Arc>>, id: Value, + acp_mcp_servers: Vec, ) -> JsonRpcResponse { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). @@ -1013,6 +1050,7 @@ async fn handle_session_new( channel_id, busy: false, cancel: None, + acp_mcp_servers, }, ); @@ -1096,6 +1134,8 @@ async fn handle_session_resume( channel_id, busy: false, cancel: None, + // The client re-presents its mcpServers on resume; re-record the acp ones. + acp_mcp_servers: parse_acp_mcp_servers(params), }, ); drop(guard); @@ -1949,7 +1989,8 @@ mod acp_requests { #[cfg(test)] mod acp_handlers { use super::{ - handle_initialize, handle_session_new, handle_session_resume, AcpSession, JsonRpcRequest, + handle_initialize, handle_session_new, handle_session_resume, parse_acp_mcp_servers, + AcpMcpServer, AcpSession, JsonRpcRequest, }; use serde_json::json; use std::collections::HashMap; @@ -1999,12 +2040,47 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = serde_json::to_value(handle_session_new(&sessions, json!(2)).await).unwrap(); + let v = serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); } + #[test] + fn parse_acp_mcp_servers_keeps_only_acp_entries() { + let params = json!({ + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "srv-1", "name": "browser"}, + {"type": "http", "url": "http://x"}, + {"type": "acp", "id": "srv-2", "name": "other"} + ] + }); + assert_eq!( + parse_acp_mcp_servers(Some(¶ms)), + vec![ + AcpMcpServer { id: "srv-1".into(), name: "browser".into() }, + AcpMcpServer { id: "srv-2".into(), name: "other".into() }, + ] + ); + // no mcpServers -> empty + assert!(parse_acp_mcp_servers(Some(&json!({"cwd": "/w"}))).is_empty()); + assert!(parse_acp_mcp_servers(None).is_empty()); + } + + #[tokio::test] + async fn session_new_records_declared_acp_servers() { + let sessions = new_sessions(); + let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; + let v = serde_json::to_value( + handle_session_new(&sessions, json!(2), servers.clone()).await, + ) + .unwrap(); + let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); + let guard = sessions.lock().await; + assert_eq!(guard.get(&sid).unwrap().acp_mcp_servers, servers); + } + #[tokio::test] async fn session_resume_valid_stores_and_invalid_errors() { let sessions = new_sessions(); From 966119169fd6496fa12c64475378119b144c1d79 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 01:46:50 +0800 Subject: [PATCH 011/138] docs: MCP-over-ACP tunnel contract for the extension (T4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec the browser extension (katashiro, T6) implements: the gateway<->extension hop of MCP-over-ACP. Covers the type:acp session/new declaration, mcp/connect -> connectionId, mcp/message (inner method/params flattened, correlate by outer ACP id, result = inner MCP result), mcp/disconnect, the baseline browser tool set (click/read_dom/navigate/type/screenshot), and that permissions are auto-approved (D1) + the WS may attach after session start (D4). D5-agnostic (only the external hop; OpenAB-internal proxy/topology is out of scope), so it lets the extension side proceed in parallel. Linked from ADR §7 T4. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 5 +- docs/mcp-over-acp-tunnel-contract.md | 121 +++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/mcp-over-acp-tunnel-contract.md diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index c1d8cc08c..1c214e1ba 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -325,8 +325,9 @@ auto-replying). Fine-grained permission control is a later, separate effort. hand-rolled envelope (D3). - 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes - these between the `/acp` client (extension) and core. 4.3 Contract doc (the spec the - extension implements). 4.4 Mock-MCP-over-tunnel tests. + these between the `/acp` client (extension) and core. 4.3 Contract doc — done: + [MCP-over-ACP tunnel — extension implementation contract](../mcp-over-acp-tunnel-contract.md). + 4.4 Mock-MCP-over-tunnel tests. **T5 — OpenAB core = MCP proxy/aggregator.** - 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md new file mode 100644 index 000000000..157bff0f1 --- /dev/null +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -0,0 +1,121 @@ +# MCP-over-ACP tunnel — extension implementation contract + +This is the wire contract the **browser extension** (the ACP client / MCP server end) +implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` WebSocket, per +[ADR: Browser control via MCP-over-ACP](./adr/acp-server-websocket-mcp-browser.md). It adopts +the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). + +Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB +routes tool calls internally (core-hosted MCP proxy, agent subprocess) is out of scope for +the extension and may change without affecting this contract. + +## Roles + +- **Extension = ACP client + MCP server.** It opens the `/acp` WS, drives the chat session, + and *serves* the browser MCP tools over that same socket. +- **Gateway = ACP server + MCP client (connector).** It initiates `mcp/connect` / + `mcp/message` / `mcp/disconnect` toward the extension. + +An MV3 extension cannot open a listening socket, so MCP is tunnelled over the outbound WS the +extension already holds — that is the whole point of this contract. + +## 1. Transport + auth (unchanged from the base) + +`GET /acp` WebSocket. Bearer auth via the `Sec-WebSocket-Protocol` offer +`openab.bearer., acp.v1`; the server echoes `acp.v1`. All frames are JSON-RPC 2.0. + +## 2. Declaring the MCP server (in `session/new`) + +When the extension creates a session it declares its browser MCP server in the `mcpServers` +array with the `acp` transport type: + +```json +{ "method": "session/new", + "params": { + "cwd": "...", + "mcpServers": [ + { "type": "acp", "id": "", "name": "browser" } + ] } } +``` + +- `id` is extension-generated and stable for the session; the gateway uses it as the `acpId` + in `mcp/connect`. +- The gateway records this declaration per session (it does not yet act on it until it + connects — see §3). + +## 3. Opening the tunnel — `mcp/connect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/connect", "params": { "acpId":"" } } +``` + +Extension replies with a fresh, extension-assigned connection handle: + +```json +{ "jsonrpc":"2.0", "id":, "result": { "connectionId":"" } } +``` + +`connectionId` scopes all subsequent `mcp/message` traffic for this MCP connection. + +## 4. Carrying MCP — `mcp/message` (bidirectional) + +The inner MCP method + params are **flattened** into the `mcp/message` params (there is **no** +inner MCP `id`; correlation is by the outer ACP JSON-RPC id): + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/message", + "params": { "connectionId":"", "method":"", "params": { ... } } } +``` + +- **Request** (outer frame has `id`): the extension executes the inner MCP method and replies + with the **inner MCP result as the ACP response `result`**: + ```json + { "jsonrpc":"2.0", "id":, "result": { ...inner MCP result... } } + ``` + An inner MCP-level error is returned as the outer JSON-RPC `error`. +- **Notification** (outer frame has no `id`): fire-and-forget inner MCP notification; no + reply. The **extension** sends these upward for server-originated MCP notifications (e.g. + `notifications/tools/list_changed` when its tool set changes). + +Inner MCP methods the extension must handle as a server: +- `initialize` → advertise `capabilities.tools`. +- `tools/list` → return the browser tools (§6). +- `tools/call` → execute the named tool in the active tab; return an MCP `CallToolResult`. + +## 5. Closing — `mcp/disconnect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/disconnect", "params": { "connectionId":"" } } +``` +Extension releases the connection and replies `{ "result": {} }`. + +## 6. Browser tools (the MCP tools the extension serves) + +Baseline DOM-semantic set (model-agnostic; OpenAB also static-advertises these so they appear +even before the extension attaches). `tools/call` executes in the **active tab**. + +| name | arguments | behaviour | +|---|---|---| +| `browser.click` | `{ "selector": string }` | click the element matching the CSS selector | +| `browser.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | +| `browser.navigate` | `{ "url": string }` | navigate the active tab to the URL | +| `browser.type` | `{ "selector": string, "text": string }` | type text into the matched element | +| `browser.screenshot` | `{}` | capture a screenshot of the active tab | + +`tools/call` returns an MCP `CallToolResult` (`{ "content": [ { "type":"text", "text":... } ] }`, +or an image content block for `screenshot`). On failure return an MCP tool error result. The +extension MAY expose additional tools beyond this baseline; they surface to the agent via +`tools/list` + a `tools/list_changed` notification. + +## 7. Permissions + +OpenAB core auto-approves tool permissions today (ADR D1); the extension does **not** need a +per-call consent UX yet. Fine-grained consent is a later addition to this contract. + +## Notes for implementers + +- One `connectionId` per `mcp/connect`; the gateway may reconnect (the MCP server / HTTP + proxy inside OpenAB is decoupled from the WS lifecycle, so the extension may attach after a + session has already started — ADR D4). +- Never assume an inner MCP `id`; always correlate by the outer ACP frame `id`. +- Keep tool execution idempotent where possible; the agent may retry. From 767c9ee00bbd7db8dc10762c3fc978f772e9abdd Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:05:48 +0800 Subject: [PATCH 012/138] =?UTF-8?q?feat(gateway/acp):=20TunnelHandle=20?= =?UTF-8?q?=E2=80=94=20per-connection=20MCP=20tunnel=20handle=20(T5.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable abstraction the core MCP proxy needs to reach a specific browser: TunnelHandle bundles one /acp connection's outbound channel + pending-request map + id counter + the mcp/connect connectionId, and exposes async mcp_message() / disconnect() that tunnel an inner MCP request to that extension and await the result. Built on the T1/T4.1 send_request + mcp_message_request helpers. D5-agnostic: both the per-session and shared core-server designs route through this same handle. Round-trip test via a mock extension driver. Next: register a TunnelHandle per session's channel_id (after mcp/connect) in a shared registry (AppState), and consume it from the core ProxyHandler. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 3baff8fd2..098ba1ed1 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -633,6 +633,55 @@ async fn mcp_disconnect( .map(|_| ()) } +/// A cloneable handle to one `/acp` connection's MCP-over-ACP tunnel (T5.3). Bundles the +/// per-connection outbound channel + pending-request map + id counter + the `connectionId` +/// from `mcp/connect`, so a holder can issue `mcp/message` requests to that browser and await +/// the result. Built by the gateway once the tunnel is open and (next) registered under the +/// session's `channel_id` in a shared registry, so the core MCP proxy can route a tool call +/// to the right browser. D5-agnostic: both the per-session and shared core-server designs use +/// this same handle. +#[derive(Clone)] +pub struct TunnelHandle { + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + connection_id: String, +} + +impl TunnelHandle { + /// Tunnel an inner MCP request (`tools/list`, `tools/call`, …) to the extension over this + /// connection and return the inner MCP result payload. + pub async fn mcp_message( + &self, + method: &str, + params: Option, + timeout_secs: u64, + ) -> Result { + mcp_message_request( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + method, + params, + timeout_secs, + ) + .await + } + + /// Close this tunnel (`mcp/disconnect`). + pub async fn disconnect(&self, timeout_secs: u64) -> Result<(), String> { + mcp_disconnect( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + timeout_secs, + ) + .await + } +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -1984,6 +2033,36 @@ mod acp_requests { assert_eq!(result["tools"][0]["name"], json!("browser.click")); ext.await.unwrap(); } + + #[tokio::test] + async fn tunnel_handle_mcp_message_roundtrips() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let handle = super::TunnelHandle { + out_tx, + pending: pending.clone(), + next_id, + connection_id: "conn-9".into(), + }; + + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/message")); + assert_eq!(f["params"]["connectionId"], json!("conn-9")); + assert_eq!(f["params"]["method"], json!("tools/call")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"ok":true}})) + .await; + }); + + let result = handle + .mcp_message("tools/call", Some(json!({"name": "browser.click"})), 5) + .await + .unwrap(); + assert_eq!(result["ok"], json!(true)); + ext.await.unwrap(); + } } #[cfg(test)] From daa23dabb2df7978c031cd55fcfbc8bd9f052de2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:19:42 +0800 Subject: [PATCH 013/138] feat(gateway/acp): tunnel registry + establish_and_register_tunnel (T5.3) - AcpTunnelRegistry (channel_id -> TunnelHandle), mirroring AcpReplyRegistry, so the core MCP proxy can look up the tunnel for a given browser session. - establish_and_register_tunnel: mcp/connect to a session's declared "type":"acp" server, build a TunnelHandle from the returned connectionId, and register it under the session's channel_id. First real caller of mcp_connect/send_request. Documented as spawn-only (awaiting mcp_connect inline in the read loop would deadlock, since only that loop delivers the response). Test: a mock extension answers mcp/connect; the handle lands in the registry keyed by channel_id. #[allow(dead_code)] until the read loop spawns it and it's threaded through AppState (next). Then the core ProxyHandler consumes the registry to forward tools/list + tools/call. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 098ba1ed1..a7c3b14e8 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -323,6 +323,16 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } +/// Registry of open MCP-over-ACP tunnels: channel_id → `TunnelHandle`. The gateway inserts a +/// handle once it has `mcp/connect`ed to a session's browser extension server; the core MCP +/// proxy looks one up to route a tool call to the right browser (T5.3). Same std::sync::Mutex +/// rationale as `AcpReplyRegistry`. +pub type AcpTunnelRegistry = Arc>>; + +pub fn new_tunnel_registry() -> AcpTunnelRegistry { + Arc::new(std::sync::Mutex::new(HashMap::new())) +} + // --------------------------------------------------------------------------- // JSON-RPC types (minimal subset for ACP) // --------------------------------------------------------------------------- @@ -682,6 +692,35 @@ impl TunnelHandle { } } +/// Open the MCP-over-ACP tunnel to a session's declared `"type":"acp"` server and register a +/// `TunnelHandle` under the session's `channel_id` so the core MCP proxy can reach it (T5.3). +/// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits +/// the client's response, which only that same read loop can deliver — awaiting it inline +/// would deadlock. +#[allow(dead_code)] +async fn establish_and_register_tunnel( + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + acp_id: String, + channel_id: String, + registry: AcpTunnelRegistry, + timeout_secs: u64, +) -> Result<(), String> { + let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; + let handle = TunnelHandle { + out_tx, + pending, + next_id, + connection_id, + }; + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(channel_id, handle); + Ok(()) +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -2063,6 +2102,42 @@ mod acp_requests { assert_eq!(result["ok"], json!(true)); ext.await.unwrap(); } + + #[tokio::test] + async fn establish_tunnel_registers_handle_under_channel_id() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let registry = super::new_tunnel_registry(); + + // mock extension: answer mcp/connect with a connectionId + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + assert_eq!(f["params"]["acpId"], json!("srv-1")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}})) + .await; + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "acp_abc".into(), + registry.clone(), + 5, + ) + .await + .unwrap(); + ext.await.unwrap(); + + assert!( + registry.lock().unwrap().contains_key("acp_abc"), + "a TunnelHandle must be registered under the session channel_id" + ); + } } #[cfg(test)] From eedf6ce81e4d475f98f511014219aa80ae50d7df Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:36:44 +0800 Subject: [PATCH 014/138] feat(gateway): thread AcpTunnelRegistry through AppState (T5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add acp_tunnel_registry: Option to AppState alongside acp_reply_registry (same #[cfg(feature="acp")] gate), initialized wherever the reply registry is. This is the shared handle the connection read loop will populate (spawning establish_and_register_tunnel per declared type:acp server) and the core MCP proxy will consume to route a tool call to the right browser. No behaviour change yet — the field is constructed but not read until the read-loop spawn wiring lands next. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index b64a4e5e6..b730c90f0 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -62,6 +62,8 @@ pub struct AppState { pub acp: Option, #[cfg(feature = "acp")] pub acp_reply_registry: Option, + #[cfg(feature = "acp")] + pub acp_tunnel_registry: Option, pub ws_token: Option, pub event_tx: broadcast::Sender, pub reply_token_cache: ReplyTokenCache, @@ -105,6 +107,8 @@ impl AppState { acp: None, #[cfg(feature = "acp")] acp_reply_registry: None, + #[cfg(feature = "acp")] + acp_tunnel_registry: None, ws_token: None, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -182,6 +186,8 @@ impl AppState { let acp = adapters::acp_server::AcpConfig::from_env(); #[cfg(feature = "acp")] let acp_reply_registry = acp.as_ref().map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp.as_ref().map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -212,6 +218,8 @@ impl AppState { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -695,6 +703,10 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { let acp_reply_registry = acp .as_ref() .map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp + .as_ref() + .map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -729,6 +741,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache, From b88a176c7e538be80dda7689a0e1f9e49f64d43b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 02:50:02 +0800 Subject: [PATCH 015/138] feat(gateway/acp): open MCP tunnels on session/new + cleanup (T5.3) Wire the tunnel producer into the connection read loop: - handle_acp_connection mints a per-connection next_req_id (Arc) for server-initiated requests. - handle_session_new returns the minted channel_id alongside the response. - On session/new, for each declared "type":"acp" server, tokio::spawn establish_and_register_tunnel (mcp/connect -> register a TunnelHandle under the channel_id). Spawned, never awaited inline: it awaits mcp/connect whose response only this same read loop delivers, so awaiting inline would deadlock. The task is tracked in prompt_tasks (aborted on disconnect). - Disconnect cleanup now removes the connection's channel_ids from BOTH the reply and tunnel registries (gathered once). Live behaviour needs a real extension (T7); this lands the plumbing + keeps the unit tests green. Next: the core ProxyHandler consumes acp_tunnel_registry to forward tools/list + tools/call to the right browser. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 72 ++++++++++++++----- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index a7c3b14e8..53320312a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -739,6 +739,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // `route_client_response` (inbound) and `send_request` (outbound, wired in T1.4). let pending_requests: Arc>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Monotonic id source for server-initiated requests (mcp/connect, mcp/message). + let next_req_id = Arc::new(AtomicU64::new(1)); let mut initialized = false; // Track spawned prompt tasks so we can abort on disconnect @@ -898,8 +900,32 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { continue; } let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); - let resp = handle_session_new(&sessions, id.clone(), acp_mcp_servers).await; + let (resp, channel_id) = + handle_session_new(&sessions, id.clone(), acp_mcp_servers.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // If the client declared "type":"acp" MCP servers, open + register a tunnel to + // each so the core MCP proxy can reach this browser. SPAWNED, not awaited + // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response + // only THIS read loop delivers — awaiting inline would deadlock. + if let Some(registry) = state.acp_tunnel_registry.clone() { + for srv in acp_mcp_servers { + let out_tx2 = out_tx.clone(); + let pending2 = pending_requests.clone(); + let next_id2 = next_req_id.clone(); + let channel = channel_id.clone(); + let reg = registry.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx2, pending2, next_id2, srv.id, channel, reg, 30, + ) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); + } + } } "session/resume" => { if !initialized { @@ -1043,25 +1069,31 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { handle.abort(); } - // Remove all sessions for this connection from the reply registry - if let Some(ref registry) = state.acp_reply_registry { + // Remove all of this connection's sessions from the reply + tunnel registries. + let channel_ids: Vec = { let sessions_guard = sessions.lock().await; - let channel_ids: Vec = sessions_guard + sessions_guard .values() .map(|s| s.channel_id.clone()) - .collect(); - drop(sessions_guard); - + .collect() + }; + if let Some(ref registry) = state.acp_reply_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); for cid in &channel_ids { reg.remove(cid); } - debug!( - connection = %connection_id, - sessions_cleaned = channel_ids.len(), - "ACP connection cleanup complete" - ); } + if let Some(ref registry) = state.acp_tunnel_registry { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + for cid in &channel_ids { + reg.remove(cid); + } + } + debug!( + connection = %connection_id, + sessions_cleaned = channel_ids.len(), + "ACP connection cleanup complete" + ); send_task.abort(); info!(connection = %connection_id, "ACP client disconnected"); @@ -1121,11 +1153,13 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { ) } +/// Returns the response plus the minted `channel_id`, so the caller can open the +/// MCP-over-ACP tunnel(s) for any declared `"type":"acp"` servers under that key. async fn handle_session_new( sessions: &Arc>>, id: Value, acp_mcp_servers: Vec, -) -> JsonRpcResponse { +) -> (JsonRpcResponse, String) { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). let uuid = Uuid::new_v4(); @@ -1135,7 +1169,7 @@ async fn handle_session_new( sessions.lock().await.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, acp_mcp_servers, @@ -1154,7 +1188,10 @@ async fn handle_session_new( meta: None, modes: None, }; - JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) + ( + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), + channel_id, + ) } /// `session/resume` — re-attach to a session the client persisted, WITHOUT @@ -2194,7 +2231,8 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await).unwrap(); + let v = + serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await.0).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); @@ -2227,7 +2265,7 @@ mod acp_handlers { let sessions = new_sessions(); let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; let v = serde_json::to_value( - handle_session_new(&sessions, json!(2), servers.clone()).await, + handle_session_new(&sessions, json!(2), servers.clone()).await.0, ) .unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); From 4af5ad9f0962546bda8d84d2065360b65a3e22ff Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:28:37 +0800 Subject: [PATCH 016/138] feat(core): BrowserTunnel trait + ProxyHandler forwards tool calls (T5.3, D6-a') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the core-side tunnel interface (D6-a'): `trait BrowserTunnel { async fn call(channel_id, method, params) }`, implemented by the root (bridging to the gateway registry) so no core<->gateway crate dependency is introduced — matching the existing ChatAdapter pattern. - ProxyHandler now carries its session channel_id + an Option> (D5-a: one server per session). call_tool forwards the tool as an MCP tools/call over the tunnel; list_tools stays static-advertised (D4); no tunnel / no browser attached -> "browser not connected" (D4). - spawn_mcp_server takes (channel_id, tunnel) and builds a per-session ProxyHandler in the service factory. Tests: forward via a mock BrowserTunnel; not-connected without one. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 112 ++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index ec537eedc..c6ed459a3 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -11,8 +11,8 @@ //! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. use rmcp::model::{ - object, CallToolRequestParams, CallToolResult, ErrorData as McpError, ListToolsResult, - PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + object, CallToolRequestParams, CallToolResult, ErrorData as McpError, JsonObject, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }; use rmcp::service::{RequestContext, RoleServer}; use rmcp::transport::streamable_http_server::{ @@ -20,9 +20,26 @@ use rmcp::transport::streamable_http_server::{ }; use rmcp::ServerHandler; use axum::response::IntoResponse; -use serde_json::json; +use serde_json::{json, Value}; use std::sync::Arc; +/// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT +/// (which bridges to the gateway's per-connection tunnel registry) and consumed by the MCP +/// proxy here. Keeping the trait in core with the impl in root preserves the core/gateway +/// sibling independence, matching the existing `ChatAdapter` pattern. +#[async_trait::async_trait] +pub trait BrowserTunnel: Send + Sync { + /// Forward an inner MCP request (e.g. `tools/call`) to the browser session identified by + /// `channel_id` and return the inner MCP result payload. Err if no browser is currently + /// attached to that session. + async fn call( + &self, + channel_id: &str, + method: &str, + params: Option, + ) -> Result; +} + /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. pub(crate) fn browser_tools() -> Vec { @@ -77,8 +94,42 @@ pub(crate) fn browser_tools() -> Vec { /// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension /// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not /// connected" on call. -#[derive(Clone, Default)] -pub struct ProxyHandler {} +#[derive(Clone)] +pub struct ProxyHandler { + /// The browser session this server instance serves (D5-a: one MCP server per session). + channel_id: String, + /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the + /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). + tunnel: Option>, +} + +impl ProxyHandler { + pub fn new(channel_id: String, tunnel: Option>) -> Self { + Self { channel_id, tunnel } + } + + /// Forward a tool call to the browser over the tunnel (as an MCP `tools/call`), or report + /// not-connected (D4) when no browser is attached. + async fn forward_tool_call( + &self, + name: &str, + arguments: Option, + ) -> Result { + let Some(tunnel) = &self.tunnel else { + return Err(McpError::internal_error( + "browser not connected: open the OpenAB side panel in your browser", + None, + )); + }; + let params = json!({ "name": name, "arguments": arguments }); + let result = tunnel + .call(&self.channel_id, "tools/call", Some(params)) + .await + .map_err(|e| McpError::internal_error(e, None))?; + serde_json::from_value(result) + .map_err(|e| McpError::internal_error(format!("malformed tool result: {e}"), None)) + } +} impl ServerHandler for ProxyHandler { fn get_info(&self) -> ServerInfo { @@ -102,14 +153,11 @@ impl ServerHandler for ProxyHandler { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, _context: RequestContext, ) -> Result { - // No extension wired yet (T5.3). Per D4, fail gracefully rather than hide the tool. - Err(McpError::internal_error( - "browser not connected: open the OpenAB side panel in your browser", - None, - )) + self.forward_tool_call(request.name.as_ref(), request.arguments) + .await } } @@ -140,6 +188,8 @@ async fn require_bearer( /// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is /// cancelled. pub async fn spawn_mcp_server( + channel_id: String, + tunnel: Option>, bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { @@ -150,7 +200,7 @@ pub async fn spawn_mcp_server( .with_cancellation_token(ct.child_token()); let service: StreamableHttpService = StreamableHttpService::new( - || Ok(ProxyHandler::default()), + move || Ok(ProxyHandler::new(channel_id.clone(), tunnel.clone())), Default::default(), config, ); @@ -172,7 +222,39 @@ pub async fn spawn_mcp_server( #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server}; + use super::{browser_tools, spawn_mcp_server, BrowserTunnel, ProxyHandler}; + + struct MockTunnel; + #[async_trait::async_trait] + impl BrowserTunnel for MockTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(channel_id, "acp_x"); + assert_eq!(method, "tools/call"); + Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) + } + } + + #[tokio::test] + async fn call_tool_forwards_to_the_tunnel() { + let h = ProxyHandler::new("acp_x".into(), Some(std::sync::Arc::new(MockTunnel))); + let result = h.forward_tool_call("browser.click", None).await.unwrap(); + let v = serde_json::to_value(&result).unwrap(); + assert_eq!(v["content"][0]["text"], serde_json::json!("clicked")); + } + + #[tokio::test] + async fn call_tool_reports_not_connected_without_a_tunnel() { + let h = ProxyHandler::new("acp_x".into(), None); + assert!( + h.forward_tool_call("browser.click", None).await.is_err(), + "a call with no attached browser must error (D4)" + ); + } #[test] fn browser_tools_advertises_the_fixed_set() { @@ -208,7 +290,7 @@ mod tests { #[tokio::test] async fn mcp_server_binds_loopback_and_initializes_with_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) .await .unwrap(); assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); @@ -237,7 +319,7 @@ mod tests { #[tokio::test] async fn mcp_server_rejects_missing_or_wrong_bearer() { let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("secret-token".to_string(), ct.clone()) + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) .await .unwrap(); let url = format!("http://{addr}/mcp"); From c9084964e488129509d5606202dac77362842285 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:33:11 +0800 Subject: [PATCH 017/138] =?UTF-8?q?feat(core):=20start=5Fsession=5Fserver?= =?UTF-8?q?=20=E2=80=94=20per-session=20server=20+=20.cursor/mcp.json=20(T?= =?UTF-8?q?5.2/D5-a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_session_server(channel_id, workdir, tunnel): mint a fresh bearer, start the loopback MCP proxy for that session, and MERGE an `openab-browser` HTTP entry (url + Authorization: Bearer) into /.cursor/mcp.json without clobbering any servers already there (Cursor's native config; D2). Returns the bound addr + a CancellationToken the pool cancels to stop the server on session evict. Tests: writes the cursor config (url+bearer); merges into an existing mcp.json. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 83 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c6ed459a3..6ef0181af 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -220,9 +220,44 @@ pub async fn spawn_mcp_server( Ok(addr) } +/// Start a per-session MCP proxy server (D5-a) and register it in the agent's native MCP +/// config so the colocated agent connects to it (D2). Mints a fresh bearer, starts the +/// loopback server, and writes/merges `/.cursor/mcp.json` with the `openab-browser` +/// HTTP entry (Cursor's config; other agents get their own writer later). Returns the bound +/// address + the `CancellationToken` the caller cancels to stop the server on session evict. +pub async fn start_session_server( + channel_id: &str, + workdir: &str, + tunnel: Option>, +) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { + let bearer = uuid::Uuid::new_v4().to_string(); + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; + + // Merge the openab-browser entry into /.cursor/mcp.json (don't clobber any + // servers the user/agent already configured). + let cursor_dir = std::path::Path::new(workdir).join(".cursor"); + tokio::fs::create_dir_all(&cursor_dir).await?; + let cfg_path = cursor_dir.join("mcp.json"); + let mut cfg: Value = match tokio::fs::read(&cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + cfg["mcpServers"]["openab-browser"] = json!({ + "url": format!("http://{addr}/mcp"), + "headers": { "Authorization": format!("Bearer {bearer}") } + }); + tokio::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + + Ok((addr, ct)) +} + #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server, BrowserTunnel, ProxyHandler}; + use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; struct MockTunnel; #[async_trait::async_trait] @@ -256,6 +291,52 @@ mod tests { ); } + #[tokio::test] + async fn start_session_server_writes_cursor_config() { + let dir = tempfile::tempdir().unwrap(); + let (addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + assert!(addr.ip().is_loopback()); + + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(".cursor/mcp.json")).unwrap()) + .unwrap(); + let entry = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(entry["url"], serde_json::json!(format!("http://{addr}/mcp"))); + assert!(entry["headers"]["Authorization"] + .as_str() + .unwrap() + .starts_with("Bearer ")); + ct.cancel(); + } + + #[tokio::test] + async fn start_session_server_merges_existing_config() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let (_addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert!( + cfg["mcpServers"]["other"].is_object(), + "existing server must be preserved" + ); + assert!( + cfg["mcpServers"]["openab-browser"].is_object(), + "openab-browser must be added" + ); + ct.cancel(); + } + #[test] fn browser_tools_advertises_the_fixed_set() { let tools = browser_tools(); From 6cbd03f581eb70029e8fd122fdd6613c1e6e0ad3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:41:28 +0800 Subject: [PATCH 018/138] feat(core): start per-session MCP proxy on agent spawn (T5.2/D5-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the per-session MCP proxy into the pool's agent-launch path (feature acp-mcp): - For a browser (`acp:`) session, get_or_create starts a loopback MCP server + writes .cursor/mcp.json BEFORE spawning the agent, so the agent connects to it on boot. Non-acp sessions (Discord, etc.) are untouched. - Lifecycle: the server's CancellationToken drop_guard is stored INSIDE the AcpConnection (new mcp_server_guard field), so the server is cancelled whenever the connection is dropped — through any evict/suspend/hung-kill path — without touching each removal site. On a failed spawn/init the guard drops early and cancels too. - SessionPool gains a browser_tunnel field + with_browser_tunnel() builder (set by the root, D6-a'); passed into each per-session ProxyHandler. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/connection.rs | 13 +++++++ crates/openab-core/src/acp/pool.rs | 45 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index f40ceb486..cb9577ddd 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -189,6 +189,11 @@ pub struct AcpConnection { pub session_reset: bool, _reader_handle: JoinHandle<()>, _stderr_handle: Option>, + /// Cancels this session's per-session MCP proxy server (D5-a) when the connection is + /// dropped. Held only for its `Drop` side effect (never read). + #[cfg(feature = "acp-mcp")] + #[allow(dead_code)] + mcp_server_guard: Option, } /// Build the final set of env vars for the agent subprocess. @@ -485,9 +490,17 @@ impl AcpConnection { session_reset: false, _reader_handle: reader_handle, _stderr_handle: stderr_handle, + #[cfg(feature = "acp-mcp")] + mcp_server_guard: None, }) } + /// Attach the guard that stops this session's MCP proxy server when the connection drops. + #[cfg(feature = "acp-mcp")] + pub fn set_mcp_guard(&mut self, guard: Option) { + self.mcp_server_guard = guard; + } + fn next_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::Relaxed) } diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 394d9f260..e1e129116 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -53,6 +53,10 @@ pub struct SessionPool { mapping_path: PathBuf, meta_path: PathBuf, default_config_options: HashMap, + /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the + /// root. `None` = no browser wiring (tool calls report not-connected). + #[cfg(feature = "acp-mcp")] + browser_tunnel: Option>, } type CancelHandle = (Arc>, String); @@ -197,9 +201,22 @@ impl SessionPool { mapping_path, meta_path, default_config_options, + #[cfg(feature = "acp-mcp")] + browser_tunnel: None, } } + /// Wire the browser tunnel bridge (D6-a', set by the root) so per-session MCP proxies can + /// reach the browser. Call before sharing the pool. + #[cfg(feature = "acp-mcp")] + pub fn with_browser_tunnel( + mut self, + tunnel: Option>, + ) -> Self { + self.browser_tunnel = tunnel; + self + } + fn load_mapping(path: &Path) -> HashMap { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { @@ -347,6 +364,32 @@ impl SessionPool { self.config.working_dir.clone() }; + // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP + // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The + // returned guard cancels that server when this connection is dropped (any evict path). + #[cfg(feature = "acp-mcp")] + let mcp_guard: Option = + if let Some(channel_id) = thread_id.strip_prefix("acp:") { + match crate::mcp_proxy::start_session_server( + channel_id, + &effective_workdir, + self.browser_tunnel.clone(), + ) + .await + { + Ok((addr, ct)) => { + info!(thread_id, %addr, "started per-session MCP proxy server"); + Some(ct.drop_guard()) + } + Err(e) => { + warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + None + } + } + } else { + None + }; + // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. let mut new_conn = AcpConnection::spawn( @@ -423,6 +466,8 @@ impl SessionPool { let activity_handle = new_conn.activity_handle(); let child_pgid = new_conn.child_pgid(); let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default(); + #[cfg(feature = "acp-mcp")] + new_conn.set_mcp_guard(mcp_guard); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; From e582383221b8a70948d374e748694cfc1dd9d62c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:46:47 +0800 Subject: [PATCH 019/138] feat(root): wire the browser tunnel bridge end-to-end (T5.3, D6-a') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootBrowserTunnel (src/browser_tunnel.rs) implements openab-core's BrowserTunnel trait by looking up a channel_id in the gateway's AcpTunnelRegistry and calling TunnelHandle.mcp_message — the root glue that connects the two sibling crates without either depending on the other (mirrors the ChatAdapter pattern). Wiring in `openab run` (feature acp): create ONE shared acp_tunnel_registry before the pool; give the pool a RootBrowserTunnel over it (with_browser_tunnel), and inject the SAME registry into the gateway AppState so acp_server populates the exact map the bridge reads. This closes the loop: agent tools/call -> core per-session MCP proxy -> RootBrowserTunnel -> gateway TunnelHandle -> mcp/message -> extension. Live path still needs a real extension + deploy (T7); everything compiles + unit tests green. Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified. Co-Authored-By: Claude Opus 4.8 --- src/browser_tunnel.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 22 ++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/browser_tunnel.rs diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs new file mode 100644 index 000000000..e11ab5efa --- /dev/null +++ b/src/browser_tunnel.rs @@ -0,0 +1,39 @@ +//! Root-side bridge implementing the core `BrowserTunnel` trait (D6-a'). Reads the gateway's +//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the browser attached +//! to a given `channel_id`. This lives in the root binary — the only place that depends on +//! BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), preserving the two +//! crates' sibling independence (mirroring the existing `ChatAdapter` glue at the root). + +use openab_core::mcp_proxy::BrowserTunnel; +use openab_gateway::adapters::acp_server::AcpTunnelRegistry; +use serde_json::Value; + +pub struct RootBrowserTunnel { + registry: AcpTunnelRegistry, +} + +impl RootBrowserTunnel { + pub fn new(registry: AcpTunnelRegistry) -> Self { + Self { registry } + } +} + +#[async_trait::async_trait] +impl BrowserTunnel for RootBrowserTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + params: Option, + ) -> Result { + // Clone the handle out under the lock; never hold the std mutex across `.await`. + let handle = { + let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); + reg.get(channel_id).cloned() + }; + match handle { + Some(h) => h.mcp_message(method, params, 30).await, + None => Err(format!("no browser attached to session {channel_id}")), + } + } +} diff --git a/src/main.rs b/src/main.rs index 32b46853c..534a3e467 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod ctl; feature = "acp", ))] mod unified_adapter; +#[cfg(feature = "acp")] +mod browser_tunnel; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -377,14 +379,24 @@ async fn main() -> anyhow::Result<()> { let shutdown_hook = cfg.hooks.pre_shutdown.clone(); - let pool = Arc::new(acp::SessionPool::new( + // Shared MCP-over-ACP tunnel registry (D6-a'): the gateway populates it per browser + // session; the core MCP proxy reads it via the RootBrowserTunnel bridge below. + #[cfg(feature = "acp")] + let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + + let pool_inner = acp::SessionPool::new( cfg.agent, cfg.pool.max_sessions, cfg.pool .prompt_hard_timeout_secs .saturating_add(cfg.pool.hung_grace_secs), cfg.pool.default_config_options, - )); + ); + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_browser_tunnel(Some(Arc::new( + browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + ))); + let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; // Resolve STT config (auto-detect GROQ_API_KEY from env) @@ -956,6 +968,12 @@ async fn main() -> anyhow::Result<()> { // Build gateway AppState from env vars (shared factory with standalone gateway) let mut gw_state_inner = openab_gateway::AppState::from_env(event_tx.clone(), None); + // Share the tunnel registry the core MCP proxy reads (D6-a'), so the gateway + // populates the same map the RootBrowserTunnel bridge looks up. + #[cfg(feature = "acp")] + { + gw_state_inner.acp_tunnel_registry = Some(acp_tunnel_registry.clone()); + } // First-class `[telegram]` config overrides env-derived values From c9896ce3d6d2030b36ec83c761a06bfa9649116b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 03:50:50 +0800 Subject: [PATCH 020/138] docs(adr): record the as-built OpenAB side (D5-a + D6-a', end-to-end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a §7 "As-built" section documenting the two decisions settled during implementation and the realised call path: D5 = per-session MCP server bound to the existing channel_id map (lifetime tied to the AcpConnection via a CancellationToken DropGuard); D6 = BrowserTunnel trait in core + impl in the root (RootBrowserTunnel), keeping core/gateway sibling-independent like the existing ChatAdapter glue. Notes remaining T5.4 / T6 / T7. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 1c214e1ba..ea9f04694 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -356,3 +356,35 @@ T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the R → then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is parallel and non-blocking. + +### As-built (2026-07-20) — OpenAB side wired end-to-end + +The OpenAB (server) side is implemented on `feat/acp-mcp-browser` (compiles + unit-tested; +live path pending the extension T6 + deploy T7). Two decisions beyond D1–D4 settled during +implementation: + +- **D5 = per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per + `acp:` session (in `openab-core/src/acp/pool.rs`, at agent spawn), constructing the + `ProxyHandler` with that session's `channel_id` so correlation is implicit — it binds to the + existing `session_key`/`channel_id` map, no in-band id. Server lifetime is tied to the + `AcpConnection` via a `CancellationToken` `DropGuard`, so it stops on any evict path. +- **D6 = tunnel trait in core, impl in root.** `openab-core` defines + `mcp_proxy::BrowserTunnel`; the **root** binary implements it (`src/browser_tunnel.rs`) + by looking up the gateway's `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. + This keeps `openab-core` and `openab-gateway` **sibling-independent** (no cross-crate dep), + mirroring the existing `ChatAdapter`/`GatewayResponse` root-glue pattern. + +Realised call path (all in one `openab run` process): + +``` +agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) + ─▶ BrowserTunnel (core trait) ─▶ RootBrowserTunnel (root, src/browser_tunnel.rs) + ─▶ gateway AcpTunnelRegistry[channel_id] ─▶ TunnelHandle::mcp_message + ═mcp/message═▶ extension (only this hop leaves the pod) +``` + +Config injection is per-agent (`.cursor/mcp.json` merged at the session workdir, loopback + +bearer). Static-advertise + not-connected fallback (D4) hold when no browser is attached. +**Remaining:** T5.4 `tools/list_changed` (enhancement; static-advertise already covers the +disconnected case), T6 extension (katashiro — see the +[tunnel contract](../mcp-over-acp-tunnel-contract.md)), and T7 live e2e + deploy. From 059da24914cd032f9556d1718417b6f5e203e18e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 04:00:07 +0800 Subject: [PATCH 021/138] test(e2e): MCP-over-ACP tunnel producer section in acp-ws-smoke (T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "MCP-over-ACP tunnel" section to the smoke suite: a mock extension declares a {type:acp} mcpServers entry in session/new, then asserts the gateway issues a server-initiated mcp/connect carrying the declared acpId, and answers it with a connectionId (registering the tunnel). This exercises the live read-loop spawn + server->client request path end-to-end — the concurrency unit tests can't reach. Runs against a live server (deploy T7). The tunnel path is inert for normal sessions (only triggers on a type:acp declaration), so it does not affect existing ACP traffic. Co-Authored-By: Claude Opus 4.8 --- scripts/acp-ws-smoke.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index cfc313edd..dc9de146d 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -314,6 +314,43 @@ async def section_lifecycle(): record("life", stop == "cancelled", "session/cancel → prompt ends stopReason:cancelled", f"got {stop!r}") +async def section_tunnel(): + """MCP-over-ACP tunnel producer (T5.3): declaring a `type:acp` MCP server in + session/new makes the gateway open a tunnel to us (a server-initiated mcp/connect + request); we answer and it registers the tunnel. This exercises the live read-loop + spawn path end-to-end (the concurrency that unit tests can't reach).""" + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + r = await c.call( + "session/new", + { + "cwd": "/home/agent", + "mcpServers": [{"type": "acp", "id": "srv-smoke", "name": "browser"}], + }, + ) + sid = r.get("result", {}).get("sessionId", "") + record("tunnel", sid.startswith("sess_"), "session/new with a type:acp mcpServers entry is accepted") + + # The gateway now issues a server-initiated mcp/connect. Wait for it. + connect = None + for _ in range(20): + try: + m = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + except asyncio.TimeoutError: + break + if m.get("method") == "mcp/connect": + connect = m + break + record("tunnel", connect is not None, "gateway sends a server-initiated mcp/connect after the type:acp declaration") + if connect: + params = connect.get("params", {}) + record("tunnel", params.get("acpId") == "srv-smoke", "mcp/connect carries the declared acpId", str(params)) + record("tunnel", connect.get("id") is not None, "mcp/connect is a request (has an id)") + await ws.send(json.dumps({"jsonrpc": "2.0", "id": connect["id"], "result": {"connectionId": "conn-smoke"}})) + record("tunnel", True, "answered mcp/connect with a connectionId (the tunnel registers)") + + async def main() -> int: if not TOKEN: print("ERROR: OPENAB_ACP_TOKEN is required (the /acp endpoint mandates a transport token off loopback).", file=sys.stderr) @@ -323,6 +360,7 @@ async def main() -> int: await section_compliance() await section_edges() await section_lifecycle() + await section_tunnel() total = len(results) passed = sum(1 for _, ok, _ in results if ok) @@ -333,7 +371,7 @@ async def main() -> int: if ok: s[0] += 1 print("\n" + "-" * 60, flush=True) - labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport"} + labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport", "tunnel": "MCP-over-ACP tunnel"} for sec, (p, t) in by_section.items(): print(f" {labels.get(sec, sec):22} {p}/{t}", flush=True) print(f"\nRESULT: {passed}/{total} checks passed", flush=True) From 6d5d1ce089643750a90e4849680da23da68b5d28 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 04:19:28 +0800 Subject: [PATCH 022/138] test(e2e): complete the MCP-over-ACP tunnel suite (fan-out + filtering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the tunnel section from a single-server check to full producer coverage via a collect_mcp_connects() helper: single type:acp → exactly one mcp/connect; fan-out (two type:acp servers → one distinct mcp/connect each, distinct request ids); mixed acp+http mcpServers → only the acp entry is tunnelled. All deterministic. Validated live against Falcon: 34/34 (tunnel 7/7). The agent→tool→browser leg is out of the WS suite's reach (needs a real extension, T6). Run: OPENAB_ACP_TOKEN= uv run scripts/acp-ws-smoke.py ws:///acp Co-Authored-By: Claude Opus 4.8 --- scripts/acp-ws-smoke.py | 82 ++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index dc9de146d..d55f3cc01 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -314,41 +314,65 @@ async def section_lifecycle(): record("life", stop == "cancelled", "session/cancel → prompt ends stopReason:cancelled", f"got {stop!r}") +async def collect_mcp_connects(c: Conn, ws, mcp_servers, window=6.0): + """session/new with the given mcpServers, then collect the server-initiated mcp/connect + requests the gateway issues within `window` seconds, answering each with a connectionId. + Returns (sessionId, [mcp/connect frames]).""" + r = await c.call("session/new", {"cwd": "/home/agent", "mcpServers": mcp_servers}) + sid = r.get("result", {}).get("sessionId", "") + connects = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + window + while loop.time() < deadline: + try: + m = json.loads(await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - loop.time()))) + except asyncio.TimeoutError: + break + if m.get("method") == "mcp/connect": + connects.append(m) + await ws.send(json.dumps({"jsonrpc": "2.0", "id": m["id"], "result": {"connectionId": f"conn-{len(connects)}"}})) + return sid, connects + + async def section_tunnel(): - """MCP-over-ACP tunnel producer (T5.3): declaring a `type:acp` MCP server in - session/new makes the gateway open a tunnel to us (a server-initiated mcp/connect - request); we answer and it registers the tunnel. This exercises the live read-loop - spawn path end-to-end (the concurrency that unit tests can't reach).""" + """MCP-over-ACP tunnel producer (T5.3): a `type:acp` mcpServers entry makes the gateway + open a tunnel to us (a server-initiated mcp/connect). Covers the single case, fan-out over + multiple servers, and mixed-transport filtering. Exercises the live read-loop spawn path + the unit tests can't reach. (The agent→tool→browser leg needs a real extension — T6.)""" + # 1) single type:acp → exactly one mcp/connect carrying the declared id async with await try_connect(TOKEN) as ws: c = Conn(ws) await c.initialize() - r = await c.call( - "session/new", - { - "cwd": "/home/agent", - "mcpServers": [{"type": "acp", "id": "srv-smoke", "name": "browser"}], - }, - ) - sid = r.get("result", {}).get("sessionId", "") + sid, connects = await collect_mcp_connects(c, ws, [{"type": "acp", "id": "srv-solo", "name": "browser"}]) record("tunnel", sid.startswith("sess_"), "session/new with a type:acp mcpServers entry is accepted") + record("tunnel", len(connects) == 1, "single type:acp server → exactly one server-initiated mcp/connect", f"got {len(connects)}") + if connects: + p = connects[0].get("params", {}) + record("tunnel", p.get("acpId") == "srv-solo", "mcp/connect carries the declared acpId", str(p)) + record("tunnel", connects[0].get("id") is not None, "mcp/connect is a request (has an id)") - # The gateway now issues a server-initiated mcp/connect. Wait for it. - connect = None - for _ in range(20): - try: - m = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) - except asyncio.TimeoutError: - break - if m.get("method") == "mcp/connect": - connect = m - break - record("tunnel", connect is not None, "gateway sends a server-initiated mcp/connect after the type:acp declaration") - if connect: - params = connect.get("params", {}) - record("tunnel", params.get("acpId") == "srv-smoke", "mcp/connect carries the declared acpId", str(params)) - record("tunnel", connect.get("id") is not None, "mcp/connect is a request (has an id)") - await ws.send(json.dumps({"jsonrpc": "2.0", "id": connect["id"], "result": {"connectionId": "conn-smoke"}})) - record("tunnel", True, "answered mcp/connect with a connectionId (the tunnel registers)") + # 2) fan-out: two type:acp servers → one distinct mcp/connect each + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-a", "name": "a"}, {"type": "acp", "id": "srv-b", "name": "b"}] + ) + ids = sorted(x.get("params", {}).get("acpId") for x in connects) + record("tunnel", ids == ["srv-a", "srv-b"], "two type:acp servers → one mcp/connect each (fan-out)", str(ids)) + outer = [x.get("id") for x in connects] + record("tunnel", len(set(outer)) == len(outer) and all(i is not None for i in outer), + "each mcp/connect uses a distinct request id", str(outer)) + + # 3) mixed transports: only the acp server is tunnelled (http is the agent's own concern) + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-x", "name": "browser"}, {"type": "http", "url": "http://example/mcp"}] + ) + ids = [x.get("params", {}).get("acpId") for x in connects] + record("tunnel", ids == ["srv-x"], "mixed acp+http mcpServers → only the acp one gets mcp/connect", str(ids)) async def main() -> int: From ddc96203e78da3e1bf1ce356498a4423efdf7676 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:14:47 +0800 Subject: [PATCH 023/138] =?UTF-8?q?fix(acp-mcp):=20address=20Mira=20review?= =?UTF-8?q?=20nits=20=E2=80=94=20constant-time=20bearer=20+=20string-numbe?= =?UTF-8?q?r=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two non-blocking hardening nits from the openab-side review: - mcp_proxy require_bearer: compare the loopback MCP bearer in constant time (subtle::ConstantTimeEq, matching the gateway's feishu/wecom signature checks) so a wrong token can't be recovered byte-by-byte via response timing. Adds `subtle` as an optional dep under the `acp-mcp` feature. - acp_server route_client_response: accept a stringified-number JSON-RPC id ("1") in addition to a numeric id, so a spec-loose client's responses still correlate to their pending request instead of being silently dropped. Gate (targeted, no repo-wide fmt — this container's rustfmt disagrees with the branch on pre-existing import ordering): clippy clean on both crates; openab-core mcp_proxy tests 8/8, openab-gateway acp_server tests 35/35 green. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/Cargo.toml | 3 ++- crates/openab-core/src/mcp_proxy.rs | 7 ++++++- crates/openab-gateway/src/adapters/acp_server.rs | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 91c24f94b..dd0cc49ab 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -55,6 +55,7 @@ http = { version = "1", optional = true } rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } axum = { version = "0.8", optional = true } tokio-util = { version = "0.7", optional = true } +subtle = { version = "2", optional = true } # constant-time bearer compare for the loopback MCP server [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -72,4 +73,4 @@ pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] # Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). -acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util"] +acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util", "dep:subtle"] diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 6ef0181af..2a2c4fd48 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -175,7 +175,12 @@ async fn require_bearer( .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) - .is_some_and(|t| t == &*expected); + // Constant-time compare so a wrong token can't be probed byte-by-byte via response + // timing (mirrors the gateway's feishu/wecom signature checks). + .is_some_and(|t| { + use subtle::ConstantTimeEq; + t.as_bytes().ct_eq(expected.as_bytes()).into() + }); if authed { next.run(req).await } else { diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 53320312a..d8cc14e34 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -524,7 +524,12 @@ async fn route_client_response( if has_method || !looks_like_response { return false; } - let Some(id) = raw.get("id").and_then(Value::as_u64) else { + // Accept a numeric id (what we mint) or a stringified number ("1") from a spec-loose + // client, so its responses still correlate to the pending request instead of being dropped. + let Some(id) = raw + .get("id") + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + else { return false; }; if let Some(tx) = pending.lock().await.remove(&id) { From 6476dae69b169af4cb2224d3515e46306fa42e87 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:31:48 +0800 Subject: [PATCH 024/138] fix(acp-mcp): establish the browser tunnel on session/resume, not just session/new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit katashiro persists its ACP session and RECONNECTS via session/resume (not session/new), re-declaring its "type":"acp" browser MCP server each time. The session/new branch spawns establish_and_register_tunnel for each declared server, but session/resume only recorded them in the session state and never opened a tunnel — so a resumed browser session had no entry in the tunnel registry and the core MCP proxy returned "no browser attached to session acp_" on every call. Mirror the session/new logic in the resume branch: derive the same deterministic channel_id from the sessionId and spawn establish_and_register_tunnel for each declared type:acp server. This is what makes the live loop work across katashiro's auto-reconnect (which always resumes). Gate: clippy clean, openab-gateway acp_server tests 35/35. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index d8cc14e34..348c4a3f0 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -949,6 +949,38 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // Re-open + register the browser tunnel(s) on resume too. katashiro persists its + // ACP session and RECONNECTS via session/resume (not session/new), re-declaring its + // "type":"acp" browser server each time. Without this, a resumed session records the + // server but never opens a tunnel, so the core proxy reports "no browser attached". + // channel_id is derived deterministically from the sessionId, matching the handler. + if let Some(registry) = state.acp_tunnel_registry.clone() { + if let Some(channel_id) = req + .params + .as_ref() + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()) + .and_then(derive_channel_id) + { + for srv in parse_acp_mcp_servers(req.params.as_ref()) { + let out_tx2 = out_tx.clone(); + let pending2 = pending_requests.clone(); + let next_id2 = next_req_id.clone(); + let channel = channel_id.clone(); + let reg = registry.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx2, pending2, next_id2, srv.id, channel, reg, 30, + ) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel on resume"); + } + })); + } + } + } } "session/prompt" => { if !initialized { From dde1b349117e89b4e9376419b0b08f829d33101d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 06:36:48 +0800 Subject: [PATCH 025/138] chore(acp-mcp): log browser tunnel open/register for live-session observability establish_and_register_tunnel is reached only when a client declared a "type":"acp" server, so an info line there answers "did the extension advertise itself?" from the gateway log alone (the raw upstream session frame isn't otherwise logged). Logs on open and on successful registry insert. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/adapters/acp_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 348c4a3f0..8cfb084b2 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -712,6 +712,9 @@ async fn establish_and_register_tunnel( registry: AcpTunnelRegistry, timeout_secs: u64, ) -> Result<(), String> { + // Observability: reaching here means the client DID declare a "type":"acp" server, so this + // line in the log answers "did the browser extension advertise itself?" for a live session. + info!(acp_id = %acp_id, channel_id = %channel_id, "ACP: opening MCP-over-ACP browser tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; let handle = TunnelHandle { out_tx, @@ -722,7 +725,8 @@ async fn establish_and_register_tunnel( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id, handle); + .insert(channel_id.clone(), handle); + info!(channel_id = %channel_id, "ACP: browser tunnel registered — extension attached"); Ok(()) } From 06165f055c2a1b4d8ac9ec151355fde09ce0f336 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:03:51 +0800 Subject: [PATCH 026/138] =?UTF-8?q?fix(acp-mcp):=20raise=20ACP=20frame=20c?= =?UTF-8?q?ap=201=E2=86=928=20MiB=20for=20browser-tool=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser tool results carried over the MCP-over-ACP tunnel (notably screenshots) routinely exceed the old 1 MiB inbound frame cap, which closed the WebSocket mid-response and wedged the extension in a reconnect loop. 8 MiB gives ample room for a compressed screenshot / large DOM snapshot while staying a sane DoS bound. Pairs with the katashiro-side switch to JPEG screenshots. Co-Authored-By: Claude Opus 4.8 --- crates/openab-gateway/src/adapters/acp_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 8cfb084b2..b76628a39 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -36,7 +36,7 @@ const ACP_PROTOCOL_VERSION: u32 = 1; /// eviction, and global connection/worker limits are a follow-up (review F6, roadmap). const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; -const MAX_FRAME_BYTES: usize = 1 << 20; // 1 MiB per inbound JSON-RPC frame +const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB /// JSON-RPC implementation-defined server error for a hit resource cap. const ACP_OVERLOADED: i32 = -32000; From 1f5b0c9cee446e2a097b134a807fa34184f89da2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:53:19 +0800 Subject: [PATCH 027/138] =?UTF-8?q?fix(acp-mcp):=20one=20browser=20tunnel?= =?UTF-8?q?=20per=20session=20=E2=80=94=20fix=20fan-out=20overwrite/orphan?= =?UTF-8?q?=20(M-B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel registry is keyed by channel_id, and both the session/new and session/resume paths looped over every declared type:acp server calling establish_and_register_tunnel. With >1 server each insert overwrote the previous under the same channel_id, leaving the earlier tunnel opened-but-unreachable (orphaned). The core proxy only ever resolves a browser by channel_id, so one tunnel per session is the actual model. Factor both call sites into spawn_browser_tunnel(), which establishes only the first declared server and warns on extras. Co-Authored-By: Claude Opus 4.8 --- .../openab-gateway/src/adapters/acp_server.rs | 91 ++++++++++++------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index b76628a39..13c42456c 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -730,6 +730,47 @@ async fn establish_and_register_tunnel( Ok(()) } +/// Open + register the browser tunnel for a session's declared `type:acp` servers. +/// +/// Exactly ONE tunnel per session is supported: the core proxy resolves a browser by +/// `channel_id`, so registering a second server under the same `channel_id` would overwrite +/// the first in the registry and orphan its already-opened tunnel. We therefore establish only +/// the first declared server and warn if the client sent more. Spawned (not awaited inline) +/// because `establish_and_register_tunnel` awaits the client's `mcp/connect` response, which +/// only the read loop delivers — awaiting inline would deadlock. +#[allow(clippy::too_many_arguments)] +fn spawn_browser_tunnel( + servers: Vec, + channel_id: String, + registry: AcpTunnelRegistry, + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &Arc, + prompt_tasks: &mut Vec>, +) { + if servers.len() > 1 { + warn!( + channel_id = %channel_id, + count = servers.len(), + "ACP: multiple type:acp servers declared; only one browser tunnel per session is supported — using the first" + ); + } + let Some(srv) = servers.into_iter().next() else { + return; + }; + let out_tx = out_tx.clone(); + let pending = pending.clone(); + let next_id = next_id.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = + establish_and_register_tunnel(out_tx, pending, next_id, srv.id, channel_id, registry, 30) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -918,22 +959,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response // only THIS read loop delivers — awaiting inline would deadlock. if let Some(registry) = state.acp_tunnel_registry.clone() { - for srv in acp_mcp_servers { - let out_tx2 = out_tx.clone(); - let pending2 = pending_requests.clone(); - let next_id2 = next_req_id.clone(); - let channel = channel_id.clone(); - let reg = registry.clone(); - prompt_tasks.push(tokio::spawn(async move { - if let Err(e) = establish_and_register_tunnel( - out_tx2, pending2, next_id2, srv.id, channel, reg, 30, - ) - .await - { - warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); - } - })); - } + spawn_browser_tunnel( + acp_mcp_servers, + channel_id.clone(), + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); } } "session/resume" => { @@ -967,22 +1001,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { .and_then(|v| v.as_str()) .and_then(derive_channel_id) { - for srv in parse_acp_mcp_servers(req.params.as_ref()) { - let out_tx2 = out_tx.clone(); - let pending2 = pending_requests.clone(); - let next_id2 = next_req_id.clone(); - let channel = channel_id.clone(); - let reg = registry.clone(); - prompt_tasks.push(tokio::spawn(async move { - if let Err(e) = establish_and_register_tunnel( - out_tx2, pending2, next_id2, srv.id, channel, reg, 30, - ) - .await - { - warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel on resume"); - } - })); - } + spawn_browser_tunnel( + parse_acp_mcp_servers(req.params.as_ref()), + channel_id, + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); } } } From bbc9e13af5a88dd47b0a72a2f0dedf1b070c22b7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:53:19 +0800 Subject: [PATCH 028/138] fix(acp-mcp): 0600 mcp.json + strip stale bearer on evict (M-B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_session_server wrote /.cursor/mcp.json with tokio::fs::write, leaving it at the umask default (typically 0644) — but the file embeds the live loopback bearer token, so any local user could read it. Write via write_private() which chmods it 0600. Also, on session evict (CancellationToken fires) strip the now-dead openab-browser entry so a stale credential doesn't linger; guarded to only remove the entry if it still points at our addr, so a concurrent/reconnected session that already replaced it isn't clobbered (the mcp.json path is shared across acp: sessions). Adds a 0600 assertion to the existing config-write test. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 57 ++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 2a2c4fd48..c576a2ff9 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -251,15 +251,58 @@ pub async fn start_session_server( if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { cfg["mcpServers"] = json!({}); } + let our_url = format!("http://{addr}/mcp"); cfg["mcpServers"]["openab-browser"] = json!({ - "url": format!("http://{addr}/mcp"), + "url": our_url, "headers": { "Authorization": format!("Bearer {bearer}") } }); - tokio::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + // 0600: the file carries a live bearer token — default umask would leave it world-readable. + write_private(&cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + + // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry + // (with its live bearer) so a stale credential doesn't linger. Only remove it if it still + // points at OUR addr — a concurrent/reconnected session may have already replaced it, and we + // must not clobber that live entry (the mcp.json path is shared across acp: sessions). + let cleanup_path = cfg_path.clone(); + let cleanup_ct = ct.clone(); + tokio::spawn(async move { + cleanup_ct.cancelled().await; + let Ok(bytes) = tokio::fs::read(&cleanup_path).await else { + return; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + return; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(our_url.as_str()); + if !still_ours { + return; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(&cleanup_path, &out).await; + } + }); Ok((addr, ct)) } +/// Write `bytes` to `path`, then tighten it to owner-only (0600). The file holds a live bearer +/// token for the loopback MCP server, so it must not be group/world readable. +async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + tokio::fs::write(path, bytes).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; @@ -313,6 +356,16 @@ mod tests { .as_str() .unwrap() .starts_with("Bearer ")); + // The file holds a live bearer — it must be owner-only (0600), not umask-default 0644. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(".cursor/mcp.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mcp.json (live bearer) must be 0600"); + } ct.cancel(); } From b9bdd087ae58ebd34b062d44c419324e22d7f777 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 07:55:15 +0800 Subject: [PATCH 029/138] chore(acp-mcp): lock subtle dependency Cargo.lock was missing the openab-core `subtle` entry added for the constant-time bearer compare, which would fail a `--locked` build. No code change. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 970462b42..50855f6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,6 +2264,7 @@ dependencies = [ "serde_json", "serenity", "sha2 0.10.9", + "subtle", "tar", "tempfile", "tokio", From a6d9654100d0be3534e554dc2d5dd9a1508fa1fa Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 14:42:32 +0800 Subject: [PATCH 030/138] feat(mcp-proxy): write openab-browser into kiro-cli config too, not just Cursor start_session_server now merges the openab-browser entry into BOTH .cursor/mcp.json and .kiro/settings/mcp.json (each CLI ignores the other's), and cleans both on evict. kiro-cli parses the {url, headers} shape identically. Deployed as acpmcp-kirofix. Also add .dockerignore (exclude target/, .git/, data/) for the acp image builds. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 4 ++ crates/openab-core/src/mcp_proxy.rs | 92 +++++++++++++++++------------ 2 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..cb222e6ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target/ +.git/ +data/ +*.tgz diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c576a2ff9..e06329544 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -239,52 +239,68 @@ pub async fn start_session_server( let ct = tokio_util::sync::CancellationToken::new(); let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; - // Merge the openab-browser entry into /.cursor/mcp.json (don't clobber any - // servers the user/agent already configured). - let cursor_dir = std::path::Path::new(workdir).join(".cursor"); - tokio::fs::create_dir_all(&cursor_dir).await?; - let cfg_path = cursor_dir.join("mcp.json"); - let mut cfg: Value = match tokio::fs::read(&cfg_path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), - Err(_) => json!({}), - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } let our_url = format!("http://{addr}/mcp"); - cfg["mcpServers"]["openab-browser"] = json!({ - "url": our_url, + let entry = json!({ + "url": our_url.clone(), "headers": { "Authorization": format!("Bearer {bearer}") } }); - // 0600: the file carries a live bearer token — default umask would leave it world-readable. - write_private(&cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + + // Merge the openab-browser entry into each colocated ACP CLI's native MCP config (don't + // clobber servers the user/agent already configured). Cursor reads /.cursor/mcp.json; + // kiro-cli reads /.kiro/settings/mcp.json. We write both — each CLI ignores the + // other's file — so the browser server reaches whichever agent is colocated. + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + cfg["mcpServers"]["openab-browser"] = entry.clone(); + // 0600: the file carries a live bearer token — default umask would leave it world-readable. + write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + } // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry - // (with its live bearer) so a stale credential doesn't linger. Only remove it if it still - // points at OUR addr — a concurrent/reconnected session may have already replaced it, and we - // must not clobber that live entry (the mcp.json path is shared across acp: sessions). - let cleanup_path = cfg_path.clone(); + // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it + // if it still points at OUR addr — a concurrent/reconnected session may have already replaced + // it, and we must not clobber that live entry (the mcp.json paths are shared across acp: sessions). + let cleanup_paths = cfg_paths.to_vec(); + let cleanup_url = our_url; let cleanup_ct = ct.clone(); tokio::spawn(async move { cleanup_ct.cancelled().await; - let Ok(bytes) = tokio::fs::read(&cleanup_path).await else { - return; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { - return; - }; - let still_ours = cfg - .pointer("/mcpServers/openab-browser/url") - .and_then(Value::as_str) - == Some(our_url.as_str()); - if !still_ours { - return; - } - if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { - servers.remove("openab-browser"); - } - if let Ok(out) = serde_json::to_vec_pretty(&cfg) { - let _ = write_private(&cleanup_path, &out).await; + for cleanup_path in &cleanup_paths { + let Ok(bytes) = tokio::fs::read(cleanup_path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(cleanup_url.as_str()); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(cleanup_path, &out).await; + } } }); From dc0009a4a4f59726a5f1e4dd67ea4f92860f91c7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 14:42:32 +0800 Subject: [PATCH 031/138] =?UTF-8?q?docs:=20browser=20MCP=20agent=20setup?= =?UTF-8?q?=20=E2=80=94=20per-variant=20mcp.json=20how-to=20(Phase=202=20#?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit How the openab-browser tools reach each agent CLI: the per-session loopback proxy + where openab writes the {url, headers} entry per variant (Cursor/Kiro auto today; Claude/Codex/Gemini paths documented, not yet auto). Honest caveat: static manual config awaits the stable-endpoint redesign (#9). Co-Authored-By: Claude Opus 4.8 --- docs/browser-mcp-agent-setup.md | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/browser-mcp-agent-setup.md diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md new file mode 100644 index 000000000..bcf3ec389 --- /dev/null +++ b/docs/browser-mcp-agent-setup.md @@ -0,0 +1,73 @@ +# Browser MCP — how the agent gets the `openab-browser` tools + +The browser MCP server exposes five DOM-semantic tools — +`browser.read_dom`, `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type` — +served by the **browser extension** over the MCP-over-ACP tunnel (see +[tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the +colocated agent CLI actually **sees** those tools. + +## How it reaches the agent + +The gateway↔extension tunnel terminates in the pod at a **per-session loopback MCP proxy** +(`openab-core` `mcp_proxy::start_session_server`). To expose it to the agent, openab writes an +`openab-browser` entry into the agent CLI's **native MCP config file** — the agent connects to +`http://127.0.0.1:/mcp` and re-lists the tools. The entry looks like: + +```json +{ + "mcpServers": { + "openab-browser": { + "url": "http://127.0.0.1:/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Both `` and `` are **minted fresh per session** and the entry is stripped on session +evict — so this is written by openab, not hand-editable to a fixed value (see *Caveat* below). + +## Per-variant MCP config location + +openab writes the same entry into whichever file the colocated CLI reads. Current state: + +| Variant | MCP config file (under `$workdir`, = `$HOME`) | HTTP MCP + `headers` | Auto-written by openab today | +|---|---|---|---| +| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | yes | ✅ yes | +| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | yes | ✅ yes | +| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | yes | ⛔ not yet | +| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | check version | ⛔ not yet | +| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | yes | ⛔ not yet | + +`start_session_server` currently writes **`.cursor/mcp.json` + `.kiro/settings/mcp.json`**. Adding +a variant = teach that function the CLI's config path + format (same `{url, headers}` shape for +JSON configs; Codex uses TOML and needs a small serializer). + +## Manual / unsupported variants + +If a variant isn't auto-written, a user *could* add the `openab-browser` entry to that CLI's +mcp.json by hand — **but** the current proxy endpoint is per-session ephemeral (fresh port + +bearer each session), so a static hand-written entry goes stale on the next session and cannot +be used as-is. Manual configuration for arbitrary variants is therefore gated on a **stable +browser-MCP endpoint** (a fixed URL + stable auth the user configures once). That redesign is +tracked separately (see `drafts/` browser-MCP stable-endpoint design). Until then: + +- **Cursor / Kiro:** work out of the box (auto-injected). +- **Other variants:** either add the variant's writer to `start_session_server`, or wait for the + stable endpoint. + +## Verify + +Inside the agent pod, after the extension attaches a browser session: + +```sh +# the entry openab wrote for this session +cat "$HOME/.cursor/mcp.json" # Cursor +cat "$HOME/.kiro/settings/mcp.json" # Kiro + +# does the CLI see the server / tools? (CLI-specific; e.g. Kiro:) +kiro-cli mcp list +``` + +Gateway log confirms the extension side: +`ACP: browser tunnel registered — extension attached`. From c2bd77d2401bb4bbbd1c71ad8e1c0096294ecfc7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 18:38:08 +0800 Subject: [PATCH 032/138] feat(mcp-proxy): per-pod browser-bridge socket server (Option C, P1) serve_browser_socket: one unix socket multiplexes all sessions; the openab browser-bridge shim forwards {channel_id, inner MCP request} frames, routed via dispatch_browser_mcp -> the shared BrowserTunnel by channel. Reuses browser_tools() + tunnel.call (single source of truth vs the HTTP ProxyHandler). +8 tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 275 +++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index e06329544..6b4797589 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -319,9 +319,138 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +// ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- +// A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim +// (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests +// tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes +// `tools/call` to that session's BrowserTunnel. This is the stable, variant-agnostic replacement +// for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: +// bridge → core : {"channel_id": "...", "request": } +// core → bridge : (omitted for notifications) + +const BROWSER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +fn mcp_result(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn mcp_error(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +/// Dispatch one inner MCP request for `channel_id`, backing `tools/call` with the shared browser +/// tunnel. Returns the MCP response, or `None` for a JSON-RPC notification (no reply). Same tool +/// set + not-connected semantics as the HTTP `ProxyHandler` (single source of truth). +pub(crate) async fn dispatch_browser_mcp( + channel_id: &str, + request: &Value, + tunnel: &Option>, +) -> Option { + // A JSON-RPC notification has no `id` → no response. + let id = request.get("id").cloned()?; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let resp = match method { + "initialize" => mcp_result( + id, + json!({ + "protocolVersion": BROWSER_MCP_PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": "openab-browser", "version": env!("CARGO_PKG_VERSION") } + }), + ), + "tools/list" => { + let tools = serde_json::to_value(browser_tools()).unwrap_or_else(|_| json!([])); + mcp_result(id, json!({ "tools": tools })) + } + "tools/call" => match tunnel { + Some(t) => match t + .call(channel_id, "tools/call", request.get("params").cloned()) + .await + { + Ok(v) => mcp_result(id, v), + Err(e) => mcp_error(id, -32603, &e), + }, + None => mcp_error( + id, + -32603, + "browser not connected: open the OpenAB side panel in your browser", + ), + }, + other => mcp_error(id, -32601, &format!("method not found: {other}")), + }; + Some(resp) +} + +/// Serve the per-pod browser-bridge socket at `path`, routing each connection's framed requests +/// via [`dispatch_browser_mcp`]. Binds a fresh 0600 unix socket (same-uid only), spawns the accept +/// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. +pub async fn serve_browser_socket( + path: std::path::PathBuf, + tunnel: Option>, + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result<()> { + let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run + let listener = tokio::net::UnixListener::bind(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + tokio::spawn(async move { + loop { + tokio::select! { + _ = ct.cancelled() => break, + accepted = listener.accept() => { + match accepted { + Ok((stream, _)) => { + tokio::spawn(handle_browser_conn(stream, tunnel.clone())); + } + Err(_) => continue, + } + } + } + } + let _ = tokio::fs::remove_file(&path).await; + }); + Ok(()) +} + +async fn handle_browser_conn( + stream: tokio::net::UnixStream, + tunnel: Option>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let Ok(frame) = serde_json::from_str::(&line) else { + continue; // skip a malformed frame rather than drop the connection + }; + let channel_id = frame.get("channel_id").and_then(Value::as_str).unwrap_or(""); + let Some(request) = frame.get("request") else { + continue; + }; + if let Some(resp) = dispatch_browser_mcp(channel_id, request, &tunnel).await { + let Ok(mut buf) = serde_json::to_vec(&resp) else { + continue; + }; + buf.push(b'\n'); + if write_half.write_all(&buf).await.is_err() { + break; + } + } + } +} + #[cfg(test)] mod tests { - use super::{browser_tools, spawn_mcp_server, start_session_server, BrowserTunnel, ProxyHandler}; + use super::{ + browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, + start_session_server, BrowserTunnel, ProxyHandler, + }; struct MockTunnel; #[async_trait::async_trait] @@ -355,6 +484,150 @@ mod tests { ); } + // --- Option C: browser-bridge socket dispatch --- + struct RecordTunnel { + result: serde_json::Value, + } + #[async_trait::async_trait] + impl BrowserTunnel for RecordTunnel { + async fn call( + &self, + channel_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(method, "tools/call"); + assert_eq!(channel_id, "acp_win1"); + Ok(self.result.clone()) + } + } + struct ErrTunnel; + #[async_trait::async_trait] + impl BrowserTunnel for ErrTunnel { + async fn call( + &self, + _c: &str, + _m: &str, + _p: Option, + ) -> Result { + Err("no browser attached".into()) + } + } + fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + } + fn arc_tunnel(t: T) -> Option> { + Some(std::sync::Arc::new(t)) + } + + #[tokio::test] + async fn dispatch_initialize_advertises_tools() { + let r = dispatch_browser_mcp("acp_x", &req(1, "initialize", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["id"], 1); + assert_eq!(r["result"]["capabilities"]["tools"], serde_json::json!({})); + assert_eq!(r["result"]["serverInfo"]["name"], "openab-browser"); + } + + #[tokio::test] + async fn dispatch_tools_list_returns_five_tools() { + let r = dispatch_browser_mcp("acp_x", &req(2, "tools/list", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 5); + } + + #[tokio::test] + async fn dispatch_tools_call_routes_to_the_channel_tunnel() { + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let r = dispatch_browser_mcp( + "acp_win1", + &req(3, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["result"]["content"][0]["text"], "ok"); + } + + #[tokio::test] + async fn dispatch_tools_call_without_tunnel_is_not_connected() { + let r = dispatch_browser_mcp( + "acp_x", + &req(4, "tools/call", serde_json::json!({ "name": "browser.click" })), + &None, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert!(r["error"]["message"].as_str().unwrap().contains("not connected")); + } + + #[tokio::test] + async fn dispatch_tools_call_surfaces_tunnel_error() { + let tunnel = arc_tunnel(ErrTunnel); + let r = dispatch_browser_mcp( + "acp_x", + &req(5, "tools/call", serde_json::json!({ "name": "browser.click" })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert_eq!(r["error"]["message"], "no browser attached"); + } + + #[tokio::test] + async fn dispatch_notification_gets_no_response() { + let notif = serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert!(dispatch_browser_mcp("acp_x", ¬if, &None).await.is_none()); + } + + #[tokio::test] + async fn dispatch_unknown_method_is_method_not_found() { + let r = dispatch_browser_mcp("acp_x", &req(6, "bogus/thing", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32601); + } + + #[tokio::test] + async fn browser_socket_round_trip() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("browser.sock"); + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let ct = tokio_util::sync::CancellationToken::new(); + serve_browser_socket(sock.clone(), tunnel, ct.clone()) + .await + .unwrap(); + let stream = loop { + match tokio::net::UnixStream::connect(&sock).await { + Ok(s) => break s, + Err(_) => tokio::task::yield_now().await, + } + }; + let (rd, mut wr) = stream.into_split(); + let frame = serde_json::json!({ + "channel_id": "acp_win1", + "request": req(9, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })) + }); + let mut line = serde_json::to_vec(&frame).unwrap(); + line.push(b'\n'); + wr.write_all(&line).await.unwrap(); + let mut resp = String::new(); + BufReader::new(rd).read_line(&mut resp).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(v["id"], 9); + assert_eq!(v["result"]["content"][0]["text"], "ok"); + ct.cancel(); + } + #[tokio::test] async fn start_session_server_writes_cursor_config() { let dir = tempfile::tempdir().unwrap(); From 73a777e8c92c65ed2c704f74622f8c4042a6b38c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 18:59:26 +0800 Subject: [PATCH 033/138] =?UTF-8?q?feat(cli):=20openab=20browser-bridge=20?= =?UTF-8?q?subcommand=20=E2=80=94=20stdio=20MCP=20relay=20to=20the=20brows?= =?UTF-8?q?er=20socket=20(Option=20C,=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin per-session shim: reads OPENAB_BROWSER_CHANNEL, wraps each stdin MCP request as {channel_id, request}, forwards to the per-pod core socket, relays responses to stdout verbatim. All browser MCP logic stays in core; the agent's config line is static. Gated by feature acp. + wrap/relay tests over in-memory pipes. Co-Authored-By: Claude Opus 4.8 --- src/browser_bridge.rs | 165 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 11 +++ 2 files changed, 176 insertions(+) create mode 100644 src/browser_bridge.rs diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs new file mode 100644 index 000000000..39aa96a6d --- /dev/null +++ b/src/browser_bridge.rs @@ -0,0 +1,165 @@ +//! `openab browser-bridge` — a stdio MCP server that is a thin relay to the per-pod browser +//! socket (Option C). The agent's MCP client spawns it per session; it reads +//! `OPENAB_BROWSER_CHANNEL` from its inherited env, wraps each stdin MCP request as +//! `{channel_id, request}`, forwards it to the core socket, and relays responses to stdout +//! verbatim. ALL browser MCP logic lives in core (`dispatch_browser_mcp`) — this is a pure pipe +//! + channel tag, so the config line agents carry is static (`{"command":"openab","args": +//! ["browser-bridge"]}`) and disambiguation is by the inherited env, never by config. +//! +//! stdout carries the MCP wire, so this path emits nothing to stdout except MCP responses +//! (diagnostics, if any, go to stderr). + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Per-pod socket path; overridable via `OPENAB_BROWSER_SOCKET`. +fn socket_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { + return p.into(); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); + std::path::Path::new(&home).join(".openab").join("browser.sock") +} + +/// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` +/// for a blank/unparseable line (skip it) so a stray line can't break the relay. +fn wrap_frame(channel: &str, line: &str) -> Option> { + let line = line.trim(); + if line.is_empty() { + return None; + } + let request: Value = serde_json::from_str(line).ok()?; + let frame = json!({ "channel_id": channel, "request": request }); + let mut buf = serde_json::to_vec(&frame).ok()?; + buf.push(b'\n'); + Some(buf) +} + +/// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and +/// socket→stdout (verbatim MCP responses) until either side closes. +pub async fn run() -> std::io::Result<()> { + let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); + let sock = tokio::net::UnixStream::connect(socket_path()).await?; + let (sock_rd, sock_wr) = sock.into_split(); + pump( + channel, + BufReader::new(tokio::io::stdin()), + tokio::io::stdout(), + BufReader::new(sock_rd), + sock_wr, + ) + .await +} + +/// The relay, generic over the four streams so it can be tested with in-memory pipes. Ends when +/// either stdin (agent gone) or the socket (core gone) closes. +async fn pump( + channel: String, + mut stdin: In, + mut stdout: Out, + mut sock_rd: SockR, + mut sock_wr: SockW, +) -> std::io::Result<()> +where + In: AsyncBufReadExt + Unpin, + Out: AsyncWriteExt + Unpin, + SockR: AsyncBufReadExt + Unpin, + SockW: AsyncWriteExt + Unpin, +{ + let to_sock = async { + let mut line = String::new(); + loop { + line.clear(); + if stdin.read_line(&mut line).await? == 0 { + break; // stdin closed → agent gone + } + if let Some(frame) = wrap_frame(&channel, &line) { + sock_wr.write_all(&frame).await?; + sock_wr.flush().await?; + } + } + Ok::<(), std::io::Error>(()) + }; + let to_stdout = async { + let mut line = String::new(); + loop { + line.clear(); + if sock_rd.read_line(&mut line).await? == 0 { + break; // socket closed → core gone + } + stdout.write_all(line.as_bytes()).await?; + stdout.flush().await?; + } + Ok::<(), std::io::Error>(()) + }; + // Whichever side closes first ends the relay; the other pump is dropped (we're shutting down). + tokio::select! { + r = to_sock => r, + r = to_stdout => r, + } +} + +#[cfg(test)] +mod tests { + use super::{pump, wrap_frame}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + #[test] + fn wrap_frame_tags_the_channel_and_appends_newline() { + let out = wrap_frame("acp_win1", r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).unwrap(); + assert_eq!(*out.last().unwrap(), b'\n'); + let v: serde_json::Value = serde_json::from_slice(&out[..out.len() - 1]).unwrap(); + assert_eq!(v["channel_id"], "acp_win1"); + assert_eq!(v["request"]["method"], "tools/list"); + assert_eq!(v["request"]["id"], 1); + } + + #[test] + fn wrap_frame_skips_blank_and_malformed_lines() { + assert!(wrap_frame("c", " ").is_none()); + assert!(wrap_frame("c", "").is_none()); + assert!(wrap_frame("c", "not json").is_none()); + } + + #[tokio::test] + async fn pump_relays_request_to_socket_and_response_to_stdout() { + // Four in-memory pipes standing in for stdin, stdout, and the two socket halves. + let (mut stdin_w, stdin_r) = tokio::io::duplex(1024); + let (stdout_w, mut stdout_r) = tokio::io::duplex(1024); + let (mut sock_peer_w, sock_rd) = tokio::io::duplex(1024); // core → bridge (responses) + let (sock_wr, mut sock_peer_r) = tokio::io::duplex(1024); // bridge → core (frames) + + let handle = tokio::spawn(pump( + "acp_win1".to_string(), + BufReader::new(stdin_r), + stdout_w, + BufReader::new(sock_rd), + sock_wr, + )); + + // Agent writes an MCP request on stdin → bridge should emit a channel-tagged frame to core. + stdin_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{}}\n") + .await + .unwrap(); + let mut frame = String::new(); + BufReader::new(&mut sock_peer_r).read_line(&mut frame).await.unwrap(); + let fv: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(fv["channel_id"], "acp_win1"); + assert_eq!(fv["request"]["id"], 9); + + // Core writes an MCP response on the socket → bridge should relay it verbatim to stdout. + sock_peer_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"ok\":true}}\n") + .await + .unwrap(); + let mut out = String::new(); + BufReader::new(&mut stdout_r).read_line(&mut out).await.unwrap(); + let ov: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(ov["id"], 9); + assert_eq!(ov["result"]["ok"], true); + + drop(stdin_w); // agent gone → relay ends + let _ = handle.await; + } +} diff --git a/src/main.rs b/src/main.rs index 534a3e467..3da07bbbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ mod ctl; mod unified_adapter; #[cfg(feature = "acp")] mod browser_tunnel; +#[cfg(feature = "acp")] +mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -101,6 +103,11 @@ enum Commands { #[arg(long, default_value = "kiro-cli acp --trust-all-tools")] command: String, }, + /// Internal: stdio MCP bridge to the per-pod browser socket (Option C). Spawned per session + /// by the agent's MCP client; relays MCP over stdio to core's browser tunnel by inherited + /// OPENAB_BROWSER_CHANNEL. + #[cfg(feature = "acp")] + BrowserBridge, /// Set a runtime value (e.g. thread.name) Set { /// Key to set (e.g. thread.name) @@ -271,6 +278,10 @@ async fn main() -> anyhow::Result<()> { } => { return acp::agentcore::run_bridge(&runtime_arn, ®ion, &command).await; } + #[cfg(feature = "acp")] + Commands::BrowserBridge => { + return browser_bridge::run().await.map_err(Into::into); + } Commands::Set { key, value, thread } => { let resp = ctl::send_request(&ctl::Request { action: ctl::Action::Set, From 8b18c481266a778d41eb337b95f0ddffb81f60df Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:22:04 +0800 Subject: [PATCH 034/138] feat(acp): inject OPENAB_BROWSER_CHANNEL into the agent env (Option C, P3) AcpConnection::spawn gains a browser_channel param; for an acp: session the pool passes the channel_id so the agent (and the browser-bridge shim it later spawns) inherits it and routes browser tool calls to THIS session's tunnel. env_clear-safe (re-injected explicitly). + set_browser_channel unit tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/connection.rs | 35 +++++++++++++++++++++++- crates/openab-core/src/acp/pool.rs | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index cb9577ddd..4ff4a4255 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -336,6 +336,7 @@ impl AcpConnection { working_dir: &str, env: &std::collections::HashMap, inherit_env: &[String], + browser_channel: Option<&str>, ) -> Result { info!(cmd = command, ?args, cwd = working_dir, "spawning agent"); @@ -402,6 +403,10 @@ impl AcpConnection { cmd.env("SystemDrive", v); } } + // Option C: hand this session's browser channel to the agent so the `openab + // browser-bridge` stdio shim it later spawns inherits it and routes tool calls to THIS + // session's tunnel. env_clear() above dropped it, so (re)inject explicitly. + set_browser_channel(&mut cmd, browser_channel); for (k, v) in env { cmd.env(k, expand_env(v)); } @@ -847,11 +852,39 @@ impl Drop for AcpConnection { } } +/// Inject the session's browser channel into the agent's env so the `openab browser-bridge` +/// stdio shim the agent later spawns inherits it (Option C) and routes tool calls to THIS +/// session's tunnel. No-op for non-browser sessions (`None`). +fn set_browser_channel(cmd: &mut tokio::process::Command, browser_channel: Option<&str>) { + if let Some(channel) = browser_channel { + cmd.env("OPENAB_BROWSER_CHANNEL", channel); + } +} + #[cfg(test)] mod tests { - use super::{build_agent_env, build_permission_response, pick_best_option}; + use super::{build_agent_env, build_permission_response, pick_best_option, set_browser_channel}; use serde_json::json; + #[test] + fn set_browser_channel_injects_env_when_present() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, Some("acp_win1")); + assert!(cmd.as_std().get_envs().any(|(k, v)| { + k == "OPENAB_BROWSER_CHANNEL" && v == Some(std::ffi::OsStr::new("acp_win1")) + })); + } + + #[test] + fn set_browser_channel_is_noop_when_absent() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, None); + assert!(!cmd + .as_std() + .get_envs() + .any(|(k, _)| k == "OPENAB_BROWSER_CHANNEL")); + } + #[test] fn picks_allow_always_over_other_options() { let options = vec![ diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index e1e129116..adbbc59b4 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -398,6 +398,7 @@ impl SessionPool { &effective_workdir, &self.config.env, &self.config.inherit_env, + thread_id.strip_prefix("acp:"), ) .await?; From 2509bde03afacbc3a2c31f76f8464d9e6ef45202 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:33:34 +0800 Subject: [PATCH 035/138] feat(mcp-proxy): static write-once browser-bridge config (Option C, P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_bridge_mcp_config writes the SAME {command:openab, args:[browser-bridge]} entry to cursor + kiro mcp.json — no port/bearer, so it never goes stale and can't clobber across sessions (the root cause of multi-window browser flakiness). Merges without touching the user's servers; idempotent. Additive — P5 wires the proxy/bridge toggle. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 73 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 6b4797589..4a335a4ff 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -319,6 +319,41 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +/// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json +/// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer +/// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be +/// written once and never goes stale. That fixes the shared-config clobber the per-session dynamic +/// write suffers when several sessions of one agent share a single mcp.json. Merges without +/// touching the user's other servers; idempotent (a no-op when already present + identical). +pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { + let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Idempotent: only rewrite when absent or changed (no needless mtime churn each session). + if cfg["mcpServers"]["openab-browser"] != entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + // ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests @@ -449,7 +484,7 @@ async fn handle_browser_conn( mod tests { use super::{ browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, - start_session_server, BrowserTunnel, ProxyHandler, + start_session_server, write_bridge_mcp_config, BrowserTunnel, ProxyHandler, }; struct MockTunnel; @@ -594,6 +629,42 @@ mod tests { assert_eq!(r["error"]["code"], -32601); } + #[tokio::test] + async fn write_bridge_config_writes_static_entry_to_both_variants() { + let dir = tempfile::tempdir().unwrap(); + write_bridge_mcp_config(dir.path().to_str().unwrap()) + .await + .unwrap(); + for rel in [".cursor/mcp.json", ".kiro/settings/mcp.json"] { + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(rel)).unwrap()).unwrap(); + let e = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(e["command"], "openab"); + assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); + assert!(e.get("url").is_none(), "bridge entry carries no url/port"); + assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); + } + } + + #[tokio::test] + async fn write_bridge_config_merges_without_clobber_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let wd = dir.path().to_str().unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); // idempotent second call + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["other"]["url"], "http://x"); // user's server preserved + assert_eq!(cfg["mcpServers"]["openab-browser"]["command"], "openab"); + } + #[tokio::test] async fn browser_socket_round_trip() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; From 822fdf91bf4c4af4b3ddff8abedf89aea490df07 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Mon, 20 Jul 2026 19:52:34 +0800 Subject: [PATCH 036/138] feat: OPENAB_BROWSER_MODE proxy|bridge toggle wiring (Option C, P5) BrowserMode + browser_mode() (default proxy) + shared browser_socket_path(). Pool branches: proxy = per-session HTTP server (unchanged default); bridge = static write-once config, no per-session server. Broker starts the per-pod socket server once in bridge mode. browser-bridge shim uses the shared socket path. + parse tests. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/pool.rs | 42 +++++++++++++------ crates/openab-core/src/mcp_proxy.rs | 64 ++++++++++++++++++++++++++++- src/browser_bridge.rs | 12 +----- src/main.rs | 21 +++++++++- 4 files changed, 112 insertions(+), 27 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index adbbc59b4..256769280 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -370,21 +370,37 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] let mcp_guard: Option = if let Some(channel_id) = thread_id.strip_prefix("acp:") { - match crate::mcp_proxy::start_session_server( - channel_id, - &effective_workdir, - self.browser_tunnel.clone(), - ) - .await - { - Ok((addr, ct)) => { - info!(thread_id, %addr, "started per-session MCP proxy server"); - Some(ct.drop_guard()) - } - Err(e) => { - warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + match crate::mcp_proxy::browser_mode() { + // Bridge mode (Option C): the agent's static mcp.json points at `openab + // browser-bridge`, which dials the pod-wide socket server (started at boot). + // Just ensure the write-once config exists — no per-session server/guard. + crate::mcp_proxy::BrowserMode::Bridge => { + if let Err(e) = + crate::mcp_proxy::write_bridge_mcp_config(&effective_workdir).await + { + warn!(thread_id, error = %e, "failed to write bridge mcp config"); + } None } + // Proxy mode (default): per-session loopback HTTP MCP server + dynamic config. + crate::mcp_proxy::BrowserMode::Proxy => { + match crate::mcp_proxy::start_session_server( + channel_id, + &effective_workdir, + self.browser_tunnel.clone(), + ) + .await + { + Ok((addr, ct)) => { + info!(thread_id, %addr, "started per-session MCP proxy server"); + Some(ct.drop_guard()) + } + Err(e) => { + warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + None + } + } + } } } else { None diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 4a335a4ff..3c5d2c34d 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -354,6 +354,43 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { Ok(()) } +/// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into +/// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe +/// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserMode { + Proxy, + Bridge, +} + +impl BrowserMode { + pub fn is_bridge(self) -> bool { + matches!(self, BrowserMode::Bridge) + } +} + +fn parse_browser_mode(s: Option<&str>) -> BrowserMode { + match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("bridge") => BrowserMode::Bridge, + _ => BrowserMode::Proxy, + } +} + +/// Read the browser transport mode from `OPENAB_BROWSER_MODE` (default: proxy). +pub fn browser_mode() -> BrowserMode { + parse_browser_mode(std::env::var("OPENAB_BROWSER_MODE").ok().as_deref()) +} + +/// Per-pod browser-bridge socket path (overridable via `OPENAB_BROWSER_SOCKET`). Single source of +/// truth shared by the core socket server and the `openab browser-bridge` shim so they agree. +pub fn browser_socket_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { + return p.into(); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); + std::path::Path::new(&home).join(".openab").join("browser.sock") +} + // ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests @@ -450,6 +487,16 @@ pub async fn serve_browser_socket( Ok(()) } +/// Start the browser socket for the process lifetime (no external cancellation handle) — used by +/// the broker in bridge mode. The pod-wide server lives as long as the process, so no caller-side +/// tokio-util dependency is needed. +pub async fn serve_browser_socket_forever( + path: std::path::PathBuf, + tunnel: Option>, +) -> std::io::Result<()> { + serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await +} + async fn handle_browser_conn( stream: tokio::net::UnixStream, tunnel: Option>, @@ -483,10 +530,23 @@ async fn handle_browser_conn( #[cfg(test)] mod tests { use super::{ - browser_tools, dispatch_browser_mcp, serve_browser_socket, spawn_mcp_server, - start_session_server, write_bridge_mcp_config, BrowserTunnel, ProxyHandler, + browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, + spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, BrowserTunnel, + ProxyHandler, }; + #[test] + fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { + assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); + assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); + assert!(BrowserMode::Bridge.is_bridge()); + assert!(!BrowserMode::Proxy.is_bridge()); + } + struct MockTunnel; #[async_trait::async_trait] impl BrowserTunnel for MockTunnel { diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs index 39aa96a6d..3c63847b7 100644 --- a/src/browser_bridge.rs +++ b/src/browser_bridge.rs @@ -12,15 +12,6 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -/// Per-pod socket path; overridable via `OPENAB_BROWSER_SOCKET`. -fn socket_path() -> std::path::PathBuf { - if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { - return p.into(); - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); - std::path::Path::new(&home).join(".openab").join("browser.sock") -} - /// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` /// for a blank/unparseable line (skip it) so a stray line can't break the relay. fn wrap_frame(channel: &str, line: &str) -> Option> { @@ -39,7 +30,8 @@ fn wrap_frame(channel: &str, line: &str) -> Option> { /// socket→stdout (verbatim MCP responses) until either side closes. pub async fn run() -> std::io::Result<()> { let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); - let sock = tokio::net::UnixStream::connect(socket_path()).await?; + let sock = + tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; let (sock_rd, sock_wr) = sock.into_split(); pump( channel, diff --git a/src/main.rs b/src/main.rs index 3da07bbbc..bc894e96f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -404,9 +404,26 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let pool_inner = pool_inner.with_browser_tunnel(Some(Arc::new( + let browser_tunnel: Arc = Arc::new( browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), - ))); + ); + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); + // Option C bridge mode: start the per-pod browser socket server once; the `openab + // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. + #[cfg(feature = "acp")] + if openab_core::mcp_proxy::browser_mode().is_bridge() { + let sock = openab_core::mcp_proxy::browser_socket_path(); + match openab_core::mcp_proxy::serve_browser_socket_forever( + sock.clone(), + Some(browser_tunnel.clone()), + ) + .await + { + Ok(()) => info!(?sock, "browser bridge socket serving (Option C)"), + Err(e) => warn!(?sock, error = %e, "failed to start browser bridge socket"), + } + } let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; From 1d513766d7f158b7b956aa1506f2e256fde5cba2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 21 Jul 2026 00:53:11 +0800 Subject: [PATCH 037/138] fix(browser-bridge): resolve channel via process-ancestry, not env (Option C, b2 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP client scrubs the child env (cursor gives the bridge only HOME/PATH/USER or the pod env, never the per-session OPENAB_BROWSER_CHANNEL), so env inheritance can't carry the channel. resolve_channel() now walks up the PPID chain and reads OPENAB_BROWSER_CHANNEL from the ancestor agent's /proc//environ (openab injected it via the pool) — generic across all stdio-MCP vendors. Logs the resolved channel to stderr. + parse_ppid_from_stat / parse_channel_from_environ unit tests. Co-Authored-By: Claude Opus 4.8 --- src/browser_bridge.rs | 71 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs index 3c63847b7..84756fc02 100644 --- a/src/browser_bridge.rs +++ b/src/browser_bridge.rs @@ -26,10 +26,61 @@ fn wrap_frame(channel: &str, line: &str) -> Option> { Some(buf) } +/// Resolve this bridge's browser channel. The MCP client scrubs the child env (verified: cursor +/// launches us with only HOME/PATH/USER, or the pod env — never the per-session +/// OPENAB_BROWSER_CHANNEL openab injects into the AGENT), so we can't read it from our own env. +/// Instead walk UP the process tree and read OPENAB_BROWSER_CHANNEL from the first ancestor that +/// has it: the agent process openab spawned (channel injected via the pool) is always an ancestor +/// of this stdio MCP server, for EVERY vendor. Prefer our own env first (clients that DO pass it); +/// return "" if not found (core then reports "no browser attached"). +fn resolve_channel() -> String { + if let Ok(c) = std::env::var("OPENAB_BROWSER_CHANNEL") { + if !c.is_empty() { + return c; + } + } + let mut pid = std::process::id(); + for _ in 0..16 { + if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { + if let Some(c) = parse_channel_from_environ(&bytes) { + return c; + } + } + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + break; + }; + match parse_ppid_from_stat(&stat) { + Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) + _ => break, + } + } + String::new() +} + +/// Extract OPENAB_BROWSER_CHANNEL from a null-separated `/proc//environ` blob. +fn parse_channel_from_environ(bytes: &[u8]) -> Option { + for kv in bytes.split(|&b| b == 0) { + if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { + if !rest.is_empty() { + return Some(String::from_utf8_lossy(rest).into_owned()); + } + } + } + None +} + +/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may +/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". +fn parse_ppid_from_stat(stat: &str) -> Option { + let after = stat.rsplit_once(')')?.1; + after.split_whitespace().nth(1)?.parse().ok() +} + /// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and /// socket→stdout (verbatim MCP responses) until either side closes. pub async fn run() -> std::io::Result<()> { - let channel = std::env::var("OPENAB_BROWSER_CHANNEL").unwrap_or_default(); + let channel = resolve_channel(); + eprintln!("[openab browser-bridge] resolved channel={channel:?}"); let sock = tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; let (sock_rd, sock_wr) = sock.into_split(); @@ -93,7 +144,23 @@ where #[cfg(test)] mod tests { - use super::{pump, wrap_frame}; + use super::{parse_channel_from_environ, parse_ppid_from_stat, pump, wrap_frame}; + + #[test] + fn parse_ppid_handles_comm_with_spaces_and_parens() { + assert_eq!(parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); // ')' inside comm + assert_eq!(parse_ppid_from_stat("nonsense"), None); + } + + #[test] + fn parse_channel_from_environ_finds_the_var() { + let env = b"HOME=/home/agent\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/bin\0"; + assert_eq!(parse_channel_from_environ(env).as_deref(), Some("acp_xyz")); + assert_eq!(parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); + assert_eq!(parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); // empty ignored + } use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[test] From c83e399732e649ed7e06383388ba57ca862ae9b4 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 21 Jul 2026 01:06:06 +0800 Subject: [PATCH 038/138] feat(mcp-proxy): revert bridge config to pure {command,args} (Option C, b2 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ${OPENAB_BROWSER_CHANNEL} config env — cursor doesn't expand it (spawns from pod/clean env). The bridge now resolves its channel via process-ancestry (B1), so the config is a byte-identical static entry again: idempotent, never stale, no clobber. Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/mcp_proxy.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 3c5d2c34d..bb329970a 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -326,6 +326,10 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< /// write suffers when several sessions of one agent share a single mcp.json. Merges without /// touching the user's other servers; idempotent (a no-op when already present + identical). pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { + // Pure static entry — byte-identical for every session (idempotent, no cross-session clobber). + // The channel is deliberately NOT carried here: the MCP client scrubs the server's env and its + // config-var expansion is vendor-specific, so the `openab browser-bridge` shim resolves its OWN + // channel by walking up to the agent process (Option C b2). This entry never goes stale. let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); let cfg_paths = [ std::path::Path::new(workdir).join(".cursor").join("mcp.json"), @@ -701,6 +705,10 @@ mod tests { let e = &cfg["mcpServers"]["openab-browser"]; assert_eq!(e["command"], "openab"); assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); + assert!( + e.get("env").is_none(), + "channel is resolved by the shim (b2), not carried in config" + ); assert!(e.get("url").is_none(), "bridge entry carries no url/port"); assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); } From 3696d13d79b9bebdce77e07124c92d454f30b438 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 24 Jul 2026 15:55:58 +0800 Subject: [PATCH 039/138] fix(acp): add acp_mcp_servers to test-only AcpSession initializers The 4 AcpSession constructors in `mod acp_review_fixes` tests missed the acp_mcp_servers field added in T4, breaking `cargo test -p openab-gateway --features acp` (E0063). build/clippy don't compile this crate's test target under `acp`, so only CI caught it. Also syncs Cargo.lock to the already-committed openab 0.10.0 version. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- crates/openab-gateway/src/adapters/acp_server.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50855f6a4..2b5016965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,7 +2210,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openab" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "async-trait", diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 13c42456c..2be85c5ef 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -2528,6 +2528,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); // Cancel arrives before the handler's stream loop (reserved-then-immediate-cancel). @@ -2571,6 +2572,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); @@ -2604,7 +2606,7 @@ mod acp_review_fixes { let cancel = Arc::new(tokio::sync::Notify::new()); sessions.lock().await.insert( sid.clone(), - AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()) }, + AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()), acp_mcp_servers: Vec::new() }, ); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -2680,6 +2682,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); let params = json!({"sessionId": sid}); From 0ed644d8e04c8b7bde48be59a00f3f795dba64f2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 24 Jul 2026 23:24:20 +0800 Subject: [PATCH 040/138] docs(acp): split into reverse-MCP mechanism ADR + browser-control ADR, embed diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ADRs instead of one: - acp-server-websocket-reverse-mcp.md — the generic reverse-MCP-over-ACP mechanism (roles, call route, protocol gap, §6 multi-server generalization: compound-key routing, dynamic tools/list + list_changed, per-server Option B). Embeds the architecture + MCP-usage sequence diagrams (mermaid), using browser control as the example. Flipped Proposed -> Accepted (as-built in #1447). - acp-server-websocket-mcp-browser.md — the browser-specific design + the contract the browser extension implements (D1-D6, detailed id-paired runtime sequence, tasks, as-built). Defers the mechanism to the reverse-MCP ADR. Update base ADR + tunnel-contract cross-links; mark base §6 browser critical-path done. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-base.md | 7 +- docs/adr/acp-server-websocket-mcp-browser.md | 488 ++++++------------- docs/adr/acp-server-websocket-reverse-mcp.md | 228 +++++++++ docs/mcp-over-acp-tunnel-contract.md | 2 +- 4 files changed, 382 insertions(+), 343 deletions(-) create mode 100644 docs/adr/acp-server-websocket-reverse-mcp.md diff --git a/docs/adr/acp-server-websocket-base.md b/docs/adr/acp-server-websocket-base.md index c6f6a2de7..783cf4de5 100644 --- a/docs/adr/acp-server-websocket-base.md +++ b/docs/adr/acp-server-websocket-base.md @@ -217,9 +217,12 @@ and rejecting forged ids. ## 6. Roadmap (re-scoped; not the original proposal's numbered phases) North star: the agent's LLM autonomously operating the user's real browser (generalized -"computer use") — see [MCP-over-ACP browser control](./acp-server-websocket-mcp-browser.md). +"computer use") — see [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md). ### Critical path (next) — everything the browser goal requires +> **Done in #1447** — all four items below shipped (agent→client request direction, the +> MCP-over-ACP tunnel + core MCP proxy, and the generated v1 wire types). See the +> [Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md). - **agent→client REQUEST direction** — the base does only client→agent + agent→client *notifications*; browser/tool use needs the agent to send *requests* to the client and await a result. The WS is already bidirectional; the dispatch loop must add this path. @@ -329,4 +332,4 @@ Notes: - Original proposal: [acp-server-websocket.md](./acp-server-websocket.md) - Official method surface + coverage: [acp-official-methods.md](../acp-official-methods.md) -- MCP-over-ACP browser control: [acp-server-websocket-mcp-browser.md](./acp-server-websocket-mcp-browser.md) +- Reverse MCP-over-ACP over WebSocket: [acp-server-websocket-reverse-mcp.md](./acp-server-websocket-reverse-mcp.md) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index ea9f04694..a650296b8 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -1,187 +1,119 @@ -# ADR: Browser control via MCP-over-ACP (proposed) +# ADR: Browser control via MCP-over-ACP -- **Status:** Proposed (design only — not implemented). North-star capability the base - builds toward; see the base ADR §6 roadmap "Critical path". -- **Date:** 2026-07-18 +- **Status:** Accepted — OpenAB side **as-built in #1447** (compiles + unit-tested; live-validated + 2026-07-20). The browser extension implements the [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **Date:** 2026-07-18 (updated 2026-07-24) - **Author:** @brettchien -- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), - [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), - [openab-agent MCP](./openab-agent-mcp.md) +- **Related:** **Mechanism — roles, call route, generalization, and the architecture + usage-sequence + diagrams: [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md).** + [Base (as-built)](./acp-server-websocket-base.md), [tunnel contract](../mcp-over-acp-tunnel-contract.md), + [browser MCP agent setup](../browser-mcp-agent-setup.md). --- -## 1. Context - -The base ships a 1:1 streaming chat ACP server at `GET /acp`; a browser side-panel -extension connects as an ACP client and drives an OpenAB agent. The next goal is for the -agent's **LLM to autonomously operate the user's browser** (click, read the DOM, navigate) -— i.e. browser "computer use", but targeting the user's real, logged-in Chrome session -rather than a sandbox VM. - -## 2. Decision - -Expose the browser as **MCP tools** and route them to the agent via **MCP-over-ACP**, -tunnelled over the **existing `/acp` WebSocket** the extension already holds. - -Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use browser -actions, they must appear in the agent's tool list (`tools/list`) so the model discovers -and calls them. A custom `ExtRequest` is a transport-level ACP extension the LLM never -sees as a tool — it only fits OpenAB-driven (non-LLM) operations. MCP is the standard way -agents receive tools, so browser actions must be MCP tools. - -### Roles -- **Extension = MCP server (role/logic).** It handles `tools/list` / `tools/call` and - executes DOM actions. An MV3 extension cannot open a *listening* socket, but MCP - server/client is about *who provides tools*, not who opens the connection — so the - extension serves MCP over the **outbound `/acp` WS it already opened**. This is the only - way a can't-listen extension can be a full MCP server. -- **OpenAB core = MCP proxy/aggregator.** OpenAB is a middlebox between two ACP - connections. It consumes the extension's tools from the upstream tunnel and re-exposes - them to the agent downstream (via `mcpServers`) so the LLM's `tools/list` sees them. -- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Gemini …) is a subprocess - colocated in the OpenAB pod; it calls the tools over its in-pod ACP/MCP link. - -### Call route — the agent is in-pod; only the extension is remote -``` - REMOTE (user's browser) OPENAB POD (`openab run` — one process tree) - ┌──────────────────┐ ┌────────────────────────────────────────────────────┐ - │ browser extension│ │ ┌─────────┐ ┌───────────┐ ┌──────────────────┐ │ - │ = MCP SERVER │◀─/acp─▶│ │ gateway │──▶│ core │──▶│ agent CLI │ │ - │ browser tools │ WS │ │ /acp srv│ │ MCP proxy │ │ (subprocess) │ │ - └──────────────────┘ (only │ └─────────┘ └───────────┘ │ LLM (MCP client)│ │ - remote │ ▲ in-pod └──────────────────┘ │ - hop) │ └── stdio: ACP + MCP(mcpServers) ──┘ - └────────────────────────────────────────────────────┘ - - one tool call (LLM clicks a button); only ❸/❺ leave the pod: - ❶ LLM ─tools/call "browser.click"─▶ core (MCP proxy) [in-pod] - ❷ core ─▶ gateway ─❸ MCP-over-ACP──▶ extension [out of pod → remote] - ❹ extension runs it in the browser - ❺ result ──▶ gateway ─❻▶ core ─❼▶ LLM continues [remote → back in-pod] +## 1. Context & scope + +The [base](./acp-server-websocket-base.md) ships a 1:1 streaming chat ACP server at `GET /acp`; a +browser side-panel extension connects as an ACP client and drives an OpenAB agent. The goal here is +for the agent's **LLM to autonomously operate the user's real, logged-in Chrome** (click, read the +DOM, navigate) — browser "computer use" against the user's own session, not a sandbox VM. + +This ADR is the **browser-specific design** and the design the **browser extension** implements. The +underlying transport — how a can't-listen WS client serves MCP over its own `/acp` WS, the roles, +the call route, and the generalization to multiple servers — is the +[Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md); its **§4 architecture diagram** +and **§5 usage-sequence diagram** illustrate this exact browser flow. + +## 2. Browser toolset + +Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (snapshot), +`browser.screenshot`, `browser.navigate`, `browser.click(selector)`, `browser.type(selector, text)`. + +- **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` + are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if + wanted, but are not the primary surface. +- **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP + frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. + +## 3. Design decisions (D1–D6) + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps + auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: + a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery + is still required for the upstream MCP tunnel. +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` parameter + is **not** reliable: Cursor's CLI ignores ACP-passed MCP servers and only loads MCP from its **own + config** (`.cursor/mcp.json`) — see [zed#50924](https://github.com/zed-industries/zed/issues/50924). + So the proxy is registered **per-agent, in that agent's native MCP config** (Cursor → `.cursor/mcp.json`; + Kiro → `.kiro/settings/mcp.json`; others via their own file/format). The **content** (an HTTP MCP + entry: `url` + `headers`) is portable across vendors. +- **D3 — where MCP is tunnelled.** Downstream (agent ↔ core) is a **normal** in-process + Streamable-HTTP MCP server on `127.0.0.1:` (loopback + bearer, via `rmcp`); the agent connects + to it like any other MCP server. Only the **upstream** (core/gateway ↔ extension) is tunnelled — an + MV3 extension cannot listen — adopting the official + [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → + `connectionId`, then `mcp/message`), not a hand-rolled envelope. +- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on + and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless + of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"), + **plus** `notifications/tools/list_changed` on attach/detach. **Superseded as the default:** the + generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise + as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise + as an opt-in for the browser case. +- **D5 — per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per `acp:` + session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so + correlation is implicit. Server lifetime is tied to the `AcpConnection` via a `CancellationToken` + `DropGuard`, so it stops on any evict path. +- **D6 — tunnel trait in core, impl in root.** `openab-core` defines `mcp_proxy::BrowserTunnel` + (generically `AcpMcpTunnel` under [reverse-MCP ADR §6.1](./acp-server-websocket-reverse-mcp.md)); the + **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's + `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and + `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue pattern. + +## 4. Runtime sequence (detailed) — one `browser.click` round-trip + +The high-level phase diagram is in [reverse-MCP ADR §5](./acp-server-websocket-reverse-mcp.md); this is +the message-level detail, including the **two id spaces**. + ``` +Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) -### One WebSocket, multiplexed -The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / -session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), -distinguished by ACP method namespace. No second connection. - -> **Refinement (see §7 "Design decisions"):** this multiplexing applies to the **upstream** -> hop (extension ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The -> **downstream** hop (core ↔ agent) is *not* tunnelled over ACP — core hosts a normal -> in-process HTTP MCP server the agent connects to. Only the extension, which cannot open a -> listening socket, needs MCP tunnelled over its `/acp` WS. - -## 3. Protocol gap to close first - -The base does only client→agent (prompt) and agent→client **notifications** (streaming -text). Browser control needs the **agent→client REQUEST** direction (request/response: -the agent asks the client to do X and awaits a result). The WS is already bidirectional; -`acp_server`'s dispatch loop must add the agent-initiated-request path. This is also the -point to move the wire types from hand-rolled to **generated** (see §5). - -## 4. Alternatives considered - -- **Custom `ExtRequest` per browser action** — rejected: not surfaced to the LLM as a - tool, so the model can't autonomously call it. Fits OpenAB-driven ops only. -- **Extension hosts a standalone MCP server (HTTP/SSE)** — rejected: MV3 extensions - cannot open a listening socket. -- **Anthropic-style `computer` tool (screenshot + pixel coords)** — subsumed: you can - expose `screenshot` + `click(x,y)` as MCP tools if desired, but DOM-semantic tools - (`click(selector)`, `read_dom`) are cheaper/more reliable and model-agnostic. - -## 5. Typing / dependencies - -- Bidirectional tool-call / client-method messages are exactly where hand-rolling breaks; - adopt **generated types** for the expanded surface. Use **v1** schema (stable; `v2` is - experimental and currently wire-identical). Prefer offline codegen (e.g. `typify`) to - emit plain-serde types — this avoids the `schemars`-heavy dependency tree the official - `agent-client-protocol-schema` crate pulls in for `JsonSchema` derives OpenAB doesn't - use at runtime. -- The MCP protocol machinery itself (handshake, tool lifecycle, tunnel framing) is NOT - just types — it needs an MCP implementation (e.g. `rmcp`, already used by - `openab-agent`), plus the ACP-tunnel transport glue. - -## 6. Relationship to Computer Use - -Same category as browser "computer use" (LLM autonomously drives a browser via a -perceive→act tool loop), but generalized: (a) targets the **user's real Chrome** (live, -logged-in), not a sandbox; (b) action surface is **extension-defined MCP tools** -(DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any -MCP-capable agent can use it. - -## 7. Implementation blueprint (task breakdown) - -North-star = the agent's LLM autonomously operating the user's browser via MCP tools -tunnelled over `/acp`. The base (PR that revives #1260) ships the 1:1 chat surface and the -**generated v1 wire types** (`acp_schema`, already committed) — one of the four critical- -path items is therefore done. What remains splits cleanly into an **OpenAB (server) side** -and an **extension (client) side**, meeting at a single **MCP-over-ACP wire contract** (T4) -so the two can proceed largely in parallel once that contract is fixed. - -### TL;DR — how one browser action flows +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) -``` - [ LLM ] ────▶ [ OpenAB (core) ] ────▶ [ browser extension ] - wants to act middle-man / relay operates the real tab - ────────────────────────────────────────────────────────────────────── - inside the server pod in the user's browser (remote) - - Request 1. LLM decides "click the Submit button" - 2. OpenAB relays the action to the extension in the user's browser - 3. the extension actually clicks it in the active tab - Result 4. the extension reports "clicked; page went to /thanks" - 5. OpenAB hands the result back to the LLM - 6. the LLM continues → the user sees its narration in the side panel - - The LLM thinks it is calling an ordinary set of tools; in reality OpenAB is a - middle-man relaying every action to the real remote browser (and relaying the - tool list the other way). Only the OpenAB↔browser leg leaves the server; the - LLM↔OpenAB legs stay in-pod. The detailed message-level sequence is below. +Precondition: session open, extension WS attached, tools/list already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 + 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the RFD, + mcp/message FLATTENS the inner method/params and does NOT carry an inner MCP id, so + mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id correlating the whole upstream round-trip (steps 4<->8); the + response result IS the inner MCP result payload. The core proxy maps mcp#7 <-> acp#55. + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). If the extension is not attached at step 5, +core returns an MCP error "browser not connected" (D4 static-advertise: fails gracefully, no crash). ``` -### Design decisions (resolved 2026-07-19) - -Four decisions were worked through and locked; they refine §2 and the tasks below. - -- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core - keeps auto-replying `session/request_permission` with OK (existing - `connection.rs` behaviour); fine-grained control is deferred. Consequence: a dedicated - `request_permission`-relay task (was T3) is **dropped**, but T1's server→client request - machinery is still required — the **upstream MCP tunnel** needs it. - -- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` - parameter is **not** reliable for this: Cursor's CLI ignores ACP-passed MCP servers and - only loads MCP from its **own config** (`.cursor/mcp.json`) — see - [zed-industries/zed#50924](https://github.com/zed-industries/zed/issues/50924). So the - proxy is registered **per-agent, in that agent's native MCP config** (Cursor → - `.cursor/mcp.json`; others via their own file/format — there is no universal location: - VS Code uses the `servers` key, Codex uses TOML). The **content** (an HTTP MCP entry: - `url` + `headers`) is portable across vendors, so "as long as it loads, we're fine". - -- **D3 — where MCP is tunnelled.** **Downstream (agent ↔ core) is a *normal* MCP server, - not an on-ACP-stream tunnel.** The ACP maintainer prototyped on-stream MCP-over-ACP and - backed off — agents already connect to MCP servers well, and a special on-stream MCP type - is invasive - ([discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). So core - hosts a **Streamable-HTTP MCP server in-process** on `127.0.0.1:` (loopback + bearer, - via `rmcp`); the agent connects to it like any other MCP server. The **upstream** - (core/gateway ↔ extension) is the one legitimate tunnel (an MV3 extension cannot listen), - and it adopts the **official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp)** - framing (`mcp/connect` → `connectionId`, then `mcp/message`), *not* a hand-rolled envelope. - The RFD's own `"type":"acp"` downstream-injection path is **not** used (Cursor doesn't - support it; see D2). - -- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is - **always-on and decoupled** from the extension WS; the extension connecting/disconnecting - only changes *backend availability*. To let browser tools appear on a session whose WS - attaches late (reconnect, or attach-to-running-session), core does **both**: (a) **static- - advertise** the fixed browser toolset regardless of WS state — a `tools/call` while no - extension is attached returns an MCP error ("browser not connected"), which decouples WS - timing from session start with no client dependency; **and** (b) emit - `notifications/tools/list_changed` when the extension attaches/detaches, so agents that - re-query pick up extension-defined extras and fresh schema. - -### Execution flow (as designed) +## 5. Execution flow (bootstrap → discovery → runtime) ``` Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) @@ -210,169 +142,37 @@ Discovery (tools/list) D3 extension returns [click, read_dom, navigate, type, screenshot] D4 core returns the list --http--> agent → the LLM now sees browser tools -Runtime (LLM clicks a button) - 1 LLM decides browser.click{selector} - 2 agent ==session/request_permission==> core → core auto-approves (D1) ==OK==> agent - 3 agent --http tools/call browser.click--> core proxy [in-pod] - 4 core --mcp/message: tools/call--> gateway - 5 gateway ==server→client request==> <=> extension (leaves pod) - 6 extension runs chrome.scripting click in the active tab - 7 extension ==result==> <=> gateway (back in pod) - 8 gateway → core (match pending id) --http result--> agent → LLM continues - -Only steps 5/7 leave the pod. Outer tunnel ids are paired by the gateway pending-map; the -inner MCP ids are the MCP layer's own bookkeeping and are never inspected by the gateway. +Runtime → see §4 for the detailed id-paired round-trip. ``` -### Runtime sequence (detailed) — one `browser.click` round-trip - -``` -Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) - G = gateway (/acp WS srv) E = extension (MCP server, browser) - -Transports --ACP--> downstream ACP over stdio (chat / permission) - --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) - ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) - -Precondition: session open, extension WS attached, tools/list already discovered --------------------------------------------------------------------------------- - 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 - 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 - .............................................................................. - 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 - 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 - params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 - 5 G ==WS===> E server->client request (T1) = MCP-over-ACP outer id=acp#55 <-off-pod - 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks - 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod - 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 - 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 - .............................................................................. -10 A LLM consumes the tool result, keeps reasoning -11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) -12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod --------------------------------------------------------------------------------- -Two id spaces (never mixed) - - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the - MCP-over-ACP RFD, mcp/message FLATTENS the inner method/params and does NOT - carry an inner MCP id, so mcp#7 never travels on the tunnel. - - acp#55 = outer ACP-envelope id that correlates the whole upstream tunnel round-trip - (steps 4<->8); the response result IS the inner MCP result payload. The core - proxy maps its downstream mcp#7 <-> the upstream acp#55. - - acp#1 = downstream ACP permission id; unrelated to the two above - -Only steps 5/7/12 leave the pod (all on the /acp WS). Permission (1-2) and tool transport -(3, 9) stay in-pod on loopback. If the extension is not attached at step 5, core returns an -MCP error "browser not connected" (D4 static-advertise: calls fail gracefully, no crash). -``` - -### T0 spike checklist (what the live PoC must confirm) - -1. Cursor loads an **HTTP** MCP server registered in `.cursor/mcp.json` (auto, or needs a - one-time `cursor-agent mcp enable`). -2. Cursor honours `notifications/tools/list_changed` and re-fetches mid-session (validates - D4(b); if not, D4(a) static-advertise carries it). -3. Cursor handles a `tools/call` error ("browser not connected") gracefully. - -### Findings that reshape the work -- The **agent→client REQUEST direction already exists on the downstream hop**: - `openab-core/src/acp/connection.rs` receives `session/request_permission` from the agent - and currently **auto-replies** it (~L252). So T1 is not green-field — it is *relaying* - those downstream requests up to the `/acp` client (and the response back) instead of - auto-answering them. -- `session/new` / `session/resume` currently send `mcpServers: []` (connection.rs L567/784), - but that path is **not** how the agent gets the browser tools (see D2 — Cursor ignores - ACP-passed MCP servers). Giving the agent browser tools = core **hosts a proxy HTTP MCP - server** and registers it in the agent's **native** MCP config (T5); the proxy tunnels - `tools/*` to the extension over the upstream MCP-over-ACP link. - -### Ownership -- **OpenAB side** (`feat/acp-mcp-browser`): T1, T2, T4 (contract + core routing), T5. -- **Extension side** (katashiro): T6; plus the client half of T4 (serve MCP over the - tunnel). (Permission is auto-approved by core per D1, so no consent UX is needed yet.) -- **Both**: T7. - -### Tasks - -**T0 — Spike (do first; de-risks everything).** PoC per the **T0 spike checklist** above: -register a mock **HTTP** MCP server in Cursor's `.cursor/mcp.json` and confirm the LLM -discovers (`tools/list`) and calls (`tools/call`) a tool, honours `tools/list_changed`, and -handles a call error gracefully. If this doesn't hold, the browser goal needs a different -path. (The agent→client request direction it depends on already exists downstream — see -Findings.) - -**T1 — agent→client REQUEST direction (relay).** -- 1.1 Decide to relay downstream requests (`request_permission`, later MCP) to the `/acp` - client instead of auto-replying; enumerate the relayed methods. -- 1.2 Gateway outbound request path: `acp_server` sends an agent-initiated REQUEST - (method + id) to the client and keeps a pending-response map (`id → oneshot`). -- 1.3 Read loop distinguishes an inbound **client response** (`id` + `result`/`error`, no - `method`) from a client request, and routes responses to the pending map. -- 1.4 core↔gateway bridge: relay the downstream request up + the client's response back - down to the agent. -- 1.5 Round-trip tests (agent request → client → response → agent). - -**T2 — migrate `acp_server` to generated typed wire (bidirectional surface).** -- 2.1 Construct response payloads from `acp_schema` types (the deferred construction - migration). 2.2 Type the new bidirectional messages (`request_permission`, MCP tunnel). - 2.3 Round-trip validate against real traffic (ACP trace mode). - -**T3 — `session/request_permission`.** **Dropped** per D1 (auto-approve; core keeps -auto-replying). Fine-grained permission control is a later, separate effort. - -**T4 — MCP-over-ACP tunnel framing (upstream only).** Adopt the official -[MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) rather than a -hand-rolled envelope (D3). -- 4.1 `mcp/connect` (→ `connectionId`) + `mcp/message` (carries the inner MCP JSON-RPC; - outer ACP id ↔ pending-map, inner MCP id opaque) + `mcp/disconnect`. 4.2 Gateway routes - these between the `/acp` client (extension) and core. 4.3 Contract doc — done: - [MCP-over-ACP tunnel — extension implementation contract](../mcp-over-acp-tunnel-contract.md). - 4.4 Mock-MCP-over-tunnel tests. - -**T5 — OpenAB core = MCP proxy/aggregator.** -- 5.1 A core-side **Streamable-HTTP MCP server hosted in-process** on `127.0.0.1:` - (loopback + bearer) that the agent connects to (D3). -- 5.2 **Per-agent adapter** registers that server in the agent's native MCP config (Cursor → - `.cursor/mcp.json`) before boot, *not* via ACP `session/new mcpServers` (D2). -- 5.3 The proxy acts as an MCP *client* to the extension over the upstream tunnel (T4). -- 5.4 Tool-call routing (agent → proxy → tunnel → extension → result → agent); static- - advertise the browser toolset + emit `tools/list_changed` on attach/detach (D4). -- 5.5 `rmcp` wiring (already used by `openab-agent`) + tests. - -**T6 — extension = MCP server + browser tools** (katashiro). -- 6.1 MCP server role over the outbound `/acp` WS (`tools/list` / `tools/call`). -- 6.2 DOM-semantic tools: `click(selector)` / `read_dom`(snapshot) / `navigate` / - `screenshot` / `type(selector, text)`. 6.3 Execute in the active tab - (`chrome.scripting` / content script + permissions). 6.4 Consent UX for - `request_permission`. 6.5 Tests. - -**T7 — integration + e2e + deploy.** -- 7.1 Full loop: `tools/list` → LLM calls `browser.click` → extension executes → result → - LLM continues. 7.2 A browser-loop e2e (extend `scripts/acp-ws-smoke.py`). - 7.3 Rebuild + redeploy the deployed Cursor agent. 7.4 Finalize this ADR. - -### Suggested order -T0 spike → T1 (server→client request direction) → T2 → **T4 (adopt the RFD framing)** → T5 -→ then T6 in parallel against the contract → T7. The heavy items are T1 (the direction), -T4/T5 (tunnel + proxy), and T6 (extension). Structured `tool_call` display (base ADR §6) is -parallel and non-blocking. - -### As-built (2026-07-20) — OpenAB side wired end-to-end - -The OpenAB (server) side is implemented on `feat/acp-mcp-browser` (compiles + unit-tested; -live path pending the extension T6 + deploy T7). Two decisions beyond D1–D4 settled during -implementation: - -- **D5 = per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per - `acp:` session (in `openab-core/src/acp/pool.rs`, at agent spawn), constructing the - `ProxyHandler` with that session's `channel_id` so correlation is implicit — it binds to the - existing `session_key`/`channel_id` map, no in-band id. Server lifetime is tied to the - `AcpConnection` via a `CancellationToken` `DropGuard`, so it stops on any evict path. -- **D6 = tunnel trait in core, impl in root.** `openab-core` defines - `mcp_proxy::BrowserTunnel`; the **root** binary implements it (`src/browser_tunnel.rs`) - by looking up the gateway's `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. - This keeps `openab-core` and `openab-gateway` **sibling-independent** (no cross-crate dep), - mirroring the existing `ChatAdapter`/`GatewayResponse` root-glue pattern. +## 6. Findings & ownership + +- The **agent→client REQUEST direction already existed downstream**: `openab-core`'s ACP connection + receives `session/request_permission` from the agent and auto-replies it. So the server→client + request work is *relaying* those upstream to the `/acp` client, not green-field. +- `session/new` / `session/resume` send `mcpServers: []`, but that path is **not** how the agent gets + the tools (D2 — Cursor ignores ACP-passed MCP servers). Tools reach the agent via core's proxy HTTP + MCP server registered in the agent's **native** config. +- **Ownership** — OpenAB side (`feat/acp-mcp-browser`): server→client request direction, generated + typed wire, tunnel framing + core proxy. Extension side (katashiro): MCP server role over the + outbound `/acp` WS + the DOM tools + executing in the active tab. Both: integration/e2e. + +## 7. Tasks (as executed) + +- **T0 spike** — confirm a CLI loads an HTTP MCP server from its native config, honours + `tools/list_changed`, and handles a `tools/call` error gracefully. +- **T1** agent→client REQUEST direction (relay; gateway outbound request + pending-response map; + read loop distinguishes client response vs request). +- **T2** migrate `acp_server` to generated typed wire (bidirectional surface). +- **T3** `session/request_permission` — **dropped** (D1 auto-approve). +- **T4** MCP-over-ACP tunnel framing (upstream only) — adopt the RFD (`mcp/connect` + `mcp/message` + + `mcp/disconnect`); contract doc: [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **T5** OpenAB core = MCP proxy/aggregator (in-process HTTP MCP server; per-agent config injection; + proxy as MCP client to the extension over the tunnel; tool-call routing; `rmcp` wiring). +- **T6** extension (katashiro) = MCP server + the five DOM tools, executing via `chrome.scripting`. +- **T7** integration + e2e (`scripts/acp-ws-smoke.py`) + deploy. + +## 8. As-built (2026-07-20, OpenAB side wired end-to-end) Realised call path (all in one `openab run` process): @@ -383,8 +183,16 @@ agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) ═mcp/message═▶ extension (only this hop leaves the pod) ``` -Config injection is per-agent (`.cursor/mcp.json` merged at the session workdir, loopback + -bearer). Static-advertise + not-connected fallback (D4) hold when no browser is attached. -**Remaining:** T5.4 `tools/list_changed` (enhancement; static-advertise already covers the -disconnected case), T6 extension (katashiro — see the -[tunnel contract](../mcp-over-acp-tunnel-contract.md)), and T7 live e2e + deploy. +Config injection is per-agent (`.cursor/mcp.json` / `.kiro/settings/mcp.json` merged at the session +workdir, loopback + bearer). The full loop (read_dom / screenshot / navigate / click / type + status +pill + reconnect on `session/resume`) was live-validated on a real deployment on 2026-07-20. A second +downstream delivery mode — `bridge` (stdio relay, `OPENAB_BROWSER_MODE`) — is also shipped; see the +[reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). + +## 9. References + +- **Mechanism:** [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md) + (roles, call route, architecture + sequence diagrams, multi-server generalization) +- [Base ADR](./acp-server-websocket-base.md) · [tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md new file mode 100644 index 000000000..e85cd5211 --- /dev/null +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -0,0 +1,228 @@ +# ADR: Reverse MCP-over-ACP over WebSocket + +- **Status:** Accepted — the mechanism is **as-built in #1447**; the generic multi-server + generalization (§6) is accepted and implementing in the same PR. +- **Date:** 2026-07-18 (updated 2026-07-24) +- **Author:** @brettchien +- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), + [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), + [openab-agent MCP](./openab-agent-mcp.md). + **Browser-specific design + the contract the extension implements:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md). + +--- + +## 1. Context + +This ADR records **reverse MCP-over-ACP**: a mechanism that lets an ACP **WebSocket client** — +one that cannot open a listening socket — nevertheless act as an **MCP server**, serving its +tools to a colocated agent over the outbound `/acp` WS it already holds. OpenAB core is the MCP +proxy/aggregator in the middle; the agent is a normal in-pod MCP client. + +The first, driving consumer is **browser control**: a browser side-panel extension serves DOM +tools so the agent's LLM can autonomously operate the user's real, logged-in Chrome (see the +[browser ADR](./acp-server-websocket-mcp-browser.md) for that concrete design and the extension +contract). This ADR describes the general mechanism and its generalization to **multiple, +arbitrary** client-side MCP servers (§6), using browser control as the running example. + +## 2. Decision + +Expose a client-side capability as **MCP tools** and route them to the agent via **MCP-over-ACP**, +tunnelled over the **existing `/acp` WebSocket** the client already holds. + +Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use a capability, its +actions must appear in the agent's tool list (`tools/list`) so the model discovers and calls them. +A custom `ExtRequest` is a transport-level ACP extension the LLM never sees as a tool — it only +fits OpenAB-driven (non-LLM) operations. MCP is the standard way agents receive tools. + +### Roles +- **ACP WS client = MCP server (role/logic).** It handles `tools/list` / `tools/call` and executes + the actions. A client that cannot open a *listening* socket (e.g. an MV3 browser extension) can + still be an MCP server — MCP server/client is about *who provides tools*, not who opens the + connection — so it serves MCP over the **outbound `/acp` WS it already opened**. This is the only + way a can't-listen client can be a full MCP server. +- **OpenAB core = MCP proxy/aggregator.** A middlebox between two connections: it consumes the + client's tools from the upstream tunnel and re-exposes them to the agent downstream so the LLM's + `tools/list` sees them. +- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Kiro …) is a subprocess colocated in + the OpenAB pod; it calls the tools over its in-pod MCP link. + +### One WebSocket, multiplexed +The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / +session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), distinguished by +ACP method namespace. No second connection. This multiplexing applies to the **upstream** hop +(client ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The **downstream** hop +(core ↔ agent) is *not* tunnelled over ACP — core hosts a normal in-process MCP server the agent +connects to; only the client, which cannot listen, needs MCP tunnelled over its `/acp` WS. + +## 3. Protocol gap to close first + +The base does only client→agent (prompt) and agent→client **notifications** (streaming text). +Reverse MCP needs the **agent→client REQUEST** direction (request/response: the agent asks the +client to do X and awaits a result). The WS is already bidirectional; `acp_server`'s dispatch loop +adds the agent-initiated-request path. This is also where the wire types move from hand-rolled to +**generated** (see §8). + +## 4. Architecture (browser control as the example) + +```mermaid +flowchart LR + EXT["Side-panel MV3 extension = MCP SERVER
(cannot open a listening socket → serves MCP
over the outbound /acp WS it already holds)
tools: read_dom · screenshot · navigate · click · type"] + subgraph POD["OPENAB POD — 'openab run', one process tree"] + direction LR + GW["openab-gateway
/acp WS server
AcpTunnelRegistry"] + CORE["openab-core
MCP proxy /
aggregator"] + AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] + GW <--> CORE + CORE ==>|"proxy mode (default)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT + CORE -.->|"bridge mode (Option C)
per-pod unix socket + stdio relay
'openab browser-bridge' · static {command,args}
channel via process-ancestry (multi-window)"| AGENT + end + EXT <==>|"UPSTREAM — only remote hop
MCP-over-ACP · mcp/message framing
multiplexed with ACP chat on ONE /acp WSS
8 MiB frame cap · JPEG screenshots"| GW + classDef remote fill:#fde68a,stroke:#b45309,color:#111; + classDef pod fill:#bfdbfe,stroke:#1e40af,color:#111; + class EXT remote; + class GW,CORE,AGENT pod; +``` + +Only the client (extension) is remote; core, gateway and agent are one in-pod `openab run` process +tree. The downstream hop has two delivery modes (§ browser ADR / §6.3): `proxy` (HTTP MCP, default) +and `bridge` (stdio relay). + +## 5. MCP usage sequence (browser.click as the example) + +```mermaid +sequenceDiagram + autonumber + participant Tab as Chrome tab
(user's real, logged-in) + participant Ext as browser ext.
MCP SERVER + participant GW as openab-gateway
/acp WS + participant Core as openab-core
MCP proxy + participant LLM as agent LLM
MCP client + + Note over Ext,LLM: PHASE 1 — connect & tool discovery + Ext->>GW: WS GET /acp — initialize
mcpServers = [ type:acp, "openab-browser" ] + GW-->>Ext: initialize result (agentCapabilities) + Ext->>GW: session/new (or session/resume on reconnect) + GW->>GW: register per-session TunnelHandle
(AcpTunnelRegistry) + GW->>Core: spawn agent + start per-session MCP proxy + Core->>Core: write "openab-browser" into agent's mcp.json
proxy: {url,headers} · bridge: static {command,args} + LLM->>Core: MCP initialize + tools/list + Core->>GW: tools/list (MCP-over-ACP: mcp/message frame) + GW->>Ext: mcp/message → tools/list + Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type + GW-->>Core: tools result + Core-->>LLM: tools/list — browser tools now in the model's tool list + + Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) + LLM->>Core: tools/call browser.click(selector) + Core->>GW: tools/call (mcp/message over the SAME /acp WS) + GW->>Ext: mcp/message → tools/call + Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate + Tab-->>Ext: DOM mutated / navigated / pixels + Ext-->>GW: tool result
(screenshot = JPEG q70, frame <= 8 MiB) + GW-->>Core: result + Core-->>LLM: tool result + LLM->>GW: session/update agent_message_chunk (narration) + GW->>Ext: streamed to the side panel + + Note over GW,Ext: only the gateway-to-extension hop leaves the pod. LLM, core and gateway stay in-pod. +``` + +The exact two-id-space bookkeeping (outer ACP-envelope id ↔ inner MCP id, flattened per the RFD) is +detailed in the [browser ADR](./acp-server-websocket-mcp-browser.md) §4. + +## 6. Generalization — multiple client-side MCP servers + +The browser path wires **one** MCP server. This section is the accepted direction (implementing in +#1447) to make reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** +`type:acp` MCP servers on `initialize`, and the agent's LLM discovers and calls each server's real +tools. The browser extension becomes *one instance* of the mechanism, not a special case. + +Three pieces already generalize and are reused as-is: +- `parse_acp_mcp_servers` already parses **N** `type:acp` entries with arbitrary `{id, name}`. +- `establish_and_register_tunnel(…, srv.id, …)` already threads the declared `srv.id` into + `mcp/connect` — the wire already carries a per-server discriminator. +- `ProxyHandler::forward_tool_call` forwards **any** tool name+args down the tunnel — no + browser-specific validation. + +### 6.1 Address every hop by `(channel_id, serverId)` +- `AcpTunnelRegistry` becomes keyed by `(channel_id, serverId)` instead of `channel_id` alone — the + "one tunnel per session" collapse was a fan-out fix; the correct fix is a **compound key**. +- Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. +- Evict all `(channel_id, *)` entries on session teardown. + +### 6.2 Dynamic tool discovery (supersedes static-advertise as the default) +- Each per-server proxy's `tools/list` forwards the client server's **real** tool list over its tunnel. +- **Unattached / reconnecting:** return an empty list for that server — never fabricate tools. +- **Attach → refresh:** on tunnel attach (or client re-declare on `session/resume`), push + `notifications/tools/list_changed` downstream so the agent re-lists. This preserves the + "usable before/without attach" UX that the browser's static-advertise (D4) gave, honestly. +- Keep a per-server **cache** of the last good `tools/list` to survive brief reconnects; **debounce** + `list_changed` against reconnect storms. +- The browser's static-advertise stays only as an **opt-in** fallback for the browser case; it is no + longer the default. + +### 6.3 Per-server downstream exposure (Option B — decided) +Each declared client server is surfaced to the agent as its **own** MCP server entry (`openab-`), +not merged into one namespaced blob: +- **proxy mode (HTTP):** one loopback MCP server per `(session, server)`, its own port + bearer; + openab writes **N entries** into the agent's native MCP config, one per server. +- **bridge mode (stdio):** the static entry gains a selector — + `{"command":"openab","args":["mcp-bridge","--server",""]}` — one relay per server (rename the + `browser-bridge` subcommand to `mcp-bridge`, keeping `browser-bridge` as a compat alias). + +Rejected — **Option A** (one aggregating proxy, `__` namespacing): the prefix leaks into +the tool names the model sees and needs reversible de-namespacing on every call. Option B is cleaner +for the LLM and maps to MCP's native "one server = one connection" model. (A stays a possible future mode.) + +### 6.4 Backward compatibility +The browser extension is unchanged: it declares `{type:acp, id, name:"openab-browser"}` and serves its +five DOM tools via its own `tools/list` — now discovered dynamically instead of static-advertised. Both +downstream modes are retained; single-server (browser-only) sessions behave identically. + +### 6.5 Generic implementation plan (folded into #1447) +- **P1** compound-key registry + `serverId` on the tunnel trait (no behaviour change; browser stays single). +- **P2** dynamic `tools/list` forwarding + per-server cache + `list_changed` attach/detach lifecycle. +- **P3** per-server downstream exposure (Option B) in both proxy + bridge modes; loop config-writing. +- **P4** generalize naming (`AcpMcpTunnel`, `openab-`, `openab mcp-bridge`) + error strings. +- **P5** e2e: a second, non-browser `type:acp` MCP server declared alongside the browser — both + discovered and callable in one session. + +## 7. Alternatives considered + +- **Custom `ExtRequest` per action** — rejected: not surfaced to the LLM as a tool, so the model + can't call it autonomously. Fits OpenAB-driven ops only. +- **Client hosts a standalone MCP server (HTTP/SSE)** — rejected for can't-listen clients: an MV3 + extension cannot open a listening socket. +- **On-stream MCP-over-ACP for the downstream hop** — rejected: agents already connect to normal MCP + servers well; a special on-stream MCP type is invasive + ([ACP discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). Only the + can't-listen *client* leg is tunnelled; downstream stays a normal in-process MCP server. +- **Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an + opt-in for browser only. + +## 8. Typing / dependencies + +- Bidirectional tool-call / client-method messages are where hand-rolling breaks; the expanded + surface uses **generated** serde-only **v1** wire types (offline `typify` codegen, avoiding the + `schemars`-heavy `agent-client-protocol-schema` crate). Landed in the base. +- The MCP machinery (handshake, tool lifecycle, tunnel framing) needs an MCP implementation + (`rmcp`, already used by `openab-agent`) plus the ACP-tunnel transport glue. + +## 9. Relationship to Computer Use + +Same category as "computer use" (LLM autonomously drives an app via a perceive→act tool loop), but +generalized: (a) targets the **user's real** app/session (e.g. logged-in Chrome), not a sandbox; (b) +the action surface is **client-defined MCP tools** (DOM-semantic or screenshot), not a model-specific +tool; (c) **model-agnostic** — any MCP-capable agent can use it. + +## 10. References + +- [Base ADR](./acp-server-websocket-base.md) · [Original proposal](./acp-server-websocket.md) · + [openab-agent MCP](./openab-agent-mcp.md) +- **Browser-specific design + extension contract:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md) +- [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [Browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) · MCP + `notifications/tools/list_changed` diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 157bff0f1..af82b4f43 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -2,7 +2,7 @@ This is the wire contract the **browser extension** (the ACP client / MCP server end) implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` WebSocket, per -[ADR: Browser control via MCP-over-ACP](./adr/acp-server-websocket-mcp-browser.md). It adopts +[ADR: Reverse MCP-over-ACP over WebSocket](./adr/acp-server-websocket-reverse-mcp.md). It adopts the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB From 144c232da766845d0a20833e4c8e96184c5dc9f2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 25 Jul 2026 00:02:19 +0800 Subject: [PATCH 041/138] docs(adr): correct D4 list_changed overclaim in browser ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_changed is designed but not yet implemented (0 hits in gateway/core crates); it was described as shipped alongside static-advertise. Reword to mark it as P2b-tracked (reverse-MCP §6.2), not as-built. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index a650296b8..efe3eceba 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -55,8 +55,10 @@ Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (sn `connectionId`, then `mcp/message`), not a hand-rolled envelope. - **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless - of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"), - **plus** `notifications/tools/list_changed` on attach/detach. **Superseded as the default:** the + of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"). + `notifications/tools/list_changed` on attach/detach is **designed but not yet implemented** — no code + emits it today (grep `list_changed` → 0 hits in the gateway/core crates); it is tracked as P2b in + [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md). **Superseded as the default:** the generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise as an opt-in for the browser case. From 1c35aaa22a743b6aaac762710647e90512e39501 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 25 Jul 2026 06:10:20 +0800 Subject: [PATCH 042/138] refactor(acp): compound-key (channel_id,server_id) tunnel registry + rename BrowserTunnel->AcpMcpTunnel (P1, Fork A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving refactor toward generic multi-server MCP-over-ACP (reverse-MCP ADR §6). No functional change for the single browser server. - AcpTunnelRegistry: HashMap -> HashMap<(String,String),_>; register under the client-declared srv.id; evict all (channel_id,*) on teardown. - Core trait BrowserTunnel -> AcpMcpTunnel; call() gains a server_id param. - Read side (Fork A): the single-browser proxy + bridge pass an empty server_id sentinel; RootBrowserTunnel resolves the sole tunnel on the channel (errors if ambiguous). Real per-server read-side routing (bridge-frame server_id) is deferred to P2. Gate: build --features acp, clippy --workspace -D warnings (+unified), test -p openab-gateway --features acp — all green (307 passed). Co-Authored-By: Claude Opus 4.8 --- crates/openab-core/src/acp/pool.rs | 4 +- crates/openab-core/src/mcp_proxy.rs | 55 ++++++++++++------- .../openab-gateway/src/adapters/acp_server.rs | 30 +++++----- src/browser_tunnel.rs | 35 +++++++++--- src/main.rs | 2 +- 5 files changed, 82 insertions(+), 44 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 256769280..215f93b1d 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -56,7 +56,7 @@ pub struct SessionPool { /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the /// root. `None` = no browser wiring (tool calls report not-connected). #[cfg(feature = "acp-mcp")] - browser_tunnel: Option>, + browser_tunnel: Option>, } type CancelHandle = (Arc>, String); @@ -211,7 +211,7 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] pub fn with_browser_tunnel( mut self, - tunnel: Option>, + tunnel: Option>, ) -> Self { self.browser_tunnel = tunnel; self diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index bb329970a..75aff634f 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -28,13 +28,19 @@ use std::sync::Arc; /// proxy here. Keeping the trait in core with the impl in root preserves the core/gateway /// sibling independence, matching the existing `ChatAdapter` pattern. #[async_trait::async_trait] -pub trait BrowserTunnel: Send + Sync { - /// Forward an inner MCP request (e.g. `tools/call`) to the browser session identified by - /// `channel_id` and return the inner MCP result payload. Err if no browser is currently - /// attached to that session. +pub trait AcpMcpTunnel: Send + Sync { + /// Forward an inner MCP request (e.g. `tools/call`) to the client MCP server identified by + /// `(channel_id, server_id)` and return the inner MCP result payload. Err if no matching + /// tunnel is currently attached to that session. + /// + /// `server_id` selects among multiple `type:acp` servers on one session (compound-key + /// registry, P1). During the single-browser transition an empty `server_id` is a sentinel + /// meaning "the sole tunnel on this channel" — the proxy/bridge callers don't yet know the + /// client-declared id at spawn time (real per-server routing lands in P2). async fn call( &self, channel_id: &str, + server_id: &str, method: &str, params: Option, ) -> Result; @@ -100,11 +106,11 @@ pub struct ProxyHandler { channel_id: String, /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). - tunnel: Option>, + tunnel: Option>, } impl ProxyHandler { - pub fn new(channel_id: String, tunnel: Option>) -> Self { + pub fn new(channel_id: String, tunnel: Option>) -> Self { Self { channel_id, tunnel } } @@ -122,8 +128,10 @@ impl ProxyHandler { )); }; let params = json!({ "name": name, "arguments": arguments }); + // Empty server_id sentinel (Fork A): this single-browser proxy doesn't know the + // client-declared server id; RootBrowserTunnel resolves the sole tunnel on the channel. let result = tunnel - .call(&self.channel_id, "tools/call", Some(params)) + .call(&self.channel_id, "", "tools/call", Some(params)) .await .map_err(|e| McpError::internal_error(e, None))?; serde_json::from_value(result) @@ -194,7 +202,7 @@ async fn require_bearer( /// cancelled. pub async fn spawn_mcp_server( channel_id: String, - tunnel: Option>, + tunnel: Option>, bearer: String, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result { @@ -233,7 +241,7 @@ pub async fn spawn_mcp_server( pub async fn start_session_server( channel_id: &str, workdir: &str, - tunnel: Option>, + tunnel: Option>, ) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { let bearer = uuid::Uuid::new_v4().to_string(); let ct = tokio_util::sync::CancellationToken::new(); @@ -399,7 +407,7 @@ pub fn browser_socket_path() -> std::path::PathBuf { // A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim // (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests // tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes -// `tools/call` to that session's BrowserTunnel. This is the stable, variant-agnostic replacement +// `tools/call` to that session's AcpMcpTunnel. This is the stable, variant-agnostic replacement // for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: // bridge → core : {"channel_id": "...", "request": } // core → bridge : (omitted for notifications) @@ -420,7 +428,7 @@ fn mcp_error(id: Value, code: i64, message: &str) -> Value { pub(crate) async fn dispatch_browser_mcp( channel_id: &str, request: &Value, - tunnel: &Option>, + tunnel: &Option>, ) -> Option { // A JSON-RPC notification has no `id` → no response. let id = request.get("id").cloned()?; @@ -439,8 +447,11 @@ pub(crate) async fn dispatch_browser_mcp( mcp_result(id, json!({ "tools": tools })) } "tools/call" => match tunnel { + // Empty server_id sentinel (Fork A): the bridge frame carries no server id yet; + // RootBrowserTunnel resolves the sole tunnel on the channel. Real per-server routing + // (server_id in the frame) lands in P2. Some(t) => match t - .call(channel_id, "tools/call", request.get("params").cloned()) + .call(channel_id, "", "tools/call", request.get("params").cloned()) .await { Ok(v) => mcp_result(id, v), @@ -462,7 +473,7 @@ pub(crate) async fn dispatch_browser_mcp( /// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. pub async fn serve_browser_socket( path: std::path::PathBuf, - tunnel: Option>, + tunnel: Option>, ct: tokio_util::sync::CancellationToken, ) -> std::io::Result<()> { let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run @@ -496,14 +507,14 @@ pub async fn serve_browser_socket( /// tokio-util dependency is needed. pub async fn serve_browser_socket_forever( path: std::path::PathBuf, - tunnel: Option>, + tunnel: Option>, ) -> std::io::Result<()> { serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await } async fn handle_browser_conn( stream: tokio::net::UnixStream, - tunnel: Option>, + tunnel: Option>, ) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let (read_half, mut write_half) = stream.into_split(); @@ -535,7 +546,7 @@ async fn handle_browser_conn( mod tests { use super::{ browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, - spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, BrowserTunnel, + spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, AcpMcpTunnel, ProxyHandler, }; @@ -553,14 +564,16 @@ mod tests { struct MockTunnel; #[async_trait::async_trait] - impl BrowserTunnel for MockTunnel { + impl AcpMcpTunnel for MockTunnel { async fn call( &self, channel_id: &str, + server_id: &str, method: &str, _params: Option, ) -> Result { assert_eq!(channel_id, "acp_x"); + assert_eq!(server_id, ""); // proxy passes the empty sentinel (Fork A) assert_eq!(method, "tools/call"); Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) } @@ -588,10 +601,11 @@ mod tests { result: serde_json::Value, } #[async_trait::async_trait] - impl BrowserTunnel for RecordTunnel { + impl AcpMcpTunnel for RecordTunnel { async fn call( &self, channel_id: &str, + _server_id: &str, method: &str, _params: Option, ) -> Result { @@ -602,10 +616,11 @@ mod tests { } struct ErrTunnel; #[async_trait::async_trait] - impl BrowserTunnel for ErrTunnel { + impl AcpMcpTunnel for ErrTunnel { async fn call( &self, _c: &str, + _s: &str, _m: &str, _p: Option, ) -> Result { @@ -615,7 +630,7 @@ mod tests { fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) } - fn arc_tunnel(t: T) -> Option> { + fn arc_tunnel(t: T) -> Option> { Some(std::sync::Arc::new(t)) } diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 2be85c5ef..581bf0df3 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -323,11 +323,13 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } -/// Registry of open MCP-over-ACP tunnels: channel_id → `TunnelHandle`. The gateway inserts a -/// handle once it has `mcp/connect`ed to a session's browser extension server; the core MCP -/// proxy looks one up to route a tool call to the right browser (T5.3). Same std::sync::Mutex -/// rationale as `AcpReplyRegistry`. -pub type AcpTunnelRegistry = Arc>>; +/// Registry of open MCP-over-ACP tunnels: `(channel_id, server_id)` → `TunnelHandle`. The +/// gateway inserts a handle once it has `mcp/connect`ed to a session's declared `type:acp` +/// server; the core MCP proxy looks one up to route a tool call to the right server (T5.3). +/// Keyed by the compound `(channel_id, server_id)` (P1) so one session can carry several +/// `type:acp` servers without collision; eviction drops all `(channel_id, *)` on teardown. Same +/// std::sync::Mutex rationale as `AcpReplyRegistry`. +pub type AcpTunnelRegistry = Arc>>; pub fn new_tunnel_registry() -> AcpTunnelRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) @@ -725,8 +727,8 @@ async fn establish_and_register_tunnel( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id.clone(), handle); - info!(channel_id = %channel_id, "ACP: browser tunnel registered — extension attached"); + .insert((channel_id.clone(), acp_id.clone()), handle); + info!(channel_id = %channel_id, server_id = %acp_id, "ACP: browser tunnel registered — extension attached"); Ok(()) } @@ -1153,9 +1155,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } if let Some(ref registry) = state.acp_tunnel_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - for cid in &channel_ids { - reg.remove(cid); - } + // Compound-key registry (P1): drop every `(channel_id, *)` tunnel for the closed session. + reg.retain(|(cid, _), _| !channel_ids.contains(cid)); } debug!( connection = %connection_id, @@ -2209,7 +2210,7 @@ mod acp_requests { } #[tokio::test] - async fn establish_tunnel_registers_handle_under_channel_id() { + async fn establish_tunnel_registers_handle_under_channel_and_server_id() { let pending = new_pending(); let next_id = Arc::new(AtomicU64::new(1)); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -2239,8 +2240,11 @@ mod acp_requests { ext.await.unwrap(); assert!( - registry.lock().unwrap().contains_key("acp_abc"), - "a TunnelHandle must be registered under the session channel_id" + registry + .lock() + .unwrap() + .contains_key(&("acp_abc".to_string(), "srv-1".to_string())), + "a TunnelHandle must be registered under (channel_id, server_id)" ); } } diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index e11ab5efa..1c46488ad 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -1,10 +1,11 @@ -//! Root-side bridge implementing the core `BrowserTunnel` trait (D6-a'). Reads the gateway's -//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the browser attached -//! to a given `channel_id`. This lives in the root binary — the only place that depends on -//! BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), preserving the two -//! crates' sibling independence (mirroring the existing `ChatAdapter` glue at the root). +//! Root-side bridge implementing the core `AcpMcpTunnel` trait (D6-a'). Reads the gateway's +//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the client MCP server +//! attached to a given `(channel_id, server_id)`. This lives in the root binary — the only place +//! that depends on BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), +//! preserving the two crates' sibling independence (mirroring the existing `ChatAdapter` glue at +//! the root). -use openab_core::mcp_proxy::BrowserTunnel; +use openab_core::mcp_proxy::AcpMcpTunnel; use openab_gateway::adapters::acp_server::AcpTunnelRegistry; use serde_json::Value; @@ -19,17 +20,35 @@ impl RootBrowserTunnel { } #[async_trait::async_trait] -impl BrowserTunnel for RootBrowserTunnel { +impl AcpMcpTunnel for RootBrowserTunnel { async fn call( &self, channel_id: &str, + server_id: &str, method: &str, params: Option, ) -> Result { // Clone the handle out under the lock; never hold the std mutex across `.await`. let handle = { let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); - reg.get(channel_id).cloned() + if server_id.is_empty() { + // Fork A sentinel: the single-browser proxy/bridge doesn't know the client- + // declared server id, so resolve the SOLE tunnel registered for this channel. + // Ambiguous (0 or >1) is an error — real per-server routing arrives in P2. + let mut matches = reg.iter().filter(|((c, _), _)| c == channel_id); + match (matches.next(), matches.next()) { + (Some((_, h)), None) => Some(h.clone()), + (None, _) => None, + (Some(_), Some(_)) => { + return Err(format!( + "multiple MCP servers attached to session {channel_id}; a server_id is required to disambiguate" + )); + } + } + } else { + reg.get(&(channel_id.to_string(), server_id.to_string())) + .cloned() + } }; match handle { Some(h) => h.mcp_message(method, params, 30).await, diff --git a/src/main.rs b/src/main.rs index bc894e96f..cd97b1ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -404,7 +404,7 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let browser_tunnel: Arc = Arc::new( + let browser_tunnel: Arc = Arc::new( browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), ); #[cfg(feature = "acp")] From 487a05a58a57824dd830bbda2d60869a26c5f2b2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 24 Jul 2026 22:08:35 -0400 Subject: [PATCH 043/138] chore: regenerate Cargo.lock for merged deps --- Cargo.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a05e04d2a..c7b795033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,6 +2533,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", + "axum", "base64", "bytes", "chrono", @@ -2550,17 +2551,20 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest 0.12.28", + "rmcp", "rpassword", "rustls 0.22.4", "serde", "serde_json", "serenity", "sha2 0.10.9", + "subtle", "tar", "tempfile", "tokio", "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", + "tokio-util", "toml", "toml_edit", "tracing", From c2a7c6a91672330cfed274de96fcf2b0dcf8c866 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 24 Jul 2026 22:21:04 -0400 Subject: [PATCH 044/138] fix(mcp-proxy): register browser server in kiro per-agent configs (--agent mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When kiro-cli runs with --agent — as every OAB bot deployment does — the MCP server list comes from .kiro/agents/.json and tools are gated by that file's default-deny allowedTools, NOT from .kiro/settings/mcp.json (verified live on the b2 fleet deployment; see docs/gmail-native.md 'Kiro CLI gotcha'). Without this, browser tools are invisible to exactly the deployments this feature targets. - merge_kiro_agent_configs: merge the openab-browser entry into every .kiro/agents/*.json and add @openab-browser to allowedTools; agent files carry unrelated config, so unparseable files are skipped, never clobbered (unlike the settings writer); macOS ._* droppings ignored; idempotent; 0600 (proxy entries carry a live bearer). - cleanup_kiro_agent_configs on session evict: remove the entry and revoke the allowlist grant only when the URL is still ours, preserving a concurrent session's live entry (same rule as the settings cleanup). - Wired into both the per-session proxy writer and the static Option C bridge writer; 4 new tests. --- crates/openab-core/src/mcp_proxy.rs | 253 +++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 75aff634f..4cd0ed371 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -280,6 +280,10 @@ pub async fn start_session_server( write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; } + // kiro `--agent ` deployments read the agent file, not settings/mcp.json — + // merge (and allowlist) there too, or the tools never reach OAB bot agents. + merge_kiro_agent_configs(workdir, &entry).await?; + // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it // if it still points at OUR addr — a concurrent/reconnected session may have already replaced @@ -287,8 +291,10 @@ pub async fn start_session_server( let cleanup_paths = cfg_paths.to_vec(); let cleanup_url = our_url; let cleanup_ct = ct.clone(); + let cleanup_workdir = workdir.to_string(); tokio::spawn(async move { cleanup_ct.cancelled().await; + cleanup_kiro_agent_configs(&cleanup_workdir, &cleanup_url).await; for cleanup_path in &cleanup_paths { let Ok(bytes) = tokio::fs::read(cleanup_path).await else { continue; @@ -327,6 +333,102 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } +/// Merge the `openab-browser` entry into every kiro **per-agent** config +/// (`/.kiro/agents/*.json`). When kiro-cli runs with `--agent ` +/// — as every OAB bot deployment does — it reads its MCP server list from the +/// agent file, NOT from `.kiro/settings/mcp.json`, and gates tools through the +/// file's `allowedTools` allowlist (verified live on the b2 fleet deployment; +/// see docs/gmail-native.md "Kiro CLI gotcha"). Without this, browser tools +/// are invisible to exactly the deployments this feature targets. +/// +/// Unlike the settings-file writer, agent files carry unrelated config +/// (model, description, allowlists), so an unparseable file is SKIPPED — +/// never clobbered with a fresh object. macOS metadata droppings +/// (`._*.json`) are ignored. Missing agents dir = no-op. +async fn merge_kiro_agent_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); // no agents dir → nothing runs with --agent here + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; // agent files carry model/allowlists — never clobber + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab-browser"] != *entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + changed = true; + } + // `allowedTools` is a default-deny allowlist: adding the server + // without allowlisting it leaves every browser tool blocked. + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab-browser")) { + allowed.push(json!("@openab-browser")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Session-evict counterpart of [`merge_kiro_agent_configs`]: strip the +/// now-dead `openab-browser` entry (and its `allowedTools` grant) from every +/// kiro agent file — but only when the entry still points at OUR `url`, so a +/// concurrent/reconnected session's live entry is never clobbered (same rule +/// as the settings-file cleanup). Static (url-less) bridge entries are left +/// alone. +async fn cleanup_kiro_agent_configs(workdir: &str, url: &str) { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return; + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(url); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + allowed.retain(|v| v.as_str() != Some("@openab-browser")); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(&path, &out).await; + } + } +} + /// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json /// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer /// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be @@ -363,6 +465,9 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; } } + // kiro `--agent` deployments read agent files, not settings/mcp.json (same + // gap as the proxy-mode writer). The static entry is idempotent there too. + merge_kiro_agent_configs(workdir, &entry).await?; Ok(()) } @@ -545,11 +650,153 @@ async fn handle_browser_conn( #[cfg(test)] mod tests { use super::{ - browser_tools, dispatch_browser_mcp, parse_browser_mode, serve_browser_socket, - spawn_mcp_server, start_session_server, write_bridge_mcp_config, BrowserMode, AcpMcpTunnel, - ProxyHandler, + browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, + merge_kiro_agent_configs, parse_browser_mode, serve_browser_socket, spawn_mcp_server, + start_session_server, write_bridge_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; + /// Unique throwaway workdir with a `.kiro/agents/` tree. + async fn tmp_workdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-{tag}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(dir.join(".kiro").join("agents")) + .await + .unwrap(); + dir + } + + #[tokio::test] + async fn kiro_agent_merge_adds_server_and_allowlist_preserving_the_rest() { + let wd = tmp_workdir("merge").await; + let agent = wd.join(".kiro/agents/terra.json"); + tokio::fs::write( + &agent, + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "terra", + "model": "gpt-5.6-terra", + "mcpServers": { "github": { "url": "http://ghpool:8080/mcp" } }, + "allowedTools": ["@builtin", "@github"] + })) + .unwrap(), + ) + .await + .unwrap(); + let entry = serde_json::json!({ + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer tok" } + }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["openab-browser"], entry); + assert_eq!( + cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp", + "pre-existing servers must be preserved" + ); + assert_eq!(cfg["model"], "gpt-5.6-terra", "unrelated fields preserved"); + let allowed = cfg["allowedTools"].as_array().unwrap(); + assert!( + allowed.iter().any(|v| v == "@openab-browser"), + "allowedTools is default-deny — the server must be allowlisted: {allowed:?}" + ); + // Idempotent: second merge changes nothing (byte-stable allowlist). + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg2: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg, cfg2); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_skips_unparseable_and_metadata_files() { + let wd = tmp_workdir("skip").await; + let junk = wd.join(".kiro/agents/broken.json"); + tokio::fs::write(&junk, b"{not json").await.unwrap(); + let meta = wd.join(".kiro/agents/._terra.json"); + tokio::fs::write(&meta, b"\x00\x05\x16\x07").await.unwrap(); + let entry = serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + assert_eq!( + tokio::fs::read(&junk).await.unwrap(), + b"{not json", + "unparseable agent files must be skipped, never clobbered" + ); + assert_eq!(tokio::fs::read(&meta).await.unwrap(), b"\x00\x05\x16\x07"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_without_agents_dir_is_noop() { + let wd = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-noop-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(&wd).await.unwrap(); + merge_kiro_agent_configs( + wd.to_str().unwrap(), + &serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }), + ) + .await + .unwrap(); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_cleanup_removes_only_our_url_and_its_grant() { + let wd = tmp_workdir("cleanup").await; + let ours = wd.join(".kiro/agents/ours.json"); + tokio::fs::write( + &ours, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:1111/mcp" } }, + "allowedTools": ["@builtin", "@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + let foreign = wd.join(".kiro/agents/foreign.json"); + tokio::fs::write( + &foreign, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:2222/mcp" } }, + "allowedTools": ["@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + cleanup_kiro_agent_configs(wd.to_str().unwrap(), "http://127.0.0.1:1111/mcp").await; + let ours_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&ours).await.unwrap()).unwrap(); + assert!(ours_cfg["mcpServers"]["openab-browser"].is_null()); + assert!( + !ours_cfg["allowedTools"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "@openab-browser"), + "the stale allowlist grant must be revoked with the entry" + ); + let foreign_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&foreign).await.unwrap()).unwrap(); + assert_eq!( + foreign_cfg["mcpServers"]["openab-browser"]["url"], "http://127.0.0.1:2222/mcp", + "a concurrent session's live entry must never be clobbered" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + #[test] fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); From bf37d25e1f63a04ac44f2a887fa22b66f58e4f86 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 25 Jul 2026 00:23:55 -0400 Subject: [PATCH 045/138] feat(mcp): browser capabilities through the session-aware facade (Facade mode, default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes browser tools through the OAB MCP Facade as a session-aware in-process capability source (openab-mcp #1454), replacing per-session proxy servers as the default transport. Proxy and Option C bridge modes are unchanged and remain explicit opt-outs (OPENAB_BROWSER_MODE). - src/browser_source.rs: CapabilitySource over the existing AcpMcpTunnel (requires_session; D4 static-advertise; tunnel errors surface as MCP error results); FacadeRegistrar adapts the facade's SessionTokens to core's new SessionTokenRegistrar hook (core stays openab-mcp-free). - core mcp_proxy: BrowserMode::Facade (new default; runtime fallback to Proxy when no facade is serving), write_facade_mcp_config — a static, write-once 'openab' entry whose Authorization references ${OPENAB_SESSION_TOKEN}; the per-session secret rides the agent process env instead of config files, eliminating the shared-workdir clobber class entirely (incl. kiro --agent files + @openab allowlist). - pool: with_facade_sessions wiring; mints/injects the token per spawn, revokes via the same DropGuard plumbing proxy mode uses. - main: facade constructed with the BrowserSource; one listener, one discovery surface (search_capabilities/execute_capability). - rmcp re-exported from openab-mcp for source implementors. - docs: facade-mode section in the browser setup guide. --- crates/openab-core/src/acp/pool.rs | 77 +++++++++++++++++- crates/openab-core/src/mcp_proxy.rs | 121 ++++++++++++++++++++++++++-- crates/openab-mcp/src/lib.rs | 4 + docs/browser-mcp-agent-setup.md | 24 ++++++ src/browser_source.rs | 96 ++++++++++++++++++++++ src/main.rs | 46 +++++++++-- 6 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 src/browser_source.rs diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 215f93b1d..2d8d24e8d 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -57,6 +57,10 @@ pub struct SessionPool { /// root. `None` = no browser wiring (tool calls report not-connected). #[cfg(feature = "acp-mcp")] browser_tunnel: Option>, + #[cfg(feature = "acp-mcp")] + session_registrar: Option>, + #[cfg(feature = "acp-mcp")] + facade_url: Option, } type CancelHandle = (Arc>, String); @@ -203,6 +207,10 @@ impl SessionPool { default_config_options, #[cfg(feature = "acp-mcp")] browser_tunnel: None, + #[cfg(feature = "acp-mcp")] + session_registrar: None, + #[cfg(feature = "acp-mcp")] + facade_url: None, } } @@ -217,6 +225,22 @@ impl SessionPool { self } + /// Wire the facade session-token registrar + facade URL (Facade mode, + /// set by the root when `[mcp]` is running). With both present, browser + /// capabilities route through the facade: the pool mints one token per + /// session, injects it as `OPENAB_SESSION_TOKEN` in the agent process + /// env, and writes the static facade MCP entry once per workdir. + #[cfg(feature = "acp-mcp")] + pub fn with_facade_sessions( + mut self, + registrar: Option>, + facade_url: Option, + ) -> Self { + self.session_registrar = registrar; + self.facade_url = facade_url; + self + } + fn load_mapping(path: &Path) -> HashMap { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { @@ -367,10 +391,47 @@ impl SessionPool { // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The // returned guard cancels that server when this connection is dropped (any evict path). + // Facade mode: mint the per-session token (returned env var rides the + // agent spawn below) and write the static facade entry once. Falls + // back to Proxy mode when the root wired no registrar (no [mcp]). + #[cfg(feature = "acp-mcp")] + let mut session_token: Option = None; #[cfg(feature = "acp-mcp")] let mcp_guard: Option = if let Some(channel_id) = thread_id.strip_prefix("acp:") { - match crate::mcp_proxy::browser_mode() { + let mode = match crate::mcp_proxy::browser_mode() { + crate::mcp_proxy::BrowserMode::Facade + if self.session_registrar.is_none() || self.facade_url.is_none() => + { + crate::mcp_proxy::BrowserMode::Proxy + } + m => m, + }; + match mode { + crate::mcp_proxy::BrowserMode::Facade => { + // unwraps guarded by the fallback arm above + let registrar = self.session_registrar.as_ref().unwrap(); + let facade_url = self.facade_url.as_ref().unwrap(); + if let Err(e) = + crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url) + .await + { + warn!(thread_id, error = %e, "failed to write facade mcp config"); + } + session_token = Some(registrar.mint(channel_id)); + info!(thread_id, "session token minted for facade browser capabilities"); + // Revoke on evict/replace: piggyback the same DropGuard + // plumbing proxy mode uses for its server teardown. + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + let chan = channel_id.to_string(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&chan); + }); + Some(ct.drop_guard()) + } // Bridge mode (Option C): the agent's static mcp.json points at `openab // browser-bridge`, which dials the pod-wide socket server (started at boot). // Just ensure the write-once config exists — no per-session server/guard. @@ -408,11 +469,23 @@ impl SessionPool { // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. + #[cfg(feature = "acp-mcp")] + let spawn_env: std::collections::HashMap = { + let mut env = self.config.env.clone(); + if let Some(tok) = &session_token { + // The static facade MCP entry references ${OPENAB_SESSION_TOKEN}; + // the value lives only in this agent process's environment. + env.insert("OPENAB_SESSION_TOKEN".to_string(), tok.clone()); + } + env + }; + #[cfg(not(feature = "acp-mcp"))] + let spawn_env = self.config.env.clone(); let mut new_conn = AcpConnection::spawn( &self.config.command, &self.config.args, &effective_workdir, - &self.config.env, + &spawn_env, &self.config.inherit_env, thread_id.strip_prefix("acp:"), ) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 4cd0ed371..c38c55513 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -48,7 +48,7 @@ pub trait AcpMcpTunnel: Send + Sync { /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- /// semantic actions the extension executes in the user's active tab; model-agnostic. -pub(crate) fn browser_tools() -> Vec { +pub fn browser_tools() -> Vec { vec![ Tool::new( "browser.click", @@ -471,11 +471,114 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { Ok(()) } +/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config +/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — +/// the per-session secret is NOT in the file: the entry references the +/// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned +/// agent process (config-var expansion is exactly how deployed agents already reference +/// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token +/// dies with the agent process and its registry entry. +pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { + let entry = json!({ + "url": facade_url, + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Publish under "openab" (the facade), not "openab-browser": the agent + // reaches ALL facade capabilities through this one entry. + if cfg["mcpServers"]["openab"] != entry { + cfg["mcpServers"]["openab"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + // kiro `--agent` deployments read agent files, not settings/mcp.json. + merge_kiro_agent_facade_configs(workdir, &entry).await?; + Ok(()) +} + +/// Facade-mode sibling of [`merge_kiro_agent_configs`]: merges the static +/// `openab` facade entry + `@openab` allowlist grant into every +/// `.kiro/agents/*.json`. Same never-clobber rules; nothing to clean up on +/// evict (the entry is static and the token lives in the process env). +async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab"] != *entry { + cfg["mcpServers"]["openab"] = entry.clone(); + changed = true; + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab")) { + allowed.push(json!("@openab")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Broker-side session credential hook (Facade mode). Implemented by the root +/// (closing over the facade's `SessionTokens` registry — core stays free of +/// the openab-mcp dependency); the pool calls it at session spawn/evict. +pub trait SessionTokenRegistrar: Send + Sync { + /// Mint (or re-mint) the token for `channel_id`; returns the value the + /// pool injects as `OPENAB_SESSION_TOKEN` in the agent's environment. + fn mint(&self, channel_id: &str) -> String; + /// Revoke every token for `channel_id` (session evicted/replaced). + fn revoke(&self, channel_id: &str); +} + /// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into /// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe /// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserMode { + /// Browser tools served through the OAB MCP Facade as a session-aware + /// in-process capability source (one listener, session identity via + /// broker-minted tokens). The default when the facade is running; + /// falls back to `Proxy` when it is not (no `[mcp]` in config). + Facade, + /// Per-session loopback HTTP MCP server + dynamic config (the original + /// default; explicit opt-out from facade routing). Proxy, Bridge, } @@ -489,7 +592,8 @@ impl BrowserMode { fn parse_browser_mode(s: Option<&str>) -> BrowserMode { match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { Some("bridge") => BrowserMode::Bridge, - _ => BrowserMode::Proxy, + Some("proxy") => BrowserMode::Proxy, + _ => BrowserMode::Facade, } } @@ -798,15 +902,20 @@ mod tests { } #[test] - fn browser_mode_defaults_to_proxy_and_opts_into_bridge() { - assert_eq!(parse_browser_mode(None), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some("")), BrowserMode::Proxy); + fn browser_mode_defaults_to_facade_with_proxy_and_bridge_opt_outs() { + // Facade is the default: browser tools ride the OAB MCP Facade as a + // session-aware source (falls back to Proxy at runtime when no + // facade is serving — see the pool's mode fallback). + assert_eq!(parse_browser_mode(None), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("")), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Facade); + // Explicit opt-outs keep their exact prior semantics. assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Proxy); assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); assert!(BrowserMode::Bridge.is_bridge()); assert!(!BrowserMode::Proxy.is_bridge()); + assert!(!BrowserMode::Facade.is_bridge()); } struct MockTunnel; diff --git a/crates/openab-mcp/src/lib.rs b/crates/openab-mcp/src/lib.rs index 53cd3fca2..b2ce17814 100644 --- a/crates/openab-mcp/src/lib.rs +++ b/crates/openab-mcp/src/lib.rs @@ -20,6 +20,10 @@ //! original `openab-agent` layout so the moved code's `crate::` paths and //! the agent's `crate::…` re-export shims stay stable. +/// Re-exported for `CapabilitySource` implementors in dependent crates +/// (the trait surface names `rmcp::model::Tool`). +pub use rmcp; + pub mod acp; pub mod auth; pub mod llm; diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index bcf3ec389..fce45231e 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -71,3 +71,27 @@ kiro-cli mcp list Gateway log confirms the extension side: `ACP: browser tunnel registered — extension attached`. + +## Facade mode (default when `[mcp]` is enabled) + +With the OAB MCP Facade running (`[mcp]` in `config.toml`), browser tools are +served as a **session-aware in-process capability source** of the facade +instead of per-session proxy servers: + +- **One listener** (the facade's, e.g. `127.0.0.1:8848/mcp`) — no per-session + ports, no per-session config rewrites. +- **Identity**: the pool mints one token per chat session and injects it as + `OPENAB_SESSION_TOKEN` into the agent process environment; the (static, + write-once) MCP config entry references it as + `"Authorization": "Bearer ${OPENAB_SESSION_TOKEN}"`. Tokens are revoked on + session evict; calls route to that session's browser via the same + `channel_id` tunnel contract as proxy mode. +- **Discovery**: agents find browser tools through `search_capabilities` + alongside every other facade capability, and execute them via + `execute_capability`. + +Mode selection (`OPENAB_BROWSER_MODE`): unset/`facade` → facade routing when +the facade is serving, with automatic fallback to `proxy` when it is not +(no `[mcp]` section); `proxy` → force the original per-session loopback +servers; `bridge` → Option C stdio bridge. Proxy and bridge behavior is +unchanged. diff --git a/src/browser_source.rs b/src/browser_source.rs new file mode 100644 index 000000000..959a95fc8 --- /dev/null +++ b/src/browser_source.rs @@ -0,0 +1,96 @@ +//! Browser capability source (Facade mode): serves the browser tool set as a +//! **session-aware in-process capability source** of the OAB MCP Facade +//! (`openab_mcp::mcp::sources`), replacing the per-session loopback proxy as +//! the default transport. Identity comes from the broker-minted session +//! token (`OPENAB_SESSION_TOKEN` in the agent's env → `Authorization` header +//! → `SessionCtx`), and calls route into the same MCP-over-ACP tunnel the +//! proxy used — `channel_id` semantics unchanged. +//! +//! Root-hosted because it needs both worlds: `openab_mcp`'s source trait and +//! `openab_core`'s tunnel bridge (core and the mcp crate stay independent). + +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use openab_core::mcp_proxy::AcpMcpTunnel; +use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use serde_json::{json, Map, Value}; + +/// Facade capability source backed by the browser MCP-over-ACP tunnel. +pub struct BrowserSource { + tunnel: Arc, +} + +impl BrowserSource { + pub fn new(tunnel: Arc) -> Self { + Self { tunnel } + } +} + +#[async_trait::async_trait] +impl CapabilitySource for BrowserSource { + fn provider(&self) -> &str { + "openab-browser" + } + + /// D4 static-advertise (unchanged from proxy mode): the tool set is + /// constant regardless of extension attachment — a call while + /// disconnected returns a "browser not connected" error result rather + /// than catalog flapping. + fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { + openab_core::mcp_proxy::browser_tools() + } + + async fn call( + &self, + ctx: Option<&SessionCtx>, + tool: &str, + args: &Map, + ) -> Result<(Value, bool)> { + // requires_session() guarantees ctx in practice; defend anyway. + let ctx = ctx.ok_or_else(|| anyhow!("browser capabilities require a session token"))?; + let params = json!({ "name": tool, "arguments": args }); + // Empty server_id sentinel (Fork A) — same routing contract as the + // per-session proxy: RootBrowserTunnel resolves the sole tunnel on + // the channel. + match self + .tunnel + .call(&ctx.channel_id, "", "tools/call", Some(params)) + .await + { + // The tunnel returns the inner MCP CallToolResult payload; pass + // it through and mirror its own isError flag. + Ok(result) => { + let is_error = result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok((result, is_error)) + } + // Tunnel-level failure (no extension attached, session gone): + // an error *result* — the agent gets an actionable message, the + // facade dispatch itself did not fault. + Err(msg) => Ok(( + json!({ "content": [{ "type": "text", "text": msg }], "isError": true }), + true, + )), + } + } + + fn requires_session(&self) -> bool { + true + } +} + +/// Root-side adapter: exposes the facade's `SessionTokens` registry through +/// core's `SessionTokenRegistrar` hook (core cannot depend on openab-mcp). +pub struct FacadeRegistrar(pub openab_mcp::mcp::sources::SessionTokens); + +impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { + fn mint(&self, channel_id: &str) -> String { + self.0.mint(channel_id) + } + fn revoke(&self, channel_id: &str) { + self.0.revoke_channel(channel_id) + } +} diff --git a/src/main.rs b/src/main.rs index 910fece7c..1be81b680 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ mod unified_adapter; #[cfg(feature = "acp")] mod browser_tunnel; #[cfg(feature = "acp")] +mod browser_source; +#[cfg(feature = "acp")] mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; @@ -469,6 +471,10 @@ async fn main() -> anyhow::Result<()> { // session; the core MCP proxy reads it via the RootBrowserTunnel bridge below. #[cfg(feature = "acp")] let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + #[cfg(feature = "acp")] + let browser_tunnel: Arc = Arc::new( + browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + ); // OAB MCP Facade (`[mcp]` in config.toml — OAB MCP Adapter ADR §6.2): // serve the loopback Streamable HTTP MCP server in-process so any coding @@ -476,10 +482,29 @@ async fn main() -> anyhow::Result<()> { // http:///mcp. Absent section = no listener (backward compat). // A bind failure is fatal at startup (fail fast, like a bad platform // token) rather than a silently missing capability surface. + // + // Browser capabilities (Facade mode, default): registered as a + // session-aware in-process source — one listener, per-session identity + // via broker-minted tokens; no per-session proxy servers. + let facade_sessions = openab_mcp::mcp::sources::SessionTokens::new(); + #[allow(unused_mut)] + let mut facade_serving = false; if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); + let tokens = facade_sessions.clone(); + #[allow(unused_mut)] + let mut sources: Vec> = Vec::new(); + #[cfg(feature = "acp")] + if !openab_core::mcp_proxy::browser_mode().is_bridge() { + sources.push(Arc::new(browser_source::BrowserSource::new( + browser_tunnel.clone(), + ))); + } + facade_serving = true; tokio::spawn(async move { - if let Err(e) = openab_mcp::mcp::facade::serve_http(&listen).await { + if let Err(e) = + openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await + { tracing::error!(error = %format!("{e:#}"), listen, "OAB MCP facade exited"); std::process::exit(1); } @@ -495,11 +520,22 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let browser_tunnel: Arc = Arc::new( - browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), - ); - #[cfg(feature = "acp")] let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); + // Facade mode session wiring: only when the facade is actually serving — + // otherwise the pool's mode fallback keeps the per-session proxy path. + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_facade_sessions( + facade_serving.then(|| { + Arc::new(browser_source::FacadeRegistrar(facade_sessions.clone())) + as Arc + }), + facade_serving.then(|| { + format!( + "http://{}/mcp", + cfg.mcp.as_ref().map(|m| m.listen.as_str()).unwrap_or("127.0.0.1:8848") + ) + }), + ); // Option C bridge mode: start the per-pod browser socket server once; the `openab // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. #[cfg(feature = "acp")] From 74e23f0eadd48cbfa7654754b9b8a3cb5868b880 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 25 Jul 2026 00:33:00 -0400 Subject: [PATCH 046/138] =?UTF-8?q?fix:=20facade=5Fserving=20is=20acp-only?= =?UTF-8?q?=20=E2=80=94=20derive=20it,=20don't=20flag=20it=20(default-feat?= =?UTF-8?q?ures=20-D=20warnings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1be81b680..622eac800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -487,8 +487,9 @@ async fn main() -> anyhow::Result<()> { // session-aware in-process source — one listener, per-session identity // via broker-minted tokens; no per-session proxy servers. let facade_sessions = openab_mcp::mcp::sources::SessionTokens::new(); - #[allow(unused_mut)] - let mut facade_serving = false; + // Only read under the acp feature (pool facade wiring below). + #[cfg(feature = "acp")] + let facade_serving = cfg.mcp.is_some(); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); @@ -500,7 +501,6 @@ async fn main() -> anyhow::Result<()> { browser_tunnel.clone(), ))); } - facade_serving = true; tokio::spawn(async move { if let Err(e) = openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await From 32cc50feb001ce49489f77f85294ad060801d7e0 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 11:39:35 +0800 Subject: [PATCH 047/138] =?UTF-8?q?docs(adr):=20=C2=A76=20builds=20on=20th?= =?UTF-8?q?e=20OAB=20MCP=20Facade=20=E2=80=94=20one=20AcpTunnelSource,=20s?= =?UTF-8?q?tatic-advertise,=20trust=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites reverse-MCP ADR §6.2-§6.6 now that the facade seam landed upstream (#1448/#1453 facade, #1454 session-aware CapabilitySource, #1446 ADR): - §6.2 expose every client-declared type:acp server through ONE in-process CapabilitySource (AcpTunnelSource) registered with the facade, rather than the bespoke per-(session,server) loopback proxies + N mcp.json entries. Sources are registered once at construction, so the source fans out internally and routes on the . prefix to (channel_id, server_id). Session identity moves to the facade's SessionTokens. - §6.3 drop notifications/tools/list_changed outright — facade discovery is pull-based (search_capabilities re-reads per call), so nothing caches a tool list to invalidate. Keep the static-advertise posture, implemented as fetch-once-per-declared-server + per-(channel,server) cache; unavailability is a call error, not a vanishing catalog entry. - §6.4 new trust requirement: #1454 assumes operator-granted tool sets, but a tunnel source's tools are client-declared — require an operator allowlist (default: browser only) plus a per-declared-server tool_filter. - §6.5 records what this retires and leaves stdio bridge-mode removal as an explicit operator call; flags the meta-tool-vs-direct hop as open. - Browser ADR: correct D4 (list_changed dropped, static-advertise kept) and add a supersession notice over D2/D3/D5. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 22 ++- docs/adr/acp-server-websocket-reverse-mcp.md | 159 ++++++++++++++----- 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index efe3eceba..3c5db76fd 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -37,6 +37,15 @@ Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (sn ## 3. Design decisions (D1–D6) +> **Supersession notice (2026-07-26).** D2, D3 and D5 describe the **as-shipped** delivery path: a +> per-`acp:`-session loopback MCP proxy that openab registers in each agent's native MCP config. That +> path is superseded by the OAB MCP Facade integration in +> [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md): browser becomes one session-aware +> `CapabilitySource` behind the facade, and session identity moves to the facade's `SessionTokens` +> (broker-minted bearer per session) instead of a per-session port + self-written `mcp.json` entry. The +> decisions below remain the record of why the shipped design looks the way it does; D6's trait split +> (core defines, root implements) carries over unchanged. + - **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery @@ -56,12 +65,13 @@ Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (sn - **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"). - `notifications/tools/list_changed` on attach/detach is **designed but not yet implemented** — no code - emits it today (grep `list_changed` → 0 hits in the gateway/core crates); it is tracked as P2b in - [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md). **Superseded as the default:** the - generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise - as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise - as an opt-in for the browser case. + `notifications/tools/list_changed` on attach/detach is **designed but never implemented** — no code + emits it (grep `list_changed` → 0 hits in the gateway/core crates). **Resolved 2026-07-26: it is + dropped, not deferred.** Under the OAB MCP Facade the agent discovers capabilities by *calling* + `search_capabilities`, so discovery is pull-based and there is no cached tool list for a notification + to invalidate. The static-advertise posture is **kept** — implemented as fetch-once-per-declared-server + plus a per-`(channel_id, server_id)` cache, with backend unavailability reported as a call error. See + [reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). - **D5 — per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per `acp:` session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so correlation is implicit. Server lifetime is tied to the `AcpConnection` via a `CancellationToken` diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index e85cd5211..db4c311de 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -151,42 +151,129 @@ Three pieces already generalize and are reused as-is: - Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. - Evict all `(channel_id, *)` entries on session teardown. -### 6.2 Dynamic tool discovery (supersedes static-advertise as the default) -- Each per-server proxy's `tools/list` forwards the client server's **real** tool list over its tunnel. -- **Unattached / reconnecting:** return an empty list for that server — never fabricate tools. -- **Attach → refresh:** on tunnel attach (or client re-declare on `session/resume`), push - `notifications/tools/list_changed` downstream so the agent re-lists. This preserves the - "usable before/without attach" UX that the browser's static-advertise (D4) gave, honestly. -- Keep a per-server **cache** of the last good `tools/list` to survive brief reconnects; **debounce** - `list_changed` against reconnect storms. -- The browser's static-advertise stays only as an **opt-in** fallback for the browser case; it is no - longer the default. - -### 6.3 Per-server downstream exposure (Option B — decided) -Each declared client server is surfaced to the agent as its **own** MCP server entry (`openab-`), -not merged into one namespaced blob: -- **proxy mode (HTTP):** one loopback MCP server per `(session, server)`, its own port + bearer; - openab writes **N entries** into the agent's native MCP config, one per server. -- **bridge mode (stdio):** the static entry gains a selector — - `{"command":"openab","args":["mcp-bridge","--server",""]}` — one relay per server (rename the - `browser-bridge` subcommand to `mcp-bridge`, keeping `browser-bridge` as a compat alias). - -Rejected — **Option A** (one aggregating proxy, `__` namespacing): the prefix leaks into -the tool names the model sees and needs reversible de-namespacing on every call. Option B is cleaner -for the LLM and maps to MCP's native "one server = one connection" model. (A stays a possible future mode.) - -### 6.4 Backward compatibility -The browser extension is unchanged: it declares `{type:acp, id, name:"openab-browser"}` and serves its -five DOM tools via its own `tools/list` — now discovered dynamically instead of static-advertised. Both -downstream modes are retained; single-server (browser-only) sessions behave identically. - -### 6.5 Generic implementation plan (folded into #1447) -- **P1** compound-key registry + `serverId` on the tunnel trait (no behaviour change; browser stays single). -- **P2** dynamic `tools/list` forwarding + per-server cache + `list_changed` attach/detach lifecycle. -- **P3** per-server downstream exposure (Option B) in both proxy + bridge modes; loop config-writing. -- **P4** generalize naming (`AcpMcpTunnel`, `openab-`, `openab mcp-bridge`) + error strings. -- **P5** e2e: a second, non-browser `type:acp` MCP server declared alongside the browser — both - discovered and callable in one session. +### 6.2 Downstream exposure — one `CapabilitySource` behind the OAB MCP Facade + +> **Revised 2026-07-26.** An earlier draft of §6.2–§6.5 proposed a bespoke path: per-`(session, server)` +> loopback MCP proxies, openab writing N entries into the agent's MCP config, dynamic `tools/list` with +> `notifications/tools/list_changed`. That is **superseded**. The +> [OAB MCP Facade](../oab-mcp-facade.md) (OAB MCP Adapter ADR, #1446; facade #1448/#1453) and its +> **session-aware in-process capability sources** (#1454) already provide the multi-provider catalog, +> discovery, policy runtime (schema validation, timeouts, circuit breaking, redaction, audit) and +> lifecycle this section was about to reinvent. Reverse-MCP-over-ACP contributes the one thing the +> facade lacks: a **transport for providers that cannot listen and are dialled in by the client**. + +``` +Facade providers today: stdio(command) http(url) ← openab dials OUT +Reverse-MCP adds: acp-tunnel(channel_id, server_id) ← client dialled IN, openab tunnels +``` + +**Decision: expose every client-declared `type:acp` server through a single in-process +`CapabilitySource` — `AcpTunnelSource` — registered once with the facade.** + +- The seam is `openab-mcp`'s `CapabilitySource` (`provider()` / `tools(ctx)` / `call(ctx, tool, args)` / + `requires_session()`), with `SessionCtx { channel_id }` identifying the owning chat session. This is + precisely the case #1454 was built for ("browser control, where `browser.click` must reach *that + conversation's* browser tab"). +- `AcpTunnelSource` lives in the **root binary**, where the tunnel state (`AcpTunnelRegistry`) already + lives — keeping `openab-core` and `openab-gateway` sibling-independent, as with `RootBrowserTunnel`. +- `requires_session() == true`: anonymous facade clients neither discover nor can execute these tools. +- **One source, N servers.** Facade sources are registered **once at construction** + (`facade::serve_http_with(addr, sources, tokens)`; there is no runtime registration API), so a source + *per* client-declared server is not possible — and not needed. `AcpTunnelSource` fans out internally: + `tools(ctx)` returns the tools of **every** `type:acp` server declared by the client of that + `channel_id`, and `call` routes on the **`.`** prefix to the matching + `(channel_id, server_id)` tunnel (§6.1). Today's names (`browser.click`, `browser.read_dom`) already + carry the server segment, so this generalizes with no renaming; the facade additionally publishes a + `:` form to resolve shadowing against `mcp.json` servers. +- **Adding another client-side MCP service is therefore declaration + policy work, not architecture + work.** The source must contain no browser-specific branch. + +**Session identity** is the facade's `SessionTokens`: the broker mints one opaque bearer per agent +session, writes it into that agent's MCP client config pointing at the facade, and revokes it on +session evict; the facade resolves the header back to a `SessionCtx` per request. This **replaces** the +bespoke per-session loopback proxy, its self-minted port/bearer, and openab's own `openab-browser` +`mcp.json` write/strip logic. + +### 6.3 Tool discovery — fetch once per declared server, then serve from cache + +The facade's discovery is **pull-based**: the agent sees only `search_capabilities` / +`execute_capability` and re-reads the catalog on each call. Two consequences: + +- **`notifications/tools/list_changed` is dropped.** There is no cached client-side tool list to + invalidate, so the notification has no consumer. (The earlier draft's `list_changed` lifecycle, + debouncing included, is removed rather than deferred.) +- **Static-advertise is the right posture**, per the facade's source contract — but implemented as + *dynamically sourced, then cached*, because tools for arbitrary declared servers cannot be hardcoded: + on declare/attach, fetch the server's real `tools/list` over its tunnel and **cache it per + `(channel_id, server_id)`**; serve `tools(ctx)` from that cache **regardless of current attach + state**. Backend unavailability surfaces as a **call error** ("browser not connected"), never as a + vanishing catalog entry. + +Distinguish two kinds of variation: **session scope** (which servers *this* session's client declared) +is legitimate and is exactly what `tools(ctx)` expresses; **attachment flapping** (is the tab connected +this second) must not reach the catalog. If nothing is cached yet — declared but not attached at first +discovery — that server contributes an empty set. An optional refinement, requiring a client wire +change, is to carry a tool manifest in the `initialize` declaration so the catalog is known without a +round-trip. + +### 6.4 Trust — client-declared tool sets need an operator gate + +#1454 states that source registration *is* the operator's grant, and that sources therefore carry no +per-source `tool_filter`. That assumption holds for code-wired sources whose tool set the operator +chose. It **does not hold** for `AcpTunnelSource`, whose tool set is declared by a **remote client**: a +connected extension could otherwise publish arbitrary tools into the agent's capability catalog. + +Therefore this ADR requires, before the source is enabled by default: + +- an operator **allowlist** of accepted declared server names (default: `browser` only) — declarations + outside it are ignored with a logged warning; and +- a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics. + +### 6.5 Backward compatibility & what this retires + +The browser extension is **unchanged**: it declares `{type:acp, id, name}` and serves its five DOM +tools over the tunnel. What changes is on the openab side — browser tools reach the agent through the +facade's meta-tools rather than a dedicated per-session MCP server. + +Retired once this lands: the per-session `mcp_proxy` browser server, its port/bearer minting, and the +`openab-browser` `mcp.json` injection. The **stdio bridge mode** (`OPENAB_BROWSER_MODE=bridge`, +`openab browser-bridge`) exists because some CLIs preferred a stdio entry; the facade is a loopback +HTTP MCP server that those CLIs read directly, so bridge mode is likely redundant — its removal is +**not** decided here and requires an explicit operator call. + +**Open question (not decided).** Under the facade the LLM reaches a browser action via +`search_capabilities` → `execute_capability`, one hop more per turn than today's direct +`browser.click`. Recommendation: ship on the meta-tool path (uniform policy, one audit surface) and +revisit a per-provider "expose directly" option only if interactive browser latency proves it needed. + +### 6.6 Status — as-built vs remaining + +**As-built (`bf37d25e`, `74e23f0e`): Facade mode is the default transport.** `src/browser_source.rs` +implements `CapabilitySource` over the existing `AcpMcpTunnel` — `requires_session()`, static-advertise +per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegistrar` adapts the facade's +`SessionTokens` to a `SessionTokenRegistrar` hook in core, so `openab-core` stays free of an +`openab-mcp` dependency. `BrowserMode::Facade` is the new default (falling back to `Proxy` when no +facade is serving); `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose +`Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's +process environment rather than a config file — which also removes the shared-workdir exposure of the +old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Proxy and +Option C bridge modes remain as explicit `OPENAB_BROWSER_MODE` opt-outs. This covers §6.2's source seam +and session identity for the **browser** case. + +**Remaining to fulfil this section:** +- **F1′ generalize the source to N client-declared servers.** Today it serves the fixed + `browser_tools()` set for one implicit server. Extend to every `type:acp` server the session's client + declared, routing on the `.` prefix to `(channel_id, server_id)` (§6.1/§6.2), with no + browser-specific branch left in the source. +- **F3′ per-`(channel_id, server_id)` discovery cache** — fetch each declared server's real `tools/list` + once on attach and serve from cache (§6.3). Required by F1′: a hardcoded tool table cannot describe + arbitrary declared servers. +- **F4 trust gate** — operator allowlist (default `browser` only) + per-declared-server `tool_filter` + (§6.4). Should land with F1′, since F1′ is what makes client-declared tool sets reachable. +- **F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; bridge-mode + removal stays an explicit operator call (§6.5). +- **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, + and two concurrent sessions each reaching only their own browser. ## 7. Alternatives considered From ee0998c3129249558c9088a1c1d3e35a153b670d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 11:43:54 +0800 Subject: [PATCH 048/138] =?UTF-8?q?docs(adr):=20align=20=C2=A76=20with=20t?= =?UTF-8?q?he=20merged=20facade=20series=20+=20record=20the=20mcpServers?= =?UTF-8?q?=20divergence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the merge of main (adapter ADR #1446, gmail doc #1455) into this branch: - Link the OAB MCP Adapter ADR directly now that it is present, and note the whole facade series (#1446/#1448/#1449/#1450/#1453/#1454) is merged with no facade PR left open — §6 builds on a settled foundation. - Cite adapter ADR §6.2 / Alternative C ("no second generic inbound MCP server", browser and external capabilities share one delivery mechanism), which makes retiring the bespoke per-session proxy (F5) an upstream design requirement rather than optional cleanup. - Record an unresolved divergence: the adapter ADR says the facade is delivered via ACP `mcpServers` and explicitly not by editing CLI config files, while the as-built `write_facade_mcp_config` does write a static entry — deliberately, since browser D2 found Cursor ignores ACP-passed mcpServers. Flagged for the facade contract owner instead of unilaterally reconciling. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-reverse-mcp.md | 43 +++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index db4c311de..0edbe70fe 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -156,11 +156,20 @@ Three pieces already generalize and are reused as-is: > **Revised 2026-07-26.** An earlier draft of §6.2–§6.5 proposed a bespoke path: per-`(session, server)` > loopback MCP proxies, openab writing N entries into the agent's MCP config, dynamic `tools/list` with > `notifications/tools/list_changed`. That is **superseded**. The -> [OAB MCP Facade](../oab-mcp-facade.md) (OAB MCP Adapter ADR, #1446; facade #1448/#1453) and its -> **session-aware in-process capability sources** (#1454) already provide the multi-provider catalog, -> discovery, policy runtime (schema validation, timeouts, circuit breaking, redaction, audit) and -> lifecycle this section was about to reinvent. Reverse-MCP-over-ACP contributes the one thing the -> facade lacks: a **transport for providers that cannot listen and are dialled in by the client**. +> [OAB MCP Facade](../oab-mcp-facade.md) ([OAB MCP Adapter ADR](./oab-mcp-adapter.md), #1446; facade +> #1448/#1453) and its **session-aware in-process capability sources** (#1454) already provide the +> multi-provider catalog, discovery, policy runtime (schema validation, timeouts, circuit breaking, +> redaction, audit) and lifecycle this section was about to reinvent. Reverse-MCP-over-ACP contributes +> the one thing the facade lacks: a **transport for providers that cannot listen and are dialled in by +> the client**. As of 2026-07-26 the whole facade series is merged upstream (#1446/#1448/#1449/#1450/ +> #1453/#1454) and no facade PR remains open, so this section builds on a settled foundation. +> +> The adapter ADR reaches the same conclusion from the other side: its §6.2 states that the facade +> occupies "the same architectural role that `acp-server-websocket-mcp-browser.md` assigns to OpenAB +> core… browser tools and external capabilities **share the delivery mechanism**", and its Alternative C +> rejects "a second generic inbound MCP server", i.e. **no agent-facing MCP server beyond this one +> aggregation point**. That makes retiring the bespoke per-session proxy (F5) a requirement of the +> upstream design, not merely cleanup. ``` Facade providers today: stdio(command) http(url) ← openab dials OUT @@ -227,7 +236,19 @@ Therefore this ADR requires, before the source is enabled by default: - an operator **allowlist** of accepted declared server names (default: `browser` only) — declarations outside it are ignored with a logged warning; and -- a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics. +- a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics, which is + **deny-all by default**. + +The name allowlist is **not** a trust boundary on its own: the name is chosen by the same remote +client that declares the tools, so a client may declare a server *named* `browser` and publish any +tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated +separately: + +- the `browser` entry ships **pinned to its five known tools** (`browser.read_dom`, + `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type`); any other tool name it + declares is dropped with a logged warning, so a same-name declaration cannot inject new tools; and +- every other allowlisted server starts **deny-all** and serves only the tools an operator has + explicitly listed. ### 6.5 Backward compatibility & what this retires @@ -260,6 +281,16 @@ old per-session `mcp.json` write. Capabilities publish under the provider name ` Option C bridge modes remain as explicit `OPENAB_BROWSER_MODE` opt-outs. This covers §6.2's source seam and session identity for the **browser** case. +⚠️ **Divergence to reconcile with the adapter ADR (not resolved here).** Adapter ADR §6.2 says delivery +is via ACP `session/new` `mcpServers`, and that "if a backing CLI does not honor ACP `mcpServers`, the +facade is unavailable for that CLI in the MVP **rather than falling back to editing the CLI's config +files**". The as-built `write_facade_mcp_config` does write a static entry into the CLI's config — +deliberately, because the browser path's D2 established that Cursor ignores ACP-passed `mcpServers` +([browser ADR](./acp-server-websocket-mcp-browser.md) D2, [zed#50924](https://github.com/zed-industries/zed/issues/50924)). +Both positions are defensible; recording the conflict rather than silently picking a side. Owner of the +facade contract should confirm whether config-file injection is an accepted exception for CLIs that +ignore `mcpServers`, or whether Facade mode should be unavailable for them. + **Remaining to fulfil this section:** - **F1′ generalize the source to N client-declared servers.** Today it serves the fixed `browser_tools()` set for one implicit server. Extend to every `type:acp` server the session's client From 3cb8bde224fbc171541efea4599191438b878bc2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 11:45:09 +0800 Subject: [PATCH 049/138] =?UTF-8?q?docs(adr):=20tighten=20=C2=A76.4=20trus?= =?UTF-8?q?t=20gate=20=E2=80=94=20deny-all=20tool=5Ffilter=20+=20pinned=20?= =?UTF-8?q?browser=20tool=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falcon's review of §6 (F7): the operator allowlist of declared server names is not a trust boundary on its own. The name is chosen by the same remote client that declares the tools, so a client may declare a server named `browser` and publish an arbitrary tool set under it. Align the §6.6 F4 summary with the requirement §6.4 now states: the per-declared-server tool_filter is deny-all by default, and the `browser` entry ships pinned to its five known tools so a same-name declaration cannot inject others. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 0edbe70fe..b088b0bda 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -299,8 +299,10 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. - **F3′ per-`(channel_id, server_id)` discovery cache** — fetch each declared server's real `tools/list` once on attach and serve from cache (§6.3). Required by F1′: a hardcoded tool table cannot describe arbitrary declared servers. -- **F4 trust gate** — operator allowlist (default `browser` only) + per-declared-server `tool_filter` - (§6.4). Should land with F1′, since F1′ is what makes client-declared tool sets reachable. +- **F4 trust gate** — operator allowlist (default `browser` only) + **deny-all-by-default** + per-declared-server `tool_filter`, with `browser` pinned to its five known tools so a same-name + declaration cannot inject others (§6.4). Should land with F1′, since F1′ is what makes + client-declared tool sets reachable. - **F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; bridge-mode removal stays an explicit operator call (§6.5). - **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, From 0cc6dd5059641a8821f1c4e8368a96881f0fd1d2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 12:35:45 +0800 Subject: [PATCH 050/138] =?UTF-8?q?docs(adr):=20=C2=A76.1/=C2=A76.2=20?= =?UTF-8?q?=E2=80=94=20declared=20id=20vs=20name,=20and=20last-attach-wins?= =?UTF-8?q?=20on=20same-name=20tunnels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while scoping F1': the routing contract as written could not be implemented. A declaration is {type:"acp", id, name} and the reference client mints `id` as a fresh crypto.randomUUID() per connection while `name` ("browser") is stable. The registry is keyed by `id`, but the `` segment of a tool name (`browser.click`) and the §6.4 allowlist are the `name` — so routing "on the prefix to the matching (channel_id, server_id) tunnel" can never match: the key is a UUID the tool name never contains. Record what review settled (Mira + Falcon, 2026-07-26): - registry stays keyed by (channel_id, id) — keying by name would let two same-name tunnels overwrite each other, the fan-out collapse §6 fixes — but must also record the declared name so a source can enumerate (name, id); - trust gating is keyed by name, since ids are per-connection UUIDs; - same-name collisions are last-attach-wins: the new tunnel replaces and evicts the older entry. Answering "ambiguous" there would wedge the client out of its own tools on every reconnect, because each reconnect mints a new id. Also clarify that the prefix selects the tunnel and is NOT stripped: the full published name is what goes over the tunnel, since that is what the server's own tools/call expects. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 31 +++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index b088b0bda..7080ac949 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -151,6 +151,25 @@ Three pieces already generalize and are reused as-is: - Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. - Evict all `(channel_id, *)` entries on session teardown. +**`id` and `name` are different things, and routing needs both.** A declaration is +`{type:"acp", id, name}`, and the two fields have very different lifetimes — the reference client mints +`id` as a fresh `crypto.randomUUID()` **per connection** while `name` (`"browser"`) is stable across +reconnects. The registry key is the **`id`**; the `` segment of a tool name (`browser.click`) +and the §6.4 allowlist are the **`name`**. Consequences, all confirmed by review 2026-07-26: + +- The registry stays keyed by `(channel_id, id)` — keying by `name` would let two same-name tunnels + overwrite each other, reintroducing exactly the fan-out collapse this section fixes — but it must + **also record the declared `name`**, so a source can enumerate `(name, id)` for a channel and resolve + a tool prefix to a tunnel. Routing purely on the registry key cannot work: the key is a UUID the tool + name never contains. +- Trust gating (§6.4) is keyed by **`name`**. An allowlist of `id`s is meaningless when they are + per-connection UUIDs. +- **Same-name collisions resolve last-attach-wins (LWW):** a newly attached tunnel whose `name` matches + an existing one on the same channel **replaces and evicts** the older entry. Because the client mints + a new `id` on every reconnect, the stale entry would otherwise linger beside the live one; answering + "ambiguous, disambiguate by server_id" there would wedge the client out of its own tools on every + reconnect. LWW keeps reconnect self-healing; the eviction is what stops unbounded growth. + ### 6.2 Downstream exposure — one `CapabilitySource` behind the OAB MCP Facade > **Revised 2026-07-26.** An earlier draft of §6.2–§6.5 proposed a bespoke path: per-`(session, server)` @@ -190,10 +209,14 @@ Reverse-MCP adds: acp-tunnel(channel_id, server_id) ← client diall (`facade::serve_http_with(addr, sources, tokens)`; there is no runtime registration API), so a source *per* client-declared server is not possible — and not needed. `AcpTunnelSource` fans out internally: `tools(ctx)` returns the tools of **every** `type:acp` server declared by the client of that - `channel_id`, and `call` routes on the **`.`** prefix to the matching - `(channel_id, server_id)` tunnel (§6.1). Today's names (`browser.click`, `browser.read_dom`) already - carry the server segment, so this generalizes with no renaming; the facade additionally publishes a - `:` form to resolve shadowing against `mcp.json` servers. + `channel_id`, and `call` routes on the **`.`** prefix to the matching tunnel. Today's + names (`browser.click`, `browser.read_dom`) already carry the server segment, so this generalizes + with no renaming — but note the segment is the declared **`name`**, not the registry key: resolving + it to a tunnel goes `name` → `(channel_id, id)` via the recorded declaration (§6.1), never straight + to the key. The tool name forwarded over the tunnel stays the **full** name the server published + (`browser.click`), since that is what the server's own `tools/call` expects; the prefix selects the + tunnel, it is not stripped. The facade additionally publishes a `:` form to resolve + shadowing against `mcp.json` servers. - **Adding another client-side MCP service is therefore declaration + policy work, not architecture work.** The source must contain no browser-specific branch. From 5dc45de05a771c0a61531227c7f2e3177164b971 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 13:03:04 +0800 Subject: [PATCH 051/138] feat(acp): record declared server name, establish all declared tunnels, LWW on re-attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tunnel-layer plumbing for §6 F1' (generalising the capability source to N client-declared servers). Behaviour-neutral for today's single-browser client: one declared server still yields exactly one tunnel. A declaration is {type:"acp", id, name} and the two fields have different lifetimes — the reference client mints `id` as a fresh crypto.randomUUID() per connection while `name` ("browser") is stable. The registry is keyed by `id`, but a tool name carries the `name` (browser.click) and the §6.4 trust gate is keyed by it too, so the name has to survive registration to be routable. - TunnelHandle records the declared `server_name` and exposes it. - establish_and_register_tunnel takes the declared name and resolves a re-declared name last-attach-wins: the new tunnel evicts stale same-name entries on the channel. Because a reconnect mints a new id, the dead tunnel would otherwise linger beside the live one, and answering "ambiguous, pass a server_id" there would wedge the client out of its own tools on every reconnect. The eviction is also what bounds registry growth. - spawn_browser_tunnel -> spawn_acp_tunnels now establishes EVERY declared server. The old first-only limit existed because the registry was keyed by channel_id alone, where a second server overwrote the first and orphaned its tunnel; the compound key removed that collision. - AcpMcpTunnel gains servers(channel_id) -> Vec<(name, id)>, implemented by RootBrowserTunnel over the registry. This is what lets a capability source resolve a tool prefix back to a tunnel; matching a prefix against the registry key alone can never work, since the key is a UUID the tool name never contains. Default impl is empty so test doubles are unaffected. The source-side consumer (AcpTunnelSource routing + the §6.4 trust gate) is the next step; nothing reads servers() yet. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 17 ++ .../openab-gateway/src/adapters/acp_server.rs | 177 ++++++++++++++---- src/browser_tunnel.rs | 12 ++ 3 files changed, 166 insertions(+), 40 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c38c55513..c5acc8221 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -44,6 +44,23 @@ pub trait AcpMcpTunnel: Send + Sync { method: &str, params: Option, ) -> Result; + + /// The `type:acp` servers currently registered for `channel_id`, as `(declared_name, + /// server_id)` pairs. + /// + /// Both halves are needed and they are *not* interchangeable (ADR §6.1): the registry is keyed + /// by the client-minted `server_id`, which the reference client mints as a fresh UUID **per + /// connection**, while a tool name carries the stable declared **name** (`browser.click`) and + /// the §6.4 trust gate is keyed by that name too. Enumerating both is what lets a capability + /// source resolve a tool prefix back to a tunnel; matching a prefix against the registry key + /// alone can never work. + /// + /// Sync because implementations just read an in-memory registry. The default is empty, so + /// implementations that track no declarations (test doubles, single-target bridges) simply + /// advertise nothing. + fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { + Vec::new() + } } /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 581bf0df3..4f1bfcbf9 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -663,9 +663,20 @@ pub struct TunnelHandle { pending: Arc>>>, next_id: Arc, connection_id: String, + /// The `name` the client declared for this server (e.g. `"browser"`). Stable across + /// reconnects, unlike the `id` the registry keys by — the reference client mints that as a + /// fresh UUID per connection. Tool prefixes (`browser.click`) and the §6.4 trust allowlist are + /// both keyed by this name, so it must survive registration to be routable (ADR §6.1). + server_name: String, } impl TunnelHandle { + /// The client-declared server name for this tunnel (see the field docs for why the declared + /// name and the registry key are deliberately different things). + pub fn server_name(&self) -> &str { + &self.server_name + } + /// Tunnel an inner MCP request (`tools/list`, `tools/call`, …) to the extension over this /// connection and return the inner MCP result payload. pub async fn mcp_message( @@ -705,43 +716,61 @@ impl TunnelHandle { /// the client's response, which only that same read loop can deliver — awaiting it inline /// would deadlock. #[allow(dead_code)] +#[allow(clippy::too_many_arguments)] async fn establish_and_register_tunnel( out_tx: mpsc::UnboundedSender, pending: Arc>>>, next_id: Arc, acp_id: String, + acp_name: String, channel_id: String, registry: AcpTunnelRegistry, timeout_secs: u64, ) -> Result<(), String> { // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. - info!(acp_id = %acp_id, channel_id = %channel_id, "ACP: opening MCP-over-ACP browser tunnel"); + info!(acp_id = %acp_id, acp_name = %acp_name, channel_id = %channel_id, "ACP: opening MCP-over-ACP tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; let handle = TunnelHandle { out_tx, pending, next_id, connection_id, + server_name: acp_name.clone(), + }; + let evicted = { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + // Last-attach-wins (ADR §6.1). The client mints a fresh `id` on every connection, so a + // reconnect would otherwise leave the dead tunnel registered beside the live one under + // the same declared name. Answering "ambiguous — pass a server_id" there would wedge the + // client out of its own tools on every reconnect, so the newest attach evicts its stale + // same-name predecessors instead — which is also what bounds registry growth. + let before = reg.len(); + reg.retain(|(c, id), h| !(c == &channel_id && h.server_name == acp_name && id != &acp_id)); + let evicted = before - reg.len(); + reg.insert((channel_id.clone(), acp_id.clone()), handle); + evicted }; - registry - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert((channel_id.clone(), acp_id.clone()), handle); - info!(channel_id = %channel_id, server_id = %acp_id, "ACP: browser tunnel registered — extension attached"); + if evicted > 0 { + info!( + channel_id = %channel_id, server_name = %acp_name, evicted, + "ACP: last-attach-wins — evicted stale same-name tunnel(s)" + ); + } + info!(channel_id = %channel_id, server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached"); Ok(()) } -/// Open + register the browser tunnel for a session's declared `type:acp` servers. +/// Open + register a tunnel for **every** `type:acp` server the session's client declared. /// -/// Exactly ONE tunnel per session is supported: the core proxy resolves a browser by -/// `channel_id`, so registering a second server under the same `channel_id` would overwrite -/// the first in the registry and orphan its already-opened tunnel. We therefore establish only -/// the first declared server and warn if the client sent more. Spawned (not awaited inline) -/// because `establish_and_register_tunnel` awaits the client's `mcp/connect` response, which -/// only the read loop delivers — awaiting inline would deadlock. +/// The old "first declared server only" limit came from the registry being keyed by `channel_id` +/// alone, where a second server would overwrite the first and orphan its open tunnel. The compound +/// `(channel_id, server_id)` key removed that collision, so all declared servers are established +/// now; a re-declared *name* is resolved last-attach-wins inside `establish_and_register_tunnel` +/// (ADR §6.1). Spawned (not awaited inline) because that function awaits the client's +/// `mcp/connect` response, which only the read loop delivers — awaiting inline would deadlock. #[allow(clippy::too_many_arguments)] -fn spawn_browser_tunnel( +fn spawn_acp_tunnels( servers: Vec, channel_id: String, registry: AcpTunnelRegistry, @@ -750,27 +779,22 @@ fn spawn_browser_tunnel( next_id: &Arc, prompt_tasks: &mut Vec>, ) { - if servers.len() > 1 { - warn!( - channel_id = %channel_id, - count = servers.len(), - "ACP: multiple type:acp servers declared; only one browser tunnel per session is supported — using the first" - ); + for srv in servers { + let out_tx = out_tx.clone(); + let pending = pending.clone(); + let next_id = next_id.clone(); + let registry = registry.clone(); + let channel_id = channel_id.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, + ) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); } - let Some(srv) = servers.into_iter().next() else { - return; - }; - let out_tx = out_tx.clone(); - let pending = pending.clone(); - let next_id = next_id.clone(); - prompt_tasks.push(tokio::spawn(async move { - if let Err(e) = - establish_and_register_tunnel(out_tx, pending, next_id, srv.id, channel_id, registry, 30) - .await - { - warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); - } - })); } async fn handle_acp_connection(state: Arc, socket: WebSocket) { @@ -961,7 +985,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response // only THIS read loop delivers — awaiting inline would deadlock. if let Some(registry) = state.acp_tunnel_registry.clone() { - spawn_browser_tunnel( + spawn_acp_tunnels( acp_mcp_servers, channel_id.clone(), registry, @@ -1003,7 +1027,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { .and_then(|v| v.as_str()) .and_then(derive_channel_id) { - spawn_browser_tunnel( + spawn_acp_tunnels( parse_acp_mcp_servers(req.params.as_ref()), channel_id, registry, @@ -2189,6 +2213,7 @@ mod acp_requests { pending: pending.clone(), next_id, connection_id: "conn-9".into(), + server_name: "browser".into(), }; let pending2 = pending.clone(); @@ -2231,6 +2256,7 @@ mod acp_requests { pending, next_id, "srv-1".into(), + "browser".into(), "acp_abc".into(), registry.clone(), 5, @@ -2239,13 +2265,84 @@ mod acp_requests { .unwrap(); ext.await.unwrap(); + let reg = registry.lock().unwrap(); + let handle = reg.get(&("acp_abc".to_string(), "srv-1".to_string())); assert!( - registry - .lock() - .unwrap() - .contains_key(&("acp_abc".to_string(), "srv-1".to_string())), + handle.is_some(), "a TunnelHandle must be registered under (channel_id, server_id)" ); + assert_eq!( + handle.unwrap().server_name(), + "browser", + "the declared name must survive registration — tool prefixes and the trust allowlist \ + match on it, not on the per-connection id" + ); + } + + /// Drive one `establish_and_register_tunnel` against a mock client that answers `mcp/connect`. + async fn attach(registry: &super::AcpTunnelRegistry, server_id: &str, name: &str) { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-1"}}), + ) + .await; + }); + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + server_id.into(), + name.into(), + "acp_abc".into(), + registry.clone(), + 5, + ) + .await + .unwrap(); + ext.await.unwrap(); + } + + /// Last-attach-wins (ADR §6.1): the client mints a fresh `id` per connection, so a reconnect + /// re-declares the same `name` under a new id. The new tunnel must replace the stale one + /// rather than coexist with it — coexistence is what would make routing ambiguous and wedge + /// the client out of its own tools on every reconnect. + #[tokio::test] + async fn reattaching_same_name_evicts_the_stale_tunnel() { + let registry = super::new_tunnel_registry(); + attach(®istry, "uuid-old", "browser").await; + attach(®istry, "uuid-new", "browser").await; + + let reg = registry.lock().unwrap(); + assert_eq!( + reg.len(), + 1, + "the reconnect must evict the stale same-name tunnel, not accumulate beside it" + ); + assert!( + reg.contains_key(&("acp_abc".to_string(), "uuid-new".to_string())), + "the most recently attached tunnel is the one that survives" + ); + } + + /// A different declared name on the same channel is a genuinely different server and must + /// coexist — that is the whole point of the compound key (§6.1/§6.2 fan-out). + #[tokio::test] + async fn different_names_on_one_channel_coexist() { + let registry = super::new_tunnel_registry(); + attach(®istry, "uuid-b", "browser").await; + attach(®istry, "uuid-o", "other").await; + + assert_eq!( + registry.lock().unwrap().len(), + 2, + "distinct declared names are distinct servers and must both stay registered" + ); } } diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index 1c46488ad..2445a641c 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -55,4 +55,16 @@ impl AcpMcpTunnel for RootBrowserTunnel { None => Err(format!("no browser attached to session {channel_id}")), } } + + /// Enumerate this channel's registered tunnels as `(declared_name, server_id)` (ADR §6.1). + /// The name is what a tool prefix and the §6.4 allowlist match on; the id is what the registry + /// is keyed by. Same-name duplicates cannot appear here — `establish_and_register_tunnel` + /// evicts the stale entry on attach (last-attach-wins). + fn servers(&self, channel_id: &str) -> Vec<(String, String)> { + let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); + reg.iter() + .filter(|((c, _), _)| c == channel_id) + .map(|((_, id), h)| (h.server_name().to_string(), id.clone())) + .collect() + } } From ba94efec214a8422b34480f6f53fa18e1b9cb9ab Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 13:20:53 +0800 Subject: [PATCH 052/138] feat(acp): route client-declared servers by name and gate their tool sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of §6 F1' — the consumer of the tunnel plumbing added in 5dc45de0. BrowserSource becomes AcpTunnelSource: it fans out to every client-declared MCP server instead of assuming one implicit browser, and enforces the §6.4 trust gate. Behaviour for today's single-browser client is unchanged. Routing (§6.1/§6.2): the `` prefix of a published tool name is the declared *name*, while the registry is keyed by the per-connection `id`, so `call` resolves name -> (channel_id, id) via the registry enumeration. The full published name is forwarded (`browser.click`), not the suffix — the prefix selects the tunnel, it is not stripped, because the server's own tools/call expects the name it published. Trust gate (§6.4), two independent checks: - the declared name must be in the operator allowlist (default: browser only); - the tool must be one that server is pinned to. The second is not redundant with the first. The name is chosen by the same remote client that declares the tools, so a client can re-declare the trusted name `browser` and publish `browser.exec`; the pin is what refuses it. Denied calls never reach the tunnel. Both cases are covered by tests. `browser` appears only as an entry in the default policy table — deliberately data, not a branch — so the routing code stays generic and admitting another client-side MCP service is a table entry (§6.2: no browser-specific branch). tools() serves the policy table statically and is deliberately NOT intersected with the tunnels currently attached: intersecting would make the catalog flap as a tab detaches, which §6.3 forbids, and would lose the pre-attach discovery D4 already provided. Availability is reported by call, never by a shrinking catalog. Session *scope* — restricting to the servers a given client declared — is a separate axis needing F3's declaration cache, so tools() ignores ctx for now; an allowlisted server's pinned tools are advertised to every session, which is the status quo for the browser. Co-Authored-By: Claude --- src/browser_source.rs | 351 ++++++++++++++++++++++++++++++++++++++---- src/main.rs | 2 +- 2 files changed, 320 insertions(+), 33 deletions(-) diff --git a/src/browser_source.rs b/src/browser_source.rs index 959a95fc8..4f4f75e19 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -1,44 +1,139 @@ -//! Browser capability source (Facade mode): serves the browser tool set as a -//! **session-aware in-process capability source** of the OAB MCP Facade -//! (`openab_mcp::mcp::sources`), replacing the per-session loopback proxy as -//! the default transport. Identity comes from the broker-minted session -//! token (`OPENAB_SESSION_TOKEN` in the agent's env → `Authorization` header -//! → `SessionCtx`), and calls route into the same MCP-over-ACP tunnel the +//! ACP-tunnel capability source (Facade mode): serves **client-declared** MCP +//! servers as a **session-aware in-process capability source** of the OAB MCP +//! Facade (`openab_mcp::mcp::sources`), replacing the per-session loopback +//! proxy as the default transport. Identity comes from the broker-minted +//! session token (`OPENAB_SESSION_TOKEN` in the agent's env → `Authorization` +//! header → `SessionCtx`), and calls route into the MCP-over-ACP tunnel the //! proxy used — `channel_id` semantics unchanged. //! //! Root-hosted because it needs both worlds: `openab_mcp`'s source trait and //! `openab_core`'s tunnel bridge (core and the mcp crate stay independent). +//! +//! **One source, N servers** (ADR §6.2). Facade sources are registered once at +//! construction, so there is no source-per-declared-server; this one fans out +//! internally, routing the `.` prefix to the right tunnel. +//! +//! **The prefix is a declared `name`, not a registry key** (ADR §6.1). A +//! declaration is `{type:"acp", id, name}`; the reference client mints `id` as +//! a fresh UUID per connection while `name` (`"browser"`) is stable. Routing +//! therefore resolves `name` → `(channel_id, id)` through the tunnel +//! registry's enumeration, and forwards the **full** published tool name +//! (`browser.click`) — the prefix selects the tunnel, it is not stripped, +//! because the server's own `tools/call` expects its full name. +use std::collections::HashMap; use std::sync::Arc; use anyhow::{anyhow, Result}; use openab_core::mcp_proxy::AcpMcpTunnel; use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use openab_mcp::rmcp::model::Tool; use serde_json::{json, Map, Value}; -/// Facade capability source backed by the browser MCP-over-ACP tunnel. -pub struct BrowserSource { +/// Trust policy for one declared server name (ADR §6.4). +/// +/// #1454 treats source registration as the operator's grant, so facade sources +/// carry no `tool_filter`. That holds for code-wired sources whose tool set the +/// operator chose; it does **not** hold here, where the tool set is declared by +/// a remote client. The declared *name* is chosen by that same client, so +/// passing the allowlist grants nothing by itself — the tool set is gated +/// separately, and **deny-all is the default**: a name with no policy entry is +/// refused outright, and a listed name may only publish the tools pinned here. +#[derive(Clone)] +struct ServerPolicy { + /// Exactly the tools this server may publish. Anything else it declares is + /// dropped from the catalog and refused on call, so a client cannot inject + /// new tools by re-declaring a trusted name. + tools: Vec, +} + +/// The default operator policy: `browser` pinned to its five known tools, every +/// other declared name denied. +/// +/// Browser-ness lives here as **policy data**, deliberately not as a branch in +/// the routing code — that is what keeps the source generic (ADR §6.2: "the +/// source must contain no browser-specific branch"). Admitting another +/// client-side MCP service is an entry in this table, not a code change. +fn default_policy() -> HashMap { + HashMap::from([( + "browser".to_string(), + ServerPolicy { + tools: openab_core::mcp_proxy::browser_tools(), + }, + )]) +} + +/// Facade capability source backed by MCP-over-ACP tunnels to client-declared +/// MCP servers. +pub struct AcpTunnelSource { tunnel: Arc, + /// Trust policy keyed by declared server **name** (§6.4). Keyed by name + /// rather than id because ids are per-connection UUIDs — an allowlist of + /// ids could never match twice. + policy: HashMap, } -impl BrowserSource { +impl AcpTunnelSource { pub fn new(tunnel: Arc) -> Self { - Self { tunnel } + Self { + tunnel, + policy: default_policy(), + } + } + + /// Split a published tool name into `(server_name, full_tool_name)`. The + /// full name is returned deliberately: the prefix picks the tunnel, and the + /// server's own `tools/call` expects the name it published. + fn split_prefix(tool: &str) -> Option<(&str, &str)> { + let (server, _rest) = tool.split_once('.')?; + if server.is_empty() { + return None; + } + Some((server, tool)) + } + + /// An error *result* (not a dispatch fault): the agent gets an actionable + /// message and the facade's own dispatch is considered to have succeeded — + /// matching how tunnel unavailability is reported. + fn error_result(message: String) -> (Value, bool) { + ( + json!({ "content": [{ "type": "text", "text": message }], "isError": true }), + true, + ) } } #[async_trait::async_trait] -impl CapabilitySource for BrowserSource { +impl CapabilitySource for AcpTunnelSource { fn provider(&self) -> &str { "openab-browser" } - /// D4 static-advertise (unchanged from proxy mode): the tool set is - /// constant regardless of extension attachment — a call while - /// disconnected returns a "browser not connected" error result rather - /// than catalog flapping. - fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { - openab_core::mcp_proxy::browser_tools() + /// The advertised catalog: every tool the trust policy pins, for every + /// allowlisted server. + /// + /// Deliberately **not** intersected with the tunnels currently attached. + /// Attachment flapping must not reach the catalog (§6.3) — a tab that is + /// closed for a second must not make the tools vanish and reappear — so + /// availability is reported by `call` ("browser not connected"), never by a + /// shrinking tool list. This keeps the pre-attach discovery behaviour the + /// static-advertise design (D4) already had. + /// + /// Session *scope* — restricting the catalog to the servers this client + /// actually declared — is a different axis and needs the per-`(channel_id, + /// server_id)` declaration cache that F3′ introduces; until then an + /// allowlisted server's pinned tools are advertised to every session, which + /// is exactly the status quo for the browser. + fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { + let mut out: Vec = self + .policy + .values() + .flat_map(|p| p.tools.iter().cloned()) + .collect(); + // Stable order: the catalog is user-visible and a HashMap iteration + // order would reshuffle it between runs. + out.sort_by(|a, b| a.name.cmp(&b.name)); + out } async fn call( @@ -48,18 +143,50 @@ impl CapabilitySource for BrowserSource { args: &Map, ) -> Result<(Value, bool)> { // requires_session() guarantees ctx in practice; defend anyway. - let ctx = ctx.ok_or_else(|| anyhow!("browser capabilities require a session token"))?; - let params = json!({ "name": tool, "arguments": args }); - // Empty server_id sentinel (Fork A) — same routing contract as the - // per-session proxy: RootBrowserTunnel resolves the sole tunnel on - // the channel. + let ctx = ctx.ok_or_else(|| anyhow!("ACP tunnel capabilities require a session token"))?; + + let Some((server_name, full_tool)) = Self::split_prefix(tool) else { + return Ok(Self::error_result(format!( + "malformed tool name {tool:?}: expected ." + ))); + }; + + // §6.4 gate, in two independent steps: the name must be allowlisted, + // and the tool must be one this server is pinned to. The second check + // is what stops a client injecting tools by re-declaring a trusted + // name, so it is not redundant with the first. + let Some(policy) = self.policy.get(server_name) else { + return Ok(Self::error_result(format!( + "server {server_name:?} is not in the operator allowlist" + ))); + }; + if !policy.tools.iter().any(|t| t.name == full_tool) { + return Ok(Self::error_result(format!( + "tool {full_tool:?} is not permitted for server {server_name:?}" + ))); + } + + // Resolve the declared name to the tunnel's registry key (§6.1). Same-name + // duplicates cannot occur — attach evicts the stale entry (last-attach-wins). + let Some((_, server_id)) = self + .tunnel + .servers(&ctx.channel_id) + .into_iter() + .find(|(name, _)| name == server_name) + else { + return Ok(Self::error_result(format!( + "{server_name} not connected: open the OpenAB side panel in your browser" + ))); + }; + + let params = json!({ "name": full_tool, "arguments": args }); match self .tunnel - .call(&ctx.channel_id, "", "tools/call", Some(params)) + .call(&ctx.channel_id, &server_id, "tools/call", Some(params)) .await { - // The tunnel returns the inner MCP CallToolResult payload; pass - // it through and mirror its own isError flag. + // The tunnel returns the inner MCP CallToolResult payload; pass it + // through and mirror its own isError flag. Ok(result) => { let is_error = result .get("isError") @@ -67,13 +194,9 @@ impl CapabilitySource for BrowserSource { .unwrap_or(false); Ok((result, is_error)) } - // Tunnel-level failure (no extension attached, session gone): - // an error *result* — the agent gets an actionable message, the - // facade dispatch itself did not fault. - Err(msg) => Ok(( - json!({ "content": [{ "type": "text", "text": msg }], "isError": true }), - true, - )), + // Tunnel-level failure (extension detached mid-call, session gone): + // an error result, not a dispatch fault. + Err(msg) => Ok(Self::error_result(msg)), } } @@ -94,3 +217,167 @@ impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { self.0.revoke_channel(channel_id) } } + +#[cfg(test)] +mod tests { + use super::{AcpTunnelSource, CapabilitySource, SessionCtx}; + use openab_core::mcp_proxy::AcpMcpTunnel; + use serde_json::{json, Map, Value}; + use std::sync::Arc; + + /// Tunnel double: reports one declared server and records what was forwarded. + struct FakeTunnel { + servers: Vec<(String, String)>, + forwarded: std::sync::Mutex>, + } + + impl FakeTunnel { + fn with(servers: &[(&str, &str)]) -> Arc { + Arc::new(Self { + servers: servers + .iter() + .map(|(n, i)| (n.to_string(), i.to_string())) + .collect(), + forwarded: std::sync::Mutex::new(Vec::new()), + }) + } + } + + #[async_trait::async_trait] + impl AcpMcpTunnel for FakeTunnel { + async fn call( + &self, + channel_id: &str, + server_id: &str, + _method: &str, + params: Option, + ) -> Result { + self.forwarded.lock().unwrap().push(( + channel_id.to_string(), + server_id.to_string(), + params.unwrap_or(Value::Null), + )); + Ok(json!({ "content": [{ "type": "text", "text": "ok" }] })) + } + + fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { + self.servers.clone() + } + } + + fn ctx() -> SessionCtx { + SessionCtx { + channel_id: "acp_x".into(), + } + } + + #[test] + fn catalog_is_the_pinned_policy_set_and_survives_detachment() { + // No tunnels attached at all: the catalog must NOT shrink (§6.3 — attachment + // flapping stays out of discovery; unavailability is reported on call). + let src = AcpTunnelSource::new(FakeTunnel::with(&[])); + let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); + assert_eq!( + names, + [ + "browser.click", + "browser.navigate", + "browser.read_dom", + "browser.screenshot", + "browser.type" + ], + "the pinned browser set is advertised regardless of attach state" + ); + } + + #[tokio::test] + async fn call_routes_the_name_prefix_to_the_declared_id_keeping_the_full_tool_name() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let src = AcpTunnelSource::new(tunnel.clone()); + let (_v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(!is_err); + + let fwd = tunnel.forwarded.lock().unwrap(); + let (channel, server_id, params) = &fwd[0]; + assert_eq!(channel, "acp_x"); + assert_eq!( + server_id, "uuid-abc", + "the declared NAME must resolve to the registry id, not be used as the key" + ); + assert_eq!( + params["name"], "browser.click", + "the prefix selects the tunnel and is NOT stripped — the server published this name" + ); + } + + #[tokio::test] + async fn unlisted_server_name_is_refused_even_when_a_tunnel_is_attached() { + // A client declaring an un-allowlisted name must not reach the agent's catalog + // OR its dispatch, even though its tunnel is registered. + let tunnel = FakeTunnel::with(&[("evil", "uuid-evil")]); + let src = AcpTunnelSource::new(tunnel.clone()); + let (v, is_err) = src + .call(Some(&ctx()), "evil.exfiltrate", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("not in the operator allowlist")); + assert!( + tunnel.forwarded.lock().unwrap().is_empty(), + "a denied call must never reach the tunnel" + ); + } + + #[tokio::test] + async fn unpinned_tool_on_an_allowlisted_server_is_refused() { + // The injection Falcon flagged: the client re-declares the trusted name + // `browser` but publishes a tool outside its pinned five. + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let src = AcpTunnelSource::new(tunnel.clone()); + let (v, is_err) = src + .call(Some(&ctx()), "browser.exec", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("is not permitted")); + assert!( + tunnel.forwarded.lock().unwrap().is_empty(), + "an unpinned tool must never reach the tunnel" + ); + } + + #[tokio::test] + async fn allowlisted_but_unattached_server_reports_not_connected() { + let tunnel = FakeTunnel::with(&[]); + let src = AcpTunnelSource::new(tunnel.clone()); + let (v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("not connected")); + } + + #[tokio::test] + async fn malformed_tool_name_without_a_prefix_is_rejected() { + let src = AcpTunnelSource::new(FakeTunnel::with(&[("browser", "uuid-abc")])); + let (v, is_err) = src.call(Some(&ctx()), "click", &Map::new()).await.unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("expected .")); + } +} diff --git a/src/main.rs b/src/main.rs index 622eac800..a3b5e6100 100644 --- a/src/main.rs +++ b/src/main.rs @@ -497,7 +497,7 @@ async fn main() -> anyhow::Result<()> { let mut sources: Vec> = Vec::new(); #[cfg(feature = "acp")] if !openab_core::mcp_proxy::browser_mode().is_bridge() { - sources.push(Arc::new(browser_source::BrowserSource::new( + sources.push(Arc::new(browser_source::AcpTunnelSource::new( browser_tunnel.clone(), ))); } From 2d334ae8350de8e4f9b91292420339eb215e18bf Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 13:32:58 +0800 Subject: [PATCH 053/138] =?UTF-8?q?docs(adr):=20=C2=A76.3=20=E2=80=94=20po?= =?UTF-8?q?licy=20entries=20seed=20the=20catalog;=20cache=20narrows,=20nev?= =?UTF-8?q?er=20grants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while scoping the discovery cache: §6.3 said an un-cached declared server "contributes an empty set", which contradicts the static-advertise posture that §6.4's pinned sets and D4 both rely on, and which the source implemented in ba94efec (browser's pinned tools are advertised before the extension attaches — confirmed correct on review). Record the layering review settled instead: - a server's §6.4 policy entry is its pre-attach SEED as well as its filter, so a pinned server never drops to empty just because nothing has attached; - the per-(channel_id, server_id) cache holds fetched ∩ allowed and replaces the seed once a fetch succeeds, narrowing the catalog to what the server really publishes without ever widening past the policy; - a declared server with no policy entry contributes nothing because §6.4 is deny-all, not because it is un-cached. Caching is never itself a grant. Also record the ordering consequence: deny-all plus pinned entries that already carry full Tool schemas means fetching cannot surface anything the operator has not already permitted, so the discovery cache is invisible until the operator-facing config surface exists. The config surface lands first; the cache then supplies real schemas once operators may list tools by name alone. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 27 +++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 7080ac949..5d5a2680a 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -243,10 +243,29 @@ The facade's discovery is **pull-based**: the agent sees only `search_capabiliti Distinguish two kinds of variation: **session scope** (which servers *this* session's client declared) is legitimate and is exactly what `tools(ctx)` expresses; **attachment flapping** (is the tab connected -this second) must not reach the catalog. If nothing is cached yet — declared but not attached at first -discovery — that server contributes an empty set. An optional refinement, requiring a client wire -change, is to carry a tool manifest in the `initialize` declaration so the catalog is known without a -round-trip. +this second) must not reach the catalog. An optional refinement, requiring a client wire change, is to +carry a tool manifest in the `initialize` declaration so the catalog is known without a round-trip. + +**Two layers, and the policy table is the lower one** (confirmed by review 2026-07-26; an earlier draft +of this section said an un-cached server "contributes an empty set", which contradicted the +static-advertise posture §6.4's pinned sets and D4 both depend on): + +- The §6.4 policy entry for a server is its **pre-attach seed** as well as its filter. A server the + operator has pinned advertises those tools from the moment the source is registered — it never drops + to empty just because nothing has attached yet. This is what preserves D4's "the browser tools are + discoverable before the extension connects". +- The per-`(channel_id, server_id)` cache holds `fetched ∩ allowed` and **replaces the seed once a + fetch succeeds**, so the catalog narrows to what the server actually publishes (a server may publish + fewer tools than the operator permitted) without ever widening past the policy. +- A declared server with **no** policy entry contributes nothing — not because it is un-cached, but + because §6.4 is deny-all. Caching changes what an *allowed* server advertises; it is never itself a + grant. + +**Ordering consequence.** Because the filter is deny-all and pinned entries already carry full `Tool` +schemas, fetching cannot surface anything an operator has not already permitted — so the discovery +cache has no visible effect until the operator-facing configuration surface exists. The config surface +therefore lands **first**; the cache is what supplies real schemas once operators are allowed to list +tools by name alone. ### 6.4 Trust — client-declared tool sets need an operator gate From eb732f1aeebf81ca4cf243acb178250bf4cfd5cd Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 13:54:30 +0800 Subject: [PATCH 054/138] feat(mcp): operator config surface for the client-declared server allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the §6.4 gate: enforcement landed in ba94efec, but the allowlist and per-server tool filter were a hardcoded table with no way for an operator to change them. Adds [[mcp.acp_servers]] entries of {name, tools}. Keyed by the declared name, never the id — the reference client mints its id as a fresh UUID per connection, so an allowlist of ids could not match twice. ServerPolicy now separates the two jobs that were conflated in one field, which is the layering §6.3 settled: - `allowed` is the deny-all gate over tool NAMES; - `seed` is the pre-attach advertisement (full Tool values), always a subset of `allowed`, so narrowing the policy narrows the catalog and can never widen it. Operators may list tools by name alone. For a server with a built-in catalog the schemas are taken from it and narrowed to what was permitted, so restricting the browser to read_dom needs no restated JSON schema. A server admitted by name with no built-in catalog has no seed yet: it dispatches, but advertises nothing until discovery caching can fetch its real schemas — which is precisely the job that gives the cache a non-redundant purpose. Two deliberate behaviours, both tested: - an ABSENT/empty section keeps the built-in browser default, so omitting the config cannot silently break existing browser control; - writing ANY entry takes over the allowlist wholesale — browser is not retained alongside an operator's list, so the config never grants more than it states. Co-Authored-By: Claude --- crates/openab-core/src/config.rs | 28 +++++ src/browser_source.rs | 202 +++++++++++++++++++++++++++---- src/main.rs | 3 +- 3 files changed, 206 insertions(+), 27 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index f33eefb23..337cbbc4e 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -78,6 +78,34 @@ pub struct McpFacadeConfig { /// boundary is the trust boundary. #[serde(default = "default_mcp_listen")] pub listen: String, + /// Operator gate for **client-declared** `type:acp` MCP servers (ADR §6.4). + /// Absent or empty keeps the built-in default: `browser` only, pinned to + /// its five known tools. Listing anything here replaces that default + /// wholesale, so an operator can narrow the browser or admit another + /// client-side service without a code change. + #[serde(default)] + pub acp_servers: Vec, +} + +/// One entry of the §6.4 operator allowlist. +/// +/// Keyed by the declared **name**, never the id: the reference client mints its +/// `id` as a fresh UUID per connection, so an allowlist of ids could not match +/// twice. The name is chosen by the same remote client that declares the tools, +/// so admitting a name grants nothing by itself — `tools` is the second, +/// independent gate that stops a client publishing extra tools under a name the +/// operator trusts. +#[derive(Debug, Clone, Deserialize)] +pub struct AcpServerPolicy { + /// Declared server name to accept — matches `name` in the client's + /// `{type:"acp", id, name}` entry and the `` prefix of its tools. + pub name: String, + /// Exactly the tool names this server may publish. **Deny-all**: omitted or + /// empty means the server is accepted but may publish nothing. Names alone + /// are enough — schemas come from the built-in catalog for known servers, + /// or from the server's own `tools/list` once discovery caching lands. + #[serde(default)] + pub tools: Vec, } fn default_mcp_listen() -> String { diff --git a/src/browser_source.rs b/src/browser_source.rs index 4f4f75e19..7238f7462 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -21,7 +21,7 @@ //! (`browser.click`) — the prefix selects the tunnel, it is not stripped, //! because the server's own `tools/call` expects its full name. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use anyhow::{anyhow, Result}; @@ -41,28 +41,74 @@ use serde_json::{json, Map, Value}; /// refused outright, and a listed name may only publish the tools pinned here. #[derive(Clone)] struct ServerPolicy { - /// Exactly the tools this server may publish. Anything else it declares is - /// dropped from the catalog and refused on call, so a client cannot inject - /// new tools by re-declaring a trusted name. - tools: Vec, + /// Exactly the tool names this server may publish. Anything else it declares + /// is dropped from the catalog and refused on call, so a client cannot + /// inject new tools by re-declaring a trusted name. + allowed: HashSet, + /// **Pre-attach seed** (ADR §6.3): full `Tool` values advertised from the + /// moment the source is registered, so a permitted server is discoverable + /// before its client ever attaches — the property D4 relies on. Known + /// servers seed from the built-in catalog; a server an operator admitted by + /// name alone seeds empty until discovery caching supplies real schemas. + /// Always a subset of `allowed`: the seed narrows with the policy, never + /// past it. + seed: Vec, } -/// The default operator policy: `browser` pinned to its five known tools, every -/// other declared name denied. +/// Built-in tool catalogs, by declared server name. The only source of full +/// `Tool` values (schemas included) before a server has been queried. /// /// Browser-ness lives here as **policy data**, deliberately not as a branch in /// the routing code — that is what keeps the source generic (ADR §6.2: "the -/// source must contain no browser-specific branch"). Admitting another -/// client-side MCP service is an entry in this table, not a code change. -fn default_policy() -> HashMap { +/// source must contain no browser-specific branch"). +fn builtin_catalogs() -> HashMap> { HashMap::from([( "browser".to_string(), - ServerPolicy { - tools: openab_core::mcp_proxy::browser_tools(), - }, + openab_core::mcp_proxy::browser_tools(), )]) } +/// The default policy when an operator has configured nothing: `browser` pinned +/// to its five known tools, every other declared name denied. +fn default_policy() -> HashMap { + builtin_catalogs() + .into_iter() + .map(|(name, tools)| { + let policy = ServerPolicy { + allowed: tools.iter().map(|t| t.name.to_string()).collect(), + seed: tools, + }; + (name, policy) + }) + .collect() +} + +/// Build the policy from the operator's `[[mcp.acp_servers]]` entries (§6.4). +/// +/// Each entry admits one declared **name** and lists exactly the tools it may +/// publish; deny-all means an entry with no tools admits the server but grants +/// nothing. Names are enough — schemas are taken from the built-in catalog +/// where one exists, narrowed to what the operator permitted, so an operator +/// can *restrict* a known server without restating its schemas. A permitted +/// tool with no known schema simply has no seed yet and appears once discovery +/// caching can fetch it. +fn policy_from_config(entries: &[openab_core::config::AcpServerPolicy]) -> HashMap { + let mut catalogs = builtin_catalogs(); + entries + .iter() + .map(|entry| { + let allowed: HashSet = entry.tools.iter().cloned().collect(); + let seed = catalogs + .remove(&entry.name) + .unwrap_or_default() + .into_iter() + .filter(|t| allowed.contains(t.name.as_ref())) + .collect(); + (entry.name.clone(), ServerPolicy { allowed, seed }) + }) + .collect() +} + /// Facade capability source backed by MCP-over-ACP tunnels to client-declared /// MCP servers. pub struct AcpTunnelSource { @@ -74,11 +120,20 @@ pub struct AcpTunnelSource { } impl AcpTunnelSource { - pub fn new(tunnel: Arc) -> Self { - Self { - tunnel, - policy: default_policy(), - } + /// Source gated by the operator's `[[mcp.acp_servers]]` entries. An empty + /// list keeps the built-in default rather than denying everything, so + /// omitting the section leaves browser control working as before; an + /// operator who writes any entry takes over the allowlist wholesale. + pub fn with_config( + tunnel: Arc, + entries: &[openab_core::config::AcpServerPolicy], + ) -> Self { + let policy = if entries.is_empty() { + default_policy() + } else { + policy_from_config(entries) + }; + Self { tunnel, policy } } /// Split a published tool name into `(server_name, full_tool_name)`. The @@ -128,7 +183,7 @@ impl CapabilitySource for AcpTunnelSource { let mut out: Vec = self .policy .values() - .flat_map(|p| p.tools.iter().cloned()) + .flat_map(|p| p.seed.iter().cloned()) .collect(); // Stable order: the catalog is user-visible and a HashMap iteration // order would reshuffle it between runs. @@ -160,7 +215,7 @@ impl CapabilitySource for AcpTunnelSource { "server {server_name:?} is not in the operator allowlist" ))); }; - if !policy.tools.iter().any(|t| t.name == full_tool) { + if !policy.allowed.contains(full_tool) { return Ok(Self::error_result(format!( "tool {full_tool:?} is not permitted for server {server_name:?}" ))); @@ -275,7 +330,7 @@ mod tests { fn catalog_is_the_pinned_policy_set_and_survives_detachment() { // No tunnels attached at all: the catalog must NOT shrink (§6.3 — attachment // flapping stays out of discovery; unavailability is reported on call). - let src = AcpTunnelSource::new(FakeTunnel::with(&[])); + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[]), &[]); let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); assert_eq!( names, @@ -293,7 +348,7 @@ mod tests { #[tokio::test] async fn call_routes_the_name_prefix_to_the_declared_id_keeping_the_full_tool_name() { let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - let src = AcpTunnelSource::new(tunnel.clone()); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (_v, is_err) = src .call(Some(&ctx()), "browser.click", &Map::new()) .await @@ -318,7 +373,7 @@ mod tests { // A client declaring an un-allowlisted name must not reach the agent's catalog // OR its dispatch, even though its tunnel is registered. let tunnel = FakeTunnel::with(&[("evil", "uuid-evil")]); - let src = AcpTunnelSource::new(tunnel.clone()); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (v, is_err) = src .call(Some(&ctx()), "evil.exfiltrate", &Map::new()) .await @@ -339,7 +394,7 @@ mod tests { // The injection Falcon flagged: the client re-declares the trusted name // `browser` but publishes a tool outside its pinned five. let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - let src = AcpTunnelSource::new(tunnel.clone()); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (v, is_err) = src .call(Some(&ctx()), "browser.exec", &Map::new()) .await @@ -358,7 +413,7 @@ mod tests { #[tokio::test] async fn allowlisted_but_unattached_server_reports_not_connected() { let tunnel = FakeTunnel::with(&[]); - let src = AcpTunnelSource::new(tunnel.clone()); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (v, is_err) = src .call(Some(&ctx()), "browser.click", &Map::new()) .await @@ -370,9 +425,104 @@ mod tests { .contains("not connected")); } + fn cfg(name: &str, tools: &[&str]) -> openab_core::config::AcpServerPolicy { + // Deserialized rather than struct-literalled so the test also pins the + // TOML shape operators actually write. + serde_json::from_value(json!({ + "name": name, + "tools": tools, + })) + .unwrap() + } + + #[test] + fn absent_config_keeps_the_builtin_browser_default() { + // Omitting [[mcp.acp_servers]] must not deny everything — existing + // browser control keeps working untouched. + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[]), &[]); + assert_eq!(src.tools(None).len(), 5); + } + + #[test] + fn operator_can_narrow_a_builtin_server_without_restating_schemas() { + let src = AcpTunnelSource::with_config( + FakeTunnel::with(&[("browser", "uuid-abc")]), + &[cfg("browser", &["browser.read_dom"])], + ); + let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); + assert_eq!( + names, + ["browser.read_dom"], + "the seed narrows to the operator's list, keeping the built-in schema" + ); + assert!( + src.tools(None)[0].input_schema.get("type").is_some(), + "narrowing must not lose the built-in schema" + ); + } + + #[tokio::test] + async fn narrowing_the_policy_actually_refuses_the_dropped_tool() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &["browser.read_dom"])]); + let (_v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(is_err, "a tool the operator removed must be refused"); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn operator_may_admit_an_unknown_server_by_name_alone() { + // No built-in catalog for "notes": it contributes no seed yet (schemas + // arrive with discovery caching), but its listed tool is permitted and + // routes to the declared id. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + assert!( + src.tools(None).is_empty(), + "a name-only server has no schemas to advertise until they are fetched" + ); + + let (_v, is_err) = src + .call(Some(&ctx()), "notes.list", &Map::new()) + .await + .unwrap(); + assert!(!is_err, "a permitted tool must dispatch even with no seed"); + assert_eq!(tunnel.forwarded.lock().unwrap()[0].1, "uuid-n"); + } + + #[tokio::test] + async fn an_entry_with_no_tools_admits_the_server_but_grants_nothing() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &[])]); + assert!(src.tools(None).is_empty(), "deny-all: no tools listed"); + let (_v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn configuring_other_servers_drops_the_browser_default() { + // Writing any entry takes over the allowlist wholesale — browser is not + // silently retained alongside the operator's list. + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + let (_v, is_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(is_err, "browser is no longer allowlisted once config is written"); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + #[tokio::test] async fn malformed_tool_name_without_a_prefix_is_rejected() { - let src = AcpTunnelSource::new(FakeTunnel::with(&[("browser", "uuid-abc")])); + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("browser", "uuid-abc")]), &[]); let (v, is_err) = src.call(Some(&ctx()), "click", &Map::new()).await.unwrap(); assert!(is_err); assert!(v["content"][0]["text"] diff --git a/src/main.rs b/src/main.rs index a3b5e6100..e45024be7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -497,8 +497,9 @@ async fn main() -> anyhow::Result<()> { let mut sources: Vec> = Vec::new(); #[cfg(feature = "acp")] if !openab_core::mcp_proxy::browser_mode().is_bridge() { - sources.push(Arc::new(browser_source::AcpTunnelSource::new( + sources.push(Arc::new(browser_source::AcpTunnelSource::with_config( browser_tunnel.clone(), + &mcp_cfg.acp_servers, ))); } tokio::spawn(async move { From 1e7ea11533616d0e7adf3c5363f4f763ffc2ca09 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 14:08:56 +0800 Subject: [PATCH 055/138] =?UTF-8?q?feat(acp):=20discovery=20cache=20?= =?UTF-8?q?=E2=80=94=20fetch=20each=20declared=20server's=20real=20tools/l?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements §6 F3'. A server an operator admitted by name alone had no schemas to advertise; it could dispatch but was invisible. Discovery fills that gap, which is the job that gives the cache a non-redundant purpose. Two deviations from §6.3 as written, both applied to the ADR in this commit: 1. The cache is keyed by (channel_id, NAME), not (channel_id, server_id). Ids are minted per connection, so an id-keyed entry would be orphaned by exactly the reconnect the cache exists to survive — it could never outlive the attach that populated it, which is the opposite of "serve regardless of current attach state". This is a corollary of the id-vs-name distinction §6.1 already records. Same-name collisions are impossible under last-attach-wins, so the name is a safe key. Covered by a test that reconnects under a fresh id. 2. Discovery is pull-triggered, not attach-triggered: a declared server with no cache entry has its fetch started from the next tools() call and its real set appears one discovery round later. The facade re-reads the catalog on every call, so one round of staleness is the whole cost, and it avoids threading an attach hook from the gateway (which owns attach) into the root (which owns the source). The cache stores what the server PUBLISHED, unfiltered, and the policy is applied on read. Filtering on read means tightening the policy takes effect immediately rather than waiting for an entry to be invalidated, and it keeps the invariant that caching is never itself a grant: a server that publishes a tool the operator never permitted stays both invisible and uncallable. Tested. A failed fetch leaves the seed in place — a seeded server never drops to empty — and clears its in-flight marker so the next round retries. Repeated discovery rounds do not pile up duplicate tools/list requests on one tunnel. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 24 +- src/browser_source.rs | 310 +++++++++++++++++-- 2 files changed, 303 insertions(+), 31 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 5d5a2680a..4689e8f70 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -236,8 +236,8 @@ The facade's discovery is **pull-based**: the agent sees only `search_capabiliti debouncing included, is removed rather than deferred.) - **Static-advertise is the right posture**, per the facade's source contract — but implemented as *dynamically sourced, then cached*, because tools for arbitrary declared servers cannot be hardcoded: - on declare/attach, fetch the server's real `tools/list` over its tunnel and **cache it per - `(channel_id, server_id)`**; serve `tools(ctx)` from that cache **regardless of current attach + fetch the server's real `tools/list` over its tunnel and **cache it per `(channel_id, name)`**; + serve `tools(ctx)` from that cache **regardless of current attach state**. Backend unavailability surfaces as a **call error** ("browser not connected"), never as a vanishing catalog entry. @@ -254,9 +254,21 @@ static-advertise posture §6.4's pinned sets and D4 both depend on): operator has pinned advertises those tools from the moment the source is registered — it never drops to empty just because nothing has attached yet. This is what preserves D4's "the browser tools are discoverable before the extension connects". -- The per-`(channel_id, server_id)` cache holds `fetched ∩ allowed` and **replaces the seed once a - fetch succeeds**, so the catalog narrows to what the server actually publishes (a server may publish - fewer tools than the operator permitted) without ever widening past the policy. +- The per-`(channel_id, name)` cache holds what the server published and is read as + `fetched ∩ allowed`, **replacing the seed once a fetch succeeds**, so the catalog narrows to what the + server actually publishes (a server may publish fewer tools than the operator permitted) without ever + widening past the policy. Filtering on read rather than on write means tightening the policy takes + effect immediately instead of waiting for a cache entry to be invalidated — **caching is never itself + a grant**. +- **The cache is keyed by the declared `name`, not `server_id`** (corrected 2026-07-26; earlier drafts + of this section said `server_id`). Ids are minted per connection, so an id-keyed entry would be + orphaned by exactly the reconnect the cache exists to survive — it could never outlive the attach it + was populated from, which is the opposite of "serve regardless of current attach state". Same-name + collisions are impossible by §6.1's last-attach-wins rule, so the name is a safe key. +- Discovery is **pull-triggered**: a declared server with no cache entry has its fetch started from the + next `tools(ctx)` call, and its real set appears one discovery round later. The facade re-reads the + catalog on every call, so a single round of staleness is the entire cost, and it avoids threading an + attach hook from the gateway (which owns attach) into the root (which owns the source). - A declared server with **no** policy entry contributes nothing — not because it is un-cached, but because §6.4 is deny-all. Caching changes what an *allowed* server advertises; it is never itself a grant. @@ -338,7 +350,7 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. `browser_tools()` set for one implicit server. Extend to every `type:acp` server the session's client declared, routing on the `.` prefix to `(channel_id, server_id)` (§6.1/§6.2), with no browser-specific branch left in the source. -- **F3′ per-`(channel_id, server_id)` discovery cache** — fetch each declared server's real `tools/list` +- **F3′ per-`(channel_id, name)` discovery cache** — fetch each declared server's real `tools/list` once on attach and serve from cache (§6.3). Required by F1′: a hardcoded tool table cannot describe arbitrary declared servers. - **F4 trust gate** — operator allowlist (default `browser` only) + **deny-all-by-default** diff --git a/src/browser_source.rs b/src/browser_source.rs index 7238f7462..24f3b2466 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -117,6 +117,34 @@ pub struct AcpTunnelSource { /// rather than id because ids are per-connection UUIDs — an allowlist of /// ids could never match twice. policy: HashMap, + /// Discovered tool sets, keyed `(channel_id, declared_name)` (§6.3). + /// + /// Keyed by **name**, not by `server_id` as an earlier draft of §6.3 said: + /// the client mints a fresh id per connection, so an id-keyed entry would be + /// orphaned by the very reconnect the cache exists to paper over. Keying by + /// name is what lets a discovered set survive a reconnect, which is the + /// cache's entire purpose. + /// + /// Holds what the server published; the policy filter is applied on read, so + /// tightening the policy takes effect immediately without invalidating it. + cache: ToolsCache, + /// Discovery fetches currently in flight, so repeated discovery rounds do + /// not pile up duplicate `tools/list` requests on one tunnel. + inflight: InflightKeys, +} + +/// Tool sets discovered from client servers, keyed `(channel_id, declared_name)`. +type ToolsCache = Arc>>>; + +/// `(channel_id, declared_name)` pairs with a discovery fetch already running. +type InflightKeys = Arc>>; + +/// Sort a tool set by name: the catalog is user-visible and `HashMap` iteration +/// order would otherwise reshuffle it between runs. +fn sorted(tools: impl IntoIterator) -> Vec { + let mut out: Vec = tools.into_iter().collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out } impl AcpTunnelSource { @@ -133,7 +161,59 @@ impl AcpTunnelSource { } else { policy_from_config(entries) }; - Self { tunnel, policy } + Self { + tunnel, + policy, + cache: Arc::new(std::sync::Mutex::new(HashMap::new())), + inflight: Arc::new(std::sync::Mutex::new(HashSet::new())), + } + } + + /// Fetch one server's real `tools/list` in the background and cache it + /// (§6.3). Cheap to call on every discovery round: it is a no-op while a + /// fetch for the same `(channel, name)` is already in flight. + /// + /// What lands in the cache is what the server published — **unfiltered**. + /// The policy is applied when the catalog is read, so caching is never + /// itself a grant: an entry for a tool the operator did not permit stays + /// invisible and uncallable. + fn spawn_discovery(&self, channel_id: &str, name: &str, server_id: &str) { + // Outside a tokio runtime (unit tests calling tools() directly) there is + // nothing to spawn onto; discovery is best-effort, so skip quietly. + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return; + }; + let key = (channel_id.to_string(), name.to_string()); + { + let mut inflight = self.inflight.lock().unwrap_or_else(|e| e.into_inner()); + if !inflight.insert(key.clone()) { + return; + } + } + let tunnel = self.tunnel.clone(); + let cache = self.cache.clone(); + let inflight = self.inflight.clone(); + let server_id = server_id.to_string(); + let channel = key.0.clone(); + handle.spawn(async move { + let fetched = tunnel + .call(&channel, &server_id, "tools/list", None) + .await + .ok() + .and_then(|v| serde_json::from_value::>(v.get("tools")?.clone()).ok()); + if let Some(tools) = fetched { + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.clone(), tools); + } + // Always clear the flag: a failed fetch must be retryable on the + // next discovery round, not wedged as permanently "in flight". + inflight + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key); + }); } /// Split a published tool name into `(server_name, full_tool_name)`. The @@ -164,8 +244,8 @@ impl CapabilitySource for AcpTunnelSource { "openab-browser" } - /// The advertised catalog: every tool the trust policy pins, for every - /// allowlisted server. + /// The advertised catalog: for every allowlisted server, its discovered + /// tool set if one has been cached, else its policy seed. /// /// Deliberately **not** intersected with the tunnels currently attached. /// Attachment flapping must not reach the catalog (§6.3) — a tab that is @@ -174,21 +254,54 @@ impl CapabilitySource for AcpTunnelSource { /// shrinking tool list. This keeps the pre-attach discovery behaviour the /// static-advertise design (D4) already had. /// - /// Session *scope* — restricting the catalog to the servers this client - /// actually declared — is a different axis and needs the per-`(channel_id, - /// server_id)` declaration cache that F3′ introduces; until then an - /// allowlisted server's pinned tools are advertised to every session, which - /// is exactly the status quo for the browser. - fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { - let mut out: Vec = self - .policy - .values() - .flat_map(|p| p.seed.iter().cloned()) + /// Discovery is *pull*-triggered rather than attach-triggered: a declared + /// server with no cache entry yet gets a background `tools/list` fetch + /// kicked off here, and its real set appears on the next discovery round. + /// The facade re-reads the catalog on every call, so one round of staleness + /// is the whole cost, and it avoids threading an attach hook from the + /// gateway (which owns attach) into the root (which owns this source). + fn tools(&self, ctx: Option<&SessionCtx>) -> Vec { + // Anonymous clients never reach here in practice (`requires_session`), + // and with no channel there is nothing to discover — serve the seeds. + let Some(ctx) = ctx else { + return sorted(self.policy.values().flat_map(|p| p.seed.iter().cloned())); + }; + + // name -> server_id for the tunnels attached to this session. Same-name + // duplicates cannot occur (last-attach-wins, §6.1). + let attached: HashMap = self + .tunnel + .servers(&ctx.channel_id) + .into_iter() .collect(); - // Stable order: the catalog is user-visible and a HashMap iteration - // order would reshuffle it between runs. - out.sort_by(|a, b| a.name.cmp(&b.name)); - out + + let mut out: Vec = Vec::new(); + for (name, policy) in &self.policy { + let cached = self + .cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&(ctx.channel_id.clone(), name.clone())) + .cloned(); + match cached { + // `fetched ∩ allowed` — the cache narrows the catalog to what the + // server actually publishes and can never widen it past policy. + Some(fetched) => out.extend( + fetched + .into_iter() + .filter(|t| policy.allowed.contains(t.name.as_ref())), + ), + None => { + // Nothing discovered yet: advertise the seed (never empty out a + // seeded server) and kick off the fetch if it is attached. + out.extend(policy.seed.iter().cloned()); + if let Some(server_id) = attached.get(name) { + self.spawn_discovery(&ctx.channel_id, name, server_id); + } + } + } + } + sorted(out) } async fn call( @@ -280,22 +393,52 @@ mod tests { use serde_json::{json, Map, Value}; use std::sync::Arc; - /// Tunnel double: reports one declared server and records what was forwarded. + /// Tunnel double: reports declared servers, records forwarded `tools/call`s, + /// and can answer or fail `tools/list`. struct FakeTunnel { - servers: Vec<(String, String)>, + servers: std::sync::Mutex>, forwarded: std::sync::Mutex>, + tools_list: std::sync::Mutex>>, + tools_list_calls: std::sync::atomic::AtomicUsize, } impl FakeTunnel { fn with(servers: &[(&str, &str)]) -> Arc { Arc::new(Self { - servers: servers - .iter() - .map(|(n, i)| (n.to_string(), i.to_string())) - .collect(), + servers: std::sync::Mutex::new( + servers + .iter() + .map(|(n, i)| (n.to_string(), i.to_string())) + .collect(), + ), forwarded: std::sync::Mutex::new(Vec::new()), + tools_list: std::sync::Mutex::new(None), + tools_list_calls: std::sync::atomic::AtomicUsize::new(0), }) } + + /// Make `tools/list` answer with these tool names. + fn set_tools_list(&self, names: &[&str]) { + *self.tools_list.lock().unwrap() = + Some(names.iter().map(|n| n.to_string()).collect()); + } + + /// Make `tools/list` fail (no answer configured). + fn fail_tools_list(&self) { + *self.tools_list.lock().unwrap() = None; + } + + fn tools_list_calls(&self) -> usize { + self.tools_list_calls + .load(std::sync::atomic::Ordering::SeqCst) + } + + /// Simulate a reconnect: same declared name, freshly minted id. + fn reattach_as(&self, name: &str, new_id: &str) { + let mut servers = self.servers.lock().unwrap(); + servers.retain(|(n, _)| n != name); + servers.push((name.to_string(), new_id.to_string())); + } } #[async_trait::async_trait] @@ -304,9 +447,21 @@ mod tests { &self, channel_id: &str, server_id: &str, - _method: &str, + method: &str, params: Option, ) -> Result { + if method == "tools/list" { + self.tools_list_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let Some(names) = self.tools_list.lock().unwrap().clone() else { + return Err("tools/list unavailable".into()); + }; + let tools: Vec = names + .iter() + .map(|n| json!({ "name": n, "inputSchema": { "type": "object" } })) + .collect(); + return Ok(json!({ "tools": tools })); + } self.forwarded.lock().unwrap().push(( channel_id.to_string(), server_id.to_string(), @@ -316,7 +471,7 @@ mod tests { } fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { - self.servers.clone() + self.servers.lock().unwrap().clone() } } @@ -520,6 +675,111 @@ mod tests { assert!(tunnel.forwarded.lock().unwrap().is_empty()); } + /// Let any spawned discovery task run to completion. + async fn settle() { + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + + #[tokio::test] + async fn discovery_fills_the_catalog_for_a_name_only_server() { + // `notes` was admitted by name alone, so it starts with no seed. After a + // discovery round its real tools/list is cached and appears. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + tunnel.set_tools_list(&["notes.list", "notes.get"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + + assert!(src.tools(Some(&ctx())).is_empty(), "nothing discovered yet"); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + names, + ["notes.list"], + "fetched ∩ allowed — notes.get was published but never permitted" + ); + } + + #[tokio::test] + async fn caching_is_never_itself_a_grant() { + // The server publishes a tool the operator did not permit. Caching it + // must not make it visible OR callable. + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + tunnel.set_tools_list(&["browser.read_dom", "browser.exec"]); + let src = + AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &["browser.read_dom"])]); + let _ = src.tools(Some(&ctx())); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!(names, ["browser.read_dom"], "the unpermitted tool stays out"); + + let (_v, is_err) = src + .call(Some(&ctx()), "browser.exec", &Map::new()) + .await + .unwrap(); + assert!(is_err, "and it stays uncallable even though it was cached"); + } + + #[tokio::test] + async fn a_seeded_server_keeps_its_seed_until_discovery_succeeds() { + // Fetch fails: the catalog must fall back to the seed, never to empty. + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + tunnel.fail_tools_list(); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + assert_eq!(src.tools(Some(&ctx())).len(), 5); + settle().await; + assert_eq!( + src.tools(Some(&ctx())).len(), + 5, + "a failed discovery must not empty out a seeded server" + ); + } + + #[tokio::test] + async fn discovery_is_not_repeated_while_one_is_in_flight() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + tunnel.set_tools_list(&["browser.read_dom"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + for _ in 0..5 { + let _ = src.tools(Some(&ctx())); + } + settle().await; + assert_eq!( + tunnel.tools_list_calls(), + 1, + "repeated discovery rounds must not pile up tools/list requests" + ); + } + + #[tokio::test] + async fn cached_tools_survive_a_reconnect_that_changes_the_server_id() { + // The whole point of keying the cache by NAME: the client mints a new id + // on reconnect, and an id-keyed entry would be orphaned by it. + let tunnel = FakeTunnel::with(&[("browser", "uuid-old")]); + tunnel.set_tools_list(&["browser.read_dom"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let _ = src.tools(Some(&ctx())); + settle().await; + assert_eq!(src.tools(Some(&ctx())).len(), 1); + + tunnel.reattach_as("browser", "uuid-new"); + assert_eq!( + src.tools(Some(&ctx())).len(), + 1, + "the discovered set must survive the id change" + ); + } + #[tokio::test] async fn malformed_tool_name_without_a_prefix_is_rejected() { let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("browser", "uuid-abc")]), &[]); From 9ab286a0f0309c89ef2a6bcc80cfb8c7595524df Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 14:35:31 +0800 Subject: [PATCH 056/138] test(acp): two client-declared servers in one session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the multi-server claim §6.2 makes, through the real source: one session declares `browser` and a second, non-browser server; both are discovered and callable, tool names do not collide, and each server's policy is enforced independently. - both servers contribute to one catalog, each under its own prefix, with no duplicate names (the case a naive un-prefixed catalog would collapse); - each tool reaches the tunnel of the server that declared it, by name -> id; - a permission granted to one server does not leak to another: browser.click is permitted while notes.click is refused, proving the gate is per-server rather than a global tool-name allowlist — an easy thing to regress in a refactor and invisible with only one server configured; - one server detaching leaves its neighbour callable. SCOPE: this is the source-side half of F6, not a full end-to-end. F6 asks that "the agent discovers + calls tools from BOTH", and the agent-side leg runs through the facade's meta-tools — which cannot be exercised while facade mode is not live anywhere (no [mcp] configured; the browser deployment runs OPENAB_BROWSER_MODE=bridge). That is the same precondition F5 is blocked on. The gateway-side half — two type:acp servers each getting their own mcp/connect — is already covered by section_tunnel in scripts/acp-ws-smoke.py. What remains genuinely unproven is the facade <-> source seam. Co-Authored-By: Claude --- src/browser_source.rs | 126 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/browser_source.rs b/src/browser_source.rs index 24f3b2466..9ca396f23 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -390,6 +390,7 @@ impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { mod tests { use super::{AcpTunnelSource, CapabilitySource, SessionCtx}; use openab_core::mcp_proxy::AcpMcpTunnel; + use std::collections::HashSet; use serde_json::{json, Map, Value}; use std::sync::Arc; @@ -433,6 +434,11 @@ mod tests { .load(std::sync::atomic::Ordering::SeqCst) } + /// Simulate a server going away (tab closed, client disconnected). + fn detach(&self, name: &str) { + self.servers.lock().unwrap().retain(|(n, _)| n != name); + } + /// Simulate a reconnect: same declared name, freshly minted id. fn reattach_as(&self, name: &str, new_id: &str) { let mut servers = self.servers.lock().unwrap(); @@ -780,6 +786,126 @@ mod tests { ); } + // --- F6: two client-declared servers in one session --- + // + // The multi-server claim §6.2 makes, exercised through the real source: one + // session declares `browser` and a second, non-browser server; both are + // discovered and callable, tool names do not collide, and each server's + // policy is enforced independently. The agent-side leg (facade meta-tools → + // this source) is not covered here — facade mode is not live anywhere yet, + // which is the same precondition F5 is blocked on. + + fn two_server_src(tunnel: Arc) -> AcpTunnelSource { + AcpTunnelSource::with_config( + tunnel, + &[ + cfg("browser", &["browser.click", "browser.read_dom"]), + cfg("notes", &["notes.list"]), + ], + ) + } + + #[tokio::test] + async fn two_declared_servers_are_both_discovered_without_collision() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + // Both servers publish a tool literally called `list` under their own + // prefix — the case a naive un-prefixed catalog would collapse. + tunnel.set_tools_list(&["browser.click", "browser.read_dom", "notes.list"]); + let src = two_server_src(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + names, + ["browser.click", "browser.read_dom", "notes.list"], + "both servers contribute, each under its own prefix" + ); + assert_eq!( + names.len(), + names.iter().collect::>().len(), + "no duplicate tool names across servers" + ); + } + + #[tokio::test] + async fn each_server_receives_only_its_own_calls() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + + for tool in ["browser.click", "notes.list"] { + let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); + assert!(!is_err, "{tool} should dispatch"); + } + + let fwd = tunnel.forwarded.lock().unwrap(); + let routed: Vec<(String, String)> = fwd + .iter() + .map(|(_c, id, params)| (id.clone(), params["name"].as_str().unwrap().to_string())) + .collect(); + assert_eq!( + routed, + [ + ("uuid-b".to_string(), "browser.click".to_string()), + ("uuid-n".to_string(), "notes.list".to_string()) + ], + "each tool reaches the tunnel of the server that declared it" + ); + } + + #[tokio::test] + async fn one_servers_policy_does_not_leak_to_another() { + // `browser.click` is permitted, `notes.click` is not — a per-server gate, + // not a global tool-name gate. + let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + + let (_v, ok_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(!ok_err); + + let (v, denied) = src + .call(Some(&ctx()), "notes.click", &Map::new()) + .await + .unwrap(); + assert!(denied, "notes may not borrow browser's permissions"); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("is not permitted")); + assert_eq!( + tunnel.forwarded.lock().unwrap().len(), + 1, + "only the permitted call reached a tunnel" + ); + } + + #[tokio::test] + async fn one_server_detaching_leaves_the_other_callable() { + let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + tunnel.detach("browser"); + + let (_v, browser_err) = src + .call(Some(&ctx()), "browser.click", &Map::new()) + .await + .unwrap(); + assert!(browser_err, "the detached server reports not connected"); + + let (_v, notes_err) = src + .call(Some(&ctx()), "notes.list", &Map::new()) + .await + .unwrap(); + assert!(!notes_err, "its neighbour is unaffected"); + } + #[tokio::test] async fn malformed_tool_name_without_a_prefix_is_rejected() { let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("browser", "uuid-abc")]), &[]); From 270a2ffdddccbdfb38f88ebe4550d4725bff4468 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 19:01:11 +0800 Subject: [PATCH 057/138] refactor(mcp): rename the client-declared browser surface to katashiro.* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counterpart to the katashiro-side rename. `browser` / `browser.*` collided with Playwright MCP's `browser_*` tools; the declared name and tool prefix become `katashiro` / `katashiro.*`. - mcp_proxy::browser_tools(): the five seed tools (D4 static-advertise) - browser_source::builtin_catalogs(): the catalog key, and every test that rides the default policy - config: the acp_servers doc comment naming the built-in default - docs: tunnel contract declaration + tool table, agent-setup tool list Both injection-regression tests (`unpinned_tool_on_an_allowlisted_server_is _refused`, `caching_is_never_itself_a_grant`) called `browser.exec`. Left unrenamed they still assert is_err, but for the wrong reason — an unknown server name rather than an unpinned tool on a trusted one — quietly gutting the check. They now call `katashiro.exec`. Names only; policy semantics, routing and schemas unchanged. Co-Authored-By: Claude --- Cargo.lock | 2 + crates/openab-core/src/config.rs | 2 +- crates/openab-core/src/mcp_proxy.rs | 34 ++++----- docs/browser-mcp-agent-setup.md | 2 +- docs/mcp-over-acp-tunnel-contract.md | 12 ++-- src/browser_source.rs | 104 +++++++++++++-------------- 6 files changed, 79 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7b795033..97ff55480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2618,6 +2618,7 @@ dependencies = [ "axum", "base64", "getrandom 0.4.3", + "http-body-util", "jsonschema", "libc", "oauth2", @@ -2631,6 +2632,7 @@ dependencies = [ "temp-env", "tempfile", "tokio", + "tower", "tracing", "url", "urlencoding", diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 337cbbc4e..743b6e44a 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -79,7 +79,7 @@ pub struct McpFacadeConfig { #[serde(default = "default_mcp_listen")] pub listen: String, /// Operator gate for **client-declared** `type:acp` MCP servers (ADR §6.4). - /// Absent or empty keeps the built-in default: `browser` only, pinned to + /// Absent or empty keeps the built-in default: `katashiro` only, pinned to /// its five known tools. Listing anything here replaces that default /// wholesale, so an operator can narrow the browser or admit another /// client-side service without a code change. diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c5acc8221..9a7df92e1 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -50,7 +50,7 @@ pub trait AcpMcpTunnel: Send + Sync { /// /// Both halves are needed and they are *not* interchangeable (ADR §6.1): the registry is keyed /// by the client-minted `server_id`, which the reference client mints as a fresh UUID **per - /// connection**, while a tool name carries the stable declared **name** (`browser.click`) and + /// connection**, while a tool name carries the stable declared **name** (`katashiro.click`) and /// the §6.4 trust gate is keyed by that name too. Enumerating both is what lets a capability /// source resolve a tool prefix back to a tunnel; matching a prefix against the registry key /// alone can never work. @@ -68,7 +68,7 @@ pub trait AcpMcpTunnel: Send + Sync { pub fn browser_tools() -> Vec { vec![ Tool::new( - "browser.click", + "katashiro.click", "Click the element matching a CSS selector in the active browser tab.", object(json!({ "type": "object", @@ -77,7 +77,7 @@ pub fn browser_tools() -> Vec { })), ), Tool::new( - "browser.read_dom", + "katashiro.read_dom", "Read a snapshot of the active tab's DOM (optionally scoped to a selector).", object(json!({ "type": "object", @@ -85,7 +85,7 @@ pub fn browser_tools() -> Vec { })), ), Tool::new( - "browser.navigate", + "katashiro.navigate", "Navigate the active browser tab to a URL.", object(json!({ "type": "object", @@ -94,7 +94,7 @@ pub fn browser_tools() -> Vec { })), ), Tool::new( - "browser.type", + "katashiro.type", "Type text into the element matching a CSS selector in the active tab.", object(json!({ "type": "object", @@ -106,7 +106,7 @@ pub fn browser_tools() -> Vec { })), ), Tool::new( - "browser.screenshot", + "katashiro.screenshot", "Capture a screenshot of the active browser tab.", object(json!({ "type": "object", "properties": {} })), ), @@ -955,7 +955,7 @@ mod tests { #[tokio::test] async fn call_tool_forwards_to_the_tunnel() { let h = ProxyHandler::new("acp_x".into(), Some(std::sync::Arc::new(MockTunnel))); - let result = h.forward_tool_call("browser.click", None).await.unwrap(); + let result = h.forward_tool_call("katashiro.click", None).await.unwrap(); let v = serde_json::to_value(&result).unwrap(); assert_eq!(v["content"][0]["text"], serde_json::json!("clicked")); } @@ -964,7 +964,7 @@ mod tests { async fn call_tool_reports_not_connected_without_a_tunnel() { let h = ProxyHandler::new("acp_x".into(), None); assert!( - h.forward_tool_call("browser.click", None).await.is_err(), + h.forward_tool_call("katashiro.click", None).await.is_err(), "a call with no attached browser must error (D4)" ); } @@ -1032,7 +1032,7 @@ mod tests { }); let r = dispatch_browser_mcp( "acp_win1", - &req(3, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })), + &req(3, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })), &tunnel, ) .await @@ -1044,7 +1044,7 @@ mod tests { async fn dispatch_tools_call_without_tunnel_is_not_connected() { let r = dispatch_browser_mcp( "acp_x", - &req(4, "tools/call", serde_json::json!({ "name": "browser.click" })), + &req(4, "tools/call", serde_json::json!({ "name": "katashiro.click" })), &None, ) .await @@ -1058,7 +1058,7 @@ mod tests { let tunnel = arc_tunnel(ErrTunnel); let r = dispatch_browser_mcp( "acp_x", - &req(5, "tools/call", serde_json::json!({ "name": "browser.click" })), + &req(5, "tools/call", serde_json::json!({ "name": "katashiro.click" })), &tunnel, ) .await @@ -1142,7 +1142,7 @@ mod tests { let (rd, mut wr) = stream.into_split(); let frame = serde_json::json!({ "channel_id": "acp_win1", - "request": req(9, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })) + "request": req(9, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })) }); let mut line = serde_json::to_vec(&frame).unwrap(); line.push(b'\n'); @@ -1218,11 +1218,11 @@ mod tests { assert_eq!( names, [ - "browser.click", - "browser.read_dom", - "browser.navigate", - "browser.type", - "browser.screenshot" + "katashiro.click", + "katashiro.read_dom", + "katashiro.navigate", + "katashiro.type", + "katashiro.screenshot" ] ); } diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index fce45231e..e000d128f 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -1,7 +1,7 @@ # Browser MCP — how the agent gets the `openab-browser` tools The browser MCP server exposes five DOM-semantic tools — -`browser.read_dom`, `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type` — +`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type` — served by the **browser extension** over the MCP-over-ACP tunnel (see [tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the colocated agent CLI actually **sees** those tools. diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index af82b4f43..2aabc9278 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -34,7 +34,7 @@ array with the `acp` transport type: "params": { "cwd": "...", "mcpServers": [ - { "type": "acp", "id": "", "name": "browser" } + { "type": "acp", "id": "", "name": "katashiro" } ] } } ``` @@ -96,11 +96,11 @@ even before the extension attaches). `tools/call` executes in the **active tab** | name | arguments | behaviour | |---|---|---| -| `browser.click` | `{ "selector": string }` | click the element matching the CSS selector | -| `browser.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | -| `browser.navigate` | `{ "url": string }` | navigate the active tab to the URL | -| `browser.type` | `{ "selector": string, "text": string }` | type text into the matched element | -| `browser.screenshot` | `{}` | capture a screenshot of the active tab | +| `katashiro.click` | `{ "selector": string }` | click the element matching the CSS selector | +| `katashiro.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | +| `katashiro.navigate` | `{ "url": string }` | navigate the active tab to the URL | +| `katashiro.type` | `{ "selector": string, "text": string }` | type text into the matched element | +| `katashiro.screenshot` | `{}` | capture a screenshot of the active tab | `tools/call` returns an MCP `CallToolResult` (`{ "content": [ { "type":"text", "text":... } ] }`, or an image content block for `screenshot`). On failure return an MCP tool error result. The diff --git a/src/browser_source.rs b/src/browser_source.rs index 9ca396f23..3cf8eabcc 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -15,10 +15,10 @@ //! //! **The prefix is a declared `name`, not a registry key** (ADR §6.1). A //! declaration is `{type:"acp", id, name}`; the reference client mints `id` as -//! a fresh UUID per connection while `name` (`"browser"`) is stable. Routing +//! a fresh UUID per connection while `name` (`"katashiro"`) is stable. Routing //! therefore resolves `name` → `(channel_id, id)` through the tunnel //! registry's enumeration, and forwards the **full** published tool name -//! (`browser.click`) — the prefix selects the tunnel, it is not stripped, +//! (`katashiro.click`) — the prefix selects the tunnel, it is not stripped, //! because the server's own `tools/call` expects its full name. use std::collections::{HashMap, HashSet}; @@ -63,12 +63,12 @@ struct ServerPolicy { /// source must contain no browser-specific branch"). fn builtin_catalogs() -> HashMap> { HashMap::from([( - "browser".to_string(), + "katashiro".to_string(), openab_core::mcp_proxy::browser_tools(), )]) } -/// The default policy when an operator has configured nothing: `browser` pinned +/// The default policy when an operator has configured nothing: `katashiro` pinned /// to its five known tools, every other declared name denied. fn default_policy() -> HashMap { builtin_catalogs() @@ -496,11 +496,11 @@ mod tests { assert_eq!( names, [ - "browser.click", - "browser.navigate", - "browser.read_dom", - "browser.screenshot", - "browser.type" + "katashiro.click", + "katashiro.navigate", + "katashiro.read_dom", + "katashiro.screenshot", + "katashiro.type" ], "the pinned browser set is advertised regardless of attach state" ); @@ -508,10 +508,10 @@ mod tests { #[tokio::test] async fn call_routes_the_name_prefix_to_the_declared_id_keeping_the_full_tool_name() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (_v, is_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(!is_err); @@ -524,7 +524,7 @@ mod tests { "the declared NAME must resolve to the registry id, not be used as the key" ); assert_eq!( - params["name"], "browser.click", + params["name"], "katashiro.click", "the prefix selects the tunnel and is NOT stripped — the server published this name" ); } @@ -553,11 +553,11 @@ mod tests { #[tokio::test] async fn unpinned_tool_on_an_allowlisted_server_is_refused() { // The injection Falcon flagged: the client re-declares the trusted name - // `browser` but publishes a tool outside its pinned five. - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + // `katashiro` but publishes a tool outside its pinned five. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (v, is_err) = src - .call(Some(&ctx()), "browser.exec", &Map::new()) + .call(Some(&ctx()), "katashiro.exec", &Map::new()) .await .unwrap(); assert!(is_err); @@ -576,7 +576,7 @@ mod tests { let tunnel = FakeTunnel::with(&[]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let (v, is_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(is_err); @@ -607,13 +607,13 @@ mod tests { #[test] fn operator_can_narrow_a_builtin_server_without_restating_schemas() { let src = AcpTunnelSource::with_config( - FakeTunnel::with(&[("browser", "uuid-abc")]), - &[cfg("browser", &["browser.read_dom"])], + FakeTunnel::with(&[("katashiro", "uuid-abc")]), + &[cfg("katashiro", &["katashiro.read_dom"])], ); let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); assert_eq!( names, - ["browser.read_dom"], + ["katashiro.read_dom"], "the seed narrows to the operator's list, keeping the built-in schema" ); assert!( @@ -624,10 +624,10 @@ mod tests { #[tokio::test] async fn narrowing_the_policy_actually_refuses_the_dropped_tool() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &["browser.read_dom"])]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &["katashiro.read_dom"])]); let (_v, is_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(is_err, "a tool the operator removed must be refused"); @@ -656,11 +656,11 @@ mod tests { #[tokio::test] async fn an_entry_with_no_tools_admits_the_server_but_grants_nothing() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &[])]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &[])]); assert!(src.tools(None).is_empty(), "deny-all: no tools listed"); let (_v, is_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(is_err); @@ -671,10 +671,10 @@ mod tests { async fn configuring_other_servers_drops_the_browser_default() { // Writing any entry takes over the allowlist wholesale — browser is not // silently retained alongside the operator's list. - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); let (_v, is_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(is_err, "browser is no longer allowlisted once config is written"); @@ -715,10 +715,10 @@ mod tests { async fn caching_is_never_itself_a_grant() { // The server publishes a tool the operator did not permit. Caching it // must not make it visible OR callable. - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - tunnel.set_tools_list(&["browser.read_dom", "browser.exec"]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.read_dom", "katashiro.exec"]); let src = - AcpTunnelSource::with_config(tunnel.clone(), &[cfg("browser", &["browser.read_dom"])]); + AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &["katashiro.read_dom"])]); let _ = src.tools(Some(&ctx())); settle().await; @@ -727,10 +727,10 @@ mod tests { .iter() .map(|t| t.name.to_string()) .collect(); - assert_eq!(names, ["browser.read_dom"], "the unpermitted tool stays out"); + assert_eq!(names, ["katashiro.read_dom"], "the unpermitted tool stays out"); let (_v, is_err) = src - .call(Some(&ctx()), "browser.exec", &Map::new()) + .call(Some(&ctx()), "katashiro.exec", &Map::new()) .await .unwrap(); assert!(is_err, "and it stays uncallable even though it was cached"); @@ -739,7 +739,7 @@ mod tests { #[tokio::test] async fn a_seeded_server_keeps_its_seed_until_discovery_succeeds() { // Fetch fails: the catalog must fall back to the seed, never to empty. - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); tunnel.fail_tools_list(); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); assert_eq!(src.tools(Some(&ctx())).len(), 5); @@ -753,8 +753,8 @@ mod tests { #[tokio::test] async fn discovery_is_not_repeated_while_one_is_in_flight() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-abc")]); - tunnel.set_tools_list(&["browser.read_dom"]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.read_dom"]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); for _ in 0..5 { let _ = src.tools(Some(&ctx())); @@ -771,14 +771,14 @@ mod tests { async fn cached_tools_survive_a_reconnect_that_changes_the_server_id() { // The whole point of keying the cache by NAME: the client mints a new id // on reconnect, and an id-keyed entry would be orphaned by it. - let tunnel = FakeTunnel::with(&[("browser", "uuid-old")]); - tunnel.set_tools_list(&["browser.read_dom"]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-old")]); + tunnel.set_tools_list(&["katashiro.read_dom"]); let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); let _ = src.tools(Some(&ctx())); settle().await; assert_eq!(src.tools(Some(&ctx())).len(), 1); - tunnel.reattach_as("browser", "uuid-new"); + tunnel.reattach_as("katashiro", "uuid-new"); assert_eq!( src.tools(Some(&ctx())).len(), 1, @@ -799,7 +799,7 @@ mod tests { AcpTunnelSource::with_config( tunnel, &[ - cfg("browser", &["browser.click", "browser.read_dom"]), + cfg("katashiro", &["katashiro.click", "katashiro.read_dom"]), cfg("notes", &["notes.list"]), ], ) @@ -807,10 +807,10 @@ mod tests { #[tokio::test] async fn two_declared_servers_are_both_discovered_without_collision() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); // Both servers publish a tool literally called `list` under their own // prefix — the case a naive un-prefixed catalog would collapse. - tunnel.set_tools_list(&["browser.click", "browser.read_dom", "notes.list"]); + tunnel.set_tools_list(&["katashiro.click", "katashiro.read_dom", "notes.list"]); let src = two_server_src(tunnel.clone()); let _ = src.tools(Some(&ctx())); @@ -823,7 +823,7 @@ mod tests { .collect(); assert_eq!( names, - ["browser.click", "browser.read_dom", "notes.list"], + ["katashiro.click", "katashiro.read_dom", "notes.list"], "both servers contribute, each under its own prefix" ); assert_eq!( @@ -835,10 +835,10 @@ mod tests { #[tokio::test] async fn each_server_receives_only_its_own_calls() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); let src = two_server_src(tunnel.clone()); - for tool in ["browser.click", "notes.list"] { + for tool in ["katashiro.click", "notes.list"] { let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); assert!(!is_err, "{tool} should dispatch"); } @@ -851,7 +851,7 @@ mod tests { assert_eq!( routed, [ - ("uuid-b".to_string(), "browser.click".to_string()), + ("uuid-b".to_string(), "katashiro.click".to_string()), ("uuid-n".to_string(), "notes.list".to_string()) ], "each tool reaches the tunnel of the server that declared it" @@ -860,13 +860,13 @@ mod tests { #[tokio::test] async fn one_servers_policy_does_not_leak_to_another() { - // `browser.click` is permitted, `notes.click` is not — a per-server gate, + // `katashiro.click` is permitted, `notes.click` is not — a per-server gate, // not a global tool-name gate. - let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); let src = two_server_src(tunnel.clone()); let (_v, ok_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(!ok_err); @@ -889,12 +889,12 @@ mod tests { #[tokio::test] async fn one_server_detaching_leaves_the_other_callable() { - let tunnel = FakeTunnel::with(&[("browser", "uuid-b"), ("notes", "uuid-n")]); + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); let src = two_server_src(tunnel.clone()); - tunnel.detach("browser"); + tunnel.detach("katashiro"); let (_v, browser_err) = src - .call(Some(&ctx()), "browser.click", &Map::new()) + .call(Some(&ctx()), "katashiro.click", &Map::new()) .await .unwrap(); assert!(browser_err, "the detached server reports not connected"); @@ -908,7 +908,7 @@ mod tests { #[tokio::test] async fn malformed_tool_name_without_a_prefix_is_rejected() { - let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("browser", "uuid-abc")]), &[]); + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("katashiro", "uuid-abc")]), &[]); let (v, is_err) = src.call(Some(&ctx()), "click", &Map::new()).await.unwrap(); assert!(is_err); assert!(v["content"][0]["text"] From 81d775c72695abc00a8acb84aaf6d9a555b1c979 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 20:42:38 +0800 Subject: [PATCH 058/138] docs(adr): follow the katashiro.* rename through both ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 270a2ffd renamed the client-declared surface (`browser.*` -> `katashiro.*`) in code, config and the tunnel contract, but the two ADRs still described the old tool names — including the §6.4 pinned-tool list and the runtime sequence diagrams, which readers would otherwise copy verbatim into a policy that no longer matches. The one remaining `browser.click` is inside a verbatim quote of upstream #1454's sources.rs doc comment and is left as written. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 8 ++++---- docs/adr/acp-server-websocket-reverse-mcp.md | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 3c5db76fd..e26290492 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -26,8 +26,8 @@ and **§5 usage-sequence diagram** illustrate this exact browser flow. ## 2. Browser toolset -Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (snapshot), -`browser.screenshot`, `browser.navigate`, `browser.click(selector)`, `browser.type(selector, text)`. +Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` (snapshot), +`katashiro.screenshot`, `katashiro.navigate`, `katashiro.click(selector)`, `katashiro.type(selector, text)`. - **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if @@ -82,7 +82,7 @@ Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (sn `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue pattern. -## 4. Runtime sequence (detailed) — one `browser.click` round-trip +## 4. Runtime sequence (detailed) — one `katashiro.click` round-trip The high-level phase diagram is in [reverse-MCP ADR §5](./acp-server-websocket-reverse-mcp.md); this is the message-level detail, including the **two id spaces**. @@ -100,7 +100,7 @@ Precondition: session open, extension WS attached, tools/list already discovered 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 .............................................................................. - 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 3 A --HTTP--> C tools/call name=katashiro.click args={selector:"#submit"} id=mcp#7 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 4689e8f70..3b3793e05 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -88,7 +88,7 @@ Only the client (extension) is remote; core, gateway and agent are one in-pod `o tree. The downstream hop has two delivery modes (§ browser ADR / §6.3): `proxy` (HTTP MCP, default) and `bridge` (stdio relay). -## 5. MCP usage sequence (browser.click as the example) +## 5. MCP usage sequence (katashiro.click as the example) ```mermaid sequenceDiagram @@ -114,7 +114,7 @@ sequenceDiagram Core-->>LLM: tools/list — browser tools now in the model's tool list Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) - LLM->>Core: tools/call browser.click(selector) + LLM->>Core: tools/call katashiro.click(selector) Core->>GW: tools/call (mcp/message over the SAME /acp WS) GW->>Ext: mcp/message → tools/call Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate @@ -154,7 +154,7 @@ Three pieces already generalize and are reused as-is: **`id` and `name` are different things, and routing needs both.** A declaration is `{type:"acp", id, name}`, and the two fields have very different lifetimes — the reference client mints `id` as a fresh `crypto.randomUUID()` **per connection** while `name` (`"browser"`) is stable across -reconnects. The registry key is the **`id`**; the `` segment of a tool name (`browser.click`) +reconnects. The registry key is the **`id`**; the `` segment of a tool name (`katashiro.click`) and the §6.4 allowlist are the **`name`**. Consequences, all confirmed by review 2026-07-26: - The registry stays keyed by `(channel_id, id)` — keying by `name` would let two same-name tunnels @@ -210,11 +210,11 @@ Reverse-MCP adds: acp-tunnel(channel_id, server_id) ← client diall *per* client-declared server is not possible — and not needed. `AcpTunnelSource` fans out internally: `tools(ctx)` returns the tools of **every** `type:acp` server declared by the client of that `channel_id`, and `call` routes on the **`.`** prefix to the matching tunnel. Today's - names (`browser.click`, `browser.read_dom`) already carry the server segment, so this generalizes + names (`katashiro.click`, `katashiro.read_dom`) already carry the server segment, so this generalizes with no renaming — but note the segment is the declared **`name`**, not the registry key: resolving it to a tunnel goes `name` → `(channel_id, id)` via the recorded declaration (§6.1), never straight to the key. The tool name forwarded over the tunnel stays the **full** name the server published - (`browser.click`), since that is what the server's own `tools/call` expects; the prefix selects the + (`katashiro.click`), since that is what the server's own `tools/call` expects; the prefix selects the tunnel, it is not stripped. The facade additionally publishes a `:` form to resolve shadowing against `mcp.json` servers. - **Adding another client-side MCP service is therefore declaration + policy work, not architecture @@ -298,8 +298,8 @@ client that declares the tools, so a client may declare a server *named* `browse tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated separately: -- the `browser` entry ships **pinned to its five known tools** (`browser.read_dom`, - `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type`); any other tool name it +- the `browser` entry ships **pinned to its five known tools** (`katashiro.read_dom`, + `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type`); any other tool name it declares is dropped with a logged warning, so a same-name declaration cannot inject new tools; and - every other allowlisted server starts **deny-all** and serves only the tools an operator has explicitly listed. @@ -318,7 +318,7 @@ HTTP MCP server that those CLIs read directly, so bridge mode is likely redundan **Open question (not decided).** Under the facade the LLM reaches a browser action via `search_capabilities` → `execute_capability`, one hop more per turn than today's direct -`browser.click`. Recommendation: ship on the meta-tool path (uniform policy, one audit surface) and +`katashiro.click`. Recommendation: ship on the meta-tool path (uniform policy, one audit surface) and revisit a per-provider "expose directly" option only if interactive browser latency proves it needed. ### 6.6 Status — as-built vs remaining From 17ecc471e7b3cf815acfb59e0587ee18743e7a6c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 20:56:23 +0800 Subject: [PATCH 059/138] docs: lead the browser-MCP setup guide with facade mode, demote proxy/bridge The guide still opened with the per-session loopback proxy as "how it reaches the agent" and only mentioned the facade in a trailing section, so a reader took the superseded design as current. Facade mode has been the default since bf37d25e. - Lead with a mode table (facade default; proxy/bridge as explicit opt-outs) and the `[mcp]` + `[[mcp.acp_servers]]` config needed to enable it. - Document what actually changes under the facade: one listener, a static write-once entry referencing `${OPENAB_SESSION_TOKEN}` (secret rides the process env, not a file), and discovery via search_capabilities rather than the agent's own tools/list. - Note the consequence the old text got backwards: because the entry is static, hand-configuring a variant openab doesn't auto-write is now viable. The old "gated on a stable browser-MCP endpoint" caveat is resolved, not pending. - Keep proxy's per-variant config table under an explicit "Legacy" heading. Co-Authored-By: Claude Opus 4.8 --- docs/browser-mcp-agent-setup.md | 181 +++++++++++++++++++------------- 1 file changed, 108 insertions(+), 73 deletions(-) diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index e000d128f..067e33911 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -1,17 +1,98 @@ -# Browser MCP — how the agent gets the `openab-browser` tools +# Browser MCP — how the agent gets the browser tools The browser MCP server exposes five DOM-semantic tools — -`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type` — -served by the **browser extension** over the MCP-over-ACP tunnel (see +`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, +`katashiro.type` — served by the **browser extension** over the MCP-over-ACP tunnel (see [tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the colocated agent CLI actually **sees** those tools. -## How it reaches the agent +There are three transports, selected by `OPENAB_BROWSER_MODE`. **Facade is the default and the one +to use**; `proxy` and `bridge` predate it and are kept as explicit opt-outs. -The gateway↔extension tunnel terminates in the pod at a **per-session loopback MCP proxy** -(`openab-core` `mcp_proxy::start_session_server`). To expose it to the agent, openab writes an -`openab-browser` entry into the agent CLI's **native MCP config file** — the agent connects to -`http://127.0.0.1:/mcp` and re-lists the tools. The entry looks like: +| mode | how the agent reaches the tools | status | +|---|---|---| +| unset / `facade` | through the **OAB MCP Facade**, one listener, static config entry | **default** | +| `proxy` | per-session loopback MCP server + per-session config rewrite | legacy opt-out | +| `bridge` | per-pod unix socket + `openab browser-bridge` stdio relay (Option C) | legacy opt-out | + +With no `[mcp]` section in `config.toml` the facade is not serving, and facade mode falls back to +`proxy` automatically. + +--- + +## Facade mode (default) + +Browser tools are a **session-aware in-process capability source** of the +[OAB MCP Facade](./oab-mcp-facade.md) — the same aggregation point that serves every other +provider in `mcp.json`. Enable the facade and it works: + +```toml +# config.toml +[mcp] +listen = "127.0.0.1:8848" + +# Operator gate for client-declared type:acp servers (reverse-MCP ADR §6.4). +# Omit entirely to keep the built-in default: `katashiro` only, pinned to its five tools. +[[mcp.acp_servers]] +name = "katashiro" +tools = ["katashiro.read_dom","katashiro.screenshot","katashiro.navigate","katashiro.click","katashiro.type"] +``` + +- **One listener** — the facade's. No per-session ports and no per-session config rewrites. +- **Identity** — the pool mints one token per chat session and injects it into the agent process as + `OPENAB_SESSION_TOKEN`. The MCP config entry openab writes is **static and write-once**, and + references the variable rather than embedding a secret: + + ```json + { + "mcpServers": { + "openab": { + "url": "http://127.0.0.1:8848/mcp", + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + } + } + } + ``` + + Tokens are revoked on session evict; calls route to that session's browser over the same + `channel_id` tunnel. Because the secret rides the process environment, it never lands in a file + a shared workdir could expose. +- **Discovery** — the agent does **not** see `katashiro.*` in its own `tools/list`. It sees the + facade's two meta-tools and finds browser tools through `search_capabilities`, then runs them via + `execute_capability`, alongside every other facade capability. A session-bound source is invisible + to anonymous facade clients — no token, no discovery, no execution. + +### Any MCP-capable CLI works + +Because the entry is static, **hand-configuring a variant openab does not auto-write is viable**: +point the CLI at `http://127.0.0.1:8848/mcp` with the bearer header above. This is the practical +difference from proxy mode, where the endpoint was per-session ephemeral and a hand-written entry +went stale on the next session. + +### Verify + +```sh +# facade listening? +grep "OAB MCP facade listening" + +# the static entry openab wrote +cat "$HOME/.cursor/mcp.json" # Cursor +cat "$HOME/.kiro/settings/mcp.json" # Kiro + +# does the catalog contain the browser capabilities for a session-bound client? +# -> call search_capabilities from the agent; expect provider "openab-browser" +# with exactly the pinned katashiro.* tools +``` + +Gateway log confirms the extension side: `ACP: browser tunnel registered — extension attached`. + +--- + +## Legacy: `proxy` mode + +`OPENAB_BROWSER_MODE=proxy` forces the original design: the gateway↔extension tunnel terminates at a +**per-session loopback MCP proxy** (`openab-core` `mcp_proxy::start_session_server`), and openab +writes a per-session entry into the agent CLI's native MCP config: ```json { @@ -24,74 +105,28 @@ The gateway↔extension tunnel terminates in the pod at a **per-session loopback } ``` -Both `` and `` are **minted fresh per session** and the entry is stripped on session -evict — so this is written by openab, not hand-editable to a fixed value (see *Caveat* below). - -## Per-variant MCP config location +`` and `` are **minted fresh per session** and the entry is stripped on evict, so it +cannot be hand-written to a fixed value. In this mode the agent sees `katashiro.*` directly in its +`tools/list` rather than behind the facade's meta-tools. -openab writes the same entry into whichever file the colocated CLI reads. Current state: +Config file per variant (what `start_session_server` writes): -| Variant | MCP config file (under `$workdir`, = `$HOME`) | HTTP MCP + `headers` | Auto-written by openab today | -|---|---|---|---| -| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | yes | ✅ yes | -| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | yes | ✅ yes | -| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | yes | ⛔ not yet | -| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | check version | ⛔ not yet | -| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | yes | ⛔ not yet | +| Variant | MCP config file (under `$workdir`, = `$HOME`) | Auto-written in proxy mode | +|---|---|---| +| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | ✅ | +| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | ✅ | +| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | ⛔ | +| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | ⛔ | +| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | ⛔ | -`start_session_server` currently writes **`.cursor/mcp.json` + `.kiro/settings/mcp.json`**. Adding -a variant = teach that function the CLI's config path + format (same `{url, headers}` shape for -JSON configs; Codex uses TOML and needs a small serializer). +Variants marked ⛔ are unreachable in proxy mode without teaching `start_session_server` their +config path and format — **or simply using facade mode**, where the static entry removes the problem. -## Manual / unsupported variants +## Legacy: `bridge` mode (Option C) -If a variant isn't auto-written, a user *could* add the `openab-browser` entry to that CLI's -mcp.json by hand — **but** the current proxy endpoint is per-session ephemeral (fresh port + -bearer each session), so a static hand-written entry goes stale on the next session and cannot -be used as-is. Manual configuration for arbitrary variants is therefore gated on a **stable -browser-MCP endpoint** (a fixed URL + stable auth the user configures once). That redesign is -tracked separately (see `drafts/` browser-MCP stable-endpoint design). Until then: - -- **Cursor / Kiro:** work out of the box (auto-injected). -- **Other variants:** either add the variant's writer to `start_session_server`, or wait for the - stable endpoint. - -## Verify - -Inside the agent pod, after the extension attaches a browser session: - -```sh -# the entry openab wrote for this session -cat "$HOME/.cursor/mcp.json" # Cursor -cat "$HOME/.kiro/settings/mcp.json" # Kiro - -# does the CLI see the server / tools? (CLI-specific; e.g. Kiro:) -kiro-cli mcp list -``` +`OPENAB_BROWSER_MODE=bridge` runs a per-pod unix-socket server plus an `openab browser-bridge` +stdio-MCP relay; the CLI entry is the static `{"command":"openab","args":["browser-bridge"]}` and the +relay resolves its session channel by process ancestry. Intended for stdio-only MCP clients. -Gateway log confirms the extension side: -`ACP: browser tunnel registered — extension attached`. - -## Facade mode (default when `[mcp]` is enabled) - -With the OAB MCP Facade running (`[mcp]` in `config.toml`), browser tools are -served as a **session-aware in-process capability source** of the facade -instead of per-session proxy servers: - -- **One listener** (the facade's, e.g. `127.0.0.1:8848/mcp`) — no per-session - ports, no per-session config rewrites. -- **Identity**: the pool mints one token per chat session and injects it as - `OPENAB_SESSION_TOKEN` into the agent process environment; the (static, - write-once) MCP config entry references it as - `"Authorization": "Bearer ${OPENAB_SESSION_TOKEN}"`. Tokens are revoked on - session evict; calls route to that session's browser via the same - `channel_id` tunnel contract as proxy mode. -- **Discovery**: agents find browser tools through `search_capabilities` - alongside every other facade capability, and execute them via - `execute_capability`. - -Mode selection (`OPENAB_BROWSER_MODE`): unset/`facade` → facade routing when -the facade is serving, with automatic fallback to `proxy` when it is not -(no `[mcp]` section); `proxy` → force the original per-session loopback -servers; `bridge` → Option C stdio bridge. Proxy and bridge behavior is -unchanged. +> Retiring proxy and bridge once facade mode has soaked is tracked as a follow-up — the OAB MCP +> Adapter ADR's Alternative C calls for a single agent-facing aggregation point. From 969684a870d2f78208ee2a30443440cf3cca852b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 21:16:06 +0800 Subject: [PATCH 060/138] docs(adr): fold the browser ADR into the reverse-MCP ADR as its worked example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the ACP ADR family from four documents to three (base, the original @pahud proposal, and this one). The browser design was split out earlier in this PR, but with the facade integration the two documents had grown overlapping diagrams and the browser ADR had accumulated content that no longer described anything shipping. Folded in as §7 "Worked example — browser control": the toolset (incl. why the declared name moved to `katashiro`), D1-D6 with their supersession notice, and the message-level round-trip with the two id spaces. Dropped rather than carried over: - "Execution flow (bootstrap)" — described the pre-facade per-session-proxy boot path and duplicated D2/D3/D5. - "Tasks (as executed)" — project-management history; the commits are the record. - The separate context/references sections, which duplicated §1 and §11. Net ~90 lines smaller than the two documents were. Referrers updated, including the two links in the merged OAB MCP Adapter ADR; no dangling references remain. Co-Authored-By: Claude Opus 4.8 --- docs/adr/acp-server-websocket-mcp-browser.md | 210 ------------------- docs/adr/acp-server-websocket-reverse-mcp.md | 149 +++++++++++-- docs/adr/oab-mcp-adapter.md | 4 +- 3 files changed, 136 insertions(+), 227 deletions(-) delete mode 100644 docs/adr/acp-server-websocket-mcp-browser.md diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md deleted file mode 100644 index e26290492..000000000 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ /dev/null @@ -1,210 +0,0 @@ -# ADR: Browser control via MCP-over-ACP - -- **Status:** Accepted — OpenAB side **as-built in #1447** (compiles + unit-tested; live-validated - 2026-07-20). The browser extension implements the [tunnel contract](../mcp-over-acp-tunnel-contract.md). -- **Date:** 2026-07-18 (updated 2026-07-24) -- **Author:** @brettchien -- **Related:** **Mechanism — roles, call route, generalization, and the architecture + usage-sequence - diagrams: [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md).** - [Base (as-built)](./acp-server-websocket-base.md), [tunnel contract](../mcp-over-acp-tunnel-contract.md), - [browser MCP agent setup](../browser-mcp-agent-setup.md). - ---- - -## 1. Context & scope - -The [base](./acp-server-websocket-base.md) ships a 1:1 streaming chat ACP server at `GET /acp`; a -browser side-panel extension connects as an ACP client and drives an OpenAB agent. The goal here is -for the agent's **LLM to autonomously operate the user's real, logged-in Chrome** (click, read the -DOM, navigate) — browser "computer use" against the user's own session, not a sandbox VM. - -This ADR is the **browser-specific design** and the design the **browser extension** implements. The -underlying transport — how a can't-listen WS client serves MCP over its own `/acp` WS, the roles, -the call route, and the generalization to multiple servers — is the -[Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md); its **§4 architecture diagram** -and **§5 usage-sequence diagram** illustrate this exact browser flow. - -## 2. Browser toolset - -Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` (snapshot), -`katashiro.screenshot`, `katashiro.navigate`, `katashiro.click(selector)`, `katashiro.type(selector, text)`. - -- **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` - are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if - wanted, but are not the primary surface. -- **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP - frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. - -## 3. Design decisions (D1–D6) - -> **Supersession notice (2026-07-26).** D2, D3 and D5 describe the **as-shipped** delivery path: a -> per-`acp:`-session loopback MCP proxy that openab registers in each agent's native MCP config. That -> path is superseded by the OAB MCP Facade integration in -> [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md): browser becomes one session-aware -> `CapabilitySource` behind the facade, and session identity moves to the facade's `SessionTokens` -> (broker-minted bearer per session) instead of a per-session port + self-written `mcp.json` entry. The -> decisions below remain the record of why the shipped design looks the way it does; D6's trait split -> (core defines, root implements) carries over unchanged. - -- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps - auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: - a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery - is still required for the upstream MCP tunnel. -- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` parameter - is **not** reliable: Cursor's CLI ignores ACP-passed MCP servers and only loads MCP from its **own - config** (`.cursor/mcp.json`) — see [zed#50924](https://github.com/zed-industries/zed/issues/50924). - So the proxy is registered **per-agent, in that agent's native MCP config** (Cursor → `.cursor/mcp.json`; - Kiro → `.kiro/settings/mcp.json`; others via their own file/format). The **content** (an HTTP MCP - entry: `url` + `headers`) is portable across vendors. -- **D3 — where MCP is tunnelled.** Downstream (agent ↔ core) is a **normal** in-process - Streamable-HTTP MCP server on `127.0.0.1:` (loopback + bearer, via `rmcp`); the agent connects - to it like any other MCP server. Only the **upstream** (core/gateway ↔ extension) is tunnelled — an - MV3 extension cannot listen — adopting the official - [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → - `connectionId`, then `mcp/message`), not a hand-rolled envelope. -- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on - and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless - of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"). - `notifications/tools/list_changed` on attach/detach is **designed but never implemented** — no code - emits it (grep `list_changed` → 0 hits in the gateway/core crates). **Resolved 2026-07-26: it is - dropped, not deferred.** Under the OAB MCP Facade the agent discovers capabilities by *calling* - `search_capabilities`, so discovery is pull-based and there is no cached tool list for a notification - to invalidate. The static-advertise posture is **kept** — implemented as fetch-once-per-declared-server - plus a per-`(channel_id, server_id)` cache, with backend unavailability reported as a call error. See - [reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). -- **D5 — per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per `acp:` - session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so - correlation is implicit. Server lifetime is tied to the `AcpConnection` via a `CancellationToken` - `DropGuard`, so it stops on any evict path. -- **D6 — tunnel trait in core, impl in root.** `openab-core` defines `mcp_proxy::BrowserTunnel` - (generically `AcpMcpTunnel` under [reverse-MCP ADR §6.1](./acp-server-websocket-reverse-mcp.md)); the - **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's - `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and - `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue pattern. - -## 4. Runtime sequence (detailed) — one `katashiro.click` round-trip - -The high-level phase diagram is in [reverse-MCP ADR §5](./acp-server-websocket-reverse-mcp.md); this is -the message-level detail, including the **two id spaces**. - -``` -Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) - G = gateway (/acp WS srv) E = extension (MCP server, browser) - -Transports --ACP--> downstream ACP over stdio (chat / permission) - --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) - ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) - -Precondition: session open, extension WS attached, tools/list already discovered --------------------------------------------------------------------------------- - 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 - 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 - .............................................................................. - 3 A --HTTP--> C tools/call name=katashiro.click args={selector:"#submit"} id=mcp#7 - 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 - params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 - 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod - 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks - 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod - 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 - 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 - .............................................................................. -10 A LLM consumes the tool result, keeps reasoning -11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) -12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod --------------------------------------------------------------------------------- -Two id spaces (never mixed) - - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the RFD, - mcp/message FLATTENS the inner method/params and does NOT carry an inner MCP id, so - mcp#7 never travels on the tunnel. - - acp#55 = outer ACP-envelope id correlating the whole upstream round-trip (steps 4<->8); the - response result IS the inner MCP result payload. The core proxy maps mcp#7 <-> acp#55. - - acp#1 = downstream ACP permission id; unrelated to the two above - -Only steps 5/7/12 leave the pod (all on the /acp WS). If the extension is not attached at step 5, -core returns an MCP error "browser not connected" (D4 static-advertise: fails gracefully, no crash). -``` - -## 5. Execution flow (bootstrap → discovery → runtime) - -``` -Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) - [C] = MCP client [S] = MCP server - - OPENAB POD (`openab run`) REMOTE (user browser) - ┌───────────┐ ┌──────────────────────────────────┐ ┌────────────────┐ - │ agent CLI │===│ gateway core │ │ browser ext. │ - │ (Cursor) │ │ /acp srv MCP proxy + │ │ (katashiro) │ - │ LLM [C] │-┐ │ HTTP MCP srv :PORT │<==WS==> │ [S] browser │ - └───────────┘ │ └──────────────────────────────────┘ └────────────────┘ - └── http 127.0.0.1:PORT ──┘ - -Bootstrap - B1 core starts in-process HTTP MCP server @127.0.0.1:PORT (loopback + bearer) - B2 core [per-agent adapter] writes the HTTP MCP entry into the agent's native config - (Cursor → .cursor/mcp.json) BEFORE the agent boots - B3 extension opens /acp WS <=> gateway; ACP initialize; session/new declares its browser - MCP server ("type":"acp") - B4 gateway --mcp/connect--> extension → connectionId (upstream tunnel established) - B5 agent boots, reads config → HTTP-connects to core's MCP server → MCP initialize - -Discovery (tools/list) - D1 agent --http tools/list--> core proxy - D2 core --mcp/message: tools/list--> gateway <=> extension (or served from static set, D4) - D3 extension returns [click, read_dom, navigate, type, screenshot] - D4 core returns the list --http--> agent → the LLM now sees browser tools - -Runtime → see §4 for the detailed id-paired round-trip. -``` - -## 6. Findings & ownership - -- The **agent→client REQUEST direction already existed downstream**: `openab-core`'s ACP connection - receives `session/request_permission` from the agent and auto-replies it. So the server→client - request work is *relaying* those upstream to the `/acp` client, not green-field. -- `session/new` / `session/resume` send `mcpServers: []`, but that path is **not** how the agent gets - the tools (D2 — Cursor ignores ACP-passed MCP servers). Tools reach the agent via core's proxy HTTP - MCP server registered in the agent's **native** config. -- **Ownership** — OpenAB side (`feat/acp-mcp-browser`): server→client request direction, generated - typed wire, tunnel framing + core proxy. Extension side (katashiro): MCP server role over the - outbound `/acp` WS + the DOM tools + executing in the active tab. Both: integration/e2e. - -## 7. Tasks (as executed) - -- **T0 spike** — confirm a CLI loads an HTTP MCP server from its native config, honours - `tools/list_changed`, and handles a `tools/call` error gracefully. -- **T1** agent→client REQUEST direction (relay; gateway outbound request + pending-response map; - read loop distinguishes client response vs request). -- **T2** migrate `acp_server` to generated typed wire (bidirectional surface). -- **T3** `session/request_permission` — **dropped** (D1 auto-approve). -- **T4** MCP-over-ACP tunnel framing (upstream only) — adopt the RFD (`mcp/connect` + `mcp/message` + - `mcp/disconnect`); contract doc: [tunnel contract](../mcp-over-acp-tunnel-contract.md). -- **T5** OpenAB core = MCP proxy/aggregator (in-process HTTP MCP server; per-agent config injection; - proxy as MCP client to the extension over the tunnel; tool-call routing; `rmcp` wiring). -- **T6** extension (katashiro) = MCP server + the five DOM tools, executing via `chrome.scripting`. -- **T7** integration + e2e (`scripts/acp-ws-smoke.py`) + deploy. - -## 8. As-built (2026-07-20, OpenAB side wired end-to-end) - -Realised call path (all in one `openab run` process): - -``` -agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) - ─▶ BrowserTunnel (core trait) ─▶ RootBrowserTunnel (root, src/browser_tunnel.rs) - ─▶ gateway AcpTunnelRegistry[channel_id] ─▶ TunnelHandle::mcp_message - ═mcp/message═▶ extension (only this hop leaves the pod) -``` - -Config injection is per-agent (`.cursor/mcp.json` / `.kiro/settings/mcp.json` merged at the session -workdir, loopback + bearer). The full loop (read_dom / screenshot / navigate / click / type + status -pill + reconnect on `session/resume`) was live-validated on a real deployment on 2026-07-20. A second -downstream delivery mode — `bridge` (stdio relay, `OPENAB_BROWSER_MODE`) — is also shipped; see the -[reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). - -## 9. References - -- **Mechanism:** [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md) - (roles, call route, architecture + sequence diagrams, multi-server generalization) -- [Base ADR](./acp-server-websocket-base.md) · [tunnel contract](../mcp-over-acp-tunnel-contract.md) · - [browser MCP agent setup](../browser-mcp-agent-setup.md) -- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 3b3793e05..8a3b217ec 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -7,8 +7,8 @@ - **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), [openab-agent MCP](./openab-agent-mcp.md). - **Browser-specific design + the contract the extension implements:** - [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md). + The browser extension's implementation contract: + [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.md). --- @@ -20,9 +20,8 @@ tools to a colocated agent over the outbound `/acp` WS it already holds. OpenAB proxy/aggregator in the middle; the agent is a normal in-pod MCP client. The first, driving consumer is **browser control**: a browser side-panel extension serves DOM -tools so the agent's LLM can autonomously operate the user's real, logged-in Chrome (see the -[browser ADR](./acp-server-websocket-mcp-browser.md) for that concrete design and the extension -contract). This ADR describes the general mechanism and its generalization to **multiple, +tools so the agent's LLM can autonomously operate the user's real, logged-in Chrome (see **§7** for that +concrete design and the extension contract). This ADR describes the general mechanism and its generalization to **multiple, arbitrary** client-side MCP servers (§6), using browser control as the running example. ## 2. Decision @@ -61,7 +60,7 @@ The base does only client→agent (prompt) and agent→client **notifications** Reverse MCP needs the **agent→client REQUEST** direction (request/response: the agent asks the client to do X and awaits a result). The WS is already bidirectional; `acp_server`'s dispatch loop adds the agent-initiated-request path. This is also where the wire types move from hand-rolled to -**generated** (see §8). +**generated** (see §9). ## 4. Architecture (browser control as the example) @@ -129,7 +128,7 @@ sequenceDiagram ``` The exact two-id-space bookkeeping (outer ACP-envelope id ↔ inner MCP id, flattened per the RFD) is -detailed in the [browser ADR](./acp-server-websocket-mcp-browser.md) §4. +detailed in **§7.3**. ## 6. Generalization — multiple client-side MCP servers @@ -184,7 +183,7 @@ and the §6.4 allowlist are the **`name`**. Consequences, all confirmed by revie > #1453/#1454) and no facade PR remains open, so this section builds on a settled foundation. > > The adapter ADR reaches the same conclusion from the other side: its §6.2 states that the facade -> occupies "the same architectural role that `acp-server-websocket-mcp-browser.md` assigns to OpenAB +> occupies "the same architectural role" this ADR assigns to OpenAB > core… browser tools and external capabilities **share the delivery mechanism**", and its Alternative C > rejects "a second generic inbound MCP server", i.e. **no agent-facing MCP server beyond this one > aggregation point**. That makes retiring the bespoke per-session proxy (F5) a requirement of the @@ -340,7 +339,7 @@ is via ACP `session/new` `mcpServers`, and that "if a backing CLI does not honor facade is unavailable for that CLI in the MVP **rather than falling back to editing the CLI's config files**". The as-built `write_facade_mcp_config` does write a static entry into the CLI's config — deliberately, because the browser path's D2 established that Cursor ignores ACP-passed `mcpServers` -([browser ADR](./acp-server-websocket-mcp-browser.md) D2, [zed#50924](https://github.com/zed-industries/zed/issues/50924)). +(**§7.2** D2, [zed#50924](https://github.com/zed-industries/zed/issues/50924)). Both positions are defensible; recording the conflict rather than silently picking a side. Owner of the facade contract should confirm whether config-file injection is an accepted exception for CLIs that ignore `mcpServers`, or whether Facade mode should be unavailable for them. @@ -362,7 +361,129 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. - **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, and two concurrent sessions each reaching only their own browser. -## 7. Alternatives considered +## 7. Worked example — browser control + +The driving consumer of this mechanism, and the design the **browser extension** implements. The +wire contract it codes against is [`mcp-over-acp-tunnel-contract.md`](../mcp-over-acp-tunnel-contract.md); +how the agent is wired to reach the tools is [`browser-mcp-agent-setup.md`](../browser-mcp-agent-setup.md). + +### 7.1 Toolset + +Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` (snapshot), +`katashiro.screenshot`, `katashiro.navigate`, `katashiro.click(selector)`, +`katashiro.type(selector, text)`. + +- **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` + are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if + wanted, but are not the primary surface. +- **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP + frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. +- The declared server name is `katashiro`; it was `browser` until 2026-07-26, when it was renamed + because Playwright MCP's `browser_*` tools sat beside it in the same catalog and the model could + not reliably tell "the user's real logged-in tab" from "a sandbox browser". + +### 7.2 Design decisions (D1–D6) + +> **Supersession notice.** D2, D3 and D5 record the **original** delivery path: a per-`acp:`-session +> loopback MCP proxy registered in each agent's native MCP config. That path is superseded by the +> facade integration in §6.2 — browser is now one session-aware `CapabilitySource`, and session +> identity is the facade's broker-minted `SessionTokens` rather than a per-session port plus a +> self-written `mcp.json` entry. They are kept because they explain *why* the shipped design looked +> the way it did, and because `proxy`/`bridge` remain selectable. D1, D4 and D6 carry over. + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps + auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: + a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery + is still required for the upstream MCP tunnel. (That direction was not green-field: `openab-core`'s + ACP connection already received `session/request_permission` from the agent and auto-replied it, so + the work was *relaying* those upstream rather than inventing the path.) +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` parameter + is **not** reliable: Cursor's CLI ignores ACP-passed MCP servers and only loads MCP from its **own + config** (`.cursor/mcp.json`) — see [zed#50924](https://github.com/zed-industries/zed/issues/50924). + So the server is registered **per-agent, in that agent's native MCP config** (Cursor → + `.cursor/mcp.json`; Kiro → `.kiro/settings/mcp.json`). The **content** (an HTTP MCP entry: `url` + + `headers`) is portable across vendors. Under §6.2 this became a *static* entry referencing + `${OPENAB_SESSION_TOKEN}` instead of a freshly minted per-session URL. +- **D3 — where MCP is tunnelled.** Downstream (agent ↔ core) is a **normal** in-process + Streamable-HTTP MCP server on `127.0.0.1:` (loopback + bearer, via `rmcp`); the agent connects + to it like any other MCP server. Only the **upstream** (core/gateway ↔ extension) is tunnelled — an + MV3 extension cannot listen — adopting the official + [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → + `connectionId`, then `mcp/message`), not a hand-rolled envelope. +- **D4 — lifecycle: the WS may connect *after* session start.** The in-pod MCP server is always-on and + decoupled from the extension WS, so browser tools are **static-advertised** regardless of WS state; a + `tools/call` with no extension attached returns an MCP error ("browser not connected") rather than + the capability disappearing. `notifications/tools/list_changed` was designed but never implemented, + and is **dropped, not deferred** (§6.3): facade discovery is pull-based, so no cached tool list + exists for a notification to invalidate. The static-advertise posture is kept, implemented as + fetch-once-per-declared-server plus a per-`(channel_id, server_id)` cache. +- **D5 — per-session MCP server.** The pool started one loopback Streamable-HTTP MCP proxy per `acp:` + session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so + correlation was implicit; lifetime was tied to the `AcpConnection` via a `CancellationToken` + `DropGuard`. Superseded by the single facade listener (§6.2); still the behaviour of `proxy` mode. +- **D6 — tunnel trait in core, impl in root.** `openab-core` defines the tunnel trait (`AcpMcpTunnel`, + §6.1); the **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's + `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and + `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue + pattern, and is why the `CapabilitySource` in §6.2 also lives in the root binary. + +### 7.3 Runtime detail — one `katashiro.click` round-trip, and the two id spaces + +§5 gives the phase-level view; this is the message-level detail. Transports below are `proxy`-mode +(agent ↔ core over loopback HTTP); under facade mode that hop is the facade listener instead, and the +id bookkeeping is unchanged. + +``` +Participants A = agent/LLM (Cursor, MCP client) C = core (in-pod MCP server + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) + +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) + +Precondition: session open, extension WS attached, tools already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=katashiro.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 + 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core hop (steps 3/9). Per the RFD, + mcp/message FLATTENS the inner method/params and does NOT carry an inner MCP id, so + mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id correlating the whole upstream round-trip (steps 4<->8); the + response result IS the inner MCP result payload. The proxy maps mcp#7 <-> acp#55. + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). +``` + +### 7.4 As-built history + +The OpenAB side was wired end-to-end on 2026-07-20 and live-validated on a real deployment: the full +loop (read_dom / screenshot / navigate / click / type), the side-panel status pill, and reconnect on +`session/resume`. At that point the realised path was +`agent → core per-session ProxyHandler → tunnel trait → root impl → AcpTunnelRegistry → extension`, +with per-agent config injection. `bridge` mode (stdio relay, Option C) shipped alongside. + +The facade integration in §6 replaced the per-session proxy as the default on 2026-07-25/26 and was +live-validated the same way: with `[mcp]` enabled, `search_capabilities` returns provider +`openab-browser` carrying exactly the pinned `katashiro.*` capabilities, while anonymous facade +clients see only the two meta-tools. + +## 8. Alternatives considered - **Custom `ExtRequest` per action** — rejected: not surfaced to the LLM as a tool, so the model can't call it autonomously. Fits OpenAB-driven ops only. @@ -375,7 +496,7 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. - **Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an opt-in for browser only. -## 8. Typing / dependencies +## 9. Typing / dependencies - Bidirectional tool-call / client-method messages are where hand-rolling breaks; the expanded surface uses **generated** serde-only **v1** wire types (offline `typify` codegen, avoiding the @@ -383,19 +504,17 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. - The MCP machinery (handshake, tool lifecycle, tunnel framing) needs an MCP implementation (`rmcp`, already used by `openab-agent`) plus the ACP-tunnel transport glue. -## 9. Relationship to Computer Use +## 10. Relationship to Computer Use Same category as "computer use" (LLM autonomously drives an app via a perceive→act tool loop), but generalized: (a) targets the **user's real** app/session (e.g. logged-in Chrome), not a sandbox; (b) the action surface is **client-defined MCP tools** (DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any MCP-capable agent can use it. -## 10. References +## 11. References - [Base ADR](./acp-server-websocket-base.md) · [Original proposal](./acp-server-websocket.md) · [openab-agent MCP](./openab-agent-mcp.md) -- **Browser-specific design + extension contract:** - [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md) - [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.md) · [Browser MCP agent setup](../browser-mcp-agent-setup.md) - [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) · MCP diff --git a/docs/adr/oab-mcp-adapter.md b/docs/adr/oab-mcp-adapter.md index b8cbf73b9..17669b26a 100644 --- a/docs/adr/oab-mcp-adapter.md +++ b/docs/adr/oab-mcp-adapter.md @@ -443,7 +443,7 @@ listen = "127.0.0.1:8848" # loopback only; this is the default CLI the operator points at it. This is the same architectural role that -[`acp-server-websocket-mcp-browser.md`](./acp-server-websocket-mcp-browser.md) +[`acp-server-websocket-reverse-mcp.md`](./acp-server-websocket-reverse-mcp.md) assigns to OpenAB core: an MCP proxy/aggregator between the agent and upstream capability sources, delivered to the agent via `mcpServers`. The OAB MCP Facade is that inbound component for external service capabilities; browser tools and @@ -679,7 +679,7 @@ escape hatch for operators who intentionally configure a server outside OAB. Rejected as unnecessary for this MVP. The OAB MCP facade is the intentionally scoped inbound server for the coding agent, filling the MCP proxy/aggregator -role that [`acp-server-websocket-mcp-browser.md`](./acp-server-websocket-mcp-browser.md) +role that [`acp-server-websocket-reverse-mcp.md`](./acp-server-websocket-reverse-mcp.md) already assigns to OpenAB core (§6.2). A separate generic server for arbitrary OAB workflows would require another authentication, tenancy, and authorization design and would blur the two-method capability boundary. From 1c1919ce095776e99b306a323fa4571ce11104b8 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 22:57:38 +0800 Subject: [PATCH 061/138] docs(mcp): note that mcp.audit must be named in RUST_LOG or auditing is silently off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp.audit` is a bare tracing target, not under the `openab` prefix, so the filter the deployment docs and our own fleet use — `RUST_LOG=openab=debug,openab_agent=debug` — matches none of the audit events and drops every audit line. Nothing indicates that auditing is disabled, so a deployment can believe it has a tool-call audit trail and have none. Found while verifying a live facade dispatch: the call demonstrably executed (the tool result reached the agent) with zero audit output. Co-Authored-By: Claude Opus 4.8 --- docs/oab-mcp-facade.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/oab-mcp-facade.md b/docs/oab-mcp-facade.md index 7fbee8409..c5334e14c 100644 --- a/docs/oab-mcp-facade.md +++ b/docs/oab-mcp-facade.md @@ -92,6 +92,19 @@ mcp.audit: mcp call_tool entry server="gmail" tool="search_threads" args_sha256= mcp.audit: mcp call_tool exit server="gmail" tool="search_threads" … outcome="ok" ``` +> **`mcp.audit` must be enabled in `RUST_LOG`, or these lines are silently dropped.** +> The audit events use `mcp.audit` as a *bare* tracing target — it is not under the +> `openab` prefix, so a filter like `RUST_LOG=openab=debug,openab_agent=debug` matches +> nothing for them and no audit line is ever emitted. Nothing warns you that auditing +> is effectively off. Name the target explicitly: +> +> ``` +> RUST_LOG=openab=debug,openab_agent=debug,mcp.audit=info +> ``` +> +> Any filter that raises the global default (`RUST_LOG=info`, `RUST_LOG=debug`, …) also +> emits them. + ## Client registration examples Kiro CLI (`~/.kiro/settings/mcp.json`, or the agent file — see gotcha): From 19f983127dacc6aa132f1bc400603ef8fda76a57 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sun, 26 Jul 2026 23:27:10 +0800 Subject: [PATCH 062/138] docs: warn that a leftover mode entry silently bypasses the facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each transport writer only adds its own mcp.json entry — facade writes `openab`, bridge writes `openab-browser` — and neither removes the other's. An agent that has run in both modes therefore loads both servers, exposing the same `katashiro.*` tools twice: once through the facade (policy + audit) and once straight through the old transport (neither). The model calls the direct one and the call leaves no audit trail at all, while appearing to work perfectly. Corrects the evidence in 1c1919ce: that commit attributed the missing audit lines to `RUST_LOG` alone, having assumed the observed call was a facade dispatch. It was not — a stale bridge entry was carrying it. The RUST_LOG note there is still correct and still required; it was simply not the reason auditing looked dead in that instance. Co-Authored-By: Claude Opus 4.8 --- docs/browser-mcp-agent-setup.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index 067e33911..3c5df50be 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -18,6 +18,24 @@ to use**; `proxy` and `bridge` predate it and are kept as explicit opt-outs. With no `[mcp]` section in `config.toml` the facade is not serving, and facade mode falls back to `proxy` automatically. +> ⚠️ **Switching modes does not clean up the previous mode's config entry, and a leftover entry +> silently bypasses the facade.** Each writer only adds its own entry — facade mode writes `openab`, +> bridge mode writes `openab-browser` — and neither removes the other's. An agent whose `mcp.json` +> still carries a stale `openab-browser` entry loads **both** servers, so the same +> `katashiro.*` tools are reachable twice: once through the facade (policy + audit) and once +> directly through the old transport (**neither**). The model will happily call the direct one, +> and every trace of that call is missing from the audit log while the tool works perfectly. +> +> After changing `OPENAB_BROWSER_MODE`, remove the other mode's entry from each agent's config: +> +> ```sh +> # leaving facade mode's entry only +> jq 'del(.mcpServers["openab-browser"])' "$HOME/.cursor/mcp.json" +> jq 'del(.mcpServers["openab-browser"])' "$HOME/.kiro/settings/mcp.json" +> ``` +> +> Symptom to watch for: browser tools work, but no `mcp.audit` line is ever emitted for them. + --- ## Facade mode (default) From b6fdeb4adadbbc5552eb006e1c2ba012899b11f1 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 10:20:33 +0800 Subject: [PATCH 063/138] ci: run the acp-mcp core and acp root tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo test --workspace` builds with default features, so two sets of tests this PR added never compiled in CI: openab-core's `acp-mcp`-gated mcp_proxy tests and the root package's `acp`-gated browser_source / browser_bridge tests. 67 tests between them — including the capability source's routing and trust-gate coverage — so the fixes they back could not gate a merge. Add two steps mirroring the existing acp-gateway one. The core step is filtered to `mcp_proxy::` on purpose. `acp-mcp` gates exactly one module, so the filter loses no coverage, and an unfiltered `-p openab-core` would pull in hooks::tests — the parallel flake the gateway step's comment already documents avoiding. Co-Authored-By: Claude --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7ec296af..db11c09d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,17 @@ jobs: # `-p openab-gateway` to avoid the workspace hooks::tests parallel flake. - name: cargo test (acp gateway) run: cargo test -p openab-gateway --features acp + # Same default-features gap on the core side: `mcp_proxy` is the only `acp-mcp`-gated module, + # so its tunnel-trait, tools-cache and bridge-dispatch tests never compile above either. + # Filtered to `mcp_proxy::` for the same reason the gateway step is scoped to one package — + # it keeps the flaky hooks::tests out of this job. + - name: cargo test (acp-mcp core) + run: cargo test -p openab-core --features acp-mcp mcp_proxy:: + # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and + # the stdio bridge shim (browser_bridge.rs) — are `acp`-gated, so `--workspace` above skips + # them too. This is where the source's routing and trust-gate coverage lives. + - name: cargo test (acp root) + run: cargo test --features acp - name: cargo build (unified) run: cargo build --features unified From 08a60532db94e8919b1777204c467764603c00a8 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 10:53:22 +0800 Subject: [PATCH 064/138] fix(acp): do not open tunnels after a rejected session/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read loop derived its own channel_id from the requested sessionId and used that as the condition for spawning tunnels. A well-formed `sess_` derives successfully on all four of the handler's rejection paths — missing sessionId, malformed sessionId, per-connection cap, busy — so the guard was not checking what it appeared to check. Combined with last-write-wins same-name re-attach, a refused resume could evict the live tunnel it had just been refused in favour of: a client mid-prompt (busy) or over the cap would knock out the browser control of the session that legitimately held it. handle_session_resume now returns (JsonRpcResponse, Option), handing back the channel only when the resume actually succeeded, and the loop spawns only on that Some. This mirrors handle_session_new's (resp, channel_id) and deletes the independent derivation rather than adding a second check beside it — leaving the derive in place would keep a misleading guard available for reuse. Regression coverage asserts a None channel on each of the four rejections. The over-cap and busy cases are the load-bearing ones: their sessionIds are well formed and their sessions really exist, so the old guard produced a channel and spawned. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 165 +++++++++++++----- 1 file changed, 120 insertions(+), 45 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 4f1bfcbf9..0dc73256c 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -1011,32 +1011,32 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; + let (resp, resumed_channel) = + handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); // Re-open + register the browser tunnel(s) on resume too. katashiro persists its // ACP session and RECONNECTS via session/resume (not session/new), re-declaring its // "type":"acp" browser server each time. Without this, a resumed session records the // server but never opens a tunnel, so the core proxy reports "no browser attached". - // channel_id is derived deterministically from the sessionId, matching the handler. - if let Some(registry) = state.acp_tunnel_registry.clone() { - if let Some(channel_id) = req - .params - .as_ref() - .and_then(|p| p.get("sessionId")) - .and_then(|v| v.as_str()) - .and_then(derive_channel_id) - { - spawn_acp_tunnels( - parse_acp_mcp_servers(req.params.as_ref()), - channel_id, - registry, - &out_tx, - &pending_requests, - &next_req_id, - &mut prompt_tasks, - ); - } + // ONLY on a resume the handler accepted — `resumed_channel` is that signal. A + // rejected resume must not touch tunnels: same-name re-attach is last-write-wins, + // so spawning here would let a refused request (busy, over-cap, unknown session) + // evict the very tunnel it was refused in favour of. Deriving the channel from the + // requested sessionId is NOT a sufficient guard — a well-formed id derives fine on + // every one of those rejection paths. + if let (Some(registry), Some(channel_id)) = + (state.acp_tunnel_registry.clone(), resumed_channel) + { + spawn_acp_tunnels( + parse_acp_mcp_servers(req.params.as_ref()), + channel_id, + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); } } "session/prompt" => { @@ -1302,23 +1302,30 @@ async fn handle_session_new( /// Security: `sessionId` is a server-minted, high-entropy capability; /// `derive_channel_id` requires a well-formed `sess_`, keeping the channel /// inside the `acp_` namespace and rejecting forged ids. +/// Returns the response and, **only when the resume actually succeeded**, the channel it resumed. +/// The caller uses that `Some` as its permission to open tunnels: deriving a channel id from the +/// requested `sessionId` is not sufficient, because a well-formed id derives fine even on the +/// paths that reject the resume (unknown session, per-connection cap, busy). async fn handle_session_resume( sessions: &Arc>>, id: Value, params: Option<&Value>, -) -> JsonRpcResponse { +) -> (JsonRpcResponse, Option) { let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) { Some(s) => s.to_string(), - None => return JsonRpcResponse::error(id, -32602, "Missing sessionId"), + None => return (JsonRpcResponse::error(id, -32602, "Missing sessionId"), None), }; let channel_id = match derive_channel_id(&session_id) { Some(cid) => cid, None => { - return JsonRpcResponse::error( - id, - -32602, - "Invalid sessionId: expected the form sess_", + return ( + JsonRpcResponse::error( + id, + -32602, + "Invalid sessionId: expected the form sess_", + ), + None, ); } }; @@ -1328,10 +1335,13 @@ async fn handle_session_resume( // path (a client can mint unlimited valid `sess_`). An already-present key is // exempt so re-resuming an existing session stays idempotent. if !guard.contains_key(&session_id) && guard.len() >= MAX_SESSIONS_PER_CONNECTION { - return JsonRpcResponse::error( - id, - ACP_OVERLOADED, - format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), + return ( + JsonRpcResponse::error( + id, + ACP_OVERLOADED, + format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), + ), + None, ); } // R16-F2: refuse to resume a session that currently has a prompt in flight. The insert @@ -1340,16 +1350,19 @@ async fn handle_session_resume( // state / registry entry, losing its replies. A busy session is already live on this // connection, so reject deterministically instead of stomping it. if guard.get(&session_id).is_some_and(|s| s.busy) { - return JsonRpcResponse::error( - id, - -32001, - "Session busy: a prompt is in progress; cannot resume", + return ( + JsonRpcResponse::error( + id, + -32001, + "Session busy: a prompt is in progress; cannot resume", + ), + None, ); } guard.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, // The client re-presents its mcpServers on resume; re-record the acp ones. @@ -1363,7 +1376,10 @@ async fn handle_session_resume( // ACP session/resume response is an empty object (no history replay) — the generated // ResumeSessionResponse default serializes to {} (T2.1, type-checked against acp_schema). let resp = crate::adapters::acp_schema::ResumeSessionResponse::default(); - JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) + ( + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), + Some(channel_id), + ) } /// Handle `session/cancel`. Per ACP it is a one-way NOTIFICATION: the notification form @@ -2448,18 +2464,18 @@ mod acp_handlers { // valid sess_ → {} and the session is (re)stored let sid = format!("sess_{}", Uuid::new_v4()); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await.0) .unwrap(); assert_eq!(v["result"], json!({})); assert!(sessions.lock().await.contains_key(&sid)); // malformed sessionId shape → -32602 let bad = json!({"sessionId": "not-a-session", "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing sessionId → -32602 let v = serde_json::to_value( - handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await, + handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await.0, ) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); @@ -2490,7 +2506,7 @@ mod acp_review_fixes { for _ in 0..MAX_SESSIONS_PER_CONNECTION { let sid = format!("sess_{}", Uuid::new_v4()); let p = json!({ "sessionId": sid }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "resume under cap should succeed"); ids.push(sid); @@ -2498,17 +2514,72 @@ mod acp_review_fixes { assert_eq!(sessions.lock().await.len(), MAX_SESSIONS_PER_CONNECTION); // A new distinct session over the cap is refused with ACP_OVERLOADED. let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(ACP_OVERLOADED), "over-cap resume must be refused"); // Re-resuming an already-present session is exempt (idempotent). let existing = json!({ "sessionId": ids[0] }); let v = - serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await) + serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } + /// A rejected `session/resume` must not become permission to open tunnels. + /// + /// The read loop spawns tunnels only when the handler hands back a channel. Deriving one from + /// the requested `sessionId` — which is what the loop used to do — is not a sufficient guard, + /// because a perfectly well-formed `sess_` derives fine on every rejection path. Combined + /// with last-write-wins same-name re-attach, that let a refused resume evict the live tunnel it + /// was refused in favour of, so each rejection is checked for a `None` channel here. + #[tokio::test] + async fn a_rejected_resume_yields_no_channel_to_open_tunnels_with() { + let sessions = sessions_map(); + + // 1. missing sessionId + let (resp, chan) = + handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"}))).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); + assert!(chan.is_none(), "missing sessionId must not yield a channel"); + + // 2. malformed sessionId + let bad = json!({"sessionId": "not-a-session", "cwd": "/w"}); + let (resp, chan) = handle_session_resume(&sessions, json!(2), Some(&bad)).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); + assert!(chan.is_none(), "malformed sessionId must not yield a channel"); + + // 3. over the per-connection cap — note the id IS well formed, so the old + // derive-from-params guard would have happily produced a channel here. + for _ in 0..MAX_SESSIONS_PER_CONNECTION { + let p = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); + let (_r, chan) = handle_session_resume(&sessions, json!(3), Some(&p)).await; + assert!(chan.is_some(), "resume under the cap should succeed"); + } + let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); + let (resp, chan) = handle_session_resume(&sessions, json!(4), Some(&over)).await; + assert_eq!( + serde_json::to_value(resp).unwrap()["error"]["code"], + json!(ACP_OVERLOADED) + ); + assert!(chan.is_none(), "an over-cap resume must not yield a channel"); + + // 4. busy — likewise a well-formed id on a session that really exists. + let busy_sid = format!("sess_{}", Uuid::new_v4()); + sessions.lock().await.insert( + busy_sid.clone(), + AcpSession { + channel_id: derive_channel_id(&busy_sid).unwrap(), + busy: true, + cancel: Some(Arc::new(tokio::sync::Notify::new())), + acp_mcp_servers: Vec::new(), + }, + ); + let (resp, chan) = + handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid}))).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32001)); + assert!(chan.is_none(), "a busy-rejected resume must not yield a channel"); + } + fn reply(channel_id: &str, reply_to: &str, text: &str, command: Option<&str>) -> GatewayReply { GatewayReply { schema: "openab.gateway.reply.v1".into(), @@ -2678,10 +2749,14 @@ mod acp_review_fixes { ); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = - serde_json::to_value(handle_session_resume(&sessions, json!(9), Some(¶ms)).await) - .unwrap(); + let (resp, resumed) = handle_session_resume(&sessions, json!(9), Some(¶ms)).await; + let v = serde_json::to_value(resp).unwrap(); assert_eq!(v["error"]["code"], json!(-32001), "resume while busy must be rejected"); + assert!( + resumed.is_none(), + "a rejected resume must not hand back a channel — that value is the caller's \ + permission to open tunnels, and same-name re-attach would evict the live one" + ); // The in-flight turn's state survives untouched. let g = sessions.lock().await; From a3068b3a39688da8b1e8bca970198b065c4a7bcc Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 13:03:02 +0800 Subject: [PATCH 065/138] fix(acp): do not mint a facade session token when its config write fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When write_facade_mcp_config failed, the code warned and carried on: it minted OPENAB_SESSION_TOKEN and spawned the agent anyway. The agent then had no `openab` entry, so it could not reach the facade at all, while a live credential stayed registered for that channel until eviction — and the only trace was a warning. Mint only when the write succeeded. The session still starts; it simply has no browser capabilities, which is the honest description of what happened. The failure is logged at ERROR, and with no token there is no revoke guard to arm. Two alternatives were considered and rejected. Aborting session setup lets a config-write failure kill an otherwise working agent, and browser control is one capability among many. Falling back to a direct transport is worse than it looks: proxy and bridge write into the same workdir, so a failure there is likely to repeat, and silently switching to a direct entry re-creates the facade-bypass this PR's other fix exists to remove. Extracted setup_facade_session so the invariant is testable — the pool's tests are pure-function units and driving the real path spawns an agent. A counting registrar proves mint is never called when the write fails, forced by making /.cursor a file so create_dir_all errors. Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 136 +++++++++++++++++++++++++---- 1 file changed, 119 insertions(+), 17 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 2d8d24e8d..15a75628f 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -110,6 +110,35 @@ fn better_candidate(current_oldest: Option, candidate_last_active: Inst } } +/// Prepare facade browser capabilities for one session: write the agent's facade MCP entry, and +/// mint its session token **only if that write succeeded**. +/// +/// The token is useless without the config. The entry is what points the agent at the facade and +/// carries `Authorization: Bearer ${OPENAB_SESSION_TOKEN}`; with no entry the agent never reaches +/// the facade and never presents the token. Minting regardless would register a live credential +/// for a session that cannot use it and leave it valid until eviction, while the failure showed up +/// only as a warning. Returning `None` keeps the session running without browser capabilities, +/// which is the honest description of what actually happened. +#[cfg(feature = "acp-mcp")] +async fn setup_facade_session( + workdir: &str, + facade_url: &str, + channel_id: &str, + registrar: &Arc, +) -> Option { + match crate::mcp_proxy::write_facade_mcp_config(workdir, facade_url).await { + Ok(()) => Some(registrar.mint(channel_id)), + Err(e) => { + tracing::error!( + workdir, error = %e, + "facade mcp config write failed — starting this session WITHOUT browser \ + capabilities and not minting a session token that could never be presented" + ); + None + } + } +} + /// Remove every non-`active` pool entry for `key`, reset-style. /// /// Hung eviction must NOT leave the session resumable: the old streaming task @@ -412,25 +441,36 @@ impl SessionPool { // unwraps guarded by the fallback arm above let registrar = self.session_registrar.as_ref().unwrap(); let facade_url = self.facade_url.as_ref().unwrap(); - if let Err(e) = - crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url) - .await + match setup_facade_session( + &effective_workdir, + facade_url, + channel_id, + registrar, + ) + .await { - warn!(thread_id, error = %e, "failed to write facade mcp config"); + Some(token) => { + session_token = Some(token); + info!( + thread_id, + "session token minted for facade browser capabilities" + ); + // Revoke on evict/replace: piggyback the same DropGuard + // plumbing proxy mode uses for its server teardown. + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + let chan = channel_id.to_string(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&chan); + }); + Some(ct.drop_guard()) + } + // No config, so no token and no revoke guard to arm. The session + // still starts — it simply has no browser capabilities. + None => None, } - session_token = Some(registrar.mint(channel_id)); - info!(thread_id, "session token minted for facade browser capabilities"); - // Revoke on evict/replace: piggyback the same DropGuard - // plumbing proxy mode uses for its server teardown. - let ct = tokio_util::sync::CancellationToken::new(); - let child = ct.child_token(); - let registrar = registrar.clone(); - let chan = channel_id.to_string(); - tokio::spawn(async move { - child.cancelled().await; - registrar.revoke(&chan); - }); - Some(ct.drop_guard()) } // Bridge mode (Option C): the agent's static mcp.json points at `openab // browser-bridge`, which dials the pod-wide socket server (started at boot). @@ -953,6 +993,68 @@ mod tests { use tokio::sync::Mutex; use tokio::time::Instant; + /// Registrar double that records every mint, so a test can assert one never happened. + #[cfg(feature = "acp-mcp")] + #[derive(Default)] + struct CountingRegistrar { + minted: std::sync::Mutex>, + } + + #[cfg(feature = "acp-mcp")] + impl crate::mcp_proxy::SessionTokenRegistrar for CountingRegistrar { + fn mint(&self, channel_id: &str) -> String { + self.minted.lock().unwrap().push(channel_id.to_string()); + "token-xyz".to_string() + } + fn revoke(&self, _channel_id: &str) {} + } + + /// A failed facade config write must not mint a token. The agent has no `openab` entry, so it + /// can never present one; minting anyway would leave a live credential registered for a + /// session that cannot use it until eviction. + #[cfg(feature = "acp-mcp")] + #[tokio::test] + async fn no_token_is_minted_when_the_facade_config_write_fails() { + let dir = tempfile::tempdir().unwrap(); + // Make `/.cursor` a FILE, so create_dir_all inside the writer fails. + std::fs::write(dir.path().join(".cursor"), b"not a directory").unwrap(); + + let counting = Arc::new(CountingRegistrar::default()); + let registrar: Arc = counting.clone(); + let token = super::setup_facade_session( + dir.path().to_str().unwrap(), + "http://127.0.0.1:8848/mcp", + "acp_x", + ®istrar, + ) + .await; + + assert!(token.is_none(), "a failed config write must yield no token"); + assert!( + counting.minted.lock().unwrap().is_empty(), + "the registrar must never be asked to mint when the config could not be written" + ); + } + + /// The happy path still mints exactly once, for the right channel. + #[cfg(feature = "acp-mcp")] + #[tokio::test] + async fn a_successful_facade_config_write_mints_one_token() { + let dir = tempfile::tempdir().unwrap(); + let counting = Arc::new(CountingRegistrar::default()); + let registrar: Arc = counting.clone(); + let token = super::setup_facade_session( + dir.path().to_str().unwrap(), + "http://127.0.0.1:8848/mcp", + "acp_x", + ®istrar, + ) + .await; + + assert_eq!(token.as_deref(), Some("token-xyz")); + assert_eq!(counting.minted.lock().unwrap().as_slice(), ["acp_x"]); + } + #[test] fn remove_if_same_handle_removes_matching_entry() { let expected = Arc::new(Mutex::new(1_u8)); From 16df6de279776ab9435a78e079f440ce736871c8 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 13:20:27 +0800 Subject: [PATCH 066/138] fix(mcp): retire the direct browser transport when facade mode sets up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The facade writer added its `openab` entry but left any previous `openab-browser` entry in place, so both loaded and the model could take the direct path — reaching the browser without passing through facade policy and audit. Observed live 2026-07-26 and until now only documented. Remove the stale entry from all three places the direct transports wrote: .cursor/mcp.json, .kiro/settings/mcp.json, and the kiro per-agent files. For the agent files the `@openab-browser` grant goes too — `allowedTools` is default deny, so a leftover grant is what keeps the bypass reachable even once the server entry is gone; removing one without the other is a half fix. Ownership is decided by exact shape, never by the key. `openab-browser` is not proof we wrote it, and an operator may have configured their own server there. Only the two shapes we ever wrote are removable: the bridge entry {command:"openab",args:["browser-bridge"]}, and the per-session proxy entry — a loopback http://127.0.0.1:/mcp url carrying a bearer header. A remote url, a bearer-less loopback, a different command or an empty port are treated as operator-owned and preserved verbatim. The matcher deliberately errs toward under-removal: a leftover entry only preserves the bypass, while deleting an operator's configuration destroys work. Tests cover shape recognition against five foreign shapes, removal alongside untouched user servers and unrelated top-level keys, a foreign `openab-browser` preserved verbatim, and the agent-file case where @github survives while @openab-browser goes. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 211 +++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 9a7df92e1..4d9639a48 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -495,6 +495,55 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { /// agent process (config-var expansion is exactly how deployed agents already reference /// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token /// dies with the agent process and its registry entry. +/// True when an `openab-browser` entry is demonstrably one **we** wrote for a direct transport, +/// and so is safe to drop when facade mode takes over. +/// +/// Matching is by exact known shape, never by name alone: the key is not proof of ownership, and +/// an operator may have configured their own server under it. Only two shapes were ever written: +/// +/// - the bridge entry, byte-identical every session: `{"command":"openab","args":["browser-bridge"]}`; +/// - the per-session proxy entry: a loopback `http://127.0.0.1:/mcp` url with a bearer header. +/// +/// Anything else — a remote url, a different command, no bearer — is left alone. Leaving a stale +/// entry only preserves the bypass; deleting someone's config destroys work, so this errs at the +/// former. +fn is_openab_direct_browser_entry(entry: &Value) -> bool { + // Bridge shape. + if entry.get("command").and_then(Value::as_str) == Some("openab") + && entry.get("args") == Some(&json!(["browser-bridge"])) + { + return true; + } + // Per-session proxy shape: loopback url + a bearer we minted. + let is_loopback_mcp = entry + .get("url") + .and_then(Value::as_str) + .and_then(|u| u.strip_prefix("http://127.0.0.1:")) + .and_then(|rest| rest.strip_suffix("/mcp")) + .is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit())); + let has_bearer = entry + .pointer("/headers/Authorization") + .and_then(Value::as_str) + .is_some_and(|a| a.starts_with("Bearer ")); + is_loopback_mcp && has_bearer +} + +/// Drop a stale direct-transport `openab-browser` entry from an `mcpServers` map, returning +/// whether anything was removed. Both entries otherwise load side by side and the model may pick +/// the direct one, bypassing the facade's policy and audit. +fn strip_direct_browser_entry(cfg: &mut Value) -> bool { + let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return false; + }; + match servers.get("openab-browser") { + Some(entry) if is_openab_direct_browser_entry(entry) => { + servers.remove("openab-browser"); + true + } + _ => false, + } +} + pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { let entry = json!({ "url": facade_url, @@ -520,8 +569,15 @@ pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io } // Publish under "openab" (the facade), not "openab-browser": the agent // reaches ALL facade capabilities through this one entry. + let mut changed = false; if cfg["mcpServers"]["openab"] != entry { cfg["mcpServers"]["openab"] = entry.clone(); + changed = true; + } + // Retire the direct transport we previously wrote here. Leaving it means both entries + // load and the model can reach the browser without passing through facade policy/audit. + changed |= strip_direct_browser_entry(&mut cfg); + if changed { tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; } } @@ -560,6 +616,15 @@ async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::i cfg["mcpServers"]["openab"] = entry.clone(); changed = true; } + // Same retirement as the settings files, plus the agent-file allowlist grant that made + // the direct server callable — `allowedTools` is default-deny, so a leftover + // `@openab-browser` is what keeps the bypass reachable here. + if strip_direct_browser_entry(&mut cfg) { + changed = true; + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + allowed.retain(|v| v.as_str() != Some("@openab-browser")); + } + } if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { if !allowed.iter().any(|v| v.as_str() == Some("@openab")) { allowed.push(json!("@openab")); @@ -772,10 +837,152 @@ async fn handle_browser_conn( mod tests { use super::{ browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, - merge_kiro_agent_configs, parse_browser_mode, serve_browser_socket, spawn_mcp_server, - start_session_server, write_bridge_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, + is_openab_direct_browser_entry, merge_kiro_agent_configs, parse_browser_mode, + serve_browser_socket, spawn_mcp_server, start_session_server, write_bridge_mcp_config, + write_facade_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; + // --- F4: facade setup retires the direct transport it replaces --- + + /// The bridge and per-session-proxy entries we wrote are recognised; anything else under the + /// same key is not ours to delete. + #[test] + fn only_our_own_direct_browser_shapes_are_recognised() { + let bridge = serde_json::json!({ "command": "openab", "args": ["browser-bridge"] }); + let proxy = serde_json::json!({ + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer abc" } + }); + assert!(is_openab_direct_browser_entry(&bridge)); + assert!(is_openab_direct_browser_entry(&proxy)); + + // Not ours: a remote url, a bearer-less loopback, a different command, an empty port. + for foreign in [ + serde_json::json!({ "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer x" } }), + serde_json::json!({ "url": "http://127.0.0.1:45678/mcp" }), + serde_json::json!({ "command": "openab", "args": ["something-else"] }), + serde_json::json!({ "command": "my-browser-tool", "args": ["browser-bridge"] }), + serde_json::json!({ "url": "http://127.0.0.1:/mcp", "headers": { "Authorization": "Bearer x" } }), + ] { + assert!( + !is_openab_direct_browser_entry(&foreign), + "must not claim ownership of {foreign}" + ); + } + } + + #[tokio::test] + async fn facade_setup_removes_the_stale_direct_entry_but_keeps_user_servers() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { + // ours, the bridge transport facade mode replaces + "openab-browser": { "command": "openab", "args": ["browser-bridge"] }, + // the operator's own servers must survive untouched + "github": { "url": "http://ghpool:8080/mcp" }, + "notes": { "command": "notes-mcp", "args": ["--stdio"] } + }, + "someUnrelatedKey": 42 + })) + .unwrap(), + ) + .unwrap(); + + write_facade_mcp_config(dir.path().to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + let servers = cfg["mcpServers"].as_object().unwrap(); + assert!( + !servers.contains_key("openab-browser"), + "the direct transport must not load alongside the facade — that is the bypass" + ); + assert_eq!(servers["openab"]["url"], "http://127.0.0.1:8848/mcp"); + assert_eq!(servers["github"]["url"], "http://ghpool:8080/mcp"); + assert_eq!(servers["notes"]["command"], "notes-mcp"); + assert_eq!(cfg["someUnrelatedKey"], 42, "unrelated config must survive"); + } + + #[tokio::test] + async fn facade_setup_leaves_a_foreign_openab_browser_entry_alone() { + // Same key, but a shape we never wrote: it belongs to the operator, so removing it would + // destroy their configuration to fix a bypass that entry does not create. + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + let foreign = serde_json::json!({ "url": "https://my-own-browser.example/mcp" }); + std::fs::write( + cursor.join("mcp.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": foreign } + })) + .unwrap(), + ) + .unwrap(); + + write_facade_mcp_config(dir.path().to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert_eq!( + cfg["mcpServers"]["openab-browser"], foreign, + "an entry we did not write must be preserved verbatim" + ); + } + + #[tokio::test] + async fn facade_setup_retires_the_direct_entry_and_its_grant_in_kiro_agent_files() { + let wd = tmp_workdir("f4-agent").await; + let agent = wd.join(".kiro/agents/terra.json"); + tokio::fs::write( + &agent, + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "terra", + "mcpServers": { + "openab-browser": { + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer tok" } + }, + "github": { "url": "http://ghpool:8080/mcp" } + }, + "allowedTools": ["@builtin", "@openab-browser", "@github"] + })) + .unwrap(), + ) + .await + .unwrap(); + + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert!(!cfg["mcpServers"].as_object().unwrap().contains_key("openab-browser")); + assert_eq!(cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp"); + let allowed: Vec<&str> = cfg["allowedTools"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!( + !allowed.contains(&"@openab-browser"), + "allowedTools is default-deny — a leftover grant is what keeps the bypass reachable" + ); + assert!(allowed.contains(&"@openab"), "the facade must be granted"); + assert!(allowed.contains(&"@github"), "unrelated grants must survive"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + /// Unique throwaway workdir with a `.kiro/agents/` tree. async fn tmp_workdir(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( From 28cad689c1c542d264b70b70ae69cf9a131a27ea Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 15:23:45 +0800 Subject: [PATCH 067/138] fix(acp): keep the 8 MiB frame allowance to tunnel results only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising MAX_FRAME_BYTES to 8 MiB for browser tool results also raised it for every other inbound frame, so one connection could hold MAX_INFLIGHT_PROMPTS (32) x 8 MiB of prompt text — the ~256 MiB worst case the review flagged. Bound the raise to the traffic it was for. Browser results arrive as client RESPONSES to our server-initiated `mcp/message` requests — id present, no `method` — so responses keep the 8 MiB ceiling, while every method-bearing frame (session/prompt included) is held to MAX_NON_TUNNEL_FRAME_BYTES, the pre-existing 1 MiB. That is what removes the exposure: the worst case came from prompts, which are method-bearing. Note this is deliberately not a `method == "mcp/message"` test, even though that is the obvious reading. `mcp/message` is only ever sent outbound; there is no inbound frame carrying that method, so matching on it would cap the screenshot responses at 1 MiB and break the case the raise exists for. The 8 MiB check stays pre-parse and still closes the connection: an oversized frame cannot be parsed back to its id, so no response can be fabricated for it. The per-kind check runs after parsing, where the id is available — oversized requests get ACP_OVERLOADED with their id, and oversized notifications are dropped without a reply, since answering a notification is a protocol violation. The unbounded outbound channel is untouched and remains a documented follow-up inherited from #1418 F6; this change bounds only what this PR added. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 0dc73256c..e54083e71 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -37,6 +37,23 @@ const ACP_PROTOCOL_VERSION: u32 = 1; const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB +/// The method of a parsed inbound frame when it exceeds the limit for its kind, else `None`. +/// +/// Only client **responses** — `id` present, no `method` — carry tunnel results and may use the +/// full [`MAX_FRAME_BYTES`]. Everything method-bearing is a client request or notification and is +/// held to [`MAX_NON_TUNNEL_FRAME_BYTES`]. +fn oversized_for_its_kind(len: usize, raw: &Value) -> Option<&str> { + let method = raw.get("method").and_then(Value::as_str)?; + (len > MAX_NON_TUNNEL_FRAME_BYTES).then_some(method) +} + +/// Ceiling for every inbound frame that is **not** a tunnel result (review F2). +/// +/// The 8 MiB allowance above exists for browser tool results, and those arrive as client +/// *responses* to our server-initiated `mcp/message` requests — `id` present, no `method`. +/// Nothing else needs it: capping method-bearing frames back at the pre-existing 1 MiB stops the +/// raise from being usable to hold `MAX_INFLIGHT_PROMPTS` × 8 MiB of prompt text per connection. +const MAX_NON_TUNNEL_FRAME_BYTES: usize = 1 << 20; // 1 MiB /// JSON-RPC implementation-defined server error for a hit resource cap. const ACP_OVERLOADED: i32 = -32000; @@ -893,6 +910,37 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } + // Per-kind size cap (review F2). The 8 MiB check above is the transport ceiling, and only + // tunnel results legitimately reach it — those are client RESPONSES (no `method`), handled + // just below. Anything carrying a `method` is a client request or notification, so hold it + // to the pre-existing 1 MiB: otherwise the browser-result allowance doubles as a way to + // park MAX_INFLIGHT_PROMPTS × 8 MiB of prompt text on one connection. + if let Some(method) = oversized_for_its_kind(text.len(), &raw) { + { + warn!( + connection = %connection_id, + method, + bytes = text.len(), + max = MAX_NON_TUNNEL_FRAME_BYTES, + "ACP frame too large for its method; rejecting" + ); + // A notification MUST NOT be answered; drop it and keep the connection. + if !is_notification { + let id = raw.get("id").cloned().unwrap_or(Value::Null); + let err_resp = JsonRpcResponse::error( + id, + ACP_OVERLOADED, + format!( + "Frame too large: {} exceeds the {MAX_NON_TUNNEL_FRAME_BYTES}-byte limit for `{method}`", + text.len() + ), + ); + let _ = out_tx.send(serde_json::to_string(&err_resp).unwrap()); + } + continue; + } + } + // A client *response* to a server-initiated request (T1): id present, no `method`, // carries `result`/`error`. Route it to the waiting `send_request` and stop — it is // neither a client request nor a notification. Gated on `!is_notification` (a @@ -2525,6 +2573,45 @@ mod acp_review_fixes { assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } + // --- F2: the 8 MiB allowance is for tunnel results only --- + + /// The raise to 8 MiB was for browser tool results, which arrive as client RESPONSES + /// (`id`, no `method`). Frames carrying a method — `session/prompt` above all — stay at the + /// pre-existing 1 MiB, so the allowance cannot be used to park + /// MAX_INFLIGHT_PROMPTS × 8 MiB of prompt text on one connection. + #[test] + fn only_tunnel_results_may_use_the_larger_frame_allowance() { + let over_1mib = super::MAX_NON_TUNNEL_FRAME_BYTES + 1; + let big_result = 8 * 1024 * 1024; // within MAX_FRAME_BYTES + + // A client response (no `method`) may be large — this is the screenshot path. + let response = json!({ "jsonrpc": "2.0", "id": 7, "result": { "content": [] } }); + assert!( + super::oversized_for_its_kind(big_result, &response).is_none(), + "an 8 MiB tunnel result must still be accepted — that is what the raise is for" + ); + + // A prompt of the same size must not be. + let prompt = json!({ "jsonrpc": "2.0", "id": 1, "method": "session/prompt" }); + assert_eq!( + super::oversized_for_its_kind(over_1mib, &prompt), + Some("session/prompt"), + "a >1 MiB prompt must be rejected" + ); + assert!( + super::oversized_for_its_kind(super::MAX_NON_TUNNEL_FRAME_BYTES, &prompt).is_none(), + "exactly 1 MiB is still allowed — the bound is inclusive" + ); + + // Notifications are method-bearing too, so they are bounded as well (the caller must + // drop them silently rather than answer). + let notification = json!({ "jsonrpc": "2.0", "method": "session/cancel" }); + assert_eq!( + super::oversized_for_its_kind(over_1mib, ¬ification), + Some("session/cancel") + ); + } + /// A rejected `session/resume` must not become permission to open tunnels. /// /// The read loop spawns tunnels only when the handler hands back a channel. Deriving one from From 63fb63a7bfff99295791b1fe160d548a17a543e1 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 16:09:27 +0800 Subject: [PATCH 068/138] fix(mcp): authenticate the bridge connection, not the frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unix socket authenticated nothing. 0600 proves the peer shares our uid, but says nothing about which session it belongs to, and the channel_id in each frame is a value the caller picks — so any same-uid process could connect and drive another live session's browser. Derive the channel server-side instead. The shim already walked its own /proc ancestry for OPENAB_BROWSER_CHANNEL; that logic was right but ran on the wrong side, because a caller can always lie about its own answer. The server now takes the peer pid from SO_PEERCRED and runs the same walk itself, so the peer cannot choose. A connection whose channel cannot be established is refused outright rather than given a default session. Frames may still carry channel_id — the shim sends it — but it is only ever compared against the authenticated value, never used to select a session; a mismatch is dropped and logged. The ancestry helpers move from the shim into openab-core, next to the server that now authenticates with them, so there is one implementation rather than two that can drift. The shim delegates to it and its frame value is advisory. serve_browser_socket keeps its signature; serve_browser_socket_with_resolver takes an injectable peer->channel mapping because a test binary's ancestry carries no channel, so the real resolver would refuse every test connection. The regression test proves a frame naming another session gets no reply at all while the next legitimate frame is answered. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 183 ++++++++++++++++++++++++++-- src/browser_bridge.rs | 57 ++------- 2 files changed, 183 insertions(+), 57 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 4d9639a48..c81cd051d 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -21,6 +21,7 @@ use rmcp::transport::streamable_http_server::{ use rmcp::ServerHandler; use axum::response::IntoResponse; use serde_json::{json, Value}; +use tracing::warn; use std::sync::Arc; /// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT @@ -762,10 +763,68 @@ pub(crate) async fn dispatch_browser_mcp( /// Serve the per-pod browser-bridge socket at `path`, routing each connection's framed requests /// via [`dispatch_browser_mcp`]. Binds a fresh 0600 unix socket (same-uid only), spawns the accept /// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. +/// Extract `OPENAB_BROWSER_CHANNEL` from a null-separated `/proc//environ` blob. +fn parse_channel_from_environ(bytes: &[u8]) -> Option { + for kv in bytes.split(|b| *b == 0) { + if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { + let v = String::from_utf8_lossy(rest).into_owned(); + if !v.is_empty() { + return Some(v); + } + } + } + None +} + +/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may +/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". +fn parse_ppid_from_stat(stat: &str) -> Option { + let after = &stat[stat.rfind(')')? + 1..]; + after.split_whitespace().nth(1)?.parse().ok() +} + +/// Walk up from `start_pid` and return the first ancestor's `OPENAB_BROWSER_CHANNEL`. +/// +/// This is the **authoritative** channel for a bridge connection: the agent process openab +/// spawned carries the variable, and the shim it spawns is always a descendant. Deriving it from +/// a kernel-supplied peer pid means a caller cannot choose which session it drives — unlike the +/// `channel_id` in the frame, which is merely a claim (review R2). +pub fn channel_from_process_ancestry(start_pid: u32) -> Option { + let mut pid = start_pid; + for _ in 0..16 { + if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { + if let Some(c) = parse_channel_from_environ(&bytes) { + return Some(c); + } + } + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + match parse_ppid_from_stat(&stat) { + Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) + _ => break, + } + } + None +} + +/// Maps a connecting peer's pid to the channel it is allowed to drive. Injectable so the socket +/// server can be tested without a real agent process tree above the test binary. +pub type ChannelResolver = Arc Option + Send + Sync>; + pub async fn serve_browser_socket( path: std::path::PathBuf, tunnel: Option>, ct: tokio_util::sync::CancellationToken, +) -> std::io::Result<()> { + let resolver: ChannelResolver = Arc::new(channel_from_process_ancestry); + serve_browser_socket_with_resolver(path, tunnel, resolver, ct).await +} + +/// [`serve_browser_socket`] with an injectable peer→channel resolver (tests). +pub async fn serve_browser_socket_with_resolver( + path: std::path::PathBuf, + tunnel: Option>, + resolver: ChannelResolver, + ct: tokio_util::sync::CancellationToken, ) -> std::io::Result<()> { let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run let listener = tokio::net::UnixListener::bind(&path)?; @@ -781,7 +840,11 @@ pub async fn serve_browser_socket( accepted = listener.accept() => { match accepted { Ok((stream, _)) => { - tokio::spawn(handle_browser_conn(stream, tunnel.clone())); + tokio::spawn(handle_browser_conn( + stream, + tunnel.clone(), + resolver.clone(), + )); } Err(_) => continue, } @@ -806,8 +869,25 @@ pub async fn serve_browser_socket_forever( async fn handle_browser_conn( stream: tokio::net::UnixStream, tunnel: Option>, + resolver: ChannelResolver, ) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + // Authenticate the CONNECTION, not the frame (review R2). 0600 on the socket only proves the + // peer shares our uid; it does not say which session the peer belongs to. The `channel_id` in + // a frame is a claim the sender chooses, so trusting it let any same-uid process drive another + // live session's browser. Derive the channel from the kernel-supplied peer pid instead, and + // refuse the connection outright when it cannot be established — an unauthenticated peer gets + // no session at all rather than a default one. + let peer_pid = stream.peer_cred().ok().and_then(|c| c.pid()); + let Some(authenticated_channel) = peer_pid.and_then(|pid| resolver(pid as u32)) else { + warn!( + peer_pid = ?peer_pid, + "browser bridge: refusing a connection whose browser channel could not be established" + ); + return; + }; + let (read_half, mut write_half) = stream.into_split(); let mut lines = BufReader::new(read_half).lines(); while let Ok(Some(line)) = lines.next_line().await { @@ -817,11 +897,22 @@ async fn handle_browser_conn( let Ok(frame) = serde_json::from_str::(&line) else { continue; // skip a malformed frame rather than drop the connection }; - let channel_id = frame.get("channel_id").and_then(Value::as_str).unwrap_or(""); + // A frame may still carry channel_id (the shim sends it), but it is only ever checked + // against the authenticated value — never used to select a session. + if let Some(claimed) = frame.get("channel_id").and_then(Value::as_str) { + if !claimed.is_empty() && claimed != authenticated_channel { + warn!( + peer_pid = ?peer_pid, + claimed, + "browser bridge: frame claimed a channel this peer does not own; dropping" + ); + continue; + } + } let Some(request) = frame.get("request") else { continue; }; - if let Some(resp) = dispatch_browser_mcp(channel_id, request, &tunnel).await { + if let Some(resp) = dispatch_browser_mcp(&authenticated_channel, request, &tunnel).await { let Ok(mut buf) = serde_json::to_vec(&resp) else { continue; }; @@ -838,7 +929,8 @@ mod tests { use super::{ browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, is_openab_direct_browser_entry, merge_kiro_agent_configs, parse_browser_mode, - serve_browser_socket, spawn_mcp_server, start_session_server, write_bridge_mcp_config, + serve_browser_socket_with_resolver, spawn_mcp_server, start_session_server, + write_bridge_mcp_config, write_facade_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; @@ -1337,9 +1429,16 @@ mod tests { result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), }); let ct = tokio_util::sync::CancellationToken::new(); - serve_browser_socket(sock.clone(), tunnel, ct.clone()) - .await - .unwrap(); + // The peer here is the test binary, whose ancestry carries no channel, so inject the + // resolver the way a real deployment's process tree would answer. + serve_browser_socket_with_resolver( + sock.clone(), + tunnel, + std::sync::Arc::new(|_pid| Some("acp_win1".to_string())), + ct.clone(), + ) + .await + .unwrap(); let stream = loop { match tokio::net::UnixStream::connect(&sock).await { Ok(s) => break s, @@ -1362,6 +1461,76 @@ mod tests { ct.cancel(); } + // --- R2: the socket authenticates the connection, not the frame --- + + #[test] + fn ancestry_parsers_read_proc_shapes() { + assert_eq!(super::parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); + assert_eq!(super::parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); + // ')' inside comm — split after the LAST ')' + assert_eq!(super::parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); + assert_eq!(super::parse_ppid_from_stat("nonsense"), None); + + let env = b"HOME=/h\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/x\0"; + assert_eq!( + super::parse_channel_from_environ(env).as_deref(), + Some("acp_xyz") + ); + assert_eq!(super::parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); + assert_eq!(super::parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); + } + + /// A same-uid peer must not be able to drive a session it does not own by naming it. The + /// socket's 0600 mode only proves same-uid; the channel comes from the peer's process + /// ancestry, and a frame claiming a different one is dropped rather than honoured. + #[tokio::test] + async fn a_frame_cannot_claim_a_channel_the_peer_does_not_own() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("browser.sock"); + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let ct = tokio_util::sync::CancellationToken::new(); + // This peer owns acp_win1 (RecordTunnel asserts it only ever sees that channel). + serve_browser_socket_with_resolver( + sock.clone(), + tunnel, + std::sync::Arc::new(|_pid| Some("acp_win1".to_string())), + ct.clone(), + ) + .await + .unwrap(); + let stream = loop { + match tokio::net::UnixStream::connect(&sock).await { + Ok(s) => break s, + Err(_) => tokio::task::yield_now().await, + } + }; + let (rd, mut wr) = stream.into_split(); + + // Claim someone else's session first, then a legitimate frame. + for (channel, id) in [("acp_victim", 1), ("acp_win1", 2)] { + let frame = serde_json::json!({ + "channel_id": channel, + "request": req(id, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })) + }); + let mut line = serde_json::to_vec(&frame).unwrap(); + line.push(b'\n'); + wr.write_all(&line).await.unwrap(); + } + + // Only the legitimate frame is answered; the spoofed one produced no reply at all. + let mut resp = String::new(); + BufReader::new(rd).read_line(&mut resp).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!( + v["id"], 2, + "the first response must be for the legitimate frame — the spoofed channel was dropped" + ); + ct.cancel(); + } + #[tokio::test] async fn start_session_server_writes_cursor_config() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs index 84756fc02..97323451c 100644 --- a/src/browser_bridge.rs +++ b/src/browser_bridge.rs @@ -39,41 +39,10 @@ fn resolve_channel() -> String { return c; } } - let mut pid = std::process::id(); - for _ in 0..16 { - if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { - if let Some(c) = parse_channel_from_environ(&bytes) { - return c; - } - } - let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { - break; - }; - match parse_ppid_from_stat(&stat) { - Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) - _ => break, - } - } - String::new() -} - -/// Extract OPENAB_BROWSER_CHANNEL from a null-separated `/proc//environ` blob. -fn parse_channel_from_environ(bytes: &[u8]) -> Option { - for kv in bytes.split(|&b| b == 0) { - if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { - if !rest.is_empty() { - return Some(String::from_utf8_lossy(rest).into_owned()); - } - } - } - None -} - -/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may -/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". -fn parse_ppid_from_stat(stat: &str) -> Option { - let after = stat.rsplit_once(')')?.1; - after.split_whitespace().nth(1)?.parse().ok() + // Same ancestry walk the socket server now performs on our pid (review R2). Kept here only so + // the frame still carries a channel for logging/compatibility — the server derives its own and + // never trusts this one, so a wrong answer here is refused rather than honoured. + openab_core::mcp_proxy::channel_from_process_ancestry(std::process::id()).unwrap_or_default() } /// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and @@ -144,23 +113,11 @@ where #[cfg(test)] mod tests { - use super::{parse_channel_from_environ, parse_ppid_from_stat, pump, wrap_frame}; + use super::{pump, wrap_frame}; - #[test] - fn parse_ppid_handles_comm_with_spaces_and_parens() { - assert_eq!(parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); - assert_eq!(parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); - assert_eq!(parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); // ')' inside comm - assert_eq!(parse_ppid_from_stat("nonsense"), None); - } + // The `/proc` parsing these covered now lives in openab-core, next to the socket server that + // authenticates with it, and is tested there. - #[test] - fn parse_channel_from_environ_finds_the_var() { - let env = b"HOME=/home/agent\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/bin\0"; - assert_eq!(parse_channel_from_environ(env).as_deref(), Some("acp_xyz")); - assert_eq!(parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); - assert_eq!(parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); // empty ignored - } use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; #[test] From 1970bae5f0f067f33e4c6e1cfdc7ffdaf984dc42 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 16:25:57 +0800 Subject: [PATCH 069/138] fix(mcp): revoke facade session tokens by token, not by channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session lifetimes overlap. `mint` replaces whatever token a channel holds, so a replaced session's drop guard runs after its successor has already minted — and the guard revoked by channel, which removed the live token. The new agent lost facade access with nothing pointing at the cause. Revocation is now token-specific end to end: SessionTokenRegistrar::revoke takes the token, SessionTokens gains revoke_token alongside the existing revoke_channel, and the pool's drop guard carries the token it minted instead of the channel it minted for. A late teardown is then a no-op rather than a silent outage. revoke_channel stays — a deliberate channel-wide evict still wants it — but both methods now document which case they are for, so the distinction is visible at the call site rather than being a trap. This is the same shape as the bridge finding: authorising on an identifier too coarse for the decision. There it was "same uid" standing in for "same session"; here it is "same channel" standing in for "same session instance". Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 12 ++++++--- crates/openab-core/src/mcp_proxy.rs | 9 +++++-- crates/openab-mcp/src/mcp/sources.rs | 39 ++++++++++++++++++++++++++++ src/browser_source.rs | 5 ++-- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 15a75628f..21f752466 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -457,13 +457,19 @@ impl SessionPool { ); // Revoke on evict/replace: piggyback the same DropGuard // plumbing proxy mode uses for its server teardown. + // + // The guard carries the TOKEN it minted, not the channel. A + // replaced session's teardown runs after its successor has already + // re-minted for the same channel, so revoking by channel would + // strip the live token and silently cut the new agent off from the + // facade; revoking this exact token is a no-op by then (R1). let ct = tokio_util::sync::CancellationToken::new(); let child = ct.child_token(); let registrar = registrar.clone(); - let chan = channel_id.to_string(); + let minted = session_token.clone().unwrap_or_default(); tokio::spawn(async move { child.cancelled().await; - registrar.revoke(&chan); + registrar.revoke(&minted); }); Some(ct.drop_guard()) } @@ -1006,7 +1012,7 @@ mod tests { self.minted.lock().unwrap().push(channel_id.to_string()); "token-xyz".to_string() } - fn revoke(&self, _channel_id: &str) {} + fn revoke(&self, _token: &str) {} } /// A failed facade config write must not mint a token. The agent has no `openab` entry, so it diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index c81cd051d..f7a2e16d5 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -646,8 +646,13 @@ pub trait SessionTokenRegistrar: Send + Sync { /// Mint (or re-mint) the token for `channel_id`; returns the value the /// pool injects as `OPENAB_SESSION_TOKEN` in the agent's environment. fn mint(&self, channel_id: &str) -> String; - /// Revoke every token for `channel_id` (session evicted/replaced). - fn revoke(&self, channel_id: &str); + /// Revoke one specific token (the session that held it was evicted). + /// + /// Deliberately keyed by token, not by channel. `mint` replaces whatever token a channel had, + /// so a replaced session's teardown runs *after* its successor has already minted a new one; + /// revoking by channel would strip that live token and silently cut the new agent off from the + /// facade. Revoking the exact token makes a late teardown a no-op instead (review R1). + fn revoke(&self, token: &str); } /// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into diff --git a/crates/openab-mcp/src/mcp/sources.rs b/crates/openab-mcp/src/mcp/sources.rs index 137def26d..abd033879 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -107,6 +107,10 @@ impl SessionTokens { } /// Revoke every token for `channel_id` (session evict / respawn). + /// + /// Prefer [`Self::revoke_token`] when tearing down a *specific* session: `mint` replaces a + /// channel's token, so a late teardown revoking by channel removes its successor's live token + /// rather than its own. pub fn revoke_channel(&self, channel_id: &str) { self.inner .write() @@ -114,6 +118,15 @@ impl SessionTokens { .retain(|_, ctx| ctx.channel_id != channel_id); } + /// Revoke exactly one token. A no-op once that token has already been replaced, which is what + /// stops an evicted session's teardown from cutting off the session that succeeded it. + pub fn revoke_token(&self, token: &str) { + self.inner + .write() + .expect("session token lock") + .remove(token); + } + /// Resolve a presented token. Constant-time comparison over stored /// tokens so a colocated process can't probe a token byte-by-byte via /// response timing (session counts are small; the linear scan is noise). @@ -168,6 +181,32 @@ pub fn session_ctx_from_extensions( mod tests { use super::*; + /// An evicted session's teardown must not cut off the session that replaced it (review R1). + /// + /// Session lifetimes overlap: the successor mints while the predecessor's drop guard is still + /// pending. Revoking by channel at that point removes the *live* token, and the new agent + /// loses facade access with nothing pointing at the cause. Revoking the specific token makes + /// the late teardown a no-op. + #[test] + fn a_replaced_sessions_teardown_cannot_revoke_its_successors_token() { + let tokens = SessionTokens::new(); + let old = tokens.mint("chan-a"); + let new = tokens.mint("chan-a"); // successor takes over the channel + + // The predecessor's guard fires late, carrying the token IT minted. + tokens.revoke_token(&old); + + assert_eq!( + tokens.resolve(&new).map(|c| c.channel_id), + Some("chan-a".to_string()), + "the successor's token must survive a late teardown of the session it replaced" + ); + + // And revoking the current token still works. + tokens.revoke_token(&new); + assert!(tokens.resolve(&new).is_none()); + } + #[test] fn mint_resolve_revoke_lifecycle() { let tokens = SessionTokens::new(); diff --git a/src/browser_source.rs b/src/browser_source.rs index 3cf8eabcc..2340b210a 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -381,8 +381,9 @@ impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { fn mint(&self, channel_id: &str) -> String { self.0.mint(channel_id) } - fn revoke(&self, channel_id: &str) { - self.0.revoke_channel(channel_id) + fn revoke(&self, token: &str) { + // Token-specific: a late teardown must not strip the successor's live token (R1). + self.0.revoke_token(token) } } From 3aa8f6a12cd132ddafdd54d5f3e64e6f89b1b1b4 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 16:39:45 +0800 Subject: [PATCH 070/138] fix(acp): disconnect tunnels replaced by last-attach-wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last-attach-wins dropped stale handles through retain(), so the only mcp_disconnect implementation was never called. The client went on believing those connections were open, accumulating state across every reconnect. Take the stale handles out of the registry instead of dropping them — collect the keys, remove them — then disconnect after the lock is released. That ordering is forced rather than merely preferred: disconnect is async and the registry is a std Mutex, so awaiting under it would not compile. The disconnects run in a spawned task. A replaced connection is often already dead — that is usually why it is being replaced — so waiting on its response would stall the tunnel that just came up in exchange for a notification that only tidies the client's bookkeeping. Failures log at debug rather than propagating: a best-effort courtesy must not fail an attach that has already succeeded. The test asserts the replaced connection receives mcp/disconnect naming its own connectionId. Without pinning that, an implementation which disconnected the freshly registered tunnel instead would pass just as happily. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 87 +++++++++++++++++-- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index e54083e71..d4bf7bc92 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -755,24 +755,41 @@ async fn establish_and_register_tunnel( connection_id, server_name: acp_name.clone(), }; - let evicted = { + let replaced = { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); // Last-attach-wins (ADR §6.1). The client mints a fresh `id` on every connection, so a // reconnect would otherwise leave the dead tunnel registered beside the live one under // the same declared name. Answering "ambiguous — pass a server_id" there would wedge the // client out of its own tools on every reconnect, so the newest attach evicts its stale // same-name predecessors instead — which is also what bounds registry growth. - let before = reg.len(); - reg.retain(|(c, id), h| !(c == &channel_id && h.server_name == acp_name && id != &acp_id)); - let evicted = before - reg.len(); + // + // Take the stale handles OUT rather than dropping them: the client still believes those + // connections are open, so each one is owed an `mcp/disconnect` (review R7). They are + // collected here and disconnected after the lock is released — `disconnect` is async and + // this is a std mutex, so awaiting under it is not an option. + let stale: Vec<(String, String)> = reg + .iter() + .filter(|((c, id), h)| c == &channel_id && h.server_name == acp_name && id != &acp_id) + .map(|(k, _)| k.clone()) + .collect(); + let replaced: Vec = stale.iter().filter_map(|k| reg.remove(k)).collect(); reg.insert((channel_id.clone(), acp_id.clone()), handle); - evicted + replaced }; - if evicted > 0 { + if !replaced.is_empty() { info!( - channel_id = %channel_id, server_name = %acp_name, evicted, + channel_id = %channel_id, server_name = %acp_name, evicted = replaced.len(), "ACP: last-attach-wins — evicted stale same-name tunnel(s)" ); + // Best-effort and off the attach path: a replaced connection may already be dead, and + // waiting on its response would stall the tunnel that just came up for no benefit. + tokio::spawn(async move { + for handle in replaced { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a replaced tunnel did not complete"); + } + } + }); } info!(channel_id = %channel_id, server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached"); Ok(()) @@ -2394,6 +2411,62 @@ mod acp_requests { ); } + /// A replaced tunnel is owed an `mcp/disconnect` (review R7). + /// + /// Last-attach-wins used to simply drop the stale handle, so the only `mcp_disconnect` impl + /// was never called and the client kept believing that connection was open — stale state that + /// accumulates across every reconnect. + #[tokio::test] + async fn a_replaced_tunnel_is_told_to_disconnect() { + let registry = super::new_tunnel_registry(); + + // First attach. Keep this connection's out_rx alive so the disconnect can be observed on + // it once the handle has been replaced. + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-old"}}), + ) + .await; + out_rx // hand the receiver back so the test can keep reading it + }); + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "uuid-old".into(), + "browser".into(), + "acp_abc".into(), + registry.clone(), + 5, + ) + .await + .unwrap(); + let mut out_rx = ext.await.unwrap(); + + // Same declared name, fresh id — this replaces the handle above. + attach(®istry, "uuid-new", "browser").await; + + // The replaced connection must be told to close, naming ITS connectionId. + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("a replaced tunnel must be sent mcp/disconnect") + .expect("channel closed before the disconnect arrived"); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["method"], json!("mcp/disconnect")); + assert_eq!( + v["params"]["connectionId"], + json!("conn-old"), + "the disconnect must name the replaced connection, not the live one" + ); + } + /// A different declared name on the same channel is a genuinely different server and must /// coexist — that is the whole point of the compound key (§6.1/§6.2 fan-out). #[tokio::test] From d9a2679a0a4f14667880055fa12c001156ed347e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 16:58:46 +0800 Subject: [PATCH 071/138] fix(mcp): bound bridge frame reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BufReader::lines() grows until it sees a newline, so a peer that never sends one pins an arbitrarily large allocation. The review frames this as a same-uid attacker, but no malice is required — a shim that dies mid-line does the same thing, which is the likelier trigger and is why the bound matters even though authenticating the connection already narrowed who can reach this socket. read_frame_bounded copies out of fill_buf before consuming, returns Ok(None) at EOF, and errors InvalidData once the pending frame would exceed the cap. Over the cap the connection is dropped: a stream cannot be resynchronised mid-frame, so continuing would only leave a partial frame buffered. The cap matches the ACP tunnel's own frame ceiling, so the bridge is never the tighter bottleneck for legitimate MCP traffic. Framing is now byte-oriented, so a non-UTF8 frame is skipped like any other malformed one instead of ending the read loop as lines() would have. Tested against the helper at a 16-byte cap rather than by driving the socket at 8 MiB: the assertion is that the bound holds, and it covers terminated reads, exactly-at-cap (inclusive), unterminated past the cap, terminated but too long, and EOF as None rather than an error. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 102 +++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index f7a2e16d5..2aec0c09f 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -815,6 +815,50 @@ pub fn channel_from_process_ancestry(start_pid: u32) -> Option { /// server can be tested without a real agent process tree above the test binary. pub type ChannelResolver = Arc Option + Send + Sync>; +/// Hard ceiling for one bridge frame (review R4). +/// +/// Matches the ACP tunnel's own frame ceiling so the bridge is never the tighter bottleneck for +/// legitimate MCP traffic, while still bounding what a single frame can make us allocate. +const MAX_BRIDGE_FRAME_BYTES: usize = 8 << 20; // 8 MiB + +/// Read one newline-terminated frame, refusing to buffer more than `max` bytes. +/// +/// `BufReader::lines()` grows until it sees a newline, so a peer that never sends one pins an +/// arbitrarily large allocation — no malice required, a wedged writer does it too. Returns +/// `Ok(None)` at EOF, and `InvalidData` once the pending frame would exceed `max`; the caller +/// drops the connection rather than trying to resynchronise mid-frame. +async fn read_frame_bounded(reader: &mut R, max: usize) -> std::io::Result>> +where + R: tokio::io::AsyncBufRead + Unpin, +{ + use tokio::io::AsyncBufReadExt; + let mut out: Vec = Vec::new(); + loop { + // Copy out of the fill buffer before consuming: `available` borrows the reader. + let (chunk, terminated) = { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok((!out.is_empty()).then_some(out)); // EOF + } + match available.iter().position(|b| *b == b'\n') { + Some(pos) => (available[..pos].to_vec(), true), + None => (available.to_vec(), false), + } + }; + if out.len() + chunk.len() > max { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("bridge frame exceeds {max} bytes"), + )); + } + out.extend_from_slice(&chunk); + reader.consume(chunk.len() + usize::from(terminated)); + if terminated { + return Ok(Some(out)); + } + } +} + pub async fn serve_browser_socket( path: std::path::PathBuf, tunnel: Option>, @@ -876,7 +920,7 @@ async fn handle_browser_conn( tunnel: Option>, resolver: ChannelResolver, ) { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::io::{AsyncWriteExt, BufReader}; // Authenticate the CONNECTION, not the frame (review R2). 0600 on the socket only proves the // peer shares our uid; it does not say which session the peer belongs to. The `channel_id` in @@ -894,8 +938,21 @@ async fn handle_browser_conn( }; let (read_half, mut write_half) = stream.into_split(); - let mut lines = BufReader::new(read_half).lines(); - while let Ok(Some(line)) = lines.next_line().await { + let mut reader = BufReader::new(read_half); + loop { + let line = match read_frame_bounded(&mut reader, MAX_BRIDGE_FRAME_BYTES).await { + Ok(Some(bytes)) => bytes, + Ok(None) => break, // EOF + Err(e) => { + // Oversized or unreadable: the stream cannot be resynchronised mid-frame, so the + // connection goes rather than leaving a partial frame buffered (R4). + warn!(peer_pid = ?peer_pid, error = %e, "browser bridge: dropping connection"); + break; + } + }; + let Ok(line) = String::from_utf8(line) else { + continue; // skip a non-UTF8 frame rather than drop the connection + }; if line.trim().is_empty() { continue; } @@ -1466,6 +1523,45 @@ mod tests { ct.cancel(); } + // --- R4: one frame cannot pin an unbounded allocation --- + + /// `BufReader::lines()` grows until it sees a newline, so a peer that never sends one — a + /// wedged writer just as much as a hostile one — pins an arbitrarily large buffer. Tested at a + /// small cap so the assertion is about the bound, not about allocating megabytes. + #[tokio::test] + async fn an_unterminated_frame_is_refused_once_it_passes_the_cap() { + // Terminated frames under the cap read normally, newline consumed. + let mut r = std::io::Cursor::new(b"hello\nworld\n".to_vec()); + assert_eq!( + super::read_frame_bounded(&mut r, 16).await.unwrap(), + Some(b"hello".to_vec()) + ); + assert_eq!( + super::read_frame_bounded(&mut r, 16).await.unwrap(), + Some(b"world".to_vec()) + ); + // EOF is None, not an error. + assert_eq!(super::read_frame_bounded(&mut r, 16).await.unwrap(), None); + + // Exactly at the cap is still allowed — the bound is inclusive. + let mut at_cap = std::io::Cursor::new(b"0123456789abcdef\n".to_vec()); + assert_eq!( + super::read_frame_bounded(&mut at_cap, 16).await.unwrap(), + Some(b"0123456789abcdef".to_vec()) + ); + + // Unterminated and over the cap: refused rather than buffered. + let mut flood = std::io::Cursor::new(vec![b'x'; 64]); + let err = super::read_frame_bounded(&mut flood, 16) + .await + .expect_err("an unterminated frame past the cap must be refused"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + + // A terminated frame that is merely too long is refused too. + let mut long = std::io::Cursor::new([vec![b'y'; 64], vec![b'\n']].concat()); + assert!(super::read_frame_bounded(&mut long, 16).await.is_err()); + } + // --- R2: the socket authenticates the connection, not the frame --- #[test] From 34b5f2a22b1cac2888c53a6ca3bf36fbd705066b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 17:03:03 +0800 Subject: [PATCH 072/138] ci: quote the acp-mcp test filter so the workflow file parses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step added to run the acp-mcp core tests ended in `mcp_proxy::`. YAML reads a colon at the end of a plain scalar as a mapping indicator, so ci.yml stopped parsing and GitHub failed the whole workflow with "workflow file issue" — every job in it, not just the new step. CI has been red on this branch since that commit while local gates passed, because the gate runs cargo directly and never parses the workflow. Quote the command. The three acp steps now parse with the filter intact. Co-Authored-By: Claude --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db11c09d4..0306c4ead 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,9 @@ jobs: # Filtered to `mcp_proxy::` for the same reason the gateway step is scoped to one package — # it keeps the flaky hooks::tests out of this job. - name: cargo test (acp-mcp core) - run: cargo test -p openab-core --features acp-mcp mcp_proxy:: + # Quoted: the trailing `::` of the filter would otherwise read as a YAML mapping + # indicator and make the whole workflow file unparseable. + run: "cargo test -p openab-core --features acp-mcp mcp_proxy::" # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and # the stdio bridge shim (browser_bridge.rs) — are `acp`-gated, so `--workspace` above skips # them too. This is where the source's routing and trust-gate coverage lives. From e308e753eb51f15722d2ef389ce36e2a99b12660 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 20:39:01 +0800 Subject: [PATCH 073/138] fix(mcp): only delete a direct browser entry we can prove we wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership check claimed to recognise "a bearer we minted" while comparing against nothing we had kept. Any openab-browser entry with a loopback url and any Bearer header matched — which is exactly the shape of a local MCP server an operator runs themselves, so facade setup deleted their configuration. It also made the reply on this PR wrong: exact-shape matching preserved everything else for the bridge entry only, not for the proxy entry. Fail closed. Only the bridge entry qualifies now: it is byte-identical every session and names our own binary and subcommand, so matching it is itself the proof. The per-session proxy url and bearer were never recorded, so ownership of that shape cannot be established and it is preserved. Preserving costs little. A leftover proxy entry names an ephemeral port from a session that is gone — start_session_server binds 127.0.0.1:0 and drops the listener with the session — so it is dead configuration rather than a live bypass. The bridge entry is the one that would still resolve and run, and it is still removed. Tests: the loopback+bearer shape moves to the not-ours list, and a new case proves an operator's own entry survives verbatim in both .cursor/mcp.json and a kiro agent file, along with its @openab-browser grant — revoking the grant would silently disable their server even with the entry intact. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 125 ++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 2aec0c09f..07b48f25e 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -496,37 +496,27 @@ pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { /// agent process (config-var expansion is exactly how deployed agents already reference /// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token /// dies with the agent process and its registry entry. -/// True when an `openab-browser` entry is demonstrably one **we** wrote for a direct transport, -/// and so is safe to drop when facade mode takes over. +/// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be dropped +/// when facade mode takes over. /// -/// Matching is by exact known shape, never by name alone: the key is not proof of ownership, and -/// an operator may have configured their own server under it. Only two shapes were ever written: +/// Only the bridge entry qualifies. It is byte-identical every session +/// (`{"command":"openab","args":["browser-bridge"]}`) and names our own binary and subcommand, so +/// matching it is itself the proof. /// -/// - the bridge entry, byte-identical every session: `{"command":"openab","args":["browser-bridge"]}`; -/// - the per-session proxy entry: a loopback `http://127.0.0.1:/mcp` url with a bearer header. +/// The per-session proxy entry deliberately does **not** qualify, correcting an earlier version of +/// this function. Its url and bearer are minted per session and never recorded anywhere, so +/// "loopback url plus some `Bearer` header" is a description, not an identity — it matches any +/// local MCP server an operator happened to configure under this key. That check claimed to +/// recognise "a bearer we minted" while comparing against nothing we had kept. With no way to +/// prove ownership we fail closed and preserve. /// -/// Anything else — a remote url, a different command, no bearer — is left alone. Leaving a stale -/// entry only preserves the bypass; deleting someone's config destroys work, so this errs at the -/// former. +/// Preserving costs little. A leftover proxy entry names an ephemeral port belonging to a session +/// that is gone — `start_session_server` binds `127.0.0.1:0` and drops the listener with the +/// session — so it is dead configuration rather than a live bypass. The bridge entry is the one +/// that would still resolve and run, and that is the one removed. fn is_openab_direct_browser_entry(entry: &Value) -> bool { - // Bridge shape. - if entry.get("command").and_then(Value::as_str) == Some("openab") + entry.get("command").and_then(Value::as_str) == Some("openab") && entry.get("args") == Some(&json!(["browser-bridge"])) - { - return true; - } - // Per-session proxy shape: loopback url + a bearer we minted. - let is_loopback_mcp = entry - .get("url") - .and_then(Value::as_str) - .and_then(|u| u.strip_prefix("http://127.0.0.1:")) - .and_then(|rest| rest.strip_suffix("/mcp")) - .is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit())); - let has_bearer = entry - .pointer("/headers/Authorization") - .and_then(Value::as_str) - .is_some_and(|a| a.starts_with("Bearer ")); - is_loopback_mcp && has_bearer } /// Drop a stale direct-transport `openab-browser` entry from an `mcpServers` map, returning @@ -1003,15 +993,13 @@ mod tests { #[test] fn only_our_own_direct_browser_shapes_are_recognised() { let bridge = serde_json::json!({ "command": "openab", "args": ["browser-bridge"] }); - let proxy = serde_json::json!({ - "url": "http://127.0.0.1:45678/mcp", - "headers": { "Authorization": "Bearer abc" } - }); assert!(is_openab_direct_browser_entry(&bridge)); - assert!(is_openab_direct_browser_entry(&proxy)); - // Not ours: a remote url, a bearer-less loopback, a different command, an empty port. + // Not provably ours. The loopback+bearer shapes are the important ones: they describe our + // old proxy entry, but they equally describe an operator's own local MCP server, and the + // per-session url/bearer were never recorded, so ownership cannot be established. for foreign in [ + serde_json::json!({ "url": "http://127.0.0.1:45678/mcp", "headers": { "Authorization": "Bearer abc" } }), serde_json::json!({ "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer x" } }), serde_json::json!({ "url": "http://127.0.0.1:45678/mcp" }), serde_json::json!({ "command": "openab", "args": ["something-else"] }), @@ -1063,6 +1051,74 @@ mod tests { assert_eq!(cfg["someUnrelatedKey"], 42, "unrelated config must survive"); } + /// An operator's own local MCP server under this key survives facade setup — the entry **and** + /// its allowlist grant (review R3-F2). + /// + /// The previous matcher treated any loopback url carrying any `Bearer` header as ours, which + /// is precisely the shape a locally-run MCP server takes, so that configuration was deleted. + /// Ownership of that shape cannot be proven — the per-session url and bearer were never + /// recorded — so it is preserved now. + #[tokio::test] + async fn a_local_mcp_server_under_our_key_is_not_deleted() { + let wd = tmp_workdir("r3f2").await; + let cursor = wd.join(".cursor"); + tokio::fs::create_dir_all(&cursor).await.unwrap(); + // Indistinguishable from our retired proxy entry by shape alone. + let theirs = serde_json::json!({ + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer their-own-token" } + }); + tokio::fs::write( + cursor.join("mcp.json"), + serde_json::to_vec_pretty( + &serde_json::json!({ "mcpServers": { "openab-browser": theirs } }), + ) + .unwrap(), + ) + .await + .unwrap(); + + let agent = wd.join(".kiro/agents/terra.json"); + tokio::fs::write( + &agent, + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "terra", + "mcpServers": { "openab-browser": theirs }, + "allowedTools": ["@builtin", "@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(cursor.join("mcp.json")).await.unwrap()) + .unwrap(); + assert_eq!( + cfg["mcpServers"]["openab-browser"], theirs, + "an entry we cannot prove we wrote must be preserved verbatim" + ); + + let agent_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(agent_cfg["mcpServers"]["openab-browser"], theirs); + let allowed: Vec<&str> = agent_cfg["allowedTools"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!( + allowed.contains(&"@openab-browser"), + "the grant must survive too — revoking it silently disables the operator's own server" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + #[tokio::test] async fn facade_setup_leaves_a_foreign_openab_browser_entry_alone() { // Same key, but a shape we never wrote: it belongs to the operator, so removing it would @@ -1101,10 +1157,7 @@ mod tests { serde_json::to_vec_pretty(&serde_json::json!({ "name": "terra", "mcpServers": { - "openab-browser": { - "url": "http://127.0.0.1:45678/mcp", - "headers": { "Authorization": "Bearer tok" } - }, + "openab-browser": { "command": "openab", "args": ["browser-bridge"] }, "github": { "url": "http://ghpool:8080/mcp" } }, "allowedTools": ["@builtin", "@openab-browser", "@github"] From 399d892db8682fc9a797a1e0e2cefde7e1e87e87 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 20:50:21 +0800 Subject: [PATCH 074/138] fix(acp): bound client-declared MCP server fan-out per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every type:acp declaration buys a spawned task, a pending mcp/connect holding a 30s timeout, and an outbound frame, and nothing limited how many a session could declare. Declarations are small enough that thousands fit inside the 1 MiB limit that applies to a session/new frame, so one accepted request could burst all of that at once. accept_acp_servers dedupes by id and enforces MAX_ACP_SERVERS_PER_SESSION, running in both session/new and session/resume before the session is inserted or any task spawned — the point is that an over-declaring request never reaches the work. Resume gets the same guard because the client re-presents its declarations every time, which makes it an equally good burst vector. Over the cap the whole request is refused rather than truncated. Truncating would silently honour part of a request the client cannot know was clipped; it would then believe it had servers that never got tunnels and debug that as a mysterious "not connected". Dedup runs before the cap, so repeats of one id are one server rather than a cap violation — the reverse order would reject merely sloppy clients. A repeated id would otherwise spawn two tunnels racing for one registry key, where the loser exists only to be overwritten. Tests cover the cap boundary, a five-thousand entry flood refused rather than clipped, order-preserving duplicate collapse, and that exactly one task and one mcp/connect are produced per unique declaration with no excess frame. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 153 +++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index d4bf7bc92..8778ad438 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -36,6 +36,13 @@ const ACP_PROTOCOL_VERSION: u32 = 1; /// eviction, and global connection/worker limits are a follow-up (review F6, roadmap). const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; +/// Cap on `type:acp` servers a single session may declare (review R3-F1). +/// +/// Every declaration costs a spawned task, a pending `mcp/connect` holding a 30s timeout, and an +/// outbound frame. Declarations are tiny, so thousands fit inside the 1 MiB limit that applies to +/// a `session/new` frame — without a cap, one accepted request bursts all of that at once. Eight +/// is far above any real client (the reference client declares one) and far below what hurts. +const MAX_ACP_SERVERS_PER_SESSION: usize = 8; const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB /// The method of a parsed inbound frame when it exceeds the limit for its kind, else `None`. /// @@ -313,6 +320,31 @@ fn parse_acp_mcp_servers(params: Option<&Value>) -> Vec { .unwrap_or_default() } +/// Validate a session's declared `type:acp` servers before any of them costs anything. +/// +/// Returns the list to act on, or an error message to reject the whole `session/new` / +/// `session/resume` with. Callers must run this **before** inserting the session or spawning +/// tunnels: the point is that an over-declaring request never reaches the work. +/// +/// Duplicate ids collapse to the first occurrence. A repeated id would otherwise spawn two tunnels +/// racing for one registry key, where the loser is a task and a pending `mcp/connect` that exist +/// only to be overwritten. Entries missing `id` or `name` were already dropped by +/// `parse_acp_mcp_servers`, so after this the list is unique and complete. +fn accept_acp_servers(servers: Vec) -> Result, String> { + let mut seen = std::collections::HashSet::new(); + let deduped: Vec = servers + .into_iter() + .filter(|s| seen.insert(s.id.clone())) + .collect(); + if deduped.len() > MAX_ACP_SERVERS_PER_SESSION { + return Err(format!( + "Too many type:acp servers declared ({}, max {MAX_ACP_SERVERS_PER_SESSION})", + deduped.len() + )); + } + Ok(deduped) +} + pub enum ReplyChunk { /// Incremental text snapshot (full text so far) Text(String), @@ -1040,7 +1072,18 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); + // Bound the declaration fan-out BEFORE the session exists or any task is + // spawned (R3-F1): an over-declaring request must cost nothing. + let acp_mcp_servers = match accept_acp_servers(parse_acp_mcp_servers( + req.params.as_ref(), + )) { + Ok(list) => list, + Err(msg) => { + let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); + let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + continue; + } + }; let (resp, channel_id) = handle_session_new(&sessions, id.clone(), acp_mcp_servers.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); @@ -1076,6 +1119,17 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } + // Same bound on resume: the client re-presents its declarations each time, so + // resume is an equally good burst vector (R3-F1). + let resumed_servers = + match accept_acp_servers(parse_acp_mcp_servers(req.params.as_ref())) { + Ok(list) => list, + Err(msg) => { + let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); + let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + continue; + } + }; let (resp, resumed_channel) = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); @@ -1094,7 +1148,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { (state.acp_tunnel_registry.clone(), resumed_channel) { spawn_acp_tunnels( - parse_acp_mcp_servers(req.params.as_ref()), + resumed_servers, channel_id, registry, &out_tx, @@ -2646,6 +2700,101 @@ mod acp_review_fixes { assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } + // --- R3-F1: declaration fan-out is bounded before it costs anything --- + + fn decl(id: &str, name: &str) -> super::AcpMcpServer { + super::AcpMcpServer { + id: id.into(), + name: name.into(), + } + } + + /// Each declaration buys a task, a pending `mcp/connect` holding a 30s timeout, and an + /// outbound frame. Declarations are small enough that thousands fit inside one accepted frame, + /// so the count is capped before the session exists or any task is spawned. + #[test] + fn declaration_fan_out_is_capped_and_deduplicated() { + let cap = super::MAX_ACP_SERVERS_PER_SESSION; + + // At the cap is fine; one past it refuses the whole request. + let at_cap: Vec<_> = (0..cap).map(|i| decl(&format!("id{i}"), "s")).collect(); + assert_eq!(super::accept_acp_servers(at_cap).unwrap().len(), cap); + + let over: Vec<_> = (0..cap + 1).map(|i| decl(&format!("id{i}"), "s")).collect(); + let err = super::accept_acp_servers(over) + .expect_err("declaring past the cap must be refused outright"); + assert!(err.contains("Too many type:acp servers"), "{err}"); + + // A burst of thousands — the shape the finding describes — is refused rather than + // truncated: truncating would silently honour part of a request the client cannot know + // was clipped. + let flood: Vec<_> = (0..5000).map(|i| decl(&format!("id{i}"), "s")).collect(); + assert!(super::accept_acp_servers(flood).is_err()); + + // Duplicate ids collapse to the first, so a repeated id cannot spawn two tunnels racing + // for one registry key. Dedup runs before the cap, so repeats are not a way to trip it. + let dupes = vec![ + decl("same", "browser"), + decl("same", "browser"), + decl("other", "notes"), + ]; + let kept = super::accept_acp_servers(dupes).unwrap(); + assert_eq!(kept.len(), 2); + assert_eq!(kept[0].id, "same"); + assert_eq!(kept[1].id, "other"); + + let many_dupes: Vec<_> = (0..5000).map(|_| decl("same", "browser")).collect(); + assert_eq!( + super::accept_acp_servers(many_dupes).unwrap().len(), + 1, + "repeats of one id are one server, not a cap violation" + ); + } + + /// The accepted list is exactly what gets spawned: one task per unique declaration, no more. + #[tokio::test] + async fn only_accepted_declarations_spawn_tunnels() { + let registry = super::new_tunnel_registry(); + // Built inline: this module has its own helpers and does not share acp_requests'. + let pending: Arc< + tokio::sync::Mutex>>, + > = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let next_id = Arc::new(std::sync::atomic::AtomicU64::new(1)); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut tasks: Vec> = Vec::new(); + + let accepted = super::accept_acp_servers(vec![ + decl("a", "browser"), + decl("a", "browser"), // duplicate — must not spawn twice + decl("b", "notes"), + ]) + .unwrap(); + super::spawn_acp_tunnels( + accepted, + "acp_abc".into(), + registry, + &out_tx, + &pending, + &next_id, + &mut tasks, + ); + + assert_eq!(tasks.len(), 2, "one task per unique declaration"); + // Exactly two mcp/connect frames go out, for the two distinct ids. + let mut ids = Vec::new(); + for _ in 0..2 { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + ids.push(f["params"]["acpId"].as_str().unwrap().to_string()); + } + ids.sort(); + assert_eq!(ids, ["a", "b"]); + assert!(out_rx.try_recv().is_err(), "no excess mcp/connect was sent"); + for t in tasks { + t.abort(); + } + } + // --- F2: the 8 MiB allowance is for tunnel results only --- /// The raise to 8 MiB was for browser tool results, which arrive as client RESPONSES From 580988cb471dc8b2f0fbdd3758eb47e8046ad0ab Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 21:41:30 +0800 Subject: [PATCH 075/138] ci: select the pool facade tests, not just compile them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F5 fixed whether these tests are compiled. This is the second half: whether they are selected. The acp-mcp core step filters on `mcp_proxy::`, but the pool's facade-session tests live in acp::pool::tests, so the filter compiled them and then selected them away — the two tests added for the config-write fix have never executed in CI. I chose that filter to keep the flaky hooks::tests out of the job, and in doing so re-created for my own tests exactly the gap F5 existed to close. Name the module instead of widening the filter: `acp::pool::` runs the facade tests (17 selected, including both config-write cases) and still selects zero hooks tests. Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0306c4ead..cabb63237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,12 @@ jobs: # Quoted: the trailing `::` of the filter would otherwise read as a YAML mapping # indicator and make the whole workflow file unparseable. run: "cargo test -p openab-core --features acp-mcp mcp_proxy::" + # The pool's facade-session tests are `acp-mcp`-gated too but live outside `mcp_proxy::`, so + # the filter above compiled them and then selected them away. Naming the module runs them + # while still leaving the flaky hooks::tests out. + - name: cargo test (acp-mcp pool) + # Quoted for the same reason as above — a trailing `::` reads as a YAML mapping indicator. + run: "cargo test -p openab-core --features acp-mcp acp::pool::" # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and # the stdio bridge shim (browser_bridge.rs) — are `acp`-gated, so `--workspace` above skips # them too. This is where the source's routing and trust-gate coverage lives. From 55eab82a3ddd34a2dfbc547d631bebfc32ebce4e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 28 Jul 2026 22:11:24 +0800 Subject: [PATCH 076/138] fix(mcp): stop a second mint from invalidating a live session token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 made revocation token-specific but left mint evicting by channel, so the race it was meant to close survived on the other side. Two builders can reach one channel — the pool supports racing builders, and a reset can start a replacement while the predecessor is still serving. The second mint deleted the first agent's live token. That agent holds OPENAB_SESSION_TOKEN in its environment and cannot observe the change: facade calls simply begin failing auth and its requires_session tools vanish from discovery, with nothing pointing at the cause. mint no longer evicts. Every mint is paired with a token-specific revoke on its own drop guard, so the map stays bounded without eviction, and revoke_channel remains for deliberate channel-wide teardown. Second half: reset_session no longer removes the creating gate. purge_session_entries documents precisely why that must not happen — the gate is concurrency control, not session state, and dropping it while a builder still holds the old Arc lets a concurrent get_or_create mint a fresh gate and run a second creation for the same key. reset did it anyway, which is how two builders reach one channel in the first place. Note an existing test asserted the old behaviour as correct ("old token must be dead"). It is rewritten to assert coexistence. The suite was green for as long as it was defending the defect, which is worth remembering when a green gate is offered as evidence. Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 5 ++- crates/openab-mcp/src/mcp/sources.rs | 47 ++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 21f752466..db7a5cdfc 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -822,7 +822,10 @@ impl SessionPool { state.pgids.remove(thread_id); state.suspended.remove(thread_id); state.persisted.remove(thread_id); - state.creating.remove(thread_id); + // Do NOT remove the creating gate — same reason `purge_session_entries` documents. It is + // concurrency control, not session state: dropping it while a builder still holds the old + // gate Arc lets a concurrent get_or_create mint a fresh gate and run a second creation for + // the same key, so two builders spawn agents and mint tokens for one channel. state.session_workdirs.remove(thread_id); self.save_mapping(&state.persisted); self.save_meta(&state.session_workdirs); diff --git a/crates/openab-mcp/src/mcp/sources.rs b/crates/openab-mcp/src/mcp/sources.rs index abd033879..8dffe9bd7 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -96,7 +96,13 @@ impl SessionTokens { getrandom::fill(&mut buf).expect("os rng"); let token = B64_URL.encode(buf); let mut map = self.inner.write().expect("session token lock"); - map.retain(|_, ctx| ctx.channel_id != channel_id); + // Deliberately does NOT evict the channel's existing tokens. Session lifetimes overlap: + // two builders can race for one channel, and a pool reset can start a replacement while + // the predecessor is still serving. Clobbering here invalidated a token whose agent was + // still using it — the agent holds OPENAB_SESSION_TOKEN in its environment, so it cannot + // notice, and every facade call then fails auth with `requires_session` tools silently + // vanishing. Each mint is paired with a token-specific revoke on its own drop guard, so + // the map stays bounded without this. map.insert( token.clone(), SessionCtx { @@ -181,6 +187,39 @@ pub fn session_ctx_from_extensions( mod tests { use super::*; + /// Two builders racing for one channel must not invalidate each other (review round 4, T1). + /// + /// R1 made revocation token-specific but left `mint` evicting by channel, so the second mint + /// killed the first agent's live token. That agent holds `OPENAB_SESSION_TOKEN` in its + /// environment and cannot observe the change: every facade call simply starts failing auth and + /// its `requires_session` tools vanish from discovery, with nothing pointing at the cause. + #[test] + fn a_second_mint_for_one_channel_does_not_invalidate_the_first() { + let tokens = SessionTokens::new(); + let first = tokens.mint("chan-a"); + let second = tokens.mint("chan-a"); + + assert_ne!(first, second, "each mint is a distinct credential"); + assert_eq!( + tokens.resolve(&first).map(|c| c.channel_id), + Some("chan-a".to_string()), + "the first builder's token must survive a concurrent second mint" + ); + assert_eq!( + tokens.resolve(&second).map(|c| c.channel_id), + Some("chan-a".to_string()) + ); + + // Each is still independently revocable, so the map stays bounded by guard pairing + // rather than by eviction-on-mint. + tokens.revoke_token(&first); + assert!(tokens.resolve(&first).is_none()); + assert!( + tokens.resolve(&second).is_some(), + "revoking one credential must not disturb the other" + ); + } + /// An evicted session's teardown must not cut off the session that replaced it (review R1). /// /// Session lifetimes overlap: the successor mints while the predecessor's drop guard is still @@ -213,11 +252,13 @@ mod tests { let t1 = tokens.mint("chan-a"); assert_eq!(tokens.resolve(&t1).unwrap().channel_id, "chan-a"); assert!(tokens.resolve("nope").is_none()); - // Re-mint for the same channel replaces the old token. + // A second mint for the channel coexists with the first — it used to evict it, which is + // the bug T1 fixes; see a_second_mint_for_one_channel_does_not_invalidate_the_first. let t2 = tokens.mint("chan-a"); - assert!(tokens.resolve(&t1).is_none(), "old token must be dead"); assert_eq!(tokens.resolve(&t2).unwrap().channel_id, "chan-a"); + // revoke_channel is the deliberate channel-wide evict and still clears both. tokens.revoke_channel("chan-a"); + assert!(tokens.resolve(&t1).is_none()); assert!(tokens.resolve(&t2).is_none()); } From bb27e9684a80aa502a8ea9b46f7a707a535fa405 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 02:46:03 +0800 Subject: [PATCH 077/138] docs(mcp): correct SessionTokens docs to match coexisting-token semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 55eab82a removed the "one live token per channel" invariant from `mint` — a second mint no longer invalidates a credential a running session is still presenting — but the public API docs on all three methods still described the old behaviour. Left as-is they teach the exact model the fix removed, and the `revoke_channel` note justified itself with a replacement that no longer happens. `mint` now states that tokens for a channel coexist and points at `revoke_token` as what keeps the map bounded; `revoke_channel` is marked a deliberate channel-wide eviction that will also destroy other live sessions' credentials; `revoke_token` is named as what a session's drop guard should use. Docs only — no behaviour change. Raised by Falcon reviewing 55eab82a. Co-Authored-By: Claude --- crates/openab-mcp/src/mcp/sources.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/openab-mcp/src/mcp/sources.rs b/crates/openab-mcp/src/mcp/sources.rs index 8dffe9bd7..30bf679a6 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -88,9 +88,13 @@ impl SessionTokens { Self::default() } - /// Mint a fresh opaque token bound to `channel_id`. A prior token for - /// the same channel (e.g. a respawned session) is replaced — exactly one - /// live token per channel. + /// Mint a fresh opaque token bound to `channel_id`. + /// + /// Tokens for a channel **coexist**: a respawned or racing session gets its own credential and + /// any already-issued token keeps resolving. There is deliberately no "one live token per + /// channel" invariant — enforcing it here invalidated credentials that a running agent was + /// still presenting. Each token is retired individually through [`Self::revoke_token`], which + /// is what keeps the map bounded. pub fn mint(&self, channel_id: &str) -> String { let mut buf = [0u8; 32]; getrandom::fill(&mut buf).expect("os rng"); @@ -112,11 +116,11 @@ impl SessionTokens { token } - /// Revoke every token for `channel_id` (session evict / respawn). + /// Revoke **every** token for `channel_id` — a deliberate channel-wide eviction. /// - /// Prefer [`Self::revoke_token`] when tearing down a *specific* session: `mint` replaces a - /// channel's token, so a late teardown revoking by channel removes its successor's live token - /// rather than its own. + /// Prefer [`Self::revoke_token`] when tearing down one specific session. Because tokens for a + /// channel coexist, revoking by channel here also destroys credentials belonging to any other + /// live session on it, which is only correct when the intent really is "end this channel". pub fn revoke_channel(&self, channel_id: &str) { self.inner .write() @@ -124,8 +128,11 @@ impl SessionTokens { .retain(|_, ctx| ctx.channel_id != channel_id); } - /// Revoke exactly one token. A no-op once that token has already been replaced, which is what - /// stops an evicted session's teardown from cutting off the session that succeeded it. + /// Revoke exactly one token, leaving every other token for that channel intact. + /// + /// This is the teardown a session's drop guard should use: it retires the credential that + /// session minted and nothing else, so a late teardown cannot cut off a session that started + /// alongside or after it. A no-op if the token was already revoked. pub fn revoke_token(&self, token: &str) { self.inner .write() From 6ea4b447c28d19073cd3cde17ebce368bfb5cc44 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 03:04:36 +0800 Subject: [PATCH 078/138] feat(acp)!: remove the stdio bridge transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `OPENAB_BROWSER_MODE=bridge` no longer selects a transport. The bridge existed because a fixed HTTP endpoint could not do per-session browser control, so each session needed its own stdio relay that resolved its channel by walking `/proc` up to the agent process. The facade is a fixed endpoint that solved exactly that with a per-session bearer, which leaves the bridge with no problem of its own to solve — and a large surface: a per-pod unix socket shared by every session, a channel derived from process ancestry, and its own hand-rolled MCP dispatch and line framing. Removed: the `openab browser-bridge` subcommand and its shim, the socket server and connection handler, the `/proc` ancestry resolver, the bounded frame reader, `write_bridge_mcp_config`, `browser_socket_path`, the `OPENAB_BROWSER_CHANNEL` injection (and the now-unused `browser_channel` parameter on `AcpConnection::spawn`), and the `Bridge` mode variant. Two things are deliberately kept: `bridge` degrades to `Facade` rather than erroring. A deployment still setting the old value has to come up with working browser control, not refuse to start on a string that was valid one release ago. The `openab-browser` entry matcher stays, now purely as migration cleanup. An upgraded deployment still has that entry in its `mcp.json`, naming a subcommand that no longer exists, and its MCP client would retry it every session; facade setup removes it. It is still matched by exact shape, since that shape is the only proof the entry is ours. Note the transport change for existing bridge deployments: they move to facade, and with no `[mcp]` configured they fall back to proxy. Still functional — making `[mcp]` required is a separate change. Also fixes a doc-comment that had drifted: `write_facade_mcp_config` had none, because its `///` block had come to sit on `is_openab_direct_browser_entry` along with that function's own. Facade-path coverage is unchanged (browser_source.rs, 21 tests); what leaves is the bridge's own MCP dispatch, framing and ancestry tests. Co-Authored-By: Claude --- .github/workflows/ci.yml | 2 +- crates/openab-core/src/acp/connection.rs | 35 +- crates/openab-core/src/acp/pool.rs | 12 - crates/openab-core/src/mcp_proxy.rs | 666 +------------------ docs/adr/acp-server-websocket-reverse-mcp.md | 24 +- docs/browser-mcp-agent-setup.md | 36 +- src/browser_bridge.rs | 181 ----- src/main.rs | 40 +- 8 files changed, 72 insertions(+), 924 deletions(-) delete mode 100644 src/browser_bridge.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cabb63237..e3014a825 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: # Quoted for the same reason as above — a trailing `::` reads as a YAML mapping indicator. run: "cargo test -p openab-core --features acp-mcp acp::pool::" # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and - # the stdio bridge shim (browser_bridge.rs) — are `acp`-gated, so `--workspace` above skips + # are `acp`-gated, so `--workspace` above skips # them too. This is where the source's routing and trust-gate coverage lives. - name: cargo test (acp root) run: cargo test --features acp diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index 4ff4a4255..cb9577ddd 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -336,7 +336,6 @@ impl AcpConnection { working_dir: &str, env: &std::collections::HashMap, inherit_env: &[String], - browser_channel: Option<&str>, ) -> Result { info!(cmd = command, ?args, cwd = working_dir, "spawning agent"); @@ -403,10 +402,6 @@ impl AcpConnection { cmd.env("SystemDrive", v); } } - // Option C: hand this session's browser channel to the agent so the `openab - // browser-bridge` stdio shim it later spawns inherits it and routes tool calls to THIS - // session's tunnel. env_clear() above dropped it, so (re)inject explicitly. - set_browser_channel(&mut cmd, browser_channel); for (k, v) in env { cmd.env(k, expand_env(v)); } @@ -852,39 +847,11 @@ impl Drop for AcpConnection { } } -/// Inject the session's browser channel into the agent's env so the `openab browser-bridge` -/// stdio shim the agent later spawns inherits it (Option C) and routes tool calls to THIS -/// session's tunnel. No-op for non-browser sessions (`None`). -fn set_browser_channel(cmd: &mut tokio::process::Command, browser_channel: Option<&str>) { - if let Some(channel) = browser_channel { - cmd.env("OPENAB_BROWSER_CHANNEL", channel); - } -} - #[cfg(test)] mod tests { - use super::{build_agent_env, build_permission_response, pick_best_option, set_browser_channel}; + use super::{build_agent_env, build_permission_response, pick_best_option}; use serde_json::json; - #[test] - fn set_browser_channel_injects_env_when_present() { - let mut cmd = tokio::process::Command::new("true"); - set_browser_channel(&mut cmd, Some("acp_win1")); - assert!(cmd.as_std().get_envs().any(|(k, v)| { - k == "OPENAB_BROWSER_CHANNEL" && v == Some(std::ffi::OsStr::new("acp_win1")) - })); - } - - #[test] - fn set_browser_channel_is_noop_when_absent() { - let mut cmd = tokio::process::Command::new("true"); - set_browser_channel(&mut cmd, None); - assert!(!cmd - .as_std() - .get_envs() - .any(|(k, _)| k == "OPENAB_BROWSER_CHANNEL")); - } - #[test] fn picks_allow_always_over_other_options() { let options = vec![ diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index db7a5cdfc..c781d4eb4 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -478,17 +478,6 @@ impl SessionPool { None => None, } } - // Bridge mode (Option C): the agent's static mcp.json points at `openab - // browser-bridge`, which dials the pod-wide socket server (started at boot). - // Just ensure the write-once config exists — no per-session server/guard. - crate::mcp_proxy::BrowserMode::Bridge => { - if let Err(e) = - crate::mcp_proxy::write_bridge_mcp_config(&effective_workdir).await - { - warn!(thread_id, error = %e, "failed to write bridge mcp config"); - } - None - } // Proxy mode (default): per-session loopback HTTP MCP server + dynamic config. crate::mcp_proxy::BrowserMode::Proxy => { match crate::mcp_proxy::start_session_server( @@ -533,7 +522,6 @@ impl SessionPool { &effective_workdir, &spawn_env, &self.config.inherit_env, - thread_id.strip_prefix("acp:"), ) .await?; diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 07b48f25e..1a238bec1 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -21,7 +21,6 @@ use rmcp::transport::streamable_http_server::{ use rmcp::ServerHandler; use axum::response::IntoResponse; use serde_json::{json, Value}; -use tracing::warn; use std::sync::Arc; /// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT @@ -447,55 +446,6 @@ async fn cleanup_kiro_agent_configs(workdir: &str, url: &str) { } } -/// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json -/// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer -/// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be -/// written once and never goes stale. That fixes the shared-config clobber the per-session dynamic -/// write suffers when several sessions of one agent share a single mcp.json. Merges without -/// touching the user's other servers; idempotent (a no-op when already present + identical). -pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { - // Pure static entry — byte-identical for every session (idempotent, no cross-session clobber). - // The channel is deliberately NOT carried here: the MCP client scrubs the server's env and its - // config-var expansion is vendor-specific, so the `openab browser-bridge` shim resolves its OWN - // channel by walking up to the agent process (Option C b2). This entry never goes stale. - let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); - let cfg_paths = [ - std::path::Path::new(workdir).join(".cursor").join("mcp.json"), - std::path::Path::new(workdir) - .join(".kiro") - .join("settings") - .join("mcp.json"), - ]; - for cfg_path in &cfg_paths { - if let Some(dir) = cfg_path.parent() { - tokio::fs::create_dir_all(dir).await?; - } - let mut cfg: Value = match tokio::fs::read(cfg_path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), - Err(_) => json!({}), - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } - // Idempotent: only rewrite when absent or changed (no needless mtime churn each session). - if cfg["mcpServers"]["openab-browser"] != entry { - cfg["mcpServers"]["openab-browser"] = entry.clone(); - tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; - } - } - // kiro `--agent` deployments read agent files, not settings/mcp.json (same - // gap as the proxy-mode writer). The static entry is idempotent there too. - merge_kiro_agent_configs(workdir, &entry).await?; - Ok(()) -} - -/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config -/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — -/// the per-session secret is NOT in the file: the entry references the -/// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned -/// agent process (config-var expansion is exactly how deployed agents already reference -/// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token -/// dies with the agent process and its registry entry. /// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be dropped /// when facade mode takes over. /// @@ -535,6 +485,13 @@ fn strip_direct_browser_entry(cfg: &mut Value) -> bool { } } +/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config +/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — +/// the per-session secret is NOT in the file: the entry references the +/// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned +/// agent process (config-var expansion is exactly how deployed agents already reference +/// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token +/// dies with the agent process and its registry entry. pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { let entry = json!({ "url": facade_url, @@ -645,9 +602,13 @@ pub trait SessionTokenRegistrar: Send + Sync { fn revoke(&self, token: &str); } -/// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into -/// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe -/// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. +/// Selected browser transport. `OPENAB_BROWSER_MODE=proxy` opts out of facade routing; anything +/// else, including unset, uses the facade. +/// +/// The stdio bridge was the third variant and is gone. `bridge` is now simply an unrecognised +/// value and falls through to `Facade` like any other — deliberately, so a deployment still +/// carrying `OPENAB_BROWSER_MODE=bridge` from the previous release comes up with working browser +/// control rather than refusing to start on a value that used to be valid. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserMode { /// Browser tools served through the OAB MCP Facade as a session-aware @@ -655,21 +616,13 @@ pub enum BrowserMode { /// broker-minted tokens). The default when the facade is running; /// falls back to `Proxy` when it is not (no `[mcp]` in config). Facade, - /// Per-session loopback HTTP MCP server + dynamic config (the original - /// default; explicit opt-out from facade routing). + /// Per-session loopback HTTP MCP server + dynamic config (explicit opt-out + /// from facade routing). Proxy, - Bridge, -} - -impl BrowserMode { - pub fn is_bridge(self) -> bool { - matches!(self, BrowserMode::Bridge) - } } fn parse_browser_mode(s: Option<&str>) -> BrowserMode { match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { - Some("bridge") => BrowserMode::Bridge, Some("proxy") => BrowserMode::Proxy, _ => BrowserMode::Facade, } @@ -680,309 +633,11 @@ pub fn browser_mode() -> BrowserMode { parse_browser_mode(std::env::var("OPENAB_BROWSER_MODE").ok().as_deref()) } -/// Per-pod browser-bridge socket path (overridable via `OPENAB_BROWSER_SOCKET`). Single source of -/// truth shared by the core socket server and the `openab browser-bridge` shim so they agree. -pub fn browser_socket_path() -> std::path::PathBuf { - if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { - return p.into(); - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); - std::path::Path::new(&home).join(".openab").join("browser.sock") -} - -// ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- -// A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim -// (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests -// tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes -// `tools/call` to that session's AcpMcpTunnel. This is the stable, variant-agnostic replacement -// for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: -// bridge → core : {"channel_id": "...", "request": } -// core → bridge : (omitted for notifications) - -const BROWSER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; - -fn mcp_result(id: Value, result: Value) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "result": result }) -} - -fn mcp_error(id: Value, code: i64, message: &str) -> Value { - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) -} - -/// Dispatch one inner MCP request for `channel_id`, backing `tools/call` with the shared browser -/// tunnel. Returns the MCP response, or `None` for a JSON-RPC notification (no reply). Same tool -/// set + not-connected semantics as the HTTP `ProxyHandler` (single source of truth). -pub(crate) async fn dispatch_browser_mcp( - channel_id: &str, - request: &Value, - tunnel: &Option>, -) -> Option { - // A JSON-RPC notification has no `id` → no response. - let id = request.get("id").cloned()?; - let method = request.get("method").and_then(Value::as_str).unwrap_or(""); - let resp = match method { - "initialize" => mcp_result( - id, - json!({ - "protocolVersion": BROWSER_MCP_PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": "openab-browser", "version": env!("CARGO_PKG_VERSION") } - }), - ), - "tools/list" => { - let tools = serde_json::to_value(browser_tools()).unwrap_or_else(|_| json!([])); - mcp_result(id, json!({ "tools": tools })) - } - "tools/call" => match tunnel { - // Empty server_id sentinel (Fork A): the bridge frame carries no server id yet; - // RootBrowserTunnel resolves the sole tunnel on the channel. Real per-server routing - // (server_id in the frame) lands in P2. - Some(t) => match t - .call(channel_id, "", "tools/call", request.get("params").cloned()) - .await - { - Ok(v) => mcp_result(id, v), - Err(e) => mcp_error(id, -32603, &e), - }, - None => mcp_error( - id, - -32603, - "browser not connected: open the OpenAB side panel in your browser", - ), - }, - other => mcp_error(id, -32601, &format!("method not found: {other}")), - }; - Some(resp) -} - -/// Serve the per-pod browser-bridge socket at `path`, routing each connection's framed requests -/// via [`dispatch_browser_mcp`]. Binds a fresh 0600 unix socket (same-uid only), spawns the accept -/// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. -/// Extract `OPENAB_BROWSER_CHANNEL` from a null-separated `/proc//environ` blob. -fn parse_channel_from_environ(bytes: &[u8]) -> Option { - for kv in bytes.split(|b| *b == 0) { - if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { - let v = String::from_utf8_lossy(rest).into_owned(); - if !v.is_empty() { - return Some(v); - } - } - } - None -} - -/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may -/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". -fn parse_ppid_from_stat(stat: &str) -> Option { - let after = &stat[stat.rfind(')')? + 1..]; - after.split_whitespace().nth(1)?.parse().ok() -} - -/// Walk up from `start_pid` and return the first ancestor's `OPENAB_BROWSER_CHANNEL`. -/// -/// This is the **authoritative** channel for a bridge connection: the agent process openab -/// spawned carries the variable, and the shim it spawns is always a descendant. Deriving it from -/// a kernel-supplied peer pid means a caller cannot choose which session it drives — unlike the -/// `channel_id` in the frame, which is merely a claim (review R2). -pub fn channel_from_process_ancestry(start_pid: u32) -> Option { - let mut pid = start_pid; - for _ in 0..16 { - if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { - if let Some(c) = parse_channel_from_environ(&bytes) { - return Some(c); - } - } - let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - match parse_ppid_from_stat(&stat) { - Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) - _ => break, - } - } - None -} - -/// Maps a connecting peer's pid to the channel it is allowed to drive. Injectable so the socket -/// server can be tested without a real agent process tree above the test binary. -pub type ChannelResolver = Arc Option + Send + Sync>; - -/// Hard ceiling for one bridge frame (review R4). -/// -/// Matches the ACP tunnel's own frame ceiling so the bridge is never the tighter bottleneck for -/// legitimate MCP traffic, while still bounding what a single frame can make us allocate. -const MAX_BRIDGE_FRAME_BYTES: usize = 8 << 20; // 8 MiB - -/// Read one newline-terminated frame, refusing to buffer more than `max` bytes. -/// -/// `BufReader::lines()` grows until it sees a newline, so a peer that never sends one pins an -/// arbitrarily large allocation — no malice required, a wedged writer does it too. Returns -/// `Ok(None)` at EOF, and `InvalidData` once the pending frame would exceed `max`; the caller -/// drops the connection rather than trying to resynchronise mid-frame. -async fn read_frame_bounded(reader: &mut R, max: usize) -> std::io::Result>> -where - R: tokio::io::AsyncBufRead + Unpin, -{ - use tokio::io::AsyncBufReadExt; - let mut out: Vec = Vec::new(); - loop { - // Copy out of the fill buffer before consuming: `available` borrows the reader. - let (chunk, terminated) = { - let available = reader.fill_buf().await?; - if available.is_empty() { - return Ok((!out.is_empty()).then_some(out)); // EOF - } - match available.iter().position(|b| *b == b'\n') { - Some(pos) => (available[..pos].to_vec(), true), - None => (available.to_vec(), false), - } - }; - if out.len() + chunk.len() > max { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("bridge frame exceeds {max} bytes"), - )); - } - out.extend_from_slice(&chunk); - reader.consume(chunk.len() + usize::from(terminated)); - if terminated { - return Ok(Some(out)); - } - } -} - -pub async fn serve_browser_socket( - path: std::path::PathBuf, - tunnel: Option>, - ct: tokio_util::sync::CancellationToken, -) -> std::io::Result<()> { - let resolver: ChannelResolver = Arc::new(channel_from_process_ancestry); - serve_browser_socket_with_resolver(path, tunnel, resolver, ct).await -} - -/// [`serve_browser_socket`] with an injectable peer→channel resolver (tests). -pub async fn serve_browser_socket_with_resolver( - path: std::path::PathBuf, - tunnel: Option>, - resolver: ChannelResolver, - ct: tokio_util::sync::CancellationToken, -) -> std::io::Result<()> { - let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run - let listener = tokio::net::UnixListener::bind(&path)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - } - tokio::spawn(async move { - loop { - tokio::select! { - _ = ct.cancelled() => break, - accepted = listener.accept() => { - match accepted { - Ok((stream, _)) => { - tokio::spawn(handle_browser_conn( - stream, - tunnel.clone(), - resolver.clone(), - )); - } - Err(_) => continue, - } - } - } - } - let _ = tokio::fs::remove_file(&path).await; - }); - Ok(()) -} - -/// Start the browser socket for the process lifetime (no external cancellation handle) — used by -/// the broker in bridge mode. The pod-wide server lives as long as the process, so no caller-side -/// tokio-util dependency is needed. -pub async fn serve_browser_socket_forever( - path: std::path::PathBuf, - tunnel: Option>, -) -> std::io::Result<()> { - serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await -} - -async fn handle_browser_conn( - stream: tokio::net::UnixStream, - tunnel: Option>, - resolver: ChannelResolver, -) { - use tokio::io::{AsyncWriteExt, BufReader}; - - // Authenticate the CONNECTION, not the frame (review R2). 0600 on the socket only proves the - // peer shares our uid; it does not say which session the peer belongs to. The `channel_id` in - // a frame is a claim the sender chooses, so trusting it let any same-uid process drive another - // live session's browser. Derive the channel from the kernel-supplied peer pid instead, and - // refuse the connection outright when it cannot be established — an unauthenticated peer gets - // no session at all rather than a default one. - let peer_pid = stream.peer_cred().ok().and_then(|c| c.pid()); - let Some(authenticated_channel) = peer_pid.and_then(|pid| resolver(pid as u32)) else { - warn!( - peer_pid = ?peer_pid, - "browser bridge: refusing a connection whose browser channel could not be established" - ); - return; - }; - - let (read_half, mut write_half) = stream.into_split(); - let mut reader = BufReader::new(read_half); - loop { - let line = match read_frame_bounded(&mut reader, MAX_BRIDGE_FRAME_BYTES).await { - Ok(Some(bytes)) => bytes, - Ok(None) => break, // EOF - Err(e) => { - // Oversized or unreadable: the stream cannot be resynchronised mid-frame, so the - // connection goes rather than leaving a partial frame buffered (R4). - warn!(peer_pid = ?peer_pid, error = %e, "browser bridge: dropping connection"); - break; - } - }; - let Ok(line) = String::from_utf8(line) else { - continue; // skip a non-UTF8 frame rather than drop the connection - }; - if line.trim().is_empty() { - continue; - } - let Ok(frame) = serde_json::from_str::(&line) else { - continue; // skip a malformed frame rather than drop the connection - }; - // A frame may still carry channel_id (the shim sends it), but it is only ever checked - // against the authenticated value — never used to select a session. - if let Some(claimed) = frame.get("channel_id").and_then(Value::as_str) { - if !claimed.is_empty() && claimed != authenticated_channel { - warn!( - peer_pid = ?peer_pid, - claimed, - "browser bridge: frame claimed a channel this peer does not own; dropping" - ); - continue; - } - } - let Some(request) = frame.get("request") else { - continue; - }; - if let Some(resp) = dispatch_browser_mcp(&authenticated_channel, request, &tunnel).await { - let Ok(mut buf) = serde_json::to_vec(&resp) else { - continue; - }; - buf.push(b'\n'); - if write_half.write_all(&buf).await.is_err() { - break; - } - } - } -} - #[cfg(test)] mod tests { use super::{ - browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, - is_openab_direct_browser_entry, merge_kiro_agent_configs, parse_browser_mode, - serve_browser_socket_with_resolver, spawn_mcp_server, start_session_server, - write_bridge_mcp_config, + browser_tools, cleanup_kiro_agent_configs, is_openab_direct_browser_entry, + merge_kiro_agent_configs, parse_browser_mode, spawn_mcp_server, start_session_server, write_facade_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; @@ -1340,13 +995,15 @@ mod tests { assert_eq!(parse_browser_mode(None), BrowserMode::Facade); assert_eq!(parse_browser_mode(Some("")), BrowserMode::Facade); assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Facade); - // Explicit opt-outs keep their exact prior semantics. + // The one remaining explicit opt-out keeps its exact prior semantics. assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); - assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); - assert!(BrowserMode::Bridge.is_bridge()); - assert!(!BrowserMode::Proxy.is_bridge()); - assert!(!BrowserMode::Facade.is_bridge()); + assert_eq!(parse_browser_mode(Some(" Proxy ")), BrowserMode::Proxy); + // `bridge` was a third mode and is gone. It must degrade to the default like any other + // unknown value rather than being special-cased into an error: a deployment still carrying + // OPENAB_BROWSER_MODE=bridge from the previous release has to come up with working browser + // control, not refuse to start on a value that used to be valid. + assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Facade); } struct MockTunnel; @@ -1414,277 +1071,6 @@ mod tests { Err("no browser attached".into()) } } - fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { - serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) - } - fn arc_tunnel(t: T) -> Option> { - Some(std::sync::Arc::new(t)) - } - - #[tokio::test] - async fn dispatch_initialize_advertises_tools() { - let r = dispatch_browser_mcp("acp_x", &req(1, "initialize", serde_json::json!({})), &None) - .await - .unwrap(); - assert_eq!(r["id"], 1); - assert_eq!(r["result"]["capabilities"]["tools"], serde_json::json!({})); - assert_eq!(r["result"]["serverInfo"]["name"], "openab-browser"); - } - - #[tokio::test] - async fn dispatch_tools_list_returns_five_tools() { - let r = dispatch_browser_mcp("acp_x", &req(2, "tools/list", serde_json::json!({})), &None) - .await - .unwrap(); - assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 5); - } - - #[tokio::test] - async fn dispatch_tools_call_routes_to_the_channel_tunnel() { - let tunnel = arc_tunnel(RecordTunnel { - result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), - }); - let r = dispatch_browser_mcp( - "acp_win1", - &req(3, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })), - &tunnel, - ) - .await - .unwrap(); - assert_eq!(r["result"]["content"][0]["text"], "ok"); - } - - #[tokio::test] - async fn dispatch_tools_call_without_tunnel_is_not_connected() { - let r = dispatch_browser_mcp( - "acp_x", - &req(4, "tools/call", serde_json::json!({ "name": "katashiro.click" })), - &None, - ) - .await - .unwrap(); - assert_eq!(r["error"]["code"], -32603); - assert!(r["error"]["message"].as_str().unwrap().contains("not connected")); - } - - #[tokio::test] - async fn dispatch_tools_call_surfaces_tunnel_error() { - let tunnel = arc_tunnel(ErrTunnel); - let r = dispatch_browser_mcp( - "acp_x", - &req(5, "tools/call", serde_json::json!({ "name": "katashiro.click" })), - &tunnel, - ) - .await - .unwrap(); - assert_eq!(r["error"]["code"], -32603); - assert_eq!(r["error"]["message"], "no browser attached"); - } - - #[tokio::test] - async fn dispatch_notification_gets_no_response() { - let notif = serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); - assert!(dispatch_browser_mcp("acp_x", ¬if, &None).await.is_none()); - } - - #[tokio::test] - async fn dispatch_unknown_method_is_method_not_found() { - let r = dispatch_browser_mcp("acp_x", &req(6, "bogus/thing", serde_json::json!({})), &None) - .await - .unwrap(); - assert_eq!(r["error"]["code"], -32601); - } - - #[tokio::test] - async fn write_bridge_config_writes_static_entry_to_both_variants() { - let dir = tempfile::tempdir().unwrap(); - write_bridge_mcp_config(dir.path().to_str().unwrap()) - .await - .unwrap(); - for rel in [".cursor/mcp.json", ".kiro/settings/mcp.json"] { - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(dir.path().join(rel)).unwrap()).unwrap(); - let e = &cfg["mcpServers"]["openab-browser"]; - assert_eq!(e["command"], "openab"); - assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); - assert!( - e.get("env").is_none(), - "channel is resolved by the shim (b2), not carried in config" - ); - assert!(e.get("url").is_none(), "bridge entry carries no url/port"); - assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); - } - } - - #[tokio::test] - async fn write_bridge_config_merges_without_clobber_and_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let cursor = dir.path().join(".cursor"); - std::fs::create_dir_all(&cursor).unwrap(); - std::fs::write( - cursor.join("mcp.json"), - r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, - ) - .unwrap(); - let wd = dir.path().to_str().unwrap(); - write_bridge_mcp_config(wd).await.unwrap(); - write_bridge_mcp_config(wd).await.unwrap(); // idempotent second call - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); - assert_eq!(cfg["mcpServers"]["other"]["url"], "http://x"); // user's server preserved - assert_eq!(cfg["mcpServers"]["openab-browser"]["command"], "openab"); - } - - #[tokio::test] - async fn browser_socket_round_trip() { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - let dir = tempfile::tempdir().unwrap(); - let sock = dir.path().join("browser.sock"); - let tunnel = arc_tunnel(RecordTunnel { - result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), - }); - let ct = tokio_util::sync::CancellationToken::new(); - // The peer here is the test binary, whose ancestry carries no channel, so inject the - // resolver the way a real deployment's process tree would answer. - serve_browser_socket_with_resolver( - sock.clone(), - tunnel, - std::sync::Arc::new(|_pid| Some("acp_win1".to_string())), - ct.clone(), - ) - .await - .unwrap(); - let stream = loop { - match tokio::net::UnixStream::connect(&sock).await { - Ok(s) => break s, - Err(_) => tokio::task::yield_now().await, - } - }; - let (rd, mut wr) = stream.into_split(); - let frame = serde_json::json!({ - "channel_id": "acp_win1", - "request": req(9, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })) - }); - let mut line = serde_json::to_vec(&frame).unwrap(); - line.push(b'\n'); - wr.write_all(&line).await.unwrap(); - let mut resp = String::new(); - BufReader::new(rd).read_line(&mut resp).await.unwrap(); - let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); - assert_eq!(v["id"], 9); - assert_eq!(v["result"]["content"][0]["text"], "ok"); - ct.cancel(); - } - - // --- R4: one frame cannot pin an unbounded allocation --- - - /// `BufReader::lines()` grows until it sees a newline, so a peer that never sends one — a - /// wedged writer just as much as a hostile one — pins an arbitrarily large buffer. Tested at a - /// small cap so the assertion is about the bound, not about allocating megabytes. - #[tokio::test] - async fn an_unterminated_frame_is_refused_once_it_passes_the_cap() { - // Terminated frames under the cap read normally, newline consumed. - let mut r = std::io::Cursor::new(b"hello\nworld\n".to_vec()); - assert_eq!( - super::read_frame_bounded(&mut r, 16).await.unwrap(), - Some(b"hello".to_vec()) - ); - assert_eq!( - super::read_frame_bounded(&mut r, 16).await.unwrap(), - Some(b"world".to_vec()) - ); - // EOF is None, not an error. - assert_eq!(super::read_frame_bounded(&mut r, 16).await.unwrap(), None); - - // Exactly at the cap is still allowed — the bound is inclusive. - let mut at_cap = std::io::Cursor::new(b"0123456789abcdef\n".to_vec()); - assert_eq!( - super::read_frame_bounded(&mut at_cap, 16).await.unwrap(), - Some(b"0123456789abcdef".to_vec()) - ); - - // Unterminated and over the cap: refused rather than buffered. - let mut flood = std::io::Cursor::new(vec![b'x'; 64]); - let err = super::read_frame_bounded(&mut flood, 16) - .await - .expect_err("an unterminated frame past the cap must be refused"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - - // A terminated frame that is merely too long is refused too. - let mut long = std::io::Cursor::new([vec![b'y'; 64], vec![b'\n']].concat()); - assert!(super::read_frame_bounded(&mut long, 16).await.is_err()); - } - - // --- R2: the socket authenticates the connection, not the frame --- - - #[test] - fn ancestry_parsers_read_proc_shapes() { - assert_eq!(super::parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); - assert_eq!(super::parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); - // ')' inside comm — split after the LAST ')' - assert_eq!(super::parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); - assert_eq!(super::parse_ppid_from_stat("nonsense"), None); - - let env = b"HOME=/h\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/x\0"; - assert_eq!( - super::parse_channel_from_environ(env).as_deref(), - Some("acp_xyz") - ); - assert_eq!(super::parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); - assert_eq!(super::parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); - } - - /// A same-uid peer must not be able to drive a session it does not own by naming it. The - /// socket's 0600 mode only proves same-uid; the channel comes from the peer's process - /// ancestry, and a frame claiming a different one is dropped rather than honoured. - #[tokio::test] - async fn a_frame_cannot_claim_a_channel_the_peer_does_not_own() { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - let dir = tempfile::tempdir().unwrap(); - let sock = dir.path().join("browser.sock"); - let tunnel = arc_tunnel(RecordTunnel { - result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), - }); - let ct = tokio_util::sync::CancellationToken::new(); - // This peer owns acp_win1 (RecordTunnel asserts it only ever sees that channel). - serve_browser_socket_with_resolver( - sock.clone(), - tunnel, - std::sync::Arc::new(|_pid| Some("acp_win1".to_string())), - ct.clone(), - ) - .await - .unwrap(); - let stream = loop { - match tokio::net::UnixStream::connect(&sock).await { - Ok(s) => break s, - Err(_) => tokio::task::yield_now().await, - } - }; - let (rd, mut wr) = stream.into_split(); - - // Claim someone else's session first, then a legitimate frame. - for (channel, id) in [("acp_victim", 1), ("acp_win1", 2)] { - let frame = serde_json::json!({ - "channel_id": channel, - "request": req(id, "tools/call", serde_json::json!({ "name": "katashiro.read_dom", "arguments": {} })) - }); - let mut line = serde_json::to_vec(&frame).unwrap(); - line.push(b'\n'); - wr.write_all(&line).await.unwrap(); - } - - // Only the legitimate frame is answered; the spoofed one produced no reply at all. - let mut resp = String::new(); - BufReader::new(rd).read_line(&mut resp).await.unwrap(); - let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); - assert_eq!( - v["id"], 2, - "the first response must be for the legitimate frame — the spoofed channel was dropped" - ); - ct.cancel(); - } - #[tokio::test] async fn start_session_server_writes_cursor_config() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 8a3b217ec..e6a1090c4 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -73,8 +73,7 @@ flowchart LR CORE["openab-core
MCP proxy /
aggregator"] AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] GW <--> CORE - CORE ==>|"proxy mode (default)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT - CORE -.->|"bridge mode (Option C)
per-pod unix socket + stdio relay
'openab browser-bridge' · static {command,args}
channel via process-ancestry (multi-window)"| AGENT + CORE -.->|"proxy mode (legacy opt-out)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT end EXT <==>|"UPSTREAM — only remote hop
MCP-over-ACP · mcp/message framing
multiplexed with ACP chat on ONE /acp WSS
8 MiB frame cap · JPEG screenshots"| GW classDef remote fill:#fde68a,stroke:#b45309,color:#111; @@ -310,10 +309,14 @@ tools over the tunnel. What changes is on the openab side — browser tools reac facade's meta-tools rather than a dedicated per-session MCP server. Retired once this lands: the per-session `mcp_proxy` browser server, its port/bearer minting, and the -`openab-browser` `mcp.json` injection. The **stdio bridge mode** (`OPENAB_BROWSER_MODE=bridge`, -`openab browser-bridge`) exists because some CLIs preferred a stdio entry; the facade is a loopback -HTTP MCP server that those CLIs read directly, so bridge mode is likely redundant — its removal is -**not** decided here and requires an explicit operator call. +`openab-browser` `mcp.json` injection. + +**Update — the operator call was made on 2026-07-28: bridge mode is removed.** The stdio bridge +(`OPENAB_BROWSER_MODE=bridge`, `openab browser-bridge`, the per-pod unix socket and its +process-ancestry channel resolver) existed because some CLIs preferred a stdio entry. The facade is +a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. `bridge` is now +an unrecognised mode value and degrades to `facade`; facade setup deletes the leftover static entry, +which is the only one whose exact shape proves we wrote it. **Open question (not decided).** Under the facade the LLM reaches a browser action via `search_capabilities` → `execute_capability`, one hop more per turn than today's direct @@ -330,9 +333,9 @@ per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegist facade is serving); `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose `Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's process environment rather than a config file — which also removes the shared-workdir exposure of the -old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Proxy and -Option C bridge modes remain as explicit `OPENAB_BROWSER_MODE` opt-outs. This covers §6.2's source seam -and session identity for the **browser** case. +old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Proxy mode +remains as the one explicit `OPENAB_BROWSER_MODE` opt-out; bridge mode was removed on 2026-07-28. +This covers §6.2's source seam and session identity for the **browser** case. ⚠️ **Divergence to reconcile with the adapter ADR (not resolved here).** Adapter ADR §6.2 says delivery is via ACP `session/new` `mcpServers`, and that "if a backing CLI does not honor ACP `mcpServers`, the @@ -476,7 +479,8 @@ The OpenAB side was wired end-to-end on 2026-07-20 and live-validated on a real loop (read_dom / screenshot / navigate / click / type), the side-panel status pill, and reconnect on `session/resume`. At that point the realised path was `agent → core per-session ProxyHandler → tunnel trait → root impl → AcpTunnelRegistry → extension`, -with per-agent config injection. `bridge` mode (stdio relay, Option C) shipped alongside. +with per-agent config injection. `bridge` mode (stdio relay, Option C) shipped alongside and was +removed on 2026-07-28. The facade integration in §6 replaced the per-session proxy as the default on 2026-07-25/26 and was live-validated the same way: with `[mcp]` enabled, `search_capabilities` returns provider diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index 3c5df50be..53ac8d298 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -6,32 +6,40 @@ The browser MCP server exposes five DOM-semantic tools — [tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the colocated agent CLI actually **sees** those tools. -There are three transports, selected by `OPENAB_BROWSER_MODE`. **Facade is the default and the one -to use**; `proxy` and `bridge` predate it and are kept as explicit opt-outs. +There are two transports, selected by `OPENAB_BROWSER_MODE`. **Facade is the default and the one +to use**; `proxy` predates it and is kept as an explicit opt-out. | mode | how the agent reaches the tools | status | |---|---|---| | unset / `facade` | through the **OAB MCP Facade**, one listener, static config entry | **default** | | `proxy` | per-session loopback MCP server + per-session config rewrite | legacy opt-out | -| `bridge` | per-pod unix socket + `openab browser-bridge` stdio relay (Option C) | legacy opt-out | With no `[mcp]` section in `config.toml` the facade is not serving, and facade mode falls back to `proxy` automatically. -> ⚠️ **Switching modes does not clean up the previous mode's config entry, and a leftover entry -> silently bypasses the facade.** Each writer only adds its own entry — facade mode writes `openab`, -> bridge mode writes `openab-browser` — and neither removes the other's. An agent whose `mcp.json` -> still carries a stale `openab-browser` entry loads **both** servers, so the same -> `katashiro.*` tools are reachable twice: once through the facade (policy + audit) and once -> directly through the old transport (**neither**). The model will happily call the direct one, -> and every trace of that call is missing from the audit log while the tool works perfectly. +> ⚠️ **`bridge` mode has been removed.** The per-pod unix socket, the `openab browser-bridge` +> subcommand and the stdio relay are gone. `OPENAB_BROWSER_MODE=bridge` is now simply an +> unrecognised value and resolves to `facade`, so a deployment still carrying it upgrades into +> working browser control instead of refusing to start — but it no longer does what it says, and +> should be removed from your environment. > -> After changing `OPENAB_BROWSER_MODE`, remove the other mode's entry from each agent's config: +> Facade setup **deletes a leftover `openab-browser` bridge entry for you** on the next session, in +> `.cursor/mcp.json`, `.kiro/settings/mcp.json` and the kiro agent files (including its +> `@openab-browser` grant). Left in place, that entry names a subcommand that no longer exists, so +> the agent's MCP client would try and fail to start it every session. It is removed only when it +> is byte-identical to the entry we wrote — `{"command":"openab","args":["browser-bridge"]}` — +> because that exact shape is the only proof we have that it is ours rather than a server you +> configured under the same key. +> +> A stale **proxy** entry is deliberately *not* removed: its url and bearer were minted per session +> and never recorded anywhere, so under that key we cannot tell your server from ours. It is dead +> configuration — the port died with its session — but if you want it gone, remove it yourself: > > ```sh -> # leaving facade mode's entry only -> jq 'del(.mcpServers["openab-browser"])' "$HOME/.cursor/mcp.json" -> jq 'del(.mcpServers["openab-browser"])' "$HOME/.kiro/settings/mcp.json" +> # edits in place; check the diff before trusting it +> for f in "$HOME/.cursor/mcp.json" "$HOME/.kiro/settings/mcp.json"; do +> [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"])' "$f" > "$f.tmp" && mv "$f.tmp" "$f" +> done > ``` > > Symptom to watch for: browser tools work, but no `mcp.audit` line is ever emitted for them. diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs deleted file mode 100644 index 97323451c..000000000 --- a/src/browser_bridge.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! `openab browser-bridge` — a stdio MCP server that is a thin relay to the per-pod browser -//! socket (Option C). The agent's MCP client spawns it per session; it reads -//! `OPENAB_BROWSER_CHANNEL` from its inherited env, wraps each stdin MCP request as -//! `{channel_id, request}`, forwards it to the core socket, and relays responses to stdout -//! verbatim. ALL browser MCP logic lives in core (`dispatch_browser_mcp`) — this is a pure pipe -//! + channel tag, so the config line agents carry is static (`{"command":"openab","args": -//! ["browser-bridge"]}`) and disambiguation is by the inherited env, never by config. -//! -//! stdout carries the MCP wire, so this path emits nothing to stdout except MCP responses -//! (diagnostics, if any, go to stderr). - -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - -/// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` -/// for a blank/unparseable line (skip it) so a stray line can't break the relay. -fn wrap_frame(channel: &str, line: &str) -> Option> { - let line = line.trim(); - if line.is_empty() { - return None; - } - let request: Value = serde_json::from_str(line).ok()?; - let frame = json!({ "channel_id": channel, "request": request }); - let mut buf = serde_json::to_vec(&frame).ok()?; - buf.push(b'\n'); - Some(buf) -} - -/// Resolve this bridge's browser channel. The MCP client scrubs the child env (verified: cursor -/// launches us with only HOME/PATH/USER, or the pod env — never the per-session -/// OPENAB_BROWSER_CHANNEL openab injects into the AGENT), so we can't read it from our own env. -/// Instead walk UP the process tree and read OPENAB_BROWSER_CHANNEL from the first ancestor that -/// has it: the agent process openab spawned (channel injected via the pool) is always an ancestor -/// of this stdio MCP server, for EVERY vendor. Prefer our own env first (clients that DO pass it); -/// return "" if not found (core then reports "no browser attached"). -fn resolve_channel() -> String { - if let Ok(c) = std::env::var("OPENAB_BROWSER_CHANNEL") { - if !c.is_empty() { - return c; - } - } - // Same ancestry walk the socket server now performs on our pid (review R2). Kept here only so - // the frame still carries a channel for logging/compatibility — the server derives its own and - // never trusts this one, so a wrong answer here is refused rather than honoured. - openab_core::mcp_proxy::channel_from_process_ancestry(std::process::id()).unwrap_or_default() -} - -/// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and -/// socket→stdout (verbatim MCP responses) until either side closes. -pub async fn run() -> std::io::Result<()> { - let channel = resolve_channel(); - eprintln!("[openab browser-bridge] resolved channel={channel:?}"); - let sock = - tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; - let (sock_rd, sock_wr) = sock.into_split(); - pump( - channel, - BufReader::new(tokio::io::stdin()), - tokio::io::stdout(), - BufReader::new(sock_rd), - sock_wr, - ) - .await -} - -/// The relay, generic over the four streams so it can be tested with in-memory pipes. Ends when -/// either stdin (agent gone) or the socket (core gone) closes. -async fn pump( - channel: String, - mut stdin: In, - mut stdout: Out, - mut sock_rd: SockR, - mut sock_wr: SockW, -) -> std::io::Result<()> -where - In: AsyncBufReadExt + Unpin, - Out: AsyncWriteExt + Unpin, - SockR: AsyncBufReadExt + Unpin, - SockW: AsyncWriteExt + Unpin, -{ - let to_sock = async { - let mut line = String::new(); - loop { - line.clear(); - if stdin.read_line(&mut line).await? == 0 { - break; // stdin closed → agent gone - } - if let Some(frame) = wrap_frame(&channel, &line) { - sock_wr.write_all(&frame).await?; - sock_wr.flush().await?; - } - } - Ok::<(), std::io::Error>(()) - }; - let to_stdout = async { - let mut line = String::new(); - loop { - line.clear(); - if sock_rd.read_line(&mut line).await? == 0 { - break; // socket closed → core gone - } - stdout.write_all(line.as_bytes()).await?; - stdout.flush().await?; - } - Ok::<(), std::io::Error>(()) - }; - // Whichever side closes first ends the relay; the other pump is dropped (we're shutting down). - tokio::select! { - r = to_sock => r, - r = to_stdout => r, - } -} - -#[cfg(test)] -mod tests { - use super::{pump, wrap_frame}; - - // The `/proc` parsing these covered now lives in openab-core, next to the socket server that - // authenticates with it, and is tested there. - - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - #[test] - fn wrap_frame_tags_the_channel_and_appends_newline() { - let out = wrap_frame("acp_win1", r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).unwrap(); - assert_eq!(*out.last().unwrap(), b'\n'); - let v: serde_json::Value = serde_json::from_slice(&out[..out.len() - 1]).unwrap(); - assert_eq!(v["channel_id"], "acp_win1"); - assert_eq!(v["request"]["method"], "tools/list"); - assert_eq!(v["request"]["id"], 1); - } - - #[test] - fn wrap_frame_skips_blank_and_malformed_lines() { - assert!(wrap_frame("c", " ").is_none()); - assert!(wrap_frame("c", "").is_none()); - assert!(wrap_frame("c", "not json").is_none()); - } - - #[tokio::test] - async fn pump_relays_request_to_socket_and_response_to_stdout() { - // Four in-memory pipes standing in for stdin, stdout, and the two socket halves. - let (mut stdin_w, stdin_r) = tokio::io::duplex(1024); - let (stdout_w, mut stdout_r) = tokio::io::duplex(1024); - let (mut sock_peer_w, sock_rd) = tokio::io::duplex(1024); // core → bridge (responses) - let (sock_wr, mut sock_peer_r) = tokio::io::duplex(1024); // bridge → core (frames) - - let handle = tokio::spawn(pump( - "acp_win1".to_string(), - BufReader::new(stdin_r), - stdout_w, - BufReader::new(sock_rd), - sock_wr, - )); - - // Agent writes an MCP request on stdin → bridge should emit a channel-tagged frame to core. - stdin_w - .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{}}\n") - .await - .unwrap(); - let mut frame = String::new(); - BufReader::new(&mut sock_peer_r).read_line(&mut frame).await.unwrap(); - let fv: serde_json::Value = serde_json::from_str(&frame).unwrap(); - assert_eq!(fv["channel_id"], "acp_win1"); - assert_eq!(fv["request"]["id"], 9); - - // Core writes an MCP response on the socket → bridge should relay it verbatim to stdout. - sock_peer_w - .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"ok\":true}}\n") - .await - .unwrap(); - let mut out = String::new(); - BufReader::new(&mut stdout_r).read_line(&mut out).await.unwrap(); - let ov: serde_json::Value = serde_json::from_str(&out).unwrap(); - assert_eq!(ov["id"], 9); - assert_eq!(ov["result"]["ok"], true); - - drop(stdin_w); // agent gone → relay ends - let _ = handle.await; - } -} diff --git a/src/main.rs b/src/main.rs index e45024be7..34783acdb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,8 +13,6 @@ mod unified_adapter; mod browser_tunnel; #[cfg(feature = "acp")] mod browser_source; -#[cfg(feature = "acp")] -mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -105,11 +103,6 @@ enum Commands { #[arg(long, default_value = "kiro-cli acp --trust-all-tools")] command: String, }, - /// Internal: stdio MCP bridge to the per-pod browser socket (Option C). Spawned per session - /// by the agent's MCP client; relays MCP over stdio to core's browser tunnel by inherited - /// OPENAB_BROWSER_CHANNEL. - #[cfg(feature = "acp")] - BrowserBridge, /// Set a runtime value (e.g. thread.name) Set { /// Key to set (e.g. thread.name) @@ -320,10 +313,6 @@ async fn main() -> anyhow::Result<()> { } => { return acp::agentcore::run_bridge(&runtime_arn, ®ion, &command).await; } - #[cfg(feature = "acp")] - Commands::BrowserBridge => { - return browser_bridge::run().await.map_err(Into::into); - } Commands::Set { key, value, thread } => { let resp = ctl::send_request(&ctl::Request { action: ctl::Action::Set, @@ -493,15 +482,17 @@ async fn main() -> anyhow::Result<()> { if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); - #[allow(unused_mut)] - let mut sources: Vec> = Vec::new(); + // The ACP tunnel source is registered unconditionally under the `acp` feature. It used to + // be skipped in bridge mode; with the bridge gone there is no mode in which the facade + // runs without it. #[cfg(feature = "acp")] - if !openab_core::mcp_proxy::browser_mode().is_bridge() { - sources.push(Arc::new(browser_source::AcpTunnelSource::with_config( + let sources: Vec> = + vec![Arc::new(browser_source::AcpTunnelSource::with_config( browser_tunnel.clone(), &mcp_cfg.acp_servers, - ))); - } + ))]; + #[cfg(not(feature = "acp"))] + let sources: Vec> = Vec::new(); tokio::spawn(async move { if let Err(e) = openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await @@ -537,21 +528,6 @@ async fn main() -> anyhow::Result<()> { ) }), ); - // Option C bridge mode: start the per-pod browser socket server once; the `openab - // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. - #[cfg(feature = "acp")] - if openab_core::mcp_proxy::browser_mode().is_bridge() { - let sock = openab_core::mcp_proxy::browser_socket_path(); - match openab_core::mcp_proxy::serve_browser_socket_forever( - sock.clone(), - Some(browser_tunnel.clone()), - ) - .await - { - Ok(()) => info!(?sock, "browser bridge socket serving (Option C)"), - Err(e) => warn!(?sock, error = %e, "failed to start browser bridge socket"), - } - } let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; From feded44c2ffbab3ddd15e00385acc4c3c359b72f Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 03:16:27 +0800 Subject: [PATCH 079/138] fix(acp): warn when OPENAB_BROWSER_MODE names the removed bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting the stale value keeps an upgraded deployment running, but accepting it silently leaves the operator believing they are on a transport that was deleted. Warn once per process, and warn with the transport actually in use. That distinction is the substance of the fix, not a detail of wording. Two demotions compose here: `bridge` is unrecognised so it falls through to Facade, and Facade falls back to Proxy when no `[mcp]` wired a registrar. An operator who set `bridge` and has no `[mcp]` is running **proxy** — the transport furthest from what they asked for. A warning that echoed the parse result would tell them "using facade" and be wrong. So the resolution and the warning now live in one place. `resolve_browser_mode` is pure and owns both demotions, so it is testable without touching process env; `browser_mode_effective` reads the env, resolves, and warns once with the resolved value. The pool's inline Facade->Proxy fallback moved into it, and `browser_mode` is removed rather than left as a second entry point that would resolve without warning. `is_unrecognised_mode` deliberately excludes unset and empty: those express no preference, and warning on them would fire for every default deployment and train operators to ignore the line. Also corrects the doc on the old `browser_mode`, which still said "default: proxy" — stale since facade became the default, and directly below an enum doc that had already been rewritten to say so. Raised by Falcon reviewing 6ea4b447. Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 13 ++-- crates/openab-core/src/mcp_proxy.rs | 93 +++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index c781d4eb4..be8f6cd3f 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -428,14 +428,11 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] let mcp_guard: Option = if let Some(channel_id) = thread_id.strip_prefix("acp:") { - let mode = match crate::mcp_proxy::browser_mode() { - crate::mcp_proxy::BrowserMode::Facade - if self.session_registrar.is_none() || self.facade_url.is_none() => - { - crate::mcp_proxy::BrowserMode::Proxy - } - m => m, - }; + // The Facade -> Proxy fallback lives inside `browser_mode_effective` so that the + // one place resolving the transport is also the place that can warn about it. + let facade_available = + self.session_registrar.is_some() && self.facade_url.is_some(); + let mode = crate::mcp_proxy::browser_mode_effective(facade_available); match mode { crate::mcp_proxy::BrowserMode::Facade => { // unwraps guarded by the fallback arm above diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 1a238bec1..0e8ab1d78 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -628,16 +628,66 @@ fn parse_browser_mode(s: Option<&str>) -> BrowserMode { } } -/// Read the browser transport mode from `OPENAB_BROWSER_MODE` (default: proxy). -pub fn browser_mode() -> BrowserMode { - parse_browser_mode(std::env::var("OPENAB_BROWSER_MODE").ok().as_deref()) +/// True when `OPENAB_BROWSER_MODE` is set to something that is not a transport we still have. +/// +/// Empty and unset are not "unrecognised" — they mean "no preference expressed". Only a value the +/// operator deliberately wrote and that no longer selects anything counts, which today is `bridge` +/// and any typo. +fn is_unrecognised_mode(raw: Option<&str>) -> Option<&str> { + let v = raw?.trim(); + if v.is_empty() || v.eq_ignore_ascii_case("proxy") || v.eq_ignore_ascii_case("facade") { + return None; + } + Some(v) +} + +/// Resolve the transport actually in use from the configured value and whether the facade is +/// really serving. Pure, so the resolution is testable without touching process env. +/// +/// Two separate demotions land here and both are silent to the operator on their own: an +/// unrecognised value falls through to `Facade`, and `Facade` falls back to `Proxy` when no +/// `[mcp]` wired a registrar. Composing them is how `OPENAB_BROWSER_MODE=bridge` ends up running +/// **proxy** — which is why the caller warns with the resolved value rather than the requested one. +fn resolve_browser_mode(raw: Option<&str>, facade_available: bool) -> BrowserMode { + match parse_browser_mode(raw) { + BrowserMode::Facade if !facade_available => BrowserMode::Proxy, + m => m, + } +} + +/// The transport to use for this process, resolved against whether the facade is serving. +/// +/// `facade_available` is false when no `[mcp]` section wired a session registrar or facade url. +/// +/// Warns once per process when `OPENAB_BROWSER_MODE` names a transport that no longer exists. +/// Accepting the stale value keeps an upgraded deployment running; accepting it *silently* would +/// leave the operator believing they are on a transport that was deleted, so the warning names the +/// transport actually in use — not the one they asked for, and not merely the one that parsing +/// picked, since the `[mcp]` fallback can demote that again. +pub fn browser_mode_effective(facade_available: bool) -> BrowserMode { + let raw = std::env::var("OPENAB_BROWSER_MODE").ok(); + let mode = resolve_browser_mode(raw.as_deref(), facade_available); + if let Some(requested) = is_unrecognised_mode(raw.as_deref()) { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + tracing::warn!( + requested, + effective = ?mode, + "OPENAB_BROWSER_MODE names a transport that no longer exists (the stdio bridge was \ + removed); continuing on the transport shown as `effective`. Unset the variable, \ + or set it to `proxy`, to make the configuration say what is actually running." + ); + }); + } + mode } #[cfg(test)] mod tests { use super::{ browser_tools, cleanup_kiro_agent_configs, is_openab_direct_browser_entry, - merge_kiro_agent_configs, parse_browser_mode, spawn_mcp_server, start_session_server, + is_unrecognised_mode, merge_kiro_agent_configs, parse_browser_mode, resolve_browser_mode, + spawn_mcp_server, start_session_server, write_facade_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, }; @@ -1006,6 +1056,41 @@ mod tests { assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Facade); } + /// The two demotions compose, and the second one is the reason the warning cannot just echo + /// what parsing returned: `bridge` degrades to Facade, and Facade degrades again to Proxy when + /// no `[mcp]` is configured. An operator who set `bridge` and has no facade is running + /// **proxy** — the transport furthest from what they wrote. + #[test] + fn a_removed_mode_resolves_through_both_demotions_to_the_transport_actually_running() { + assert_eq!( + resolve_browser_mode(Some("bridge"), false), + BrowserMode::Proxy, + "bridge + no [mcp] must resolve to proxy, which is what the operator is really running" + ); + assert_eq!(resolve_browser_mode(Some("bridge"), true), BrowserMode::Facade); + // An explicit opt-out is not a fallback and is never re-resolved. + assert_eq!(resolve_browser_mode(Some("proxy"), true), BrowserMode::Proxy); + assert_eq!(resolve_browser_mode(Some("proxy"), false), BrowserMode::Proxy); + // Unset behaves like any other non-preference. + assert_eq!(resolve_browser_mode(None, true), BrowserMode::Facade); + assert_eq!(resolve_browser_mode(None, false), BrowserMode::Proxy); + } + + /// Only a value the operator actually wrote and that no longer selects anything is worth + /// warning about. Warning on unset would fire for every default deployment, and warning on a + /// live value would train operators to ignore it. + #[test] + fn only_a_deliberately_set_dead_value_is_reported_as_unrecognised() { + assert_eq!(is_unrecognised_mode(Some("bridge")), Some("bridge")); + assert_eq!(is_unrecognised_mode(Some(" Bridge ")), Some("Bridge")); + assert_eq!(is_unrecognised_mode(Some("typo")), Some("typo")); + assert_eq!(is_unrecognised_mode(None), None); + assert_eq!(is_unrecognised_mode(Some("")), None); + assert_eq!(is_unrecognised_mode(Some(" ")), None); + assert_eq!(is_unrecognised_mode(Some("proxy")), None); + assert_eq!(is_unrecognised_mode(Some("FACADE")), None); + } + struct MockTunnel; #[async_trait::async_trait] impl AcpMcpTunnel for MockTunnel { From 01dff0193df9ff6d915f8f4d80c9faa14975b199 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 03:52:32 +0800 Subject: [PATCH 080/138] feat(acp)!: remove the per-session proxy and require [mcp] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: browser control now requires an `[mcp]` section in config.toml. Without one there is none, and nothing is auto-started. With the bridge gone the proxy was the last alternative to the facade, and keeping it meant keeping a second way to reach the browser that the facade's policy and audit do not cover. Removed: `ProxyHandler` and its `ServerHandler` impl, `require_bearer`, `spawn_mcp_server`, `start_session_server`, the per-session config write and cleanup, and `merge_kiro_agent_configs` / `cleanup_kiro_agent_configs`. The pool's three-arm transport match collapses to one facade path, which deletes the silent `Facade -> Proxy` demotion. That demotion is the reason `[mcp]` could be absent without anyone noticing: browser tools still appeared, served by a transport the operator had not chosen. Now an unconfigured deployment has no browser control and is told so once at startup. `OPENAB_BROWSER_MODE` no longer selects anything, so the enum, the parser and the resolver introduced one commit ago all go with it. What replaces them is a startup notice rather than nothing: `warn_if_browser_mode_set` reports the value that is being ignored *and* whether browser control survives (`facade` or `disabled`), because "ignored" alone does not tell an operator whether they still have the feature. It runs once at configuration time rather than on the session path, where a warning would repeat per spawn. `proxy` warns as loudly as `bridge`. It is equally inert now, and it is the value more deployments actually set, so staying quiet for it would rebuild the silence this notice exists to remove. `browser_mode_migration_notice` holds the decision as a pure function so it is testable without a subscriber or process env — the same split that made `resolve_browser_mode` reviewable. Kept: `browser_tools` (the facade source's seed catalog), `write_private`, and the bridge-entry matcher, which is now migration cleanup. Docs: the setup guide's two "Legacy: " sections are replaced by one migration note. The bridge section had survived the previous commit describing a socket server and shim that no longer exist, in the present tense. Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 115 ++-- crates/openab-core/src/mcp_proxy.rs | 907 ++++------------------------ docs/browser-mcp-agent-setup.md | 101 ++-- src/main.rs | 6 + 4 files changed, 201 insertions(+), 928 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index be8f6cd3f..c91fff859 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -417,87 +417,50 @@ impl SessionPool { self.config.working_dir.clone() }; - // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP - // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The - // returned guard cancels that server when this connection is dropped (any evict path). - // Facade mode: mint the per-session token (returned env var rides the - // agent spawn below) and write the static facade entry once. Falls - // back to Proxy mode when the root wired no registrar (no [mcp]). + // Browser capabilities for an `acp:` session come from the OAB MCP Facade and nowhere + // else: mint a per-session token (it rides the agent spawn below as OPENAB_SESSION_TOKEN) + // and write the static facade entry before the agent boots. The returned guard revokes + // that token when this connection is dropped, on any evict path. + // + // There is no transport fallback. Without `[mcp]` the root wires no registrar, and the + // session simply starts without browser capabilities — which is the honest outcome and is + // reported once at startup rather than being silently substituted per session. #[cfg(feature = "acp-mcp")] let mut session_token: Option = None; #[cfg(feature = "acp-mcp")] - let mcp_guard: Option = - if let Some(channel_id) = thread_id.strip_prefix("acp:") { - // The Facade -> Proxy fallback lives inside `browser_mode_effective` so that the - // one place resolving the transport is also the place that can warn about it. - let facade_available = - self.session_registrar.is_some() && self.facade_url.is_some(); - let mode = crate::mcp_proxy::browser_mode_effective(facade_available); - match mode { - crate::mcp_proxy::BrowserMode::Facade => { - // unwraps guarded by the fallback arm above - let registrar = self.session_registrar.as_ref().unwrap(); - let facade_url = self.facade_url.as_ref().unwrap(); - match setup_facade_session( - &effective_workdir, - facade_url, - channel_id, - registrar, - ) - .await - { - Some(token) => { - session_token = Some(token); - info!( - thread_id, - "session token minted for facade browser capabilities" - ); - // Revoke on evict/replace: piggyback the same DropGuard - // plumbing proxy mode uses for its server teardown. - // - // The guard carries the TOKEN it minted, not the channel. A - // replaced session's teardown runs after its successor has already - // re-minted for the same channel, so revoking by channel would - // strip the live token and silently cut the new agent off from the - // facade; revoking this exact token is a no-op by then (R1). - let ct = tokio_util::sync::CancellationToken::new(); - let child = ct.child_token(); - let registrar = registrar.clone(); - let minted = session_token.clone().unwrap_or_default(); - tokio::spawn(async move { - child.cancelled().await; - registrar.revoke(&minted); - }); - Some(ct.drop_guard()) - } - // No config, so no token and no revoke guard to arm. The session - // still starts — it simply has no browser capabilities. - None => None, - } - } - // Proxy mode (default): per-session loopback HTTP MCP server + dynamic config. - crate::mcp_proxy::BrowserMode::Proxy => { - match crate::mcp_proxy::start_session_server( - channel_id, - &effective_workdir, - self.browser_tunnel.clone(), - ) - .await - { - Ok((addr, ct)) => { - info!(thread_id, %addr, "started per-session MCP proxy server"); - Some(ct.drop_guard()) - } - Err(e) => { - warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); - None - } - } + let mcp_guard: Option = match ( + thread_id.strip_prefix("acp:"), + self.session_registrar.as_ref(), + self.facade_url.as_ref(), + ) { + (Some(channel_id), Some(registrar), Some(facade_url)) => { + match setup_facade_session(&effective_workdir, facade_url, channel_id, registrar) + .await + { + Some(token) => { + session_token = Some(token.clone()); + info!(thread_id, "session token minted for facade browser capabilities"); + // The guard carries the TOKEN it minted, not the channel. A replaced + // session's teardown runs after its successor has already re-minted for + // the same channel, so revoking by channel would strip the live token and + // silently cut the new agent off from the facade; revoking this exact + // token is a no-op by then (R1). + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&token); + }); + Some(ct.drop_guard()) } + // No config, so no token and no revoke guard to arm. The session still + // starts — it simply has no browser capabilities. + None => None, } - } else { - None - }; + } + _ => None, + }; // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 0e8ab1d78..67981ccd2 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -1,27 +1,22 @@ -//! Core-hosted MCP proxy server for MCP-over-ACP browser control (feature `acp-mcp`). +//! Agent-facing wiring for MCP-over-ACP browser control (feature `acp-mcp`). //! -//! Per ADR §7 (D3), OpenAB **core** hosts an in-process Streamable-HTTP MCP server on -//! loopback that the colocated agent CLI connects to as a normal MCP client. The server is a -//! proxy: its tool list + tool execution are backed by the remote browser extension over the -//! `/acp` MCP-over-ACP tunnel (wired in T5.3). Per D4 the browser tool set is -//! **static-advertised** regardless of whether an extension is currently attached — a call -//! while disconnected returns a "browser not connected" error rather than hiding the tools. +//! The module name is historical. It no longer hosts a proxy: the per-session loopback MCP +//! server, its bearer and its per-session config rewrite were removed along with the stdio +//! bridge, leaving the OAB MCP Facade as the only way an agent reaches browser tools. //! -//! This module currently provides the static tool set; the `ServerHandler` + loopback -//! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. - -use rmcp::model::{ - object, CallToolRequestParams, CallToolResult, ErrorData as McpError, JsonObject, - ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, -}; -use rmcp::service::{RequestContext, RoleServer}; -use rmcp::transport::streamable_http_server::{ - session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, -}; -use rmcp::ServerHandler; -use axum::response::IntoResponse; +//! What remains is the seam between core and the colocated agent CLI: +//! +//! - [`browser_tools`] — the static tool set (D4 static-advertise), now consumed by the facade's +//! capability source rather than served here. It is advertised whether or not an extension is +//! attached; a call while disconnected reports "browser not connected" instead of the tools +//! silently disappearing. +//! - [`AcpMcpTunnel`] — the trait core calls to reach a session's tunnel, implemented in the root. +//! - [`write_facade_mcp_config`] — writes the one static facade entry into each colocated CLI's +//! config, and retires the bridge entry it replaces. +//! - [`warn_if_browser_mode_set`] — startup migration notice for the removed `OPENAB_BROWSER_MODE`. + +use rmcp::model::{object, Tool}; use serde_json::{json, Value}; -use std::sync::Arc; /// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT /// (which bridges to the gateway's per-connection tunnel registry) and consumed by the MCP @@ -113,230 +108,6 @@ pub fn browser_tools() -> Vec { ] } -/// The core-hosted MCP server the colocated agent connects to (D3). A proxy: it advertises -/// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension -/// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not -/// connected" on call. -#[derive(Clone)] -pub struct ProxyHandler { - /// The browser session this server instance serves (D5-a: one MCP server per session). - channel_id: String, - /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the - /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). - tunnel: Option>, -} - -impl ProxyHandler { - pub fn new(channel_id: String, tunnel: Option>) -> Self { - Self { channel_id, tunnel } - } - - /// Forward a tool call to the browser over the tunnel (as an MCP `tools/call`), or report - /// not-connected (D4) when no browser is attached. - async fn forward_tool_call( - &self, - name: &str, - arguments: Option, - ) -> Result { - let Some(tunnel) = &self.tunnel else { - return Err(McpError::internal_error( - "browser not connected: open the OpenAB side panel in your browser", - None, - )); - }; - let params = json!({ "name": name, "arguments": arguments }); - // Empty server_id sentinel (Fork A): this single-browser proxy doesn't know the - // client-declared server id; RootBrowserTunnel resolves the sole tunnel on the channel. - let result = tunnel - .call(&self.channel_id, "", "tools/call", Some(params)) - .await - .map_err(|e| McpError::internal_error(e, None))?; - serde_json::from_value(result) - .map_err(|e| McpError::internal_error(format!("malformed tool result: {e}"), None)) - } -} - -impl ServerHandler for ProxyHandler { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( - "OpenAB browser-control proxy: DOM-semantic tools executed in the user's browser \ - via MCP-over-ACP.", - ) - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - // D4 static-advertise: expose the browser tools regardless of extension state. - Ok(ListToolsResult { - tools: browser_tools(), - ..Default::default() - }) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - self.forward_tool_call(request.name.as_ref(), request.arguments) - .await - } -} - -/// Loopback bearer gate for the MCP server (D3): even bound to 127.0.0.1, require the token -/// the agent's MCP config carries, so another local process on the host can't reach the -/// browser tools. Returns 401 when the `Authorization: Bearer ` header is absent or -/// wrong. -async fn require_bearer( - axum::extract::State(expected): axum::extract::State>, - req: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let authed = req - .headers() - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - // Constant-time compare so a wrong token can't be probed byte-by-byte via response - // timing (mirrors the gateway's feishu/wecom signature checks). - .is_some_and(|t| { - use subtle::ConstantTimeEq; - t.as_bytes().ct_eq(expected.as_bytes()).into() - }); - if authed { - next.run(req).await - } else { - axum::http::StatusCode::UNAUTHORIZED.into_response() - } -} - -/// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port -/// (D3), gated by `bearer`. Returns the bound address; the caller hands `addr.port()` + the -/// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is -/// cancelled. -pub async fn spawn_mcp_server( - channel_id: String, - tunnel: Option>, - bearer: String, - ct: tokio_util::sync::CancellationToken, -) -> std::io::Result { - let config = StreamableHttpServerConfig::default() - .with_stateful_mode(false) - .with_json_response(true) - .with_sse_keep_alive(None) - .with_cancellation_token(ct.child_token()); - let service: StreamableHttpService = - StreamableHttpService::new( - move || Ok(ProxyHandler::new(channel_id.clone(), tunnel.clone())), - Default::default(), - config, - ); - let router = axum::Router::new() - .nest_service("/mcp", service) - .layer(axum::middleware::from_fn_with_state( - Arc::::from(bearer), - require_bearer, - )); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - tokio::spawn(async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { ct.cancelled_owned().await }) - .await; - }); - Ok(addr) -} - -/// Start a per-session MCP proxy server (D5-a) and register it in the agent's native MCP -/// config so the colocated agent connects to it (D2). Mints a fresh bearer, starts the -/// loopback server, and writes/merges `/.cursor/mcp.json` with the `openab-browser` -/// HTTP entry (Cursor's config; other agents get their own writer later). Returns the bound -/// address + the `CancellationToken` the caller cancels to stop the server on session evict. -pub async fn start_session_server( - channel_id: &str, - workdir: &str, - tunnel: Option>, -) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { - let bearer = uuid::Uuid::new_v4().to_string(); - let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; - - let our_url = format!("http://{addr}/mcp"); - let entry = json!({ - "url": our_url.clone(), - "headers": { "Authorization": format!("Bearer {bearer}") } - }); - - // Merge the openab-browser entry into each colocated ACP CLI's native MCP config (don't - // clobber servers the user/agent already configured). Cursor reads /.cursor/mcp.json; - // kiro-cli reads /.kiro/settings/mcp.json. We write both — each CLI ignores the - // other's file — so the browser server reaches whichever agent is colocated. - let cfg_paths = [ - std::path::Path::new(workdir).join(".cursor").join("mcp.json"), - std::path::Path::new(workdir) - .join(".kiro") - .join("settings") - .join("mcp.json"), - ]; - for cfg_path in &cfg_paths { - if let Some(dir) = cfg_path.parent() { - tokio::fs::create_dir_all(dir).await?; - } - let mut cfg: Value = match tokio::fs::read(cfg_path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), - Err(_) => json!({}), - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } - cfg["mcpServers"]["openab-browser"] = entry.clone(); - // 0600: the file carries a live bearer token — default umask would leave it world-readable. - write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; - } - - // kiro `--agent ` deployments read the agent file, not settings/mcp.json — - // merge (and allowlist) there too, or the tools never reach OAB bot agents. - merge_kiro_agent_configs(workdir, &entry).await?; - - // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry - // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it - // if it still points at OUR addr — a concurrent/reconnected session may have already replaced - // it, and we must not clobber that live entry (the mcp.json paths are shared across acp: sessions). - let cleanup_paths = cfg_paths.to_vec(); - let cleanup_url = our_url; - let cleanup_ct = ct.clone(); - let cleanup_workdir = workdir.to_string(); - tokio::spawn(async move { - cleanup_ct.cancelled().await; - cleanup_kiro_agent_configs(&cleanup_workdir, &cleanup_url).await; - for cleanup_path in &cleanup_paths { - let Ok(bytes) = tokio::fs::read(cleanup_path).await else { - continue; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { - continue; - }; - let still_ours = cfg - .pointer("/mcpServers/openab-browser/url") - .and_then(Value::as_str) - == Some(cleanup_url.as_str()); - if !still_ours { - continue; - } - if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { - servers.remove("openab-browser"); - } - if let Ok(out) = serde_json::to_vec_pretty(&cfg) { - let _ = write_private(cleanup_path, &out).await; - } - } - }); - - Ok((addr, ct)) -} /// Write `bytes` to `path`, then tighten it to owner-only (0600). The file holds a live bearer /// token for the loopback MCP server, so it must not be group/world readable. @@ -350,120 +121,20 @@ async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result< Ok(()) } -/// Merge the `openab-browser` entry into every kiro **per-agent** config -/// (`/.kiro/agents/*.json`). When kiro-cli runs with `--agent ` -/// — as every OAB bot deployment does — it reads its MCP server list from the -/// agent file, NOT from `.kiro/settings/mcp.json`, and gates tools through the -/// file's `allowedTools` allowlist (verified live on the b2 fleet deployment; -/// see docs/gmail-native.md "Kiro CLI gotcha"). Without this, browser tools -/// are invisible to exactly the deployments this feature targets. +/// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be removed. /// -/// Unlike the settings-file writer, agent files carry unrelated config -/// (model, description, allowlists), so an unparseable file is SKIPPED — -/// never clobbered with a fresh object. macOS metadata droppings -/// (`._*.json`) are ignored. Missing agents dir = no-op. -async fn merge_kiro_agent_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { - let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); - let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { - return Ok(()); // no agents dir → nothing runs with --agent here - }; - while let Ok(Some(f)) = rd.next_entry().await { - let path = f.path(); - let name = f.file_name(); - let name = name.to_string_lossy(); - if !name.ends_with(".json") || name.starts_with("._") { - continue; - } - let Ok(bytes) = tokio::fs::read(&path).await else { - continue; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { - continue; // agent files carry model/allowlists — never clobber - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } - let mut changed = false; - if cfg["mcpServers"]["openab-browser"] != *entry { - cfg["mcpServers"]["openab-browser"] = entry.clone(); - changed = true; - } - // `allowedTools` is a default-deny allowlist: adding the server - // without allowlisting it leaves every browser tool blocked. - if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { - if !allowed.iter().any(|v| v.as_str() == Some("@openab-browser")) { - allowed.push(json!("@openab-browser")); - changed = true; - } - } - if changed { - write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; - } - } - Ok(()) -} - -/// Session-evict counterpart of [`merge_kiro_agent_configs`]: strip the -/// now-dead `openab-browser` entry (and its `allowedTools` grant) from every -/// kiro agent file — but only when the entry still points at OUR `url`, so a -/// concurrent/reconnected session's live entry is never clobbered (same rule -/// as the settings-file cleanup). Static (url-less) bridge entries are left -/// alone. -async fn cleanup_kiro_agent_configs(workdir: &str, url: &str) { - let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); - let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { - return; - }; - while let Ok(Some(f)) = rd.next_entry().await { - let path = f.path(); - let name = f.file_name(); - let name = name.to_string_lossy(); - if !name.ends_with(".json") || name.starts_with("._") { - continue; - } - let Ok(bytes) = tokio::fs::read(&path).await else { - continue; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { - continue; - }; - let still_ours = cfg - .pointer("/mcpServers/openab-browser/url") - .and_then(Value::as_str) - == Some(url); - if !still_ours { - continue; - } - if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { - servers.remove("openab-browser"); - } - if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { - allowed.retain(|v| v.as_str() != Some("@openab-browser")); - } - if let Ok(out) = serde_json::to_vec_pretty(&cfg) { - let _ = write_private(&path, &out).await; - } - } -} - -/// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be dropped -/// when facade mode takes over. -/// -/// Only the bridge entry qualifies. It is byte-identical every session -/// (`{"command":"openab","args":["browser-bridge"]}`) and names our own binary and subcommand, so -/// matching it is itself the proof. +/// Only the removed bridge entry qualifies. It was byte-identical every session +/// (`{"command":"openab","args":["browser-bridge"]}`) and names our own binary and a subcommand +/// that no longer exists, so matching that exact shape is itself the proof — and leaving it would +/// have the agent's MCP client fail to start it on every session. /// -/// The per-session proxy entry deliberately does **not** qualify, correcting an earlier version of -/// this function. Its url and bearer are minted per session and never recorded anywhere, so -/// "loopback url plus some `Bearer` header" is a description, not an identity — it matches any -/// local MCP server an operator happened to configure under this key. That check claimed to -/// recognise "a bearer we minted" while comparing against nothing we had kept. With no way to -/// prove ownership we fail closed and preserve. -/// -/// Preserving costs little. A leftover proxy entry names an ephemeral port belonging to a session -/// that is gone — `start_session_server` binds `127.0.0.1:0` and drops the listener with the -/// session — so it is dead configuration rather than a live bypass. The bridge entry is the one -/// that would still resolve and run, and that is the one removed. +/// The per-session proxy entry deliberately does **not** qualify. Its url and bearer were minted +/// per session and never recorded anywhere, so "loopback url plus some `Bearer` header" is a +/// description rather than an identity: it matches any local MCP server an operator configured +/// under this key. An earlier version of this function claimed to recognise "a bearer we minted" +/// while comparing against nothing we had kept. With no way to prove ownership we fail closed and +/// preserve — deleting an operator's configuration is worse than leaving a dead entry, and with +/// the proxy gone the entry it names is dead configuration rather than a live bypass. fn is_openab_direct_browser_entry(entry: &Value) -> bool { entry.get("command").and_then(Value::as_str) == Some("openab") && entry.get("args") == Some(&json!(["browser-bridge"])) @@ -590,107 +261,117 @@ async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::i /// (closing over the facade's `SessionTokens` registry — core stays free of /// the openab-mcp dependency); the pool calls it at session spawn/evict. pub trait SessionTokenRegistrar: Send + Sync { - /// Mint (or re-mint) the token for `channel_id`; returns the value the - /// pool injects as `OPENAB_SESSION_TOKEN` in the agent's environment. + /// Mint a fresh token for `channel_id`; returns the value the pool injects as + /// `OPENAB_SESSION_TOKEN` in the agent's environment. + /// + /// Does **not** replace a token the channel already has — tokens for a channel coexist, so a + /// respawned or racing session gets its own credential without invalidating one a running + /// agent is still presenting. fn mint(&self, channel_id: &str) -> String; /// Revoke one specific token (the session that held it was evicted). /// - /// Deliberately keyed by token, not by channel. `mint` replaces whatever token a channel had, - /// so a replaced session's teardown runs *after* its successor has already minted a new one; - /// revoking by channel would strip that live token and silently cut the new agent off from the - /// facade. Revoking the exact token makes a late teardown a no-op instead (review R1). + /// Deliberately keyed by token, not by channel. Because tokens coexist and session lifetimes + /// overlap, a replaced session's teardown runs *after* its successor has already minted its + /// own token for the same channel. Revoking by channel would take the successor's live + /// credential with it and silently cut the new agent off from the facade; revoking this exact + /// token is a no-op by then instead (review R1). fn revoke(&self, token: &str); } -/// Selected browser transport. `OPENAB_BROWSER_MODE=proxy` opts out of facade routing; anything -/// else, including unset, uses the facade. -/// -/// The stdio bridge was the third variant and is gone. `bridge` is now simply an unrecognised -/// value and falls through to `Facade` like any other — deliberately, so a deployment still -/// carrying `OPENAB_BROWSER_MODE=bridge` from the previous release comes up with working browser -/// control rather than refusing to start on a value that used to be valid. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserMode { - /// Browser tools served through the OAB MCP Facade as a session-aware - /// in-process capability source (one listener, session identity via - /// broker-minted tokens). The default when the facade is running; - /// falls back to `Proxy` when it is not (no `[mcp]` in config). - Facade, - /// Per-session loopback HTTP MCP server + dynamic config (explicit opt-out - /// from facade routing). - Proxy, -} - -fn parse_browser_mode(s: Option<&str>) -> BrowserMode { - match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { - Some("proxy") => BrowserMode::Proxy, - _ => BrowserMode::Facade, - } -} - -/// True when `OPENAB_BROWSER_MODE` is set to something that is not a transport we still have. +/// Report, once at startup, that `OPENAB_BROWSER_MODE` no longer selects anything. /// -/// Empty and unset are not "unrecognised" — they mean "no preference expressed". Only a value the -/// operator deliberately wrote and that no longer selects anything counts, which today is `bridge` -/// and any typo. -fn is_unrecognised_mode(raw: Option<&str>) -> Option<&str> { - let v = raw?.trim(); - if v.is_empty() || v.eq_ignore_ascii_case("proxy") || v.eq_ignore_ascii_case("facade") { - return None; - } - Some(v) -} - -/// Resolve the transport actually in use from the configured value and whether the facade is -/// really serving. Pure, so the resolution is testable without touching process env. +/// Call this from configuration/startup, **not** from a session path: nothing per-session is +/// decided by it any more, and a warning on that path would repeat for every spawn. /// -/// Two separate demotions land here and both are silent to the operator on their own: an -/// unrecognised value falls through to `Facade`, and `Facade` falls back to `Proxy` when no -/// `[mcp]` wired a registrar. Composing them is how `OPENAB_BROWSER_MODE=bridge` ends up running -/// **proxy** — which is why the caller warns with the resolved value rather than the requested one. -fn resolve_browser_mode(raw: Option<&str>, facade_available: bool) -> BrowserMode { - match parse_browser_mode(raw) { - BrowserMode::Facade if !facade_available => BrowserMode::Proxy, - m => m, +/// The variable used to choose between three transports. Two are gone and the third is no longer +/// optional, so any value it holds is inert. Ignoring it silently would leave an operator +/// believing they had configured something — the same failure the removed-bridge warning existed +/// to prevent, one level up. So the message says the value is ignored *and* reports what is +/// actually in force, since "ignored" alone does not tell them whether they still have browser +/// control at all. +pub fn warn_if_browser_mode_set(mcp_configured: bool) { + let raw = std::env::var("OPENAB_BROWSER_MODE").ok(); + if let Some((requested, browser_control)) = + browser_mode_migration_notice(raw.as_deref(), mcp_configured) + { + tracing::warn!( + requested, + browser_control, + "OPENAB_BROWSER_MODE is ignored — it no longer selects a transport and can be unset. \ + Browser control is configured by the [mcp] section of config.toml; `browser_control` \ + reports what is actually in force for this process." + ); } } -/// The transport to use for this process, resolved against whether the facade is serving. +/// Decide whether to warn and what to say: `(requested, browser_control)`, or `None` to stay quiet. /// -/// `facade_available` is false when no `[mcp]` section wired a session registrar or facade url. +/// Split out from the logging so the decision is testable without a subscriber or process env. /// -/// Warns once per process when `OPENAB_BROWSER_MODE` names a transport that no longer exists. -/// Accepting the stale value keeps an upgraded deployment running; accepting it *silently* would -/// leave the operator believing they are on a transport that was deleted, so the warning names the -/// transport actually in use — not the one they asked for, and not merely the one that parsing -/// picked, since the `[mcp]` fallback can demote that again. -pub fn browser_mode_effective(facade_available: bool) -> BrowserMode { - let raw = std::env::var("OPENAB_BROWSER_MODE").ok(); - let mode = resolve_browser_mode(raw.as_deref(), facade_available); - if let Some(requested) = is_unrecognised_mode(raw.as_deref()) { - static WARNED: std::sync::Once = std::sync::Once::new(); - WARNED.call_once(|| { - tracing::warn!( - requested, - effective = ?mode, - "OPENAB_BROWSER_MODE names a transport that no longer exists (the stdio bridge was \ - removed); continuing on the transport shown as `effective`. Unset the variable, \ - or set it to `proxy`, to make the configuration say what is actually running." - ); - }); +/// **Every** non-empty value warns, `proxy` and `facade` included. `proxy` no longer selects +/// anything either, so staying quiet for it would be the same silence this notice exists to +/// remove; `facade` is merely redundant, but reporting it costs one line at startup and saying +/// "this variable is read" of some values and not others would be false. +fn browser_mode_migration_notice( + raw: Option<&str>, + mcp_configured: bool, +) -> Option<(&str, &'static str)> { + let requested = raw?.trim(); + if requested.is_empty() { + return None; } - mode + Some(( + requested, + if mcp_configured { "facade" } else { "disabled" }, + )) } + #[cfg(test)] mod tests { use super::{ - browser_tools, cleanup_kiro_agent_configs, is_openab_direct_browser_entry, - is_unrecognised_mode, merge_kiro_agent_configs, parse_browser_mode, resolve_browser_mode, - spawn_mcp_server, start_session_server, - write_facade_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, + browser_mode_migration_notice, browser_tools, is_openab_direct_browser_entry, + write_facade_mcp_config, }; + /// The variable is inert now, so the notice must fire for every value an operator could have + /// set — including `proxy`, which used to be a real selection. Staying quiet for it would + /// reproduce the silence this notice exists to remove. + #[test] + fn every_set_browser_mode_value_is_reported_as_ignored() { + for v in ["bridge", "proxy", "facade", " Bridge ", "typo"] { + assert!( + browser_mode_migration_notice(Some(v), true).is_some(), + "{v:?} is a value someone deliberately set; it must not be ignored silently" + ); + } + // Unset and blank express no preference — warning on them would fire for every default + // deployment and train operators to skip the line. + assert_eq!(browser_mode_migration_notice(None, true), None); + assert_eq!(browser_mode_migration_notice(Some(""), true), None); + assert_eq!(browser_mode_migration_notice(Some(" "), true), None); + } + + /// The second field is the one an operator actually needs: not which mode they are in (there + /// are none left) but whether they still have browser control at all. Without `[mcp]` they do + /// not, and the removed Facade->Proxy fallback no longer hides that. + #[test] + fn the_notice_reports_whether_browser_control_survives_not_which_mode() { + assert_eq!( + browser_mode_migration_notice(Some("bridge"), true), + Some(("bridge", "facade")) + ); + assert_eq!( + browser_mode_migration_notice(Some("bridge"), false), + Some(("bridge", "disabled")) + ); + // Trimmed, so the log shows what was set rather than the surrounding whitespace. + assert_eq!( + browser_mode_migration_notice(Some(" proxy "), false), + Some(("proxy", "disabled")) + ); + } + // --- F4: facade setup retires the direct transport it replaces --- /// The bridge and per-session-proxy entries we wrote are recognised; anything else under the @@ -908,309 +589,18 @@ mod tests { dir } - #[tokio::test] - async fn kiro_agent_merge_adds_server_and_allowlist_preserving_the_rest() { - let wd = tmp_workdir("merge").await; - let agent = wd.join(".kiro/agents/terra.json"); - tokio::fs::write( - &agent, - serde_json::to_vec_pretty(&serde_json::json!({ - "name": "terra", - "model": "gpt-5.6-terra", - "mcpServers": { "github": { "url": "http://ghpool:8080/mcp" } }, - "allowedTools": ["@builtin", "@github"] - })) - .unwrap(), - ) - .await - .unwrap(); - let entry = serde_json::json!({ - "url": "http://127.0.0.1:45678/mcp", - "headers": { "Authorization": "Bearer tok" } - }); - merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) - .await - .unwrap(); - let cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); - assert_eq!(cfg["mcpServers"]["openab-browser"], entry); - assert_eq!( - cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp", - "pre-existing servers must be preserved" - ); - assert_eq!(cfg["model"], "gpt-5.6-terra", "unrelated fields preserved"); - let allowed = cfg["allowedTools"].as_array().unwrap(); - assert!( - allowed.iter().any(|v| v == "@openab-browser"), - "allowedTools is default-deny — the server must be allowlisted: {allowed:?}" - ); - // Idempotent: second merge changes nothing (byte-stable allowlist). - merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) - .await - .unwrap(); - let cfg2: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); - assert_eq!(cfg, cfg2); - let _ = tokio::fs::remove_dir_all(&wd).await; - } - #[tokio::test] - async fn kiro_agent_merge_skips_unparseable_and_metadata_files() { - let wd = tmp_workdir("skip").await; - let junk = wd.join(".kiro/agents/broken.json"); - tokio::fs::write(&junk, b"{not json").await.unwrap(); - let meta = wd.join(".kiro/agents/._terra.json"); - tokio::fs::write(&meta, b"\x00\x05\x16\x07").await.unwrap(); - let entry = serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }); - merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) - .await - .unwrap(); - assert_eq!( - tokio::fs::read(&junk).await.unwrap(), - b"{not json", - "unparseable agent files must be skipped, never clobbered" - ); - assert_eq!(tokio::fs::read(&meta).await.unwrap(), b"\x00\x05\x16\x07"); - let _ = tokio::fs::remove_dir_all(&wd).await; - } - #[tokio::test] - async fn kiro_agent_merge_without_agents_dir_is_noop() { - let wd = std::env::temp_dir().join(format!( - "oab-mcp-proxy-test-noop-{}-{}", - std::process::id(), - uuid::Uuid::new_v4() - )); - tokio::fs::create_dir_all(&wd).await.unwrap(); - merge_kiro_agent_configs( - wd.to_str().unwrap(), - &serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }), - ) - .await - .unwrap(); - let _ = tokio::fs::remove_dir_all(&wd).await; - } - #[tokio::test] - async fn kiro_agent_cleanup_removes_only_our_url_and_its_grant() { - let wd = tmp_workdir("cleanup").await; - let ours = wd.join(".kiro/agents/ours.json"); - tokio::fs::write( - &ours, - serde_json::to_vec_pretty(&serde_json::json!({ - "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:1111/mcp" } }, - "allowedTools": ["@builtin", "@openab-browser"] - })) - .unwrap(), - ) - .await - .unwrap(); - let foreign = wd.join(".kiro/agents/foreign.json"); - tokio::fs::write( - &foreign, - serde_json::to_vec_pretty(&serde_json::json!({ - "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:2222/mcp" } }, - "allowedTools": ["@openab-browser"] - })) - .unwrap(), - ) - .await - .unwrap(); - cleanup_kiro_agent_configs(wd.to_str().unwrap(), "http://127.0.0.1:1111/mcp").await; - let ours_cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&ours).await.unwrap()).unwrap(); - assert!(ours_cfg["mcpServers"]["openab-browser"].is_null()); - assert!( - !ours_cfg["allowedTools"] - .as_array() - .unwrap() - .iter() - .any(|v| v == "@openab-browser"), - "the stale allowlist grant must be revoked with the entry" - ); - let foreign_cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&foreign).await.unwrap()).unwrap(); - assert_eq!( - foreign_cfg["mcpServers"]["openab-browser"]["url"], "http://127.0.0.1:2222/mcp", - "a concurrent session's live entry must never be clobbered" - ); - let _ = tokio::fs::remove_dir_all(&wd).await; - } - #[test] - fn browser_mode_defaults_to_facade_with_proxy_and_bridge_opt_outs() { - // Facade is the default: browser tools ride the OAB MCP Facade as a - // session-aware source (falls back to Proxy at runtime when no - // facade is serving — see the pool's mode fallback). - assert_eq!(parse_browser_mode(None), BrowserMode::Facade); - assert_eq!(parse_browser_mode(Some("")), BrowserMode::Facade); - assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Facade); - // The one remaining explicit opt-out keeps its exact prior semantics. - assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); - assert_eq!(parse_browser_mode(Some(" Proxy ")), BrowserMode::Proxy); - // `bridge` was a third mode and is gone. It must degrade to the default like any other - // unknown value rather than being special-cased into an error: a deployment still carrying - // OPENAB_BROWSER_MODE=bridge from the previous release has to come up with working browser - // control, not refuse to start on a value that used to be valid. - assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Facade); - assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Facade); - } - /// The two demotions compose, and the second one is the reason the warning cannot just echo - /// what parsing returned: `bridge` degrades to Facade, and Facade degrades again to Proxy when - /// no `[mcp]` is configured. An operator who set `bridge` and has no facade is running - /// **proxy** — the transport furthest from what they wrote. - #[test] - fn a_removed_mode_resolves_through_both_demotions_to_the_transport_actually_running() { - assert_eq!( - resolve_browser_mode(Some("bridge"), false), - BrowserMode::Proxy, - "bridge + no [mcp] must resolve to proxy, which is what the operator is really running" - ); - assert_eq!(resolve_browser_mode(Some("bridge"), true), BrowserMode::Facade); - // An explicit opt-out is not a fallback and is never re-resolved. - assert_eq!(resolve_browser_mode(Some("proxy"), true), BrowserMode::Proxy); - assert_eq!(resolve_browser_mode(Some("proxy"), false), BrowserMode::Proxy); - // Unset behaves like any other non-preference. - assert_eq!(resolve_browser_mode(None, true), BrowserMode::Facade); - assert_eq!(resolve_browser_mode(None, false), BrowserMode::Proxy); - } - /// Only a value the operator actually wrote and that no longer selects anything is worth - /// warning about. Warning on unset would fire for every default deployment, and warning on a - /// live value would train operators to ignore it. - #[test] - fn only_a_deliberately_set_dead_value_is_reported_as_unrecognised() { - assert_eq!(is_unrecognised_mode(Some("bridge")), Some("bridge")); - assert_eq!(is_unrecognised_mode(Some(" Bridge ")), Some("Bridge")); - assert_eq!(is_unrecognised_mode(Some("typo")), Some("typo")); - assert_eq!(is_unrecognised_mode(None), None); - assert_eq!(is_unrecognised_mode(Some("")), None); - assert_eq!(is_unrecognised_mode(Some(" ")), None); - assert_eq!(is_unrecognised_mode(Some("proxy")), None); - assert_eq!(is_unrecognised_mode(Some("FACADE")), None); - } - struct MockTunnel; - #[async_trait::async_trait] - impl AcpMcpTunnel for MockTunnel { - async fn call( - &self, - channel_id: &str, - server_id: &str, - method: &str, - _params: Option, - ) -> Result { - assert_eq!(channel_id, "acp_x"); - assert_eq!(server_id, ""); // proxy passes the empty sentinel (Fork A) - assert_eq!(method, "tools/call"); - Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) - } - } - #[tokio::test] - async fn call_tool_forwards_to_the_tunnel() { - let h = ProxyHandler::new("acp_x".into(), Some(std::sync::Arc::new(MockTunnel))); - let result = h.forward_tool_call("katashiro.click", None).await.unwrap(); - let v = serde_json::to_value(&result).unwrap(); - assert_eq!(v["content"][0]["text"], serde_json::json!("clicked")); - } - #[tokio::test] - async fn call_tool_reports_not_connected_without_a_tunnel() { - let h = ProxyHandler::new("acp_x".into(), None); - assert!( - h.forward_tool_call("katashiro.click", None).await.is_err(), - "a call with no attached browser must error (D4)" - ); - } // --- Option C: browser-bridge socket dispatch --- - struct RecordTunnel { - result: serde_json::Value, - } - #[async_trait::async_trait] - impl AcpMcpTunnel for RecordTunnel { - async fn call( - &self, - channel_id: &str, - _server_id: &str, - method: &str, - _params: Option, - ) -> Result { - assert_eq!(method, "tools/call"); - assert_eq!(channel_id, "acp_win1"); - Ok(self.result.clone()) - } - } - struct ErrTunnel; - #[async_trait::async_trait] - impl AcpMcpTunnel for ErrTunnel { - async fn call( - &self, - _c: &str, - _s: &str, - _m: &str, - _p: Option, - ) -> Result { - Err("no browser attached".into()) - } - } - #[tokio::test] - async fn start_session_server_writes_cursor_config() { - let dir = tempfile::tempdir().unwrap(); - let (addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) - .await - .unwrap(); - assert!(addr.ip().is_loopback()); - - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(dir.path().join(".cursor/mcp.json")).unwrap()) - .unwrap(); - let entry = &cfg["mcpServers"]["openab-browser"]; - assert_eq!(entry["url"], serde_json::json!(format!("http://{addr}/mcp"))); - assert!(entry["headers"]["Authorization"] - .as_str() - .unwrap() - .starts_with("Bearer ")); - // The file holds a live bearer — it must be owner-only (0600), not umask-default 0644. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.path().join(".cursor/mcp.json")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600, "mcp.json (live bearer) must be 0600"); - } - ct.cancel(); - } - #[tokio::test] - async fn start_session_server_merges_existing_config() { - let dir = tempfile::tempdir().unwrap(); - let cursor = dir.path().join(".cursor"); - std::fs::create_dir_all(&cursor).unwrap(); - std::fs::write( - cursor.join("mcp.json"), - r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, - ) - .unwrap(); - let (_addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) - .await - .unwrap(); - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); - assert!( - cfg["mcpServers"]["other"].is_object(), - "existing server must be preserved" - ); - assert!( - cfg["mcpServers"]["openab-browser"].is_object(), - "openab-browser must be added" - ); - ct.cancel(); - } #[test] fn browser_tools_advertises_the_fixed_set() { @@ -1243,66 +633,5 @@ mod tests { const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; - #[tokio::test] - async fn mcp_server_binds_loopback_and_initializes_with_bearer() { - let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) - .await - .unwrap(); - assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); - - let url = format!("http://{addr}/mcp"); - let resp = reqwest::Client::new() - .post(&url) - .header("Authorization", "Bearer secret-token") - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(body["jsonrpc"], "2.0"); - assert!(body["result"].is_object(), "initialize must return a result"); - assert!( - body["result"]["capabilities"]["tools"].is_object(), - "server must advertise the tools capability" - ); - ct.cancel(); - } - - #[tokio::test] - async fn mcp_server_rejects_missing_or_wrong_bearer() { - let ct = tokio_util::sync::CancellationToken::new(); - let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) - .await - .unwrap(); - let url = format!("http://{addr}/mcp"); - let client = reqwest::Client::new(); - // no Authorization header -> 401 - let no_auth = client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await - .unwrap(); - assert_eq!(no_auth.status(), 401); - - // wrong token -> 401 - let wrong = client - .post(&url) - .header("Authorization", "Bearer nope") - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .body(INIT_BODY) - .send() - .await - .unwrap(); - assert_eq!(wrong.status(), 401); - ct.cancel(); - } } diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index 53ac8d298..c8f37b79b 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -6,33 +6,27 @@ The browser MCP server exposes five DOM-semantic tools — [tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the colocated agent CLI actually **sees** those tools. -There are two transports, selected by `OPENAB_BROWSER_MODE`. **Facade is the default and the one -to use**; `proxy` predates it and is kept as an explicit opt-out. - -| mode | how the agent reaches the tools | status | -|---|---|---| -| unset / `facade` | through the **OAB MCP Facade**, one listener, static config entry | **default** | -| `proxy` | per-session loopback MCP server + per-session config rewrite | legacy opt-out | - -With no `[mcp]` section in `config.toml` the facade is not serving, and facade mode falls back to -`proxy` automatically. - -> ⚠️ **`bridge` mode has been removed.** The per-pod unix socket, the `openab browser-bridge` -> subcommand and the stdio relay are gone. `OPENAB_BROWSER_MODE=bridge` is now simply an -> unrecognised value and resolves to `facade`, so a deployment still carrying it upgrades into -> working browser control instead of refusing to start — but it no longer does what it says, and -> should be removed from your environment. +There is **one** transport: the OAB MCP Facade. The per-session `proxy` and the `openab +browser-bridge` stdio relay both existed before it and have been removed, along with the +`OPENAB_BROWSER_MODE` variable that selected between them. See [Removed +transports](#removed-transports) if you are upgrading from either. + +**Browser control now requires `[mcp]` in `config.toml`.** This is a breaking change. Without that +section there is no browser control — openab does **not** start a listener you did not configure, +and says so once at startup rather than leaving you to infer it from missing tools. + +> ⚠️ **A leftover `openab-browser` entry from either old transport can still sit in your agent's +> `mcp.json`,** and the two are handled differently. > -> Facade setup **deletes a leftover `openab-browser` bridge entry for you** on the next session, in -> `.cursor/mcp.json`, `.kiro/settings/mcp.json` and the kiro agent files (including its -> `@openab-browser` grant). Left in place, that entry names a subcommand that no longer exists, so -> the agent's MCP client would try and fail to start it every session. It is removed only when it -> is byte-identical to the entry we wrote — `{"command":"openab","args":["browser-bridge"]}` — -> because that exact shape is the only proof we have that it is ours rather than a server you -> configured under the same key. +> The **bridge** entry is deleted for you on the next session — in `.cursor/mcp.json`, +> `.kiro/settings/mcp.json` and the kiro agent files, including its `@openab-browser` grant. Left +> in place it names a subcommand that no longer exists, so the agent's MCP client would try and +> fail to start it every session. It is removed only when byte-identical to the entry we wrote +> (`{"command":"openab","args":["browser-bridge"]}`), because that exact shape is the only proof we +> have that it is ours rather than a server you configured under the same key. > -> A stale **proxy** entry is deliberately *not* removed: its url and bearer were minted per session -> and never recorded anywhere, so under that key we cannot tell your server from ours. It is dead +> The **proxy** entry is deliberately *not* removed: its url and bearer were minted per session and +> never recorded anywhere, so under that key we cannot tell your server from ours. It is dead > configuration — the port died with its session — but if you want it gone, remove it yourself: > > ```sh @@ -41,8 +35,6 @@ With no `[mcp]` section in `config.toml` the facade is not serving, and facade m > [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"])' "$f" > "$f.tmp" && mv "$f.tmp" "$f" > done > ``` -> -> Symptom to watch for: browser tools work, but no `mcp.audit` line is ever emitted for them. --- @@ -114,45 +106,28 @@ Gateway log confirms the extension side: `ACP: browser tunnel registered — ext --- -## Legacy: `proxy` mode - -`OPENAB_BROWSER_MODE=proxy` forces the original design: the gateway↔extension tunnel terminates at a -**per-session loopback MCP proxy** (`openab-core` `mcp_proxy::start_session_server`), and openab -writes a per-session entry into the agent CLI's native MCP config: - -```json -{ - "mcpServers": { - "openab-browser": { - "url": "http://127.0.0.1:/mcp", - "headers": { "Authorization": "Bearer " } - } - } -} -``` - -`` and `` are **minted fresh per session** and the entry is stripped on evict, so it -cannot be hand-written to a fixed value. In this mode the agent sees `katashiro.*` directly in its -`tools/list` rather than behind the facade's meta-tools. +## Removed transports -Config file per variant (what `start_session_server` writes): +Both legacy transports are gone. This section is kept as a migration note, not as documentation of +anything you can still turn on. -| Variant | MCP config file (under `$workdir`, = `$HOME`) | Auto-written in proxy mode | -|---|---|---| -| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | ✅ | -| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | ✅ | -| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | ⛔ | -| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | ⛔ | -| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | ⛔ | +**`proxy` mode** terminated the gateway↔extension tunnel at a per-session loopback MCP server and +rewrote the agent CLI's MCP config with a freshly minted url and bearer on every session. It is +removed: the per-session server, its bearer, and the per-session config write and cleanup. -Variants marked ⛔ are unreachable in proxy mode without teaching `start_session_server` their -config path and format — **or simply using facade mode**, where the static entry removes the problem. +**`bridge` mode (Option C)** ran a per-pod unix-socket server plus an `openab browser-bridge` +stdio-MCP relay that resolved its session channel by walking `/proc`. It is removed: the +subcommand, the socket server, the ancestry resolver and the static config entry. -## Legacy: `bridge` mode (Option C) +`OPENAB_BROWSER_MODE` no longer selects anything and can be unset. If it is still set to any value, +openab logs one warning at startup naming the value and reporting what is actually in force — +`facade` when `[mcp]` is configured, `disabled` when it is not. -`OPENAB_BROWSER_MODE=bridge` runs a per-pod unix-socket server plus an `openab browser-bridge` -stdio-MCP relay; the CLI entry is the static `{"command":"openab","args":["browser-bridge"]}` and the -relay resolves its session channel by process ancestry. Intended for stdio-only MCP clients. +**What to do when upgrading:** configure `[mcp]` in `config.toml`. Without it there is no browser +control at all — nothing is auto-started, because starting a listener you did not ask for is the +coupling this design deliberately avoids. A leftover `openab-browser` bridge entry is deleted for +you on the next session; a leftover proxy entry is not, for the ownership reason given above. -> Retiring proxy and bridge once facade mode has soaked is tracked as a follow-up — the OAB MCP -> Adapter ADR's Alternative C calls for a single agent-facing aggregation point. +Proxy mode also only ever auto-wrote two of the five CLI variants (Cursor and Kiro); the other +three had to be configured by hand. The facade's static entry removes that gap rather than +extending it. diff --git a/src/main.rs b/src/main.rs index 34783acdb..3eabc9f61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -479,6 +479,12 @@ async fn main() -> anyhow::Result<()> { // Only read under the acp feature (pool facade wiring below). #[cfg(feature = "acp")] let facade_serving = cfg.mcp.is_some(); + // Startup, not per-session: OPENAB_BROWSER_MODE selects nothing any more, and an operator + // still setting it needs to be told that here rather than discovering it as missing tools. + // Gated on `acp` (the root feature that pulls in core's `acp-mcp`), not on `acp-mcp` itself — + // that is a core feature and naming it here is an unknown-cfg error. + #[cfg(feature = "acp")] + openab_core::mcp_proxy::warn_if_browser_mode_set(cfg.mcp.is_some()); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); From 8e7009dd7a6f53d9de01e60ccacb8e4fd4fa8678 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 05:02:18 +0800 Subject: [PATCH 081/138] fix(acp): report browser control at startup, and drop the proxy's leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the previous commit left behind, all raised by Falcon. **The startup report did not exist for the case that needed it most.** The notice only fired when `OPENAB_BROWSER_MODE` was set, so a clean deployment with no `[mcp]` and no legacy variable was told nothing at all — exactly the deployment that just lost browser control. Worse, the same commit's documentation promised openab "says so once at startup", and the runbook asked for an INFO line in as many words. The claim was written and the code was not. `warn_if_browser_mode_set` becomes `report_browser_control`, which always logs which of the two states the process is in, then adds the migration warning when the dead variable is set. Silence was the failure this whole change set exists to remove; shipping a new instance of it while documenting the cure was the wrong way round. **`SessionPool::browser_tunnel` and `with_browser_tunnel` are gone.** The field existed to hand a tunnel to `start_session_server`; with that deleted nothing reads it, and the root's own `browser_tunnel` (which the facade's capability source does use) stays. A field that is written and never read does not fail any lint while a `pub` setter keeps it reachable. **`axum` and `subtle` leave `openab-core`, and `rmcp` loses its server features.** `subtle` was the constant-time bearer compare, `axum` the loopback listener, and `server` / `transport-streamable-http-server` the MCP server itself — all removed with the proxy. Only `rmcp::model` is still used, for the browser tool definitions the facade source seeds from. None of these is gate-detectable: unused Cargo features do not fail clippy, a written-never-read field behind a public setter does not either, and a false sentence in a document has no checker at all. Co-Authored-By: Claude --- Cargo.lock | 2 -- crates/openab-core/Cargo.toml | 11 +++++------ crates/openab-core/src/acp/pool.rs | 17 ----------------- crates/openab-core/src/mcp_proxy.rs | 20 +++++++++++++++++--- docs/browser-mcp-agent-setup.md | 12 ++++++++++-- src/main.rs | 3 +-- 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97ff55480..89f00d9c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,7 +2533,6 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", - "axum", "base64", "bytes", "chrono", @@ -2558,7 +2557,6 @@ dependencies = [ "serde_json", "serenity", "sha2 0.10.9", - "subtle", "tar", "tempfile", "tokio", diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index dd0cc49ab..e2b4f9604 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -50,12 +50,11 @@ urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } # MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process -# Streamable-HTTP MCP server the colocated agent connects to (D3). rmcp server + a -# self-owned loopback axum listener; kept optional so non-acp builds don't pull them. -rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } -axum = { version = "0.8", optional = true } +# Browser tool definitions for the facade's capability source. Only `rmcp::model` is used now — +# the loopback MCP server that needed the server + streamable-http transport features (and its own +# axum listener) was removed with the per-session proxy. +rmcp = { version = "1.7", default-features = false, optional = true } tokio-util = { version = "0.7", optional = true } -subtle = { version = "2", optional = true } # constant-time bearer compare for the loopback MCP server [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -73,4 +72,4 @@ pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] # Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). -acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util", "dep:subtle"] +acp-mcp = ["dep:rmcp", "dep:tokio-util"] diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index c91fff859..f8c663b25 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -53,10 +53,6 @@ pub struct SessionPool { mapping_path: PathBuf, meta_path: PathBuf, default_config_options: HashMap, - /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the - /// root. `None` = no browser wiring (tool calls report not-connected). - #[cfg(feature = "acp-mcp")] - browser_tunnel: Option>, #[cfg(feature = "acp-mcp")] session_registrar: Option>, #[cfg(feature = "acp-mcp")] @@ -235,25 +231,12 @@ impl SessionPool { meta_path, default_config_options, #[cfg(feature = "acp-mcp")] - browser_tunnel: None, - #[cfg(feature = "acp-mcp")] session_registrar: None, #[cfg(feature = "acp-mcp")] facade_url: None, } } - /// Wire the browser tunnel bridge (D6-a', set by the root) so per-session MCP proxies can - /// reach the browser. Call before sharing the pool. - #[cfg(feature = "acp-mcp")] - pub fn with_browser_tunnel( - mut self, - tunnel: Option>, - ) -> Self { - self.browser_tunnel = tunnel; - self - } - /// Wire the facade session-token registrar + facade URL (Facade mode, /// set by the root when `[mcp]` is running). With both present, browser /// capabilities route through the facade: the pool mints one token per diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 67981ccd2..1ddafc7b5 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -13,7 +13,8 @@ //! - [`AcpMcpTunnel`] — the trait core calls to reach a session's tunnel, implemented in the root. //! - [`write_facade_mcp_config`] — writes the one static facade entry into each colocated CLI's //! config, and retires the bridge entry it replaces. -//! - [`warn_if_browser_mode_set`] — startup migration notice for the removed `OPENAB_BROWSER_MODE`. +//! - [`report_browser_control`] — startup report of whether browser control is on, plus the +//! migration notice for the removed `OPENAB_BROWSER_MODE`. use rmcp::model::{object, Tool}; use serde_json::{json, Value}; @@ -278,7 +279,8 @@ pub trait SessionTokenRegistrar: Send + Sync { fn revoke(&self, token: &str); } -/// Report, once at startup, that `OPENAB_BROWSER_MODE` no longer selects anything. +/// Report, once at startup, whether browser control is enabled — and that +/// `OPENAB_BROWSER_MODE` no longer selects anything. /// /// Call this from configuration/startup, **not** from a session path: nothing per-session is /// decided by it any more, and a warning on that path would repeat for every spawn. @@ -289,7 +291,19 @@ pub trait SessionTokenRegistrar: Send + Sync { /// to prevent, one level up. So the message says the value is ignored *and* reports what is /// actually in force, since "ignored" alone does not tell them whether they still have browser /// control at all. -pub fn warn_if_browser_mode_set(mcp_configured: bool) { +pub fn report_browser_control(mcp_configured: bool) { + if mcp_configured { + tracing::info!("browser control: enabled via the OAB MCP Facade ([mcp] configured)"); + } else { + // Unconditional, and the whole point of the change: with the proxy fallback gone, an + // unconfigured deployment has NO browser control. Saying nothing would leave that to be + // inferred from tools that never appear — which is the failure this replaced, not a + // quieter version of it. + tracing::info!( + "browser control: unconfigured — no [mcp] section in config.toml, so browser tools \ + are unavailable and nothing was started. Add [mcp] to enable them." + ); + } let raw = std::env::var("OPENAB_BROWSER_MODE").ok(); if let Some((requested, browser_control)) = browser_mode_migration_notice(raw.as_deref(), mcp_configured) diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index c8f37b79b..a1ab0fce1 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -12,8 +12,16 @@ browser-bridge` stdio relay both existed before it and have been removed, along transports](#removed-transports) if you are upgrading from either. **Browser control now requires `[mcp]` in `config.toml`.** This is a breaking change. Without that -section there is no browser control — openab does **not** start a listener you did not configure, -and says so once at startup rather than leaving you to infer it from missing tools. +section there is no browser control — openab does **not** start a listener you did not configure. + +openab reports which of the two you are in once at startup, so it is never something you have to +infer from tools that do not appear: + +``` +INFO browser control: enabled via the OAB MCP Facade ([mcp] configured) +INFO browser control: unconfigured — no [mcp] section in config.toml, so browser tools are + unavailable and nothing was started. Add [mcp] to enable them. +``` > ⚠️ **A leftover `openab-browser` entry from either old transport can still sit in your agent's > `mcp.json`,** and the two are handled differently. diff --git a/src/main.rs b/src/main.rs index 3eabc9f61..f7f31da88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -484,7 +484,7 @@ async fn main() -> anyhow::Result<()> { // Gated on `acp` (the root feature that pulls in core's `acp-mcp`), not on `acp-mcp` itself — // that is a core feature and naming it here is an unknown-cfg error. #[cfg(feature = "acp")] - openab_core::mcp_proxy::warn_if_browser_mode_set(cfg.mcp.is_some()); + openab_core::mcp_proxy::report_browser_control(cfg.mcp.is_some()); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); @@ -518,7 +518,6 @@ async fn main() -> anyhow::Result<()> { cfg.pool.default_config_options, ); #[cfg(feature = "acp")] - let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); // Facade mode session wiring: only when the facade is actually serving — // otherwise the pool's mode fallback keeps the per-session proxy path. #[cfg(feature = "acp")] From e0c4a92eed1f4140301d84c1569802bbb727e94e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 06:17:14 +0800 Subject: [PATCH 082/138] docs(acp): retire the descriptions the removed transports left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code was already correct; ten sentences describing it were not. None of these is reachable by any gate — comments, doc comments, dependency notes and an identifier are not checked by anything. - `Cargo.toml`: an orphaned "core-hosted MCP proxy server" header left above the replacement comment, and the `acp-mcp` feature note claiming core hosts an MCP server. Core hosts none. - `AcpConnection`'s guard: the doc said it cancels a per-session MCP proxy server. It revokes a facade token. The field was also named `mcp_server_guard`, which is the same false claim in a form the compiler propagates to every use — renamed to `facade_token_guard`. - `main.rs`: a comment promising the pool's mode fallback keeps the per-session proxy path. There is no such path. The same edit had also left two consecutive identical `#[cfg]` attributes on one statement. - `write_private`: justified `0600` by "the file holds a live bearer token". It no longer does — the facade entry references `${OPENAB_SESSION_TOKEN}` and the value lives only in the agent's environment. The mode is kept, with the actual reason. - A dangling `// --- Option C: browser-bridge socket dispatch ---` marker left labelling tests that outlived the section. - `mcp-over-acp-tunnel-contract.md`: still described internal routing as the core-hosted proxy. That file describes the extension-facing hop, which is exactly why removing an internal transport never led anyone to it. Left in place deliberately: text that names a removed thing in order to explain why something else exists — why a stale bridge entry is recognised and removed, and why a stale proxy entry is not. Found by sweeping the vocabulary of the removed transports across the repo rather than the files under edit. A second sweep, minutes after the first, still turned up two of the above; both were outside the files being changed. Co-Authored-By: Claude --- crates/openab-core/Cargo.toml | 4 ++-- crates/openab-core/src/acp/connection.rs | 17 ++++++++++------- crates/openab-core/src/acp/pool.rs | 4 ++-- crates/openab-core/src/mcp_proxy.rs | 11 ++++++----- docs/mcp-over-acp-tunnel-contract.md | 5 +++-- src/main.rs | 6 +++--- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index e2b4f9604..1ede398f1 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -49,7 +49,6 @@ aws-credential-types = { version = "1", optional = true } urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } -# MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process # Browser tool definitions for the facade's capability source. Only `rmcp::model` is used now — # the loopback MCP server that needed the server + streamable-http transport features (and its own # axum listener) was removed with the per-session proxy. @@ -71,5 +70,6 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"] pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"] filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] -# Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). +# Core-side wiring for MCP-over-ACP browser control (enabled by the root `acp`): the browser tool +# definitions and the agent's facade MCP config. Core hosts no MCP server of its own any more. acp-mcp = ["dep:rmcp", "dep:tokio-util"] diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index cb9577ddd..5f5d83747 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -189,11 +189,14 @@ pub struct AcpConnection { pub session_reset: bool, _reader_handle: JoinHandle<()>, _stderr_handle: Option>, - /// Cancels this session's per-session MCP proxy server (D5-a) when the connection is - /// dropped. Held only for its `Drop` side effect (never read). + /// Revokes this session's facade token when the connection is dropped, on any evict path. + /// Held only for its `Drop` side effect (never read). + /// + /// It used to cancel a per-session MCP proxy server; that server is gone and the guard now + /// carries the minted token instead. #[cfg(feature = "acp-mcp")] #[allow(dead_code)] - mcp_server_guard: Option, + facade_token_guard: Option, } /// Build the final set of env vars for the agent subprocess. @@ -491,14 +494,14 @@ impl AcpConnection { _reader_handle: reader_handle, _stderr_handle: stderr_handle, #[cfg(feature = "acp-mcp")] - mcp_server_guard: None, + facade_token_guard: None, }) } - /// Attach the guard that stops this session's MCP proxy server when the connection drops. + /// Attach the guard that revokes this session's facade token when the connection drops. #[cfg(feature = "acp-mcp")] - pub fn set_mcp_guard(&mut self, guard: Option) { - self.mcp_server_guard = guard; + pub fn set_facade_token_guard(&mut self, guard: Option) { + self.facade_token_guard = guard; } fn next_id(&self) -> u64 { diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index f8c663b25..779fd2eb3 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -411,7 +411,7 @@ impl SessionPool { #[cfg(feature = "acp-mcp")] let mut session_token: Option = None; #[cfg(feature = "acp-mcp")] - let mcp_guard: Option = match ( + let facade_token_guard: Option = match ( thread_id.strip_prefix("acp:"), self.session_registrar.as_ref(), self.facade_url.as_ref(), @@ -534,7 +534,7 @@ impl SessionPool { let child_pgid = new_conn.child_pgid(); let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default(); #[cfg(feature = "acp-mcp")] - new_conn.set_mcp_guard(mcp_guard); + new_conn.set_facade_token_guard(facade_token_guard); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 1ddafc7b5..1b7d627f7 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -110,8 +110,12 @@ pub fn browser_tools() -> Vec { } -/// Write `bytes` to `path`, then tighten it to owner-only (0600). The file holds a live bearer -/// token for the loopback MCP server, so it must not be group/world readable. +/// Write `bytes` to `path`, then tighten it to owner-only (0600). +/// +/// The file no longer holds a secret — the facade entry references `${OPENAB_SESSION_TOKEN}` and +/// the value lives only in the agent process's environment. `0600` is kept anyway: it is an +/// agent's MCP configuration, a shared workdir is the normal deployment, and nothing needs it to +/// be readable by other users. async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { tokio::fs::write(path, bytes).await?; #[cfg(unix)] @@ -613,9 +617,6 @@ mod tests { - // --- Option C: browser-bridge socket dispatch --- - - #[test] fn browser_tools_advertises_the_fixed_set() { let tools = browser_tools(); diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 2aabc9278..8132af2ee 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -6,8 +6,9 @@ implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` W the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB -routes tool calls internally (core-hosted MCP proxy, agent subprocess) is out of scope for -the extension and may change without affecting this contract. +routes tool calls internally (today: the OAB MCP Facade and the agent subprocess) is out of scope +for the extension and may change without affecting this contract — as it already has: the +per-session MCP proxy and the stdio bridge that this line used to name are both gone. ## Roles diff --git a/src/main.rs b/src/main.rs index f7f31da88..c5ba10ea2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -517,9 +517,9 @@ async fn main() -> anyhow::Result<()> { .saturating_add(cfg.pool.hung_grace_secs), cfg.pool.default_config_options, ); - #[cfg(feature = "acp")] - // Facade mode session wiring: only when the facade is actually serving — - // otherwise the pool's mode fallback keeps the per-session proxy path. + // Facade session wiring: only when the facade is actually serving. With no `[mcp]` there is + // no registrar and no facade url, and the pool simply starts sessions without browser + // capabilities — there is no longer a proxy path for it to fall back to. #[cfg(feature = "acp")] let pool_inner = pool_inner.with_facade_sessions( facade_serving.then(|| { From 93c8e1563ed4fd2ea546b200e4901f32b683f08b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 06:21:15 +0800 Subject: [PATCH 083/138] docs(adr): correct the descriptions the removed transports left in the ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR had been excluded from every sweep this change set ran, because the first one filtered `docs/adr/` out and later sweeps copied that scope forward. Five sites were wrong. Two were substantive rather than prose: - §6 lists `ProxyHandler::forward_tool_call` among three pieces that "already generalize and are reused as-is", describing it as forwarding any tool name with no browser-specific validation. `ProxyHandler` is deleted, and its replacement is the opposite: `AcpTunnelSource::call` refuses an unlisted server name and an unpinned tool before the tunnel is resolved at all. - The sequence diagram still had core starting a per-session MCP proxy and writing whichever of the two entry shapes the mode selected. Three were stale claims: - "Proxy mode remains as the one explicit `OPENAB_BROWSER_MODE` opt-out; bridge mode was removed" — written two commits ago, false in both halves now. - F5 was still listed as a pending follow-up ("retire the superseded per-session proxy path once Facade mode has soaked") when this PR is what did it. Struck through and dated, since a follow-up list that still contains finished work will have someone doing it twice. - D5's supersession note ended "still the behaviour of `proxy` mode". Historical text that names a removed transport in order to explain why the current design looks the way it does is kept — that is the point of the D sections and the supersession notice. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 25 +++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index e6a1090c4..7c7b470b1 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -102,8 +102,8 @@ sequenceDiagram GW-->>Ext: initialize result (agentCapabilities) Ext->>GW: session/new (or session/resume on reconnect) GW->>GW: register per-session TunnelHandle
(AcpTunnelRegistry) - GW->>Core: spawn agent + start per-session MCP proxy - Core->>Core: write "openab-browser" into agent's mcp.json
proxy: {url,headers} · bridge: static {command,args} + GW->>Core: spawn agent (mint facade session token) + Core->>Core: write static "openab" facade entry into agent's mcp.json
{url, Authorization: Bearer ${OPENAB_SESSION_TOKEN}} LLM->>Core: MCP initialize + tools/list Core->>GW: tools/list (MCP-over-ACP: mcp/message frame) GW->>Ext: mcp/message → tools/list @@ -140,8 +140,11 @@ Three pieces already generalize and are reused as-is: - `parse_acp_mcp_servers` already parses **N** `type:acp` entries with arbitrary `{id, name}`. - `establish_and_register_tunnel(…, srv.id, …)` already threads the declared `srv.id` into `mcp/connect` — the wire already carries a per-server discriminator. -- `ProxyHandler::forward_tool_call` forwards **any** tool name+args down the tunnel — no - browser-specific validation. +- ~~`ProxyHandler::forward_tool_call` forwards **any** tool name+args down the tunnel — no + browser-specific validation.~~ `ProxyHandler` was removed with the per-session proxy on + 2026-07-28. Forwarding is now `AcpTunnelSource::call` in the facade capability source, and it is + **not** unvalidated: the §6.4 trust gate refuses a tool whose server name is not allowlisted or + whose name is not pinned, before anything reaches the tunnel. ### 6.1 Address every hop by `(channel_id, serverId)` - `AcpTunnelRegistry` becomes keyed by `(channel_id, serverId)` instead of `channel_id` alone — the @@ -333,8 +336,9 @@ per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegist facade is serving); `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose `Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's process environment rather than a config file — which also removes the shared-workdir exposure of the -old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Proxy mode -remains as the one explicit `OPENAB_BROWSER_MODE` opt-out; bridge mode was removed on 2026-07-28. +old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Both +legacy transports were removed on 2026-07-28 — bridge first, then the per-session proxy — and +`OPENAB_BROWSER_MODE` no longer selects anything; `[mcp]` is now required for browser control. This covers §6.2's source seam and session identity for the **browser** case. ⚠️ **Divergence to reconcile with the adapter ADR (not resolved here).** Adapter ADR §6.2 says delivery @@ -359,8 +363,10 @@ ignore `mcpServers`, or whether Facade mode should be unavailable for them. per-declared-server `tool_filter`, with `browser` pinned to its five known tools so a same-name declaration cannot inject others (§6.4). Should land with F1′, since F1′ is what makes client-declared tool sets reachable. -- **F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; bridge-mode - removal stays an explicit operator call (§6.5). +- ~~**F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; + bridge-mode removal stays an explicit operator call (§6.5).~~ **Done 2026-07-28**: the operator + call was made and both transports were removed in this PR, so there is no soak period and no + remaining opt-out. - **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, and two concurrent sessions each reaching only their own browser. @@ -423,7 +429,8 @@ Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` ( - **D5 — per-session MCP server.** The pool started one loopback Streamable-HTTP MCP proxy per `acp:` session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so correlation was implicit; lifetime was tied to the `AcpConnection` via a `CancellationToken` - `DropGuard`. Superseded by the single facade listener (§6.2); still the behaviour of `proxy` mode. + `DropGuard`. Superseded by the single facade listener (§6.2). `proxy` mode kept this behaviour + until it too was removed on 2026-07-28, so this section is now purely historical. - **D6 — tunnel trait in core, impl in root.** `openab-core` defines the tunnel trait (`AcpMcpTunnel`, §6.1); the **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and From 87206fd949e47ffc573b054d70396ed57fe1245c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 06:25:47 +0800 Subject: [PATCH 084/138] docs(adr): remove the last four present-tense claims about the old transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four described the removed transports as current: - §4 "The downstream hop has two delivery modes: `proxy` (HTTP MCP, default) and `bridge` (stdio relay)". There is one, and neither of those. - §6.6 "`BrowserMode::Facade` is the new default (falling back to `Proxy` when no facade is serving)". That enum no longer exists. - The supersession notice kept D2/D3/D5 partly "because `proxy`/`bridge` remain selectable". They are not. - The 2026-07-28 bridge-removal update said `bridge` "degrades to `facade`". True for exactly one commit: removing the proxy took `BrowserMode` and the whole `OPENAB_BROWSER_MODE` mechanism with it, so the variable now selects nothing and is only reported at startup. A correction can go stale as readily as the claim it corrected, which is worth saying out loud in the paragraph rather than silently rewriting it. Found by review, not by the vocabulary sweeps this change set has been using. Two of the four did match the sweep's terms and were lost because it excluded `docs/adr/`; the other two never matched any term in it — one says "two delivery modes", the other "remain selectable". A term list only finds phrasings its author thought of, so it cannot be sufficient evidence that a removal is fully described. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 7c7b470b1..965ae048c 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -83,8 +83,8 @@ flowchart LR ``` Only the client (extension) is remote; core, gateway and agent are one in-pod `openab run` process -tree. The downstream hop has two delivery modes (§ browser ADR / §6.3): `proxy` (HTTP MCP, default) -and `bridge` (stdio relay). +tree. The downstream hop has **one** delivery path: the OAB MCP Facade. It had two others — `proxy` +(HTTP MCP, once the default) and `bridge` (stdio relay) — and both were removed on 2026-07-28. ## 5. MCP usage sequence (katashiro.click as the example) @@ -317,9 +317,12 @@ Retired once this lands: the per-session `mcp_proxy` browser server, its port/be **Update — the operator call was made on 2026-07-28: bridge mode is removed.** The stdio bridge (`OPENAB_BROWSER_MODE=bridge`, `openab browser-bridge`, the per-pod unix socket and its process-ancestry channel resolver) existed because some CLIs preferred a stdio entry. The facade is -a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. `bridge` is now -an unrecognised mode value and degrades to `facade`; facade setup deletes the leftover static entry, -which is the only one whose exact shape proves we wrote it. +a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. Facade setup +deletes the leftover static entry, which is the only one whose exact shape proves we wrote it. + +This paragraph said `bridge` "degrades to `facade`", which was true for one commit. The per-session +proxy was removed hours later, taking `BrowserMode` and the whole `OPENAB_BROWSER_MODE` mechanism +with it: the variable now selects nothing at all and is merely reported at startup. **Open question (not decided).** Under the facade the LLM reaches a browser action via `search_capabilities` → `execute_capability`, one hop more per turn than today's direct @@ -332,8 +335,7 @@ revisit a per-provider "expose directly" option only if interactive browser late implements `CapabilitySource` over the existing `AcpMcpTunnel` — `requires_session()`, static-advertise per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegistrar` adapts the facade's `SessionTokens` to a `SessionTokenRegistrar` hook in core, so `openab-core` stays free of an -`openab-mcp` dependency. `BrowserMode::Facade` is the new default (falling back to `Proxy` when no -facade is serving); `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose +`openab-mcp` dependency. `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose `Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's process environment rather than a config file — which also removes the shared-workdir exposure of the old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Both @@ -398,7 +400,8 @@ Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` ( > facade integration in §6.2 — browser is now one session-aware `CapabilitySource`, and session > identity is the facade's broker-minted `SessionTokens` rather than a per-session port plus a > self-written `mcp.json` entry. They are kept because they explain *why* the shipped design looked -> the way it did, and because `proxy`/`bridge` remain selectable. D1, D4 and D6 carry over. +> the way it did. Neither `proxy` nor `bridge` is selectable any more — both were removed on +> 2026-07-28. D1, D4 and D6 carry over. - **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: From f2b4dbd03b3e456e78c08ec54d34b631cc47d61d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 07:34:54 +0800 Subject: [PATCH 085/138] docs(adr): correct nine claims found by two independent semantic reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither read was a keyword sweep; both went through the ADR section by section checking each present-tense claim against the code. They overlapped on four, and each found things the other missed — one and four respectively. A sweep would have found fewer than either. Reviewer-only: - §5's sequence still ended with the agent seeing `katashiro.*` in its own `tools/list` and calling `tools/call katashiro.click`. Under the facade the model sees two meta-tools and goes through `search_capabilities` → `execute_capability`. Two commits ago I corrected lines 105-106 of this same diagram and did not read the twelve lines below them. Mine only: - §6.2 said the broker "writes it into that agent's MCP client config". The config gets the literal `${OPENAB_SESSION_TOKEN}`; the value goes only into the agent's process environment. The sentence described the shared-workdir exposure this design exists to prevent, while §6.6 described it correctly. - The §6.4 allowlist default is `katashiro`, not `browser` — §7.1 records that rename and §6.4 was never updated. - "Facade mode is the default transport" — there are no transports to be default among. - Capabilities publish as `openab-browser`, not `openab`. `openab` is the mcp.json entry key. Both: - The §4 diagram still drew the per-session proxy as the live core→agent edge. - F1′, F3′ and F4 were still listed as remaining; all three landed here. F5 was struck through three commits ago while these three sat directly above it untouched, which is how the same list gets worked twice. - D4 described the discovery cache as keyed by `(channel_id, server_id)`; it is keyed by declared name, so a reconnect minting a fresh id keeps the cache. - §8 called static-advertise superseded by dynamic + `list_changed`, kept as a browser-only opt-in. It is the implemented posture, `list_changed` was dropped, and there is no opt-in. F6 is left open deliberately and the section header now says so: no test covers a host-level `mcp.json` provider coexisting with the tunnel source, or two concurrent sessions each reaching only their own browser. The test double ignores `channel_id` and every test shares one context. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 57 +++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 965ae048c..88bf94d1f 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -70,10 +70,10 @@ flowchart LR subgraph POD["OPENAB POD — 'openab run', one process tree"] direction LR GW["openab-gateway
/acp WS server
AcpTunnelRegistry"] - CORE["openab-core
MCP proxy /
aggregator"] + CORE["openab-core
OAB MCP Facade"] AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] GW <--> CORE - CORE -.->|"proxy mode (legacy opt-out)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT + CORE ==>|"OAB MCP Facade (only path)
one listener, requires [mcp]
static {url, Bearer ${OPENAB_SESSION_TOKEN}} → .cursor / .kiro mcp.json
token in agent env, revoked on evict"| AGENT end EXT <==>|"UPSTREAM — only remote hop
MCP-over-ACP · mcp/message framing
multiplexed with ACP chat on ONE /acp WSS
8 MiB frame cap · JPEG screenshots"| GW classDef remote fill:#fde68a,stroke:#b45309,color:#111; @@ -94,7 +94,7 @@ sequenceDiagram participant Tab as Chrome tab
(user's real, logged-in) participant Ext as browser ext.
MCP SERVER participant GW as openab-gateway
/acp WS - participant Core as openab-core
MCP proxy + participant Core as openab-core
OAB MCP Facade participant LLM as agent LLM
MCP client Note over Ext,LLM: PHASE 1 — connect & tool discovery @@ -109,10 +109,10 @@ sequenceDiagram GW->>Ext: mcp/message → tools/list Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type GW-->>Core: tools result - Core-->>LLM: tools/list — browser tools now in the model's tool list + Core-->>LLM: tools/list — the facade's TWO meta-tools
(search_capabilities · execute_capability); katashiro.* are NOT in the model's tool list Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) - LLM->>Core: tools/call katashiro.click(selector) + LLM->>Core: search_capabilities → execute_capability("openab-browser:katashiro.click", {selector}) Core->>GW: tools/call (mcp/message over the SAME /acp WS) GW->>Ext: mcp/message → tools/call Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate @@ -222,8 +222,9 @@ Reverse-MCP adds: acp-tunnel(channel_id, server_id) ← client diall work.** The source must contain no browser-specific branch. **Session identity** is the facade's `SessionTokens`: the broker mints one opaque bearer per agent -session, writes it into that agent's MCP client config pointing at the facade, and revokes it on -session evict; the facade resolves the header back to a `SessionCtx` per request. This **replaces** the +session, injects it into that agent's process environment as `OPENAB_SESSION_TOKEN`, and revokes it +on session evict. The config file gets only the literal `${OPENAB_SESSION_TOKEN}` reference, never the +value — that is what keeps the secret out of a shared workdir; the facade resolves the header back to a `SessionCtx` per request. This **replaces** the bespoke per-session loopback proxy, its self-minted port/bearer, and openab's own `openab-browser` `mcp.json` write/strip logic. @@ -289,7 +290,7 @@ connected extension could otherwise publish arbitrary tools into the agent's cap Therefore this ADR requires, before the source is enabled by default: -- an operator **allowlist** of accepted declared server names (default: `browser` only) — declarations +- an operator **allowlist** of accepted declared server names (default: `katashiro` only) — declarations outside it are ignored with a logged warning; and - a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics, which is **deny-all by default**. @@ -299,7 +300,7 @@ client that declares the tools, so a client may declare a server *named* `browse tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated separately: -- the `browser` entry ships **pinned to its five known tools** (`katashiro.read_dom`, +- the `katashiro` entry ships **pinned to its five known tools** (`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type`); any other tool name it declares is dropped with a logged warning, so a same-name declaration cannot inject new tools; and - every other allowlisted server starts **deny-all** and serves only the tools an operator has @@ -331,14 +332,15 @@ revisit a per-provider "expose directly" option only if interactive browser late ### 6.6 Status — as-built vs remaining -**As-built (`bf37d25e`, `74e23f0e`): Facade mode is the default transport.** `src/browser_source.rs` +**As-built (`bf37d25e`, `74e23f0e`): the facade is the only transport.** `src/browser_source.rs` implements `CapabilitySource` over the existing `AcpMcpTunnel` — `requires_session()`, static-advertise per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegistrar` adapts the facade's `SessionTokens` to a `SessionTokenRegistrar` hook in core, so `openab-core` stays free of an `openab-mcp` dependency. `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose `Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's process environment rather than a config file — which also removes the shared-workdir exposure of the -old per-session `mcp.json` write. Capabilities publish under the provider name `openab`. Both +old per-session `mcp.json` write. Capabilities publish under the provider name `openab-browser` (`openab` is the mcp.json entry key, +a different thing). Both legacy transports were removed on 2026-07-28 — bridge first, then the per-session proxy — and `OPENAB_BROWSER_MODE` no longer selects anything; `[mcp]` is now required for browser control. This covers §6.2's source seam and session identity for the **browser** case. @@ -353,18 +355,18 @@ Both positions are defensible; recording the conflict rather than silently picki facade contract should confirm whether config-file injection is an accepted exception for CLIs that ignore `mcpServers`, or whether Facade mode should be unavailable for them. -**Remaining to fulfil this section:** -- **F1′ generalize the source to N client-declared servers.** Today it serves the fixed - `browser_tools()` set for one implicit server. Extend to every `type:acp` server the session's client - declared, routing on the `.` prefix to `(channel_id, server_id)` (§6.1/§6.2), with no - browser-specific branch left in the source. -- **F3′ per-`(channel_id, name)` discovery cache** — fetch each declared server's real `tools/list` - once on attach and serve from cache (§6.3). Required by F1′: a hardcoded tool table cannot describe - arbitrary declared servers. -- **F4 trust gate** — operator allowlist (default `browser` only) + **deny-all-by-default** - per-declared-server `tool_filter`, with `browser` pinned to its five known tools so a same-name - declaration cannot inject others (§6.4). Should land with F1′, since F1′ is what makes - client-declared tool sets reachable. +**Remaining to fulfil this section** — F1′, F3′, F4 and F5 all landed in #1447 and are struck +through; **F6 genuinely remains**: +- ~~**F1′ generalize the source to N client-declared servers.**~~ **Done in #1447**: the source + holds an N-entry policy map, enumerates `tunnel.servers(channel_id)` and routes on the + `.` prefix to `(channel_id, server_id)`. Browser-ness is data in `builtin_catalogs`, + not a branch. +- ~~**F3′ per-`(channel_id, name)` discovery cache**~~ **Done in #1447**: `ToolsCache` keyed + `(channel_id, declared_name)` with in-flight dedupe and pull-triggered discovery (§6.3). +- ~~**F4 trust gate** — operator allowlist + **deny-all-by-default** per-declared-server + `tool_filter` (§6.4).~~ **Done in #1447**: `ServerPolicy` / `policy_from_config` over + `[[mcp.acp_servers]]`, enforced in both `tools()` and `call()` before the tunnel is resolved; + default allowlist is `katashiro` pinned to its five tools. - ~~**F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; bridge-mode removal stays an explicit operator call (§6.5).~~ **Done 2026-07-28**: the operator call was made and both transports were removed in this PR, so there is no soak period and no @@ -428,7 +430,8 @@ Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` ( the capability disappearing. `notifications/tools/list_changed` was designed but never implemented, and is **dropped, not deferred** (§6.3): facade discovery is pull-based, so no cached tool list exists for a notification to invalidate. The static-advertise posture is kept, implemented as - fetch-once-per-declared-server plus a per-`(channel_id, server_id)` cache. + fetch-once-per-declared-server plus a per-`(channel_id, declared_name)` cache — keyed by NAME, not + `server_id`, so a reconnect that mints a fresh id does not lose the cache (§6.3). - **D5 — per-session MCP server.** The pool started one loopback Streamable-HTTP MCP proxy per `acp:` session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so correlation was implicit; lifetime was tied to the `AcpConnection` via a `CancellationToken` @@ -507,8 +510,10 @@ clients see only the two meta-tools. servers well; a special on-stream MCP type is invasive ([ACP discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). Only the can't-listen *client* leg is tunnelled; downstream stays a normal in-process MCP server. -- **Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an - opt-in for browser only. +- ~~**Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an + opt-in for browser only.~~ **Reversed:** static-advertise IS the implemented posture, + `list_changed` was dropped with no consumer (§6.3), and there is no opt-in — the source is + registered unconditionally whenever `[mcp]` is present. ## 9. Typing / dependencies From a91437c55fcec1909946065a3a76f10e305be3f3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 07:37:27 +0800 Subject: [PATCH 086/138] docs(adr): resolve the two claims that were ambiguous between past and present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were flagged rather than guessed at, and the reviewer ruled on them. §6.5 said "Retired once this lands" of work this PR has done. Now stated as retired in this PR, and extended to name the bridge as well — the sentence listed only the proxy, so on its own it read as though one transport survived. D4's "The in-pod MCP server is always-on" could not simply be moved to past tense: the supersession notice explicitly carries D4 over, so it is a live claim, not a historical one. Rewritten to the condition that actually holds — with `[mcp]` configured the facade listener is process-lifetime and decoupled from extension attach; without `[mcp]` no listener starts and there is no browser control. Softening it into history would have quietly dropped a decision the ADR still relies on. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 88bf94d1f..9bd47a5e0 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -312,8 +312,9 @@ The browser extension is **unchanged**: it declares `{type:acp, id, name}` and s tools over the tunnel. What changes is on the openab side — browser tools reach the agent through the facade's meta-tools rather than a dedicated per-session MCP server. -Retired once this lands: the per-session `mcp_proxy` browser server, its port/bearer minting, and the -`openab-browser` `mcp.json` injection. +**Retired in this PR (2026-07-28):** the per-session `mcp_proxy` browser server, its port/bearer +minting, and the `openab-browser` `mcp.json` injection — along with the stdio bridge described +below. Both legacy transports are gone; the facade is the only downstream path. **Update — the operator call was made on 2026-07-28: bridge mode is removed.** The stdio bridge (`OPENAB_BROWSER_MODE=bridge`, `openab browser-bridge`, the per-pod unix socket and its @@ -424,8 +425,10 @@ Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` ( MV3 extension cannot listen — adopting the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → `connectionId`, then `mcp/message`), not a hand-rolled envelope. -- **D4 — lifecycle: the WS may connect *after* session start.** The in-pod MCP server is always-on and - decoupled from the extension WS, so browser tools are **static-advertised** regardless of WS state; a +- **D4 — lifecycle: the WS may connect *after* session start.** When `[mcp]` is configured the + facade listener is process-lifetime and decoupled from the extension WS — it is not + unconditionally always-on, since without `[mcp]` no listener starts at all and there is no browser + control. Given a listener, browser tools are **static-advertised** regardless of WS state; a `tools/call` with no extension attached returns an MCP error ("browser not connected") rather than the capability disappearing. `notifications/tools/list_changed` was designed but never implemented, and is **dropped, not deferred** (§6.3): facade discovery is pull-based, so no cached tool list From bdd70c3703a0199f106e92f40932430e5ed5e05d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 07:39:47 +0800 Subject: [PATCH 087/138] docs(adr): fix the discovery order, the status line and the last `browser` name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more from a reviewer's full re-read. The §5 sequence had the agent's `tools/list` triggering an upstream `tools/list` to the extension and only then returning the meta-tools. Backwards: `tools()` returns the two meta-tools immediately with no upstream call, and discovery is spawned from the *pull* — `search_capabilities` — per `(channel_id, declared_name)`, then cached. Redrawn as three phases, and PHASE 1's label no longer says "tool discovery", because that phase does none. This diagram has now needed four passes. Twice before I corrected the lines I was looking at — 105-106, then the closing two — and treated the rest of the same diagram as already checked. The status lines still said the §6 generalization was "implementing" in this PR. F1′, F3′, F4 and F5 have landed; F6 has not, so the status says that instead. `:156` still used `"browser"` as the example of the stable `name`, in the passage explaining which of `id`/`name` is stable. The same name was corrected at three other sites two commits ago. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 26 +++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 9bd47a5e0..71f3fc15e 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -1,7 +1,7 @@ # ADR: Reverse MCP-over-ACP over WebSocket -- **Status:** Accepted — the mechanism is **as-built in #1447**; the generic multi-server - generalization (§6) is accepted and implementing in the same PR. +- **Status:** Accepted — the mechanism and the generic multi-server generalization (§6) are both + **as-built in #1447** (F1′, F3′, F4 and F5 landed; F6 e2e coverage remains). - **Date:** 2026-07-18 (updated 2026-07-24) - **Author:** @brettchien - **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), @@ -97,7 +97,7 @@ sequenceDiagram participant Core as openab-core
OAB MCP Facade participant LLM as agent LLM
MCP client - Note over Ext,LLM: PHASE 1 — connect & tool discovery + Note over Ext,LLM: PHASE 1 — connect & agent wiring (no tool discovery yet) Ext->>GW: WS GET /acp — initialize
mcpServers = [ type:acp, "openab-browser" ] GW-->>Ext: initialize result (agentCapabilities) Ext->>GW: session/new (or session/resume on reconnect) @@ -105,14 +105,18 @@ sequenceDiagram GW->>Core: spawn agent (mint facade session token) Core->>Core: write static "openab" facade entry into agent's mcp.json
{url, Authorization: Bearer ${OPENAB_SESSION_TOKEN}} LLM->>Core: MCP initialize + tools/list - Core->>GW: tools/list (MCP-over-ACP: mcp/message frame) + Core-->>LLM: the facade's TWO meta-tools ONLY
(search_capabilities · execute_capability) — returns at once,
no upstream call; katashiro.* are NOT in the model's tool list + + Note over Tab,LLM: PHASE 2 — discovery is PULL-triggered by the model + LLM->>Core: search_capabilities("browser") + Core->>GW: tools/list (MCP-over-ACP: mcp/message frame)
spawned on first pull per (channel_id, declared_name), then cached GW->>Ext: mcp/message → tools/list Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type GW-->>Core: tools result - Core-->>LLM: tools/list — the facade's TWO meta-tools
(search_capabilities · execute_capability); katashiro.* are NOT in the model's tool list + Core-->>LLM: capabilities: openab-browser:katashiro.* - Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) - LLM->>Core: search_capabilities → execute_capability("openab-browser:katashiro.click", {selector}) + Note over Tab,LLM: PHASE 3 — one autonomous action (e.g. click) + LLM->>Core: execute_capability("openab-browser:katashiro.click", {selector}) Core->>GW: tools/call (mcp/message over the SAME /acp WS) GW->>Ext: mcp/message → tools/call Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate @@ -131,8 +135,8 @@ detailed in **§7.3**. ## 6. Generalization — multiple client-side MCP servers -The browser path wires **one** MCP server. This section is the accepted direction (implementing in -#1447) to make reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** +The browser path wires **one** MCP server. This section is the accepted direction, **as-built in +#1447**, making reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** `type:acp` MCP servers on `initialize`, and the agent's LLM discovers and calls each server's real tools. The browser extension becomes *one instance* of the mechanism, not a special case. @@ -154,8 +158,8 @@ Three pieces already generalize and are reused as-is: **`id` and `name` are different things, and routing needs both.** A declaration is `{type:"acp", id, name}`, and the two fields have very different lifetimes — the reference client mints -`id` as a fresh `crypto.randomUUID()` **per connection** while `name` (`"browser"`) is stable across -reconnects. The registry key is the **`id`**; the `` segment of a tool name (`katashiro.click`) +`id` as a fresh `crypto.randomUUID()` **per connection** while `name` (`"katashiro"`) is stable +across reconnects. The registry key is the **`id`**; the `` segment of a tool name (`katashiro.click`) and the §6.4 allowlist are the **`name`**. Consequences, all confirmed by review 2026-07-26: - The registry stays keyed by `(channel_id, id)` — keying by `name` would let two same-name tunnels From 0f58183b931814060bea91f2e990e9706c51ba7e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 07:50:27 +0800 Subject: [PATCH 088/138] docs(adr): correct nine more claims, and record two gaps instead of hiding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second end-to-end read, aimed at the sections earlier passes had not opened. Seven are prose corrections; two are not documentation bugs at all. Prose: - §2's rationale still said the facade re-exposes the client's tools "so the LLM's `tools/list` sees them". It does not — that is the whole point of the meta-tool indirection, which §6.3 describes correctly. - §6 and §6.3 said servers are declared "on `initialize`". They are declared on `session/new` and re-declared on `session/resume`; `handle_initialize` never reads `mcpServers`. This one misleads anyone implementing the client side against the contract. - §6.3 said `tools(ctx)` expresses which servers the session's client declared. It iterates the operator policy map: a pinned server is advertised with nothing attached, and a declared server with no policy entry contributes nothing. What varies per session is the discovery cache. - §6.4's example of a name that grants nothing was `browser`, which is not allowlisted, so the example was refused by the gate it was illustrating — the fifth surviving site of that rename. - §7.1 said PNG base64 (~5.5 MB) "would exceed the cap". It exceeded the old 1 MiB cap; it fits the 8 MiB one that replaced it. Gaps, recorded in §6.4 and filed as F7: - §6.4 twice promised a "logged warning" — for a declaration outside the allowlist, and for a tool dropped by the pin. Neither is logged; `src/browser_source.rs` has no logging call at all. An operator who mistypes a name in `[[mcp.acp_servers]]` sees missing tools and nothing else, which is the failure §6.4 exists to prevent. - §6 claims the facade gives capability sources "schema validation, timeouts, circuit breaking, redaction, audit". Only validation and audit apply on the source path; timeout, breaker and redaction live in `meta_tool::dispatch`, which only downstream `mcp.json` servers traverse. Both are code changes and out of scope here. Rewording the ADR down to match the code would have deleted the record of a real defect by editing the specification that named it, so the section now states the gap and F7 carries it. Co-Authored-By: Claude --- docs/adr/acp-server-websocket-reverse-mcp.md | 54 +++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 71f3fc15e..5f106d66b 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -41,8 +41,9 @@ fits OpenAB-driven (non-LLM) operations. MCP is the standard way agents receive connection — so it serves MCP over the **outbound `/acp` WS it already opened**. This is the only way a can't-listen client can be a full MCP server. - **OpenAB core = MCP proxy/aggregator.** A middlebox between two connections: it consumes the - client's tools from the upstream tunnel and re-exposes them to the agent downstream so the LLM's - `tools/list` sees them. + client's tools from the upstream tunnel and re-exposes them to the agent downstream. Note the LLM's + own `tools/list` does **not** show them: it sees the facade's two meta-tools, and reaches the + client's tools through `search_capabilities` / `execute_capability` (§6.3). - **Agent = MCP client.** The agent (Claude / Codex / Cursor / Kiro …) is a subprocess colocated in the OpenAB pod; it calls the tools over its in-pod MCP link. @@ -137,7 +138,8 @@ detailed in **§7.3**. The browser path wires **one** MCP server. This section is the accepted direction, **as-built in #1447**, making reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** -`type:acp` MCP servers on `initialize`, and the agent's LLM discovers and calls each server's real +`type:acp` MCP servers on `session/new` (and re-declare them on `session/resume`) — **not** on +`initialize`, which never reads `mcpServers` — and the agent's LLM discovers and calls each server's real tools. The browser extension becomes *one instance* of the mechanism, not a special case. Three pieces already generalize and are reused as-is: @@ -247,10 +249,12 @@ The facade's discovery is **pull-based**: the agent sees only `search_capabiliti state**. Backend unavailability surfaces as a **call error** ("browser not connected"), never as a vanishing catalog entry. -Distinguish two kinds of variation: **session scope** (which servers *this* session's client declared) -is legitimate and is exactly what `tools(ctx)` expresses; **attachment flapping** (is the tab connected -this second) must not reach the catalog. An optional refinement, requiring a client wire change, is to -carry a tool manifest in the `initialize` declaration so the catalog is known without a round-trip. +Distinguish two kinds of variation: **session scope** is legitimate; **attachment flapping** (is the +tab connected this second) must not reach the catalog. Note what `tools(ctx)` actually varies by +session is the **discovery cache**, not the declaration set — it iterates the *operator policy* map, +so a pinned server is advertised even when the client declared nothing, and a client-declared server +with no policy entry contributes nothing. An optional refinement, requiring a client wire change, is to +carry a tool manifest in the `session/new` declaration so the catalog is known without a round-trip. **Two layers, and the policy table is the lower one** (confirmed by review 2026-07-26; an earlier draft of this section said an un-cached server "contributes an empty set", which contradicted the @@ -294,19 +298,37 @@ connected extension could otherwise publish arbitrary tools into the agent's cap Therefore this ADR requires, before the source is enabled by default: -- an operator **allowlist** of accepted declared server names (default: `katashiro` only) — declarations - outside it are ignored with a logged warning; and +- an operator **allowlist** of accepted declared server names (default: `katashiro` only) — a + declaration outside it is refused by the capability source: it contributes no tools and its calls + return an error result. **Note it is not refused at declaration time** — the gateway still opens + and registers the tunnel — **and nothing is logged today** (see the gap noted below); and - a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics, which is **deny-all by default**. +> ⚠️ **Two gaps between this section and the code, recorded rather than quietly reworded.** +> +> **No logging.** This section said twice that a refused declaration and a dropped tool are "logged". +> `src/browser_source.rs` contains no logging call at all — both refusals are silent. An operator +> who mis-types a server name in `[[mcp.acp_servers]]` gets missing tools and no signal, which is +> the shape of failure this ADR spends §6.4 preventing. **Fixing this is a code change, so it is +> filed as follow-up F7 rather than made here.** +> +> **The policy runtime does not wrap in-process sources.** §6 claims the facade already provides +> "schema validation, timeouts, circuit breaking, redaction, audit" to capability sources. Only +> argument validation and audit apply on the source path (`facade.rs` `execute_capability`); +> timeout/cancellation, the circuit breaker and redaction live in `meta_tool::dispatch`, which only +> downstream `mcp.json` servers traverse. A hung browser tunnel is bounded by the tunnel's own +> timeout, not by the facade's. **Also F7.** + The name allowlist is **not** a trust boundary on its own: the name is chosen by the same remote -client that declares the tools, so a client may declare a server *named* `browser` and publish any -tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated +client that declares the tools, so a client may declare a server under an allowlisted name — say +`katashiro` — and publish any tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated separately: - the `katashiro` entry ships **pinned to its five known tools** (`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type`); any other tool name it - declares is dropped with a logged warning, so a same-name declaration cannot inject new tools; and + declares is dropped, so a same-name declaration cannot inject new tools (dropped silently today — + see the gap below); and - every other allowlisted server starts **deny-all** and serves only the tools an operator has explicitly listed. @@ -376,6 +398,11 @@ through; **F6 genuinely remains**: bridge-mode removal stays an explicit operator call (§6.5).~~ **Done 2026-07-28**: the operator call was made and both transports were removed in this PR, so there is no soak period and no remaining opt-out. +- **F7 close the two §6.4 gaps** — (a) log a warning when a declared server is refused by the + allowlist and when a fetched tool is dropped by the pin, since both are silent today and an + operator's only symptom is missing tools; (b) decide whether in-process capability sources should + traverse the same timeout / circuit-breaker / redaction path as downstream servers, or whether the + ADR should stop claiming they do. Both are code changes, deliberately not made in #1447. - **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, and two concurrent sessions each reaching only their own browser. @@ -395,7 +422,8 @@ Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` ( are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if wanted, but are not the primary surface. - **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP - frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. + frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) exceeded the **old** 1 MiB + cap, which is why JPEG was chosen; it fits within the 8 MiB cap that replaced it. - The declared server name is `katashiro`; it was `browser` until 2026-07-26, when it was renamed because Playwright MCP's `browser_*` tools sat beside it in the same catalog and the model could not reliably tell "the user's real logged-in tab" from "a sandbox browser". From deb25777f151e7d03747d1b21106628d6da4f504 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 11:37:03 +0800 Subject: [PATCH 089/138] test(acp): drive the real /acp WebSocket end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other test in this file calls the handlers directly with hand-built structures, so the axum route, the upgrade, the frame codec and the request/reply correlation had never been exercised over a socket. The tunnel was asserted by construction rather than observed working — which is what the reviewer objected to. `browser_tunnel.rs` had no tests at all. Three tests bind a real listener and drive it with a scripted `tokio-tungstenite` client: `initialize` → `session/new` declaring a `type:acp` server → answer the real `mcp/connect`, then call server-side so a genuine `mcp/message` crosses the socket. Both halves are asserted — a result comes back as a result, and a JSON-RPC error comes back as `Err` rather than a null result, which a handler that swallowed errors would otherwise pass. Two things writing it made clear: Replacement had to be a **resume**, not a second `session/new`. Eviction is scoped `c == &channel_id`, and a new session is a new channel, so two independent sessions declaring the same name do not collide — and must not. The first version of this test drove it with two `session/new` calls; it asserted nothing while looking like R7 coverage. Registration completes *after* the client answers `mcp/connect`, so reading the registry straight after replying races it. `wait_for_tunnels` polls the real condition rather than sleeping a fixed interval and hoping. The R7 assertion was mutation-checked: flipping the expected `connectionId` from the replaced connection to its replacement fails the test, so it distinguishes disconnecting the right tunnel from disconnecting the wrong one. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 8778ad438..1c6fbf374 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -3182,3 +3182,256 @@ mod acp_review_fixes { ); } } + +/// End-to-end tests over the **real** `/acp` WebSocket route. +/// +/// Every other test in this file calls the handlers directly with hand-built structures, so +/// nothing had ever exercised the axum route, the upgrade, the frame codec, or the request/reply +/// correlation across an actual socket. A reviewer raised exactly that: the tunnel was asserted +/// by construction rather than observed working. These bind a real listener and drive it with a +/// scripted `tokio-tungstenite` client — no browser, no model. +#[cfg(test)] +mod acp_ws_integration { + use super::*; + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message as WsMessage; + + /// Serve `/acp` on an ephemeral loopback port. Returns the URL and the tunnel registry, so a + /// test can drive the server side the way core does. + async fn serve() -> (String, AcpTunnelRegistry) { + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut state = crate::AppState::test_default(tx); + state.acp = Some(AcpConfig { + // Keyless loopback: no bearer. A client sending no `Origin` is accepted, which is + // what a non-browser client (this test, and the real extension's native host) is. + auth_key: None, + allowed_origins: vec![], + }); + state.acp_reply_registry = Some(new_reply_registry()); + let registry = new_tunnel_registry(); + state.acp_tunnel_registry = Some(registry.clone()); + + let app = axum::Router::new() + .route("/acp", axum::routing::get(ws_upgrade)) + .with_state(std::sync::Arc::new(state)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("ws://{addr}/acp"), registry) + } + + type Ws = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + async fn send(ws: &mut Ws, v: Value) { + ws.send(WsMessage::Text(v.to_string())).await.unwrap(); + } + + /// Next JSON frame from the server, skipping anything that is not text (pings etc). + async fn recv(ws: &mut Ws) -> Value { + loop { + match tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for a server frame") + .expect("socket closed") + .unwrap() + { + WsMessage::Text(t) => return serde_json::from_str(&t).unwrap(), + WsMessage::Close(_) => panic!("server closed the socket"), + _ => continue, + } + } + } + + /// Drive `initialize` + `session/new` declaring one `type:acp` server, then answer the + /// `mcp/connect` the gateway sends back. Returns the session id. + async fn handshake(ws: &mut Ws, acp_id: &str, name: &str, connection_id: &str) -> String { + send(ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let init = recv(ws).await; + assert!(init.get("result").is_some(), "initialize failed: {init}"); + + send(ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": acp_id, "name": name}] + } + })).await; + + // The gateway now does two things concurrently: answer session/new, and open the tunnel + // by sending mcp/connect. Order is not guaranteed, so accept either first. + let mut session_id = None; + let mut connected = false; + while session_id.is_none() || !connected { + let frame = recv(ws).await; + if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!( + frame["params"]["acpId"], json!(acp_id), + "mcp/connect must name the declared id" + ); + send(ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": connection_id} + })).await; + connected = true; + } else if frame.get("id") == Some(&json!(2)) { + session_id = Some( + frame["result"]["sessionId"].as_str().expect("sessionId").to_string(), + ); + } + } + session_id.unwrap() + } + + /// Wait until `n` tunnels are registered. + /// + /// Registration happens *after* the client answers `mcp/connect` — the establishing task still + /// has to build the handle and take the registry lock — so reading the registry straight after + /// replying is a race. Polling the real condition is honest; sleeping a fixed interval and + /// hoping would make this test flaky on a loaded machine. + async fn wait_for_tunnels(registry: &AcpTunnelRegistry, n: usize) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let len = registry.lock().unwrap().len(); + if len == n { + return; + } + assert!( + std::time::Instant::now() < deadline, + "expected {n} tunnel(s) registered, still {len} after 5s" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + /// A real `mcp/message` crosses the socket and its result comes back to the caller. + #[tokio::test] + async fn a_tool_call_crosses_the_real_socket_and_returns_its_result() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "uuid-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Server side, exactly as core reaches a session's tunnel. + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().expect("a tunnel must be registered").clone() + }; + let call = tokio::spawn(async move { + handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + }); + + let framed = recv(&mut ws).await; + assert_eq!(framed["method"], json!("mcp/message")); + assert_eq!(framed["params"]["connectionId"], json!("conn-1")); + assert_eq!(framed["params"]["method"], json!("tools/call")); + assert_eq!(framed["params"]["params"]["name"], json!("katashiro.click")); + + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + })).await; + + let got = call.await.unwrap().expect("the call must succeed"); + assert_eq!(got["content"][0]["text"], json!("clicked")); + } + + /// The error half: a JSON-RPC error from the client surfaces as `Err`, not as a null result. + /// Asserting only the happy path would let a handler that swallows errors pass. + #[tokio::test] + async fn an_error_from_the_client_surfaces_as_an_error_not_an_empty_result() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "uuid-2", "katashiro", "conn-2").await; + wait_for_tunnels(®istry, 1).await; + + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().unwrap().clone() + }; + let call = tokio::spawn(async move { handle.mcp_message("tools/call", None, 5).await }); + + let framed = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "error": {"code": -32603, "message": "no active tab"} + })).await; + + let err = call.await.unwrap().expect_err("a remote error must not read as success"); + assert!( + err.contains("no active tab"), + "the client's message must reach the caller, got: {err}" + ); + } + + /// A reconnect that **resumes** the same session re-declares the same NAME with a fresh id. + /// That evicts the stale tunnel, and the client is owed an `mcp/disconnect` for the connection + /// it still believes is open (review R7). + /// + /// It has to be a resume, not a second `session/new`: eviction is scoped to one `channel_id` + /// (`c == &channel_id` in `establish_and_register_tunnel`), and a new session is a new channel, + /// so two independent sessions declaring the same name do not collide and must not evict each + /// other. Writing this as two `session/new` calls asserts nothing. + #[tokio::test] + async fn a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced() { + let (url, registry) = serve().await; + let (mut first, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut first, "uuid-old", "katashiro", "conn-old").await; + wait_for_tunnels(®istry, 1).await; + + // The client comes back on a fresh socket and resumes, re-declaring under the same name + // with the fresh id its runtime mints per connection. + let (mut second, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut second, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut second).await; + send(&mut second, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": { + "sessionId": session_id, + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "uuid-new", "name": "katashiro"}] + } + })).await; + loop { + let frame = recv(&mut second).await; + if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut second, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + break; + } + if frame.get("id") == Some(&json!(2)) { + assert!( + frame.get("result").is_some(), + "session/resume was refused, so no tunnel is opened: {frame}" + ); + } + } + + // The disconnect must name the REPLACED connection. Without this assertion an + // implementation that disconnected the newly registered tunnel would pass too. + let framed = recv(&mut first).await; + assert_eq!(framed["method"], json!("mcp/disconnect")); + assert_eq!( + framed["params"]["connectionId"], json!("conn-old"), + "the evicted connection is the one owed a disconnect, not its replacement" + ); + + // And the survivor is the new one. + let names: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.connection_id.clone()).collect() + }; + assert_eq!(names, vec!["conn-new".to_string()], "only the newest tunnel may remain"); + } +} From 197bb4de7864085b9a6e42cbbb4720f7affe9aeb Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 12:30:19 +0800 Subject: [PATCH 090/138] fix(acp): scope registry teardown to the connection that owns the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both registries are keyed by identifiers that outlive a connection — the reply registry by `channel_id`, the tunnel registry by `(channel_id, server_id)` — and `session/resume` hands the same channel to a *new* connection. So a closing connection's cleanup could delete a successor's live entry: the reply sink stops delivering, or the tunnel disappears from under a working session, in both cases with no error anywhere. "Only remove keys I inserted" does not fix it, because the key is exactly what gets reused. `ReplySink` and `TunnelHandle` now record the `acp_conn_*` id of the connection that installed them, and teardown removes an entry only when the channel matches *and* the owner is the closing connection. Eviction is deliberately NOT owner-scoped, and I had that wrong first. Last-attach-wins is cross-connection by design: a reconnecting client arrives on a new socket with a fresh `server_id`, so its predecessor's handle belongs to the old connection. Scoping eviction by owner leaves that dead tunnel registered beside the live one under the same declared name — the ambiguity §6.1 exists to prevent. The WebSocket test added for R7 caught this by failing; the test that had been written for the wrong semantics is deleted rather than kept, since it would have pinned the regression in place while looking like coverage. Teardown asks whose entries these are. Eviction asks which tunnel now owns a name. Only the first needs an owner. Three acceptance cases, all verified to fail against key-only teardown: a late cleanup must not remove a successor's tunnel, or its reply sink, and a cleanup must still remove the entries it does own. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 170 ++++++++++++++++-- 1 file changed, 159 insertions(+), 11 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 1c6fbf374..d78f9d951 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -361,6 +361,12 @@ pub struct ReplySink { /// Originating `GatewayEvent.event_id` (`evt_`), round-tripped as `reply_to`. pub turn_id: String, pub tx: mpsc::UnboundedSender, + /// The `acp_conn_*` id of the WebSocket connection that installed this sink. + /// + /// Teardown removes only what it owns. "Remove the keys for my sessions" is not enough: a + /// client that reconnects and resumes takes over the same `channel_id`, so a slow cleanup from + /// the old connection would delete the *successor's* live sink and silently stop its replies. + pub owner: String, } /// Registry of active ACP sessions: channel_id → reply sink. @@ -717,6 +723,13 @@ pub struct TunnelHandle { /// fresh UUID per connection. Tool prefixes (`browser.click`) and the §6.4 trust allowlist are /// both keyed by this name, so it must survive registration to be routable (ADR §6.1). server_name: String, + /// The `acp_conn_*` id of the WebSocket connection this tunnel belongs to. + /// + /// The registry key is `(channel_id, server_id)` and a reconnecting client mints a fresh + /// `server_id`, so a key is not a stable identity — and even the channel is reused across + /// connections by `session/resume`. Teardown therefore matches on this owner rather than on + /// the key, so a late cleanup cannot remove a handle installed by a different connection. + owner: String, } impl TunnelHandle { @@ -775,6 +788,7 @@ async fn establish_and_register_tunnel( channel_id: String, registry: AcpTunnelRegistry, timeout_secs: u64, + owner: String, ) -> Result<(), String> { // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. @@ -786,6 +800,7 @@ async fn establish_and_register_tunnel( next_id, connection_id, server_name: acp_name.clone(), + owner: owner.clone(), }; let replaced = { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); @@ -801,7 +816,15 @@ async fn establish_and_register_tunnel( // this is a std mutex, so awaiting under it is not an option. let stale: Vec<(String, String)> = reg .iter() - .filter(|((c, id), h)| c == &channel_id && h.server_name == acp_name && id != &acp_id) + .filter(|((c, id), h)| { + // Deliberately NOT scoped to the attaching connection. A reconnecting client + // arrives on a NEW socket with a fresh `server_id`, so its predecessor's handle is + // owned by the old connection — restricting eviction to one owner would leave that + // dead tunnel registered beside the live one under the same declared name, which + // is the ambiguity last-attach-wins exists to prevent (ADR §6.1). Teardown is + // owner-scoped; eviction is not, and they are different questions. + c == &channel_id && h.server_name == acp_name && id != &acp_id + }) .map(|(k, _)| k.clone()) .collect(); let replaced: Vec = stale.iter().filter_map(|k| reg.remove(k)).collect(); @@ -844,6 +867,7 @@ fn spawn_acp_tunnels( pending: &Arc>>>, next_id: &Arc, prompt_tasks: &mut Vec>, + owner: &str, ) { for srv in servers { let out_tx = out_tx.clone(); @@ -851,9 +875,10 @@ fn spawn_acp_tunnels( let next_id = next_id.clone(); let registry = registry.clone(); let channel_id = channel_id.clone(); + let owner = owner.to_string(); prompt_tasks.push(tokio::spawn(async move { if let Err(e) = establish_and_register_tunnel( - out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, + out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, ) .await { @@ -1101,6 +1126,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &pending_requests, &next_req_id, &mut prompt_tasks, + &connection_id, ); } } @@ -1155,6 +1181,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &pending_requests, &next_req_id, &mut prompt_tasks, + &connection_id, ); } } @@ -1229,6 +1256,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let state_clone = state.clone(); let sessions_clone = sessions.clone(); let out_tx_clone = out_tx.clone(); + let conn_for_prompt = connection_id.clone(); let handle = tokio::spawn(async move { handle_session_prompt( &state_clone, @@ -1238,6 +1266,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &out_tx_clone, session_id, cancel, + &conn_for_prompt, ) .await; }); @@ -1290,16 +1319,18 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { .map(|s| s.channel_id.clone()) .collect() }; + // Teardown removes only what THIS connection owns. Matching on the key alone is wrong for + // both registries: a client that reconnects and resumes takes over the same `channel_id`, so a + // late cleanup from the closing connection would delete the successor's live entry — the + // reply sink stops delivering, or the tunnel disappears from under a working session. if let Some(ref registry) = state.acp_reply_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - for cid in &channel_ids { - reg.remove(cid); - } + reg.retain(|cid, sink| !(channel_ids.contains(cid) && sink.owner == connection_id)); } if let Some(ref registry) = state.acp_tunnel_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - // Compound-key registry (P1): drop every `(channel_id, *)` tunnel for the closed session. - reg.retain(|(cid, _), _| !channel_ids.contains(cid)); + // Compound-key registry (P1): drop this connection's `(channel_id, *)` tunnels only. + reg.retain(|(cid, _), h| !(channel_ids.contains(cid) && h.owner == connection_id)); } debug!( connection = %connection_id, @@ -1552,6 +1583,9 @@ async fn release_prompt( } } +// 8 args: the connection id is threaded in so the reply sink records which connection installed +// it. Bundling these into a struct would hide that relationship at the call site. +#[allow(clippy::too_many_arguments)] async fn handle_session_prompt( state: &Arc, sessions: &Arc>>, @@ -1562,6 +1596,9 @@ async fn handle_session_prompt( // `cancel` installed under the session lock (R16-F1). This task owns releasing it on return. session_id: String, cancel: Arc, + // `acp_conn_*` id of the connection running this prompt, stamped on the reply sink so + // teardown removes only the sinks this connection installed. + connection_id: &str, ) { // sessionId was validated + reserved by the caller; only the prompt body can still be bad. let prompt_text = match extract_prompt_params(params) { @@ -1614,7 +1651,10 @@ async fn handle_session_prompt( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id.clone(), ReplySink { turn_id, tx: reply_tx }); + .insert( + channel_id.clone(), + ReplySink { turn_id, tx: reply_tx, owner: connection_id.to_string() }, + ); } // Send event through the broadcast channel @@ -2344,6 +2384,7 @@ mod acp_requests { let next_id = Arc::new(AtomicU64::new(1)); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); let handle = super::TunnelHandle { + owner: "conn-test".into(), out_tx, pending: pending.clone(), next_id, @@ -2395,6 +2436,7 @@ mod acp_requests { "acp_abc".into(), registry.clone(), 5, + "conn-test".into(), ) .await .unwrap(); @@ -2437,6 +2479,7 @@ mod acp_requests { "acp_abc".into(), registry.clone(), 5, + "conn-test".into(), ) .await .unwrap(); @@ -2499,6 +2542,7 @@ mod acp_requests { "acp_abc".into(), registry.clone(), 5, + "conn-test".into(), ) .await .unwrap(); @@ -2777,6 +2821,7 @@ mod acp_review_fixes { &pending, &next_id, &mut tasks, + "conn-test", ); assert_eq!(tasks.len(), 2, "one task per unique declaration"); @@ -2915,7 +2960,10 @@ mod acp_review_fixes { registry .lock() .unwrap() - .insert("acp_chan".into(), ReplySink { turn_id: "evt_current".into(), tx }); + .insert( + "acp_chan".into(), + ReplySink { turn_id: "evt_current".into(), tx, owner: "conn-test".into() }, + ); // Stale reply (previous turn's event id) → dropped. handle_reply(&reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), ®istry) @@ -3017,7 +3065,7 @@ mod acp_review_fixes { let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); let params = json!({"sessionId": sid, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&state, &sessions, json!(7), Some(¶ms), &out_tx, sid.clone(), cancel) + handle_session_prompt(&state, &sessions, json!(7), Some(¶ms), &out_tx, sid.clone(), cancel, "conn-test") .await; // The final response (matching our request id) must carry stopReason "cancelled". @@ -3100,7 +3148,7 @@ mod acp_review_fixes { let sid2 = sid.clone(); let handle = tokio::spawn(async move { let params = json!({"sessionId": sid2, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&st2, &sessions2, json!(11), Some(¶ms), &out_tx, sid2.clone(), cancel) + handle_session_prompt(&st2, &sessions2, json!(11), Some(¶ms), &out_tx, sid2.clone(), cancel, "conn-test") .await; }); @@ -3435,3 +3483,103 @@ mod acp_ws_integration { assert_eq!(names, vec!["conn-new".to_string()], "only the newest tunnel may remain"); } } + +/// Teardown ownership (canonical item 2). +/// +/// Both registries are keyed by things that outlive a connection — the reply registry by +/// `channel_id`, the tunnel registry by `(channel_id, server_id)` — and `session/resume` hands the +/// same channel to a *new* connection. So "remove the keys for my sessions" deletes a successor's +/// live entry. "Only remove keys I inserted" is no better: the key is reused, so it cannot tell the +/// two apart. The owner can. +/// +/// All three cases below fail against key-only teardown. +#[cfg(test)] +mod acp_teardown_ownership { + use super::*; + + fn tunnel(owner: &str, connection_id: &str) -> TunnelHandle { + let (out_tx, _rx) = mpsc::unbounded_channel::(); + TunnelHandle { + out_tx, + pending: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + next_id: Arc::new(AtomicU64::new(1)), + connection_id: connection_id.into(), + server_name: "katashiro".into(), + owner: owner.into(), + } + } + + /// Model the teardown predicate exactly as `cleanup` applies it, so the test exercises the + /// rule rather than a paraphrase of it. + fn teardown(reg: &AcpTunnelRegistry, channel_ids: &[&str], closing: &str) { + let mut reg = reg.lock().unwrap(); + reg.retain(|(cid, _), h| !(channel_ids.contains(&cid.as_str()) && h.owner == closing)); + } + + /// A connection that closes must not take its successor's tunnel with it. + /// + /// The old connection's cleanup runs late — after the client reconnected, resumed the same + /// channel and registered a fresh tunnel. Under key-only teardown the successor's handle is + /// removed and a working session loses its browser with no error anywhere. + #[test] + fn a_late_cleanup_does_not_remove_the_successors_tunnel() { + let reg = new_tunnel_registry(); + { + let mut r = reg.lock().unwrap(); + // The successor: same channel, fresh server id, different connection. + r.insert(("acp_x".into(), "srv-new".into()), tunnel("conn-B", "cid-new")); + } + teardown(®, &["acp_x"], "conn-A"); + + let r = reg.lock().unwrap(); + assert!( + r.contains_key(&("acp_x".to_string(), "srv-new".to_string())), + "conn-A's cleanup must not remove a tunnel owned by conn-B on the same channel" + ); + } + + /// Its own entries still go. + #[test] + fn a_cleanup_still_removes_the_tunnels_it_owns() { + let reg = new_tunnel_registry(); + { + let mut r = reg.lock().unwrap(); + r.insert(("acp_x".into(), "srv-a".into()), tunnel("conn-A", "cid-a")); + r.insert(("acp_x".into(), "srv-b".into()), tunnel("conn-B", "cid-b")); + } + teardown(®, &["acp_x"], "conn-A"); + + let r = reg.lock().unwrap(); + assert!(!r.contains_key(&("acp_x".to_string(), "srv-a".to_string())), "conn-A's own tunnel must go"); + assert!(r.contains_key(&("acp_x".to_string(), "srv-b".to_string())), "conn-B's must stay"); + assert_eq!(r.len(), 1); + } + + /// A reply sink installed by a successor survives the predecessor's cleanup. + /// + /// Same defect, quieter symptom: the sink is how a prompt's output reaches the client, so + /// deleting the wrong one produces a session that accepts prompts and answers none. + #[test] + fn a_late_cleanup_does_not_remove_the_successors_reply_sink() { + let reg = new_reply_registry(); + { + let (tx, _rx) = mpsc::unbounded_channel::(); + reg.lock().unwrap().insert( + "acp_x".into(), + ReplySink { turn_id: "evt_new".into(), tx, owner: "conn-B".into() }, + ); + } + { + let mut r = reg.lock().unwrap(); + let channel_ids = ["acp_x"]; + r.retain(|cid, s| !(channel_ids.contains(&cid.as_str()) && s.owner == "conn-A")); + } + let r = reg.lock().unwrap(); + assert_eq!( + r.get("acp_x").map(|s| s.turn_id.as_str()), + Some("evt_new"), + "conn-A's cleanup must leave conn-B's sink in place, or the live session goes mute" + ); + } + +} From 8045a1bf56dbf8f00a9ad211e4140002d9fe9b6c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 12:53:59 +0800 Subject: [PATCH 091/138] refactor(acp): give reset_session and hung eviction one implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical item 3(b) asked for `reset_session` to stop removing the creating gate. It already does — that landed in `55eab82a`, the same commit credited in the backlog with 3(a) only. Verified: `state.creating` is touched solely by `get_or_insert_gate`, no `remove`/`retain` exists on it, and `git log -S` dates the comment to that commit. What was still true is why the bug was possible. `reset_session` carried a verbatim second copy of the six removals `purge_session_entries` performs, down to a duplicated comment saying the creating gate must survive. Two copies of a rule are two places for it to drift, and the line likeliest to be lost from a copy is one that says *not* to do something — which is exactly the line that had gone missing here before. `reset_session` now removes `active` itself and delegates the rest. The existing assertion that the gate survives eviction becomes load-bearing for the reset path too, rather than needing a second test that could drift in its own turn. The doc on `purge_session_entries` told the reader to "mirror `reset_session`". That relationship is now the other way round, so it says so. Co-Authored-By: Claude --- crates/openab-core/src/acp/pool.rs | 31 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 779fd2eb3..1139d7828 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -135,13 +135,17 @@ async fn setup_facade_session( } } -/// Remove every non-`active` pool entry for `key`, reset-style. +/// Remove every non-`active` pool entry for `key`. /// -/// Hung eviction must NOT leave the session resumable: the old streaming task -/// still holds an Arc clone of the connection, so the agent process may be -/// alive and mid-turn. If the session id stayed in `suspended`/`persisted`, -/// the next message would `session/load` the same session while the old -/// process still owns an in-flight turn. Mirror `reset_session` instead. +/// The single implementation for both hung eviction and [`SessionPool::reset_session`]; the latter +/// removes `active` itself and then calls this. It used to be a second copy of the same list, which +/// is how the two could drift — and the line most likely to be lost from a copy is the one below +/// about the creating gate, because it says *not* to remove something. +/// +/// Hung eviction must NOT leave the session resumable: the old streaming task still holds an Arc +/// clone of the connection, so the agent process may be alive and mid-turn. If the session id +/// stayed in `suspended`/`persisted`, the next message would `session/load` the same session while +/// the old process still owns an in-flight turn. fn purge_session_entries(state: &mut PoolState, key: &str) { state.cancel_handles.remove(key); state.activity.remove(key); @@ -748,16 +752,11 @@ impl SessionPool { let mut state = self.state.write().await; let had_active = state.active.remove(thread_id).is_some(); - state.cancel_handles.remove(thread_id); - state.activity.remove(thread_id); - state.pgids.remove(thread_id); - state.suspended.remove(thread_id); - state.persisted.remove(thread_id); - // Do NOT remove the creating gate — same reason `purge_session_entries` documents. It is - // concurrency control, not session state: dropping it while a builder still holds the old - // gate Arc lets a concurrent get_or_create mint a fresh gate and run a second creation for - // the same key, so two builders spawn agents and mint tokens for one channel. - state.session_workdirs.remove(thread_id); + // Everything else a reset clears is exactly what hung eviction clears, including the rule + // that the creating gate survives. Call the one implementation rather than keeping a second + // copy of the list: the copies are what let the two drift, and the gate rule is precisely + // the kind of line that gets dropped from a duplicate without anyone noticing. + purge_session_entries(&mut state, thread_id); self.save_mapping(&state.persisted); self.save_meta(&state.session_workdirs); if had_active { From 468c141fac79bc970e8e045508d6e530fb716000 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 13:07:26 +0800 Subject: [PATCH 092/138] fix(acp): resume stores what it accepted, and retires what it withdrew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of canonical item 9. `handle_session_resume` re-parsed the raw params and stored that list, while the read loop had already computed the deduplicated, capped one and opened tunnels from it. So the session record and the tunnels disagreed about what the client declared, and the record was the unbounded version. It now takes the accepted list as an argument, exactly as `session/new` does. A resume re-presents the client's whole declaration set, so a server the session had and the client no longer offers has been withdrawn — and its tunnel was staying registered and reachable for the rest of the connection. The handler now returns those withdrawn declarations and the read loop retires them: removed from the registry under the lock, disconnected after it is released (`disconnect` is async, the registry is a std mutex), scoped to the channel the resume actually established and to the ids it dropped so a server the client still offers cannot be caught. Test drives the real WebSocket, per the runbook: a unit test of `accept_acp_servers` observes neither the raw-versus-accepted divergence nor the withdrawal, which is how this got through the last review. Writing it surfaced an interaction worth pinning. A resume that re-declares one server under a fresh id and withdraws another owes TWO disconnects, for different reasons: the withdrawn declaration (this change) and the superseded same-name tunnel (last-attach-wins, R7). Asserting on whichever arrived first tested the scheduler; the test now asserts the exact set, so an implementation that gets either mechanism wrong fails. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 206 ++++++++++++++++-- 1 file changed, 186 insertions(+), 20 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index d78f9d951..02b849ad2 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -1156,10 +1156,50 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { continue; } }; - let (resp, resumed_channel) = - handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; + let (resp, resumed_channel, retired) = handle_session_resume( + &sessions, + id.clone(), + req.params.as_ref(), + resumed_servers.clone(), + ) + .await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + // Retire tunnels for declarations this resume withdrew. Scoped to the channel the + // resume actually established and to the ids it dropped, so it cannot touch a + // server the client still offers. The handles are taken out under the lock and + // disconnected after it is released — `disconnect` is async and this is a std + // mutex — and each is owed that disconnect because the client still believes the + // connection is open (same obligation as a same-name replacement, R7). + if let (Some(registry), Some(ref channel_id)) = + (state.acp_tunnel_registry.clone(), resumed_channel.as_ref()) + { + if !retired.is_empty() { + let dropped: Vec = { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + retired + .iter() + .filter_map(|srv| { + reg.remove(&(channel_id.to_string(), srv.id.clone())) + }) + .collect() + }; + if !dropped.is_empty() { + info!( + channel_id = %channel_id, retired = dropped.len(), + "ACP: resume withdrew declarations — retiring their tunnels" + ); + tokio::spawn(async move { + for handle in dropped { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a withdrawn declaration did not complete"); + } + } + }); + } + } + } + // Re-open + register the browser tunnel(s) on resume too. katashiro persists its // ACP session and RECONNECTS via session/resume (not session/new), re-declaring its // "type":"acp" browser server each time. Without this, a resumed session records the @@ -1456,14 +1496,27 @@ async fn handle_session_new( /// The caller uses that `Some` as its permission to open tunnels: deriving a channel id from the /// requested `sessionId` is not sufficient, because a well-formed id derives fine even on the /// paths that reject the resume (unknown session, per-connection cap, busy). +/// Returns the response, the channel on success, and the declarations this resume RETIRES — +/// servers the session had that the client no longer offers. +/// +/// `accepted` is the deduplicated, capped list, threaded in exactly as `session/new` receives it. +/// Re-parsing the raw params here instead stored an unbounded list in the session record while the +/// tunnels were opened from the accepted one, so the two disagreed about what the client declared. async fn handle_session_resume( sessions: &Arc>>, id: Value, params: Option<&Value>, -) -> (JsonRpcResponse, Option) { + accepted: Vec, +) -> (JsonRpcResponse, Option, Vec) { let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) { Some(s) => s.to_string(), - None => return (JsonRpcResponse::error(id, -32602, "Missing sessionId"), None), + None => { + return ( + JsonRpcResponse::error(id, -32602, "Missing sessionId"), + None, + Vec::new(), + ) + } }; let channel_id = match derive_channel_id(&session_id) { @@ -1476,6 +1529,7 @@ async fn handle_session_resume( "Invalid sessionId: expected the form sess_", ), None, + Vec::new(), ); } }; @@ -1492,6 +1546,7 @@ async fn handle_session_resume( format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), ), None, + Vec::new(), ); } // R16-F2: refuse to resume a session that currently has a prompt in flight. The insert @@ -1507,16 +1562,31 @@ async fn handle_session_resume( "Session busy: a prompt is in progress; cannot resume", ), None, + Vec::new(), ); } + // A resume re-presents the client's WHOLE declaration set, so anything the session had that + // is absent now has been withdrawn and its tunnel must be retired — otherwise a server the + // client has stopped offering stays reachable for the rest of the connection. + let retired: Vec = guard + .get(&session_id) + .map(|prev| { + prev.acp_mcp_servers + .iter() + .filter(|old| !accepted.iter().any(|new| new.id == old.id)) + .cloned() + .collect() + }) + .unwrap_or_default(); guard.insert( session_id.clone(), AcpSession { channel_id: channel_id.clone(), busy: false, cancel: None, - // The client re-presents its mcpServers on resume; re-record the acp ones. - acp_mcp_servers: parse_acp_mcp_servers(params), + // Store the ACCEPTED list — deduplicated and capped — not a fresh raw parse. Storing + // the raw one let the session record disagree with the tunnels actually opened. + acp_mcp_servers: accepted, }, ); drop(guard); @@ -1529,6 +1599,7 @@ async fn handle_session_resume( ( JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), Some(channel_id), + retired, ) } @@ -2683,18 +2754,18 @@ mod acp_handlers { // valid sess_ → {} and the session is (re)stored let sid = format!("sess_{}", Uuid::new_v4()); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms), Vec::new()).await.0) .unwrap(); assert_eq!(v["result"], json!({})); assert!(sessions.lock().await.contains_key(&sid)); // malformed sessionId shape → -32602 let bad = json!({"sessionId": "not-a-session", "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad), Vec::new()).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing sessionId → -32602 let v = serde_json::to_value( - handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await.0, + handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"})), Vec::new()).await.0, ) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); @@ -2725,7 +2796,7 @@ mod acp_review_fixes { for _ in 0..MAX_SESSIONS_PER_CONNECTION { let sid = format!("sess_{}", Uuid::new_v4()); let p = json!({ "sessionId": sid }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p), Vec::new()).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "resume under cap should succeed"); ids.push(sid); @@ -2733,13 +2804,13 @@ mod acp_review_fixes { assert_eq!(sessions.lock().await.len(), MAX_SESSIONS_PER_CONNECTION); // A new distinct session over the cap is refused with ACP_OVERLOADED. let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over), Vec::new()).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(ACP_OVERLOADED), "over-cap resume must be refused"); // Re-resuming an already-present session is exempt (idempotent). let existing = json!({ "sessionId": ids[0] }); let v = - serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await.0) + serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing), Vec::new()).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } @@ -2891,14 +2962,14 @@ mod acp_review_fixes { let sessions = sessions_map(); // 1. missing sessionId - let (resp, chan) = - handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"}))).await; + let (resp, chan, _) = + handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"})), Vec::new()).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); assert!(chan.is_none(), "missing sessionId must not yield a channel"); // 2. malformed sessionId let bad = json!({"sessionId": "not-a-session", "cwd": "/w"}); - let (resp, chan) = handle_session_resume(&sessions, json!(2), Some(&bad)).await; + let (resp, chan, _) = handle_session_resume(&sessions, json!(2), Some(&bad), Vec::new()).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); assert!(chan.is_none(), "malformed sessionId must not yield a channel"); @@ -2906,11 +2977,11 @@ mod acp_review_fixes { // derive-from-params guard would have happily produced a channel here. for _ in 0..MAX_SESSIONS_PER_CONNECTION { let p = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let (_r, chan) = handle_session_resume(&sessions, json!(3), Some(&p)).await; + let (_r, chan, _) = handle_session_resume(&sessions, json!(3), Some(&p), Vec::new()).await; assert!(chan.is_some(), "resume under the cap should succeed"); } let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let (resp, chan) = handle_session_resume(&sessions, json!(4), Some(&over)).await; + let (resp, chan, _) = handle_session_resume(&sessions, json!(4), Some(&over), Vec::new()).await; assert_eq!( serde_json::to_value(resp).unwrap()["error"]["code"], json!(ACP_OVERLOADED) @@ -2928,8 +2999,8 @@ mod acp_review_fixes { acp_mcp_servers: Vec::new(), }, ); - let (resp, chan) = - handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid}))).await; + let (resp, chan, _) = + handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid})), Vec::new()).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32001)); assert!(chan.is_none(), "a busy-rejected resume must not yield a channel"); } @@ -3106,7 +3177,7 @@ mod acp_review_fixes { ); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let (resp, resumed) = handle_session_resume(&sessions, json!(9), Some(¶ms)).await; + let (resp, resumed, _) = handle_session_resume(&sessions, json!(9), Some(¶ms), Vec::new()).await; let v = serde_json::to_value(resp).unwrap(); assert_eq!(v["error"]["code"], json!(-32001), "resume while busy must be rejected"); assert!( @@ -3418,6 +3489,101 @@ mod acp_ws_integration { ); } + /// A resume that stops declaring a server must retire its tunnel. + /// + /// Driven through the real read loop on purpose. The defect this covers was that + /// `handle_session_resume` re-parsed the raw params and stored an uncapped list while the + /// tunnels were opened from the accepted one — a unit test of `accept_acp_servers` alone sees + /// neither half of that, which is how it survived the last review. + #[tokio::test] + async fn a_resume_that_withdraws_a_declaration_retires_its_tunnel() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + + // Declare TWO servers, answer both mcp/connect calls. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [ + {"type": "acp", "id": "keep-1", "name": "katashiro"}, + {"type": "acp", "id": "drop-1", "name": "other"} + ]} + })).await; + let mut session_id = None; + let mut connects = 0; + while session_id.is_none() || connects < 2 { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + let acp_id = f["params"]["acpId"].as_str().unwrap().to_string(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": format!("conn-{acp_id}")} + })).await; + connects += 1; + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + wait_for_tunnels(®istry, 2).await; + + // Resume declaring ONLY the first — "other" is withdrawn. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": { + "sessionId": session_id.unwrap(), + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "keep-2", "name": "katashiro"}] + } + })).await; + + // TWO disconnects are owed here, for different reasons, and both are correct: + // - `conn-drop-1` because the resume WITHDREW that declaration (this item), and + // - `conn-keep-1` because the resume re-declared the same NAME with a fresh id, so + // last-attach-wins evicts the stale tunnel (R7). + // Asserting on whichever arrives first tests the scheduler, not the behaviour. + let mut disconnected: Vec = Vec::new(); + let mut reconnected = false; + while disconnected.len() < 2 || !reconnected { + let f = recv(&mut ws).await; + match f.get("method").and_then(Value::as_str) { + Some("mcp/disconnect") => { + disconnected.push(f["params"]["connectionId"].as_str().unwrap().to_string()); + } + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-keep-2"} + })).await; + reconnected = true; + } + _ => {} + } + } + disconnected.sort(); + assert_eq!( + disconnected, + vec!["conn-drop-1".to_string(), "conn-keep-1".to_string()], + "the withdrawn declaration AND the superseded same-name tunnel are both owed a \ + disconnect; nothing else may be" + ); + + // Only the re-declared server survives: `drop-1` was withdrawn and `keep-1` superseded. + wait_for_tunnels(®istry, 1).await; + let keys: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + keys, + vec!["keep-2".to_string()], + "the withdrawn declaration must not stay reachable, got {keys:?}" + ); + } + /// A reconnect that **resumes** the same session re-declares the same NAME with a fresh id. /// That evicts the stale tunnel, and the client is owed an `mcp/disconnect` for the connection /// it still believes is open (review R7). From 147c094c57dea2b96d9d19b110033933656440a8 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 13:23:30 +0800 Subject: [PATCH 093/138] fix(acp): give tunnel establishes their own task budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish tasks were pushed into `prompt_tasks` and counted against `MAX_INFLIGHT_PROMPTS`, so a client with enough slow `mcp/connect`s outstanding was told "Too many in-flight prompts (max 32)" while having sent one — a limit it had not reached, for work it had not asked for. Two sets, two caps. `MAX_INFLIGHT_ESTABLISHES` is 64; over it a declaration is dropped with a warning rather than the whole request being refused, since the session is valid and its other servers should still attach. Two things beyond the literal split: The disconnect drain now aborts establish tasks as well. Only `prompt_tasks` were aborted before, so an establish still awaiting `mcp/connect` could insert its handle into the registry after cleanup had run — a dead tunnel registered against a closed connection, which is the second acceptance case canonical item 2 asks for. Separating the sets is what made it visible; with one shared vec the abort covered both by accident. The periodic sweep drops finished handles from both sets, so a long-lived connection does not accumulate them in the new one. The first version of this test was vacuous and I nearly shipped it. One session can park at most `MAX_ACP_SERVERS_PER_SESSION` (8) establishes against a prompt cap of 32, so it never crossed the threshold the bug lives at and passed identically with or without the fix. It now derives the session count from both constants and asserts up front that the product exceeds the cap, so changing either constant fails the test rather than quietly returning it to proving nothing. Mutation-checked: sharing the budget again fails it with "40 parked mcp/connects ... got: Too many in-flight prompts (max 32)". Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 127 +++++++++++++++++- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 02b849ad2..43da4a6ef 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -36,6 +36,14 @@ const ACP_PROTOCOL_VERSION: u32 = 1; /// eviction, and global connection/worker limits are a follow-up (review F6, roadmap). const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; +/// Cap on concurrently-establishing tunnels per connection. +/// +/// Separate from [`MAX_INFLIGHT_PROMPTS`] on purpose. These tasks used to share one budget, so a +/// client with several slow `mcp/connect`s outstanding could exhaust it and make ordinary +/// `session/prompt` calls fail with "Too many in-flight prompts" — an error naming a limit the +/// client had not reached, for work it had not asked for. `MAX_ACP_SERVERS_PER_SESSION` bounds one +/// session's declarations; this bounds every session's establishes on a connection at once. +const MAX_INFLIGHT_ESTABLISHES: usize = 64; /// Cap on `type:acp` servers a single session may declare (review R3-F1). /// /// Every declaration costs a spawned task, a pending `mcp/connect` holding a 30s timeout, and an @@ -866,9 +874,13 @@ fn spawn_acp_tunnels( out_tx: &mpsc::UnboundedSender, pending: &Arc>>>, next_id: &Arc, - prompt_tasks: &mut Vec>, + establish_tasks: &mut Vec>, owner: &str, ) { + // Drop finished handles first so a long-lived connection does not accumulate them, then bound + // what is still running. Over the cap the declaration is dropped with a warning rather than + // refusing the whole request: the session itself is valid and its other servers still attach. + establish_tasks.retain(|h| !h.is_finished()); for srv in servers { let out_tx = out_tx.clone(); let pending = pending.clone(); @@ -876,7 +888,14 @@ fn spawn_acp_tunnels( let registry = registry.clone(); let channel_id = channel_id.clone(); let owner = owner.to_string(); - prompt_tasks.push(tokio::spawn(async move { + if establish_tasks.len() >= MAX_INFLIGHT_ESTABLISHES { + warn!( + max = MAX_INFLIGHT_ESTABLISHES, + "ACP: too many tunnels establishing on this connection — dropping a declaration" + ); + break; + } + establish_tasks.push(tokio::spawn(async move { if let Err(e) = establish_and_register_tunnel( out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, ) @@ -912,6 +931,9 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // Track spawned prompt tasks so we can abort on disconnect let mut prompt_tasks: Vec> = Vec::new(); + // Tunnel establishes get their own set. Sharing one with prompts meant a pending `mcp/connect` + // consumed prompt budget, and the client saw an overload error for work it never requested. + let mut establish_tasks: Vec> = Vec::new(); // Channel for sending messages back to the client let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -1125,7 +1147,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &out_tx, &pending_requests, &next_req_id, - &mut prompt_tasks, + &mut establish_tasks, &connection_id, ); } @@ -1220,7 +1242,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &out_tx, &pending_requests, &next_req_id, - &mut prompt_tasks, + &mut establish_tasks, &connection_id, ); } @@ -1334,8 +1356,9 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } - // Clean up finished tasks + // Clean up finished tasks (both sets) prompt_tasks.retain(|h| !h.is_finished()); + establish_tasks.retain(|h| !h.is_finished()); } // Drain any in-flight server-initiated requests: dropping each oneshot sender makes the @@ -1346,8 +1369,10 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } // --- Disconnect cleanup --- - // Abort any in-flight prompt tasks to prevent registry leaks - for handle in prompt_tasks { + // Abort any in-flight tasks to prevent registry leaks. Establishes are aborted too: a task + // still waiting on `mcp/connect` would otherwise insert a handle into the registry AFTER the + // teardown below has run, leaving a dead tunnel registered for a closed connection. + for handle in prompt_tasks.into_iter().chain(establish_tasks) { handle.abort(); } @@ -3489,6 +3514,94 @@ mod acp_ws_integration { ); } + /// A prompt must not be refused because tunnels are still establishing. + /// + /// The two used to share `prompt_tasks` and `MAX_INFLIGHT_PROMPTS`, so a client with enough + /// slow `mcp/connect`s outstanding got "Too many in-flight prompts" — a limit it had not + /// reached, for work it had not asked for. + /// + /// It takes **more than `MAX_INFLIGHT_PROMPTS` parked establishes** to reach the old bug, and + /// one session cannot supply them: `MAX_ACP_SERVERS_PER_SESSION` is 8 against a prompt cap of + /// 32. A first version of this test used a single session, so it passed against the shared + /// budget too and proved nothing. Hence several sessions. + #[tokio::test] + async fn pending_tunnel_establishes_do_not_consume_the_prompt_budget() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + + // Enough sessions to park strictly more than MAX_INFLIGHT_PROMPTS establishes. + let sessions_needed = MAX_INFLIGHT_PROMPTS / MAX_ACP_SERVERS_PER_SESSION + 1; + let want_connects = sessions_needed * MAX_ACP_SERVERS_PER_SESSION; + assert!( + want_connects > MAX_INFLIGHT_PROMPTS, + "the test must exceed the prompt cap or it cannot observe the old behaviour" + ); + + let mut last_session = None; + let mut connects = 0; + let mut answered_new = 0; + for n in 0..sessions_needed { + let req_id = 100 + n as i64; + let servers: Vec = (0..MAX_ACP_SERVERS_PER_SESSION) + .map(|i| json!({"type": "acp", "id": format!("s{n}-{i}"), "name": format!("n{n}-{i}")})) + .collect(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": req_id, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": servers} + })).await; + // Collect this session's response; mcp/connect frames are observed and NEVER answered, + // which is what keeps each establish task in flight. + let mut got_resp = false; + while !got_resp { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + connects += 1; + } else if f.get("id") == Some(&json!(req_id)) { + last_session = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + answered_new += 1; + got_resp = true; + } + } + } + assert_eq!(answered_new, sessions_needed); + + // Drain any remaining mcp/connect frames so the parked count is what we think it is. + while connects < want_connects { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + connects += 1; + } + } + + // With >32 establishes parked, a prompt must still be accepted. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/prompt", + "params": {"sessionId": last_session.unwrap(), "prompt": [{"type": "text", "text": "hi"}]} + })).await; + + let mut saw = None; + for _ in 0..40 { + let f = recv(&mut ws).await; + if f.get("id") == Some(&json!(3)) { + saw = Some(f); + break; + } + } + let f = saw.expect("no response to the prompt"); + if let Some(err) = f.get("error") { + let msg = err["message"].as_str().unwrap_or(""); + assert!( + !msg.contains("in-flight prompts"), + "{connects} parked mcp/connects must not spend the prompt budget, got: {msg}" + ); + } + } + /// A resume that stops declaring a server must retire its tunnel. /// /// Driven through the real read loop on purpose. The defect this covers was that From bfeebb8c88f17670f60020516a3b519267824628 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 14:41:21 +0800 Subject: [PATCH 094/138] fix(acp): make the facade config writer safe on a user's mcp.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the transport deletions this is the only code that writes a file the user owns, and it had four ways to damage one. **Fail closed on a parse failure.** It parsed with `unwrap_or_else(|_| json!({}))` and then wrote, so a `mcp.json` containing a `//` comment — which editors and humans put there — came back holding only our entry. Everything else the user had configured was gone and unrecoverable. An unreadable config is now skipped with a warning. A *missing* file is a different case and still starts from empty: there is nothing to lose. **Guard a non-object root.** `cfg["mcpServers"] = …` on a `Value::Array` does not return an error, it panics through `IndexMut` and takes the process with it. A `[]`-rooted file is now skipped like any other config we cannot merge into. **Write atomically, owner-only.** A same-directory temp file created `0600` before any bytes reach it, fsynced, then renamed. Writing in place lets a reader see a half-written config; chmod-after-write leaves a window where it is world-readable. This supersedes `write_private`, which is removed rather than left beside the new helper — two write paths where one is subtly unsafe is how the next change reintroduces this. **Serialise per path.** Two sessions starting together read-modify-write the same file. With the atomic rename above this is *worse* than a torn write: the loser's entire file replaces the winner's, silently. The kiro agent-file writer had the root-panic and non-atomic write too, and now shares the same three helpers. Five tests, four of them with a demonstrated failure mode: reverting the parse and write behaviour fails the unparseable, array-root and permissions cases; removing the lock fails the concurrency case 6 times out of 6, deterministically. The fifth pins merge semantics that were already correct — a regression guard, not defect coverage, and counted as such. Co-Authored-By: Claude --- crates/openab-core/src/mcp_proxy.rs | 256 +++++++++++++++++++++++++--- 1 file changed, 236 insertions(+), 20 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 1b7d627f7..51d1a238b 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -110,22 +110,100 @@ pub fn browser_tools() -> Vec { } -/// Write `bytes` to `path`, then tighten it to owner-only (0600). +/// Serialise writers per path. /// -/// The file no longer holds a secret — the facade entry references `${OPENAB_SESSION_TOKEN}` and -/// the value lives only in the agent process's environment. `0600` is kept anyway: it is an -/// agent's MCP configuration, a shared workdir is the normal deployment, and nothing needs it to -/// be readable by other users. -async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { - tokio::fs::write(path, bytes).await?; - #[cfg(unix)] +/// Two sessions starting at once write the same `mcp.json`. Read-modify-write without this lets +/// the later read see the earlier state and drop the other's entry — and with `rename` below the +/// loser's whole file wins, so the interleaving is silent rather than merely partial. +fn config_write_lock(path: &std::path::Path) -> std::sync::Arc> { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static LOCKS: OnceLock< + Mutex>>>, + > = + OnceLock::new(); + let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = map.lock().unwrap_or_else(|e| e.into_inner()); + guard + .entry(path.to_path_buf()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} + +/// Read a JSON config we intend to merge into, or `None` when it must be left alone. +/// +/// `None` means **skip this file**, never "start from empty". Returning `{}` on a parse failure and +/// then writing is how a config with a comment in it, or any file this parser does not accept, gets +/// replaced by ours — destroying configuration we did not write and cannot reconstruct. A missing +/// file is different and returns `Some({})`: there is nothing to lose. +/// +/// A non-object root is also `None`. It cannot be merged into, and indexing a `Value::Array` with a +/// string key **panics** rather than failing, so this guard is what stops a `[]`-rooted file from +/// taking the process down. +async fn load_mergeable_config(path: &std::path::Path) -> Option { + match tokio::fs::read(path).await { + Err(_) => Some(json!({})), + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(v) if v.is_object() => Some(v), + Ok(_) => { + tracing::warn!( + path = %path.display(), + "MCP config root is not a JSON object — leaving it untouched; browser tools \ + will not be configured here" + ); + None + } + Err(e) => { + tracing::warn!( + path = %path.display(), error = %e, + "MCP config is not parseable JSON — leaving it untouched rather than replacing \ + it; browser tools will not be configured here" + ); + None + } + }, + } +} + +/// Write `value` to `path` atomically, owner-only. +/// +/// Same-directory temp file created `0600` *before* any bytes reach it, then `rename`. Writing in +/// place leaves a window where a reader sees a half-written config, and chmod-after-write leaves +/// one where the file is world-readable. `rename` within a directory is atomic, so a concurrent +/// reader sees either the old file or the new one. +async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(value)?; + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let tmp = dir.join(format!( + ".{}.openab-tmp", + path.file_name().and_then(|n| n.to_str()).unwrap_or("mcp.json") + )); { - use std::os::unix::fs::PermissionsExt; - tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + let mut opts = tokio::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + // tokio's OpenOptions carries `mode` itself; no std extension trait needed. + opts.mode(0o600); + } + let mut f = opts.open(&tmp).await?; + use tokio::io::AsyncWriteExt; + f.write_all(&bytes).await?; + f.flush().await?; + // Durability before the rename: a crash must not leave the new name pointing at a file + // whose contents never reached disk. + f.sync_all().await?; + } + match tokio::fs::rename(&tmp, path).await { + Ok(()) => Ok(()), + Err(e) => { + let _ = tokio::fs::remove_file(&tmp).await; + Err(e) + } } - Ok(()) } + /// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be removed. /// /// Only the removed bridge entry qualifies. It was byte-identical every session @@ -184,9 +262,15 @@ pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io if let Some(dir) = cfg_path.parent() { tokio::fs::create_dir_all(dir).await?; } - let mut cfg: Value = match tokio::fs::read(cfg_path).await { - Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), - Err(_) => json!({}), + // Held across the read-modify-write so a concurrent session cannot read pre-write state + // and then rename its copy over ours. + let lock = config_write_lock(cfg_path); + let _guard = lock.lock().await; + + let Some(mut cfg) = load_mergeable_config(cfg_path).await else { + // Unparseable or non-object root: already logged. Skip rather than replace — this is + // the user's file and we cannot merge into it safely. + continue; }; if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { cfg["mcpServers"] = json!({}); @@ -202,7 +286,7 @@ pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io // load and the model can reach the browser without passing through facade policy/audit. changed |= strip_direct_browser_entry(&mut cfg); if changed { - tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + write_json_atomic(cfg_path, &cfg).await?; } } // kiro `--agent` deployments read agent files, not settings/mcp.json. @@ -226,10 +310,12 @@ async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::i if !name.ends_with(".json") || name.starts_with("._") { continue; } - let Ok(bytes) = tokio::fs::read(&path).await else { - continue; - }; - let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + let lock = config_write_lock(&path); + let _guard = lock.lock().await; + // Same fail-closed rule as the settings files: unparseable, or a root we cannot merge + // into, means leave the agent file alone. These carry model, description and allowlists + // that are none of our business to rewrite. + let Some(mut cfg) = load_mergeable_config(&path).await else { continue; }; if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { @@ -256,7 +342,7 @@ async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::i } } if changed { - write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + write_json_atomic(&path, &cfg).await?; } } Ok(()) @@ -345,6 +431,136 @@ fn browser_mode_migration_notice( } +/// The destructive cases for the only code that touches a user's `mcp.json`. +/// +/// Each of these previously either destroyed a file or panicked the process, and none of them is +/// exotic: a comment in a JSON config is common, `[]` is what an empty array-shaped config looks +/// like, and two sessions starting together is the normal case on a busy pod. +#[cfg(test)] +mod facade_config_writer { + use super::*; + + async fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("openab-cfg-{tag}-{}", std::process::id())); + let _ = tokio::fs::remove_dir_all(&d).await; + tokio::fs::create_dir_all(d.join(".cursor")).await.unwrap(); + d + } + + /// A config this parser cannot read must be left EXACTLY as it was. + /// + /// The old code parsed with `unwrap_or_else(|_| json!({}))` and then wrote, so a file with a + /// `//` comment — which plenty of editors and humans put in `mcp.json` — came back containing + /// only our entry. Everything the user had configured was gone, unrecoverably. + #[tokio::test] + async fn an_unparseable_config_is_left_untouched() { + let wd = tmp_dir("unparseable").await; + let path = wd.join(".cursor").join("mcp.json"); + let original = "{\n // my servers\n \"mcpServers\": { \"mine\": { \"command\": \"x\" } }\n}"; + tokio::fs::write(&path, original).await.unwrap(); + + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let after = tokio::fs::read_to_string(&path).await.unwrap(); + assert_eq!( + after, original, + "an unparseable config must survive byte-for-byte — replacing it destroys work we \ + cannot reconstruct" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// A non-object root must not panic. + /// + /// `cfg["mcpServers"] = ...` on a `Value::Array` does not return an error — `IndexMut` panics, + /// taking the process down. The guard has to run before any indexing. + #[tokio::test] + async fn an_array_root_does_not_panic_and_is_left_untouched() { + let wd = tmp_dir("arrayroot").await; + let path = wd.join(".cursor").join("mcp.json"); + tokio::fs::write(&path, "[]").await.unwrap(); + + let r = write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp").await; + assert!(r.is_ok(), "a `[]` root must be skipped, not fatal: {r:?}"); + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + "[]", + "we cannot merge into an array root, so it is left alone" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// A user's own servers survive, and ours is added beside them. + #[tokio::test] + async fn a_valid_config_keeps_the_users_servers() { + let wd = tmp_dir("merge").await; + let path = wd.join(".cursor").join("mcp.json"); + tokio::fs::write(&path, r#"{"mcpServers":{"mine":{"command":"x"}},"other":42}"#) + .await + .unwrap(); + + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let v: Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["mine"]["command"], json!("x"), "user server preserved"); + assert_eq!(v["other"], json!(42), "unrelated top-level keys preserved"); + assert_eq!( + v["mcpServers"]["openab"]["headers"]["Authorization"], + json!("Bearer ${OPENAB_SESSION_TOKEN}"), + "and ours is added by reference, never with the token value" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// Concurrent writers must not lose each other's work. + /// + /// With an atomic rename and no lock this is *worse* than a torn write: the loser's entire + /// file replaces the winner's, silently. Both calls write the same entry, so what this pins is + /// that the user's pre-existing server survives both — a lost update would drop it. + #[tokio::test] + async fn concurrent_writers_do_not_lose_the_users_config() { + let wd = tmp_dir("concurrent").await; + let path = wd.join(".cursor").join("mcp.json"); + tokio::fs::write(&path, r#"{"mcpServers":{"mine":{"command":"x"}}}"#) + .await + .unwrap(); + + let w = wd.to_str().unwrap().to_string(); + let (a, b) = tokio::join!( + write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), + write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), + ); + a.unwrap(); + b.unwrap(); + + let v: Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["mine"]["command"], json!("x"), "user server survived both"); + assert!(v["mcpServers"]["openab"].is_object(), "ours is present"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// The file we write is owner-only. + #[cfg(unix)] + #[tokio::test] + async fn the_written_config_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let wd = tmp_dir("perms").await; + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + let path = wd.join(".cursor").join("mcp.json"); + let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "0600 must be set at creation, not chmod'd after"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } +} + #[cfg(test)] mod tests { use super::{ From 9249a5ac2592fe4a14a0c6910557cd1ade2bae59 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 15:08:34 +0800 Subject: [PATCH 095/138] fix(acp): stop logging a resume credential at info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `channel_id` is `acp_` and `session_id` is `sess_` — one is trivially derivable from the other — so either appearing in an operator log is a working `session/resume` credential. Logs travel further, and live longer, than the sessions they describe. Five sites, not the two the item names. A multi-line-aware sweep found the others, two of which are `info!` calls added earlier in this same round; a line-oriented grep could not see them because the macro and the field sat on different lines. Redacted rather than demoted to `debug!`. These lines answer "did the extension attach?", which is the question operators actually have, so hiding them trades away real signal to fix the leak. `redact_id` hashes to a short stable tag instead: the same session tags identically on every line, so logs stay correlatable, and the tag does not go back to the id. The regression test scans the source for the class rather than pinning the five known sites — pinning them would pass while the next wrapped `info!` reintroduced the leak, which is precisely how this reached five sites. Mutation-checked against a wrapped call: it reports the offender by line and content. Two accompanying tests pin the property that makes this a defect (the ids are the same uuid) and that the tag is stable but non-reversible. Discord, Slack, LINE and Feishu `channel_id` logs are deliberately untouched: those are public identifiers, not credentials. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 116 +++++++++++++++++- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 43da4a6ef..520ae9cdd 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -800,7 +800,7 @@ async fn establish_and_register_tunnel( ) -> Result<(), String> { // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. - info!(acp_id = %acp_id, acp_name = %acp_name, channel_id = %channel_id, "ACP: opening MCP-over-ACP tunnel"); + info!(acp_id = %acp_id, acp_name = %acp_name, channel = %redact_id(&channel_id), "ACP: opening MCP-over-ACP tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; let handle = TunnelHandle { out_tx, @@ -841,7 +841,7 @@ async fn establish_and_register_tunnel( }; if !replaced.is_empty() { info!( - channel_id = %channel_id, server_name = %acp_name, evicted = replaced.len(), + channel = %redact_id(&channel_id), server_name = %acp_name, evicted = replaced.len(), "ACP: last-attach-wins — evicted stale same-name tunnel(s)" ); // Best-effort and off the attach path: a replaced connection may already be dead, and @@ -854,7 +854,7 @@ async fn establish_and_register_tunnel( } }); } - info!(channel_id = %channel_id, server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached"); + info!(channel = %redact_id(&channel_id), server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached"); Ok(()) } @@ -1208,7 +1208,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { }; if !dropped.is_empty() { info!( - channel_id = %channel_id, retired = dropped.len(), + channel = %redact_id(channel_id), retired = dropped.len(), "ACP: resume withdrew declarations — retiring their tunnels" ); tokio::spawn(async move { @@ -1661,6 +1661,23 @@ async fn handle_session_cancel( /// `sessionId` (`sess_`). Returns `None` if malformed — the uuid must /// parse, which keeps a resumed channel inside the `acp_` namespace and rejects /// forged ids. +/// A stable, non-reversible tag for an ACP channel or session id, for logs. +/// +/// `channel_id` is `acp_` and `session_id` is `sess_` — see +/// [`derive_channel_id`] — so either one in a log line is a working `session/resume` credential. +/// Anyone who can read operator logs could take over a live session, and logs travel further than +/// the sessions they describe. +/// +/// Dropping the lines to `debug!` would hide them from the operators who need them ("did the +/// extension attach?"), so the id is hashed instead: the same session tags identically on every +/// line, which is what makes a log readable, but the tag cannot be turned back into the id. +fn redact_id(id: &str) -> String { + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + fn derive_channel_id(session_id: &str) -> Option { let uuid = session_id.strip_prefix("sess_")?; Uuid::parse_str(uuid).ok()?; @@ -1824,7 +1841,7 @@ async fn handle_session_prompt( } Ok(Some(ReplyChunk::Done)) | Ok(None) => break, Err(_) => { - warn!(session = %session_id, "ACP: prompt timed out waiting for reply"); + warn!(session = %redact_id(&session_id), "ACP: prompt timed out waiting for reply"); timed_out = true; break; } @@ -3772,6 +3789,95 @@ mod acp_ws_integration { /// two apart. The owner can. /// /// All three cases below fail against key-only teardown. +/// Operator logs must not hand out a resume credential. +#[cfg(test)] +mod acp_log_redaction { + use super::*; + + /// The two ids are the same uuid under different prefixes, so either one in a log line is a + /// working `session/resume` credential. This is the property that makes redaction necessary — + /// if it ever stops holding, the redaction is solving a problem that moved. + #[test] + fn a_channel_id_yields_its_session_id() { + let uuid = Uuid::new_v4(); + let session_id = format!("sess_{uuid}"); + let channel_id = derive_channel_id(&session_id).unwrap(); + assert_eq!(channel_id, format!("acp_{uuid}")); + assert_eq!( + channel_id.strip_prefix("acp_"), + session_id.strip_prefix("sess_"), + "one is trivially derivable from the other — that is why neither may be logged raw" + ); + } + + #[test] + fn a_redacted_id_is_stable_but_does_not_contain_the_original() { + let uuid = Uuid::new_v4(); + let channel_id = format!("acp_{uuid}"); + let tag = redact_id(&channel_id); + + assert_eq!(tag, redact_id(&channel_id), "same id must tag identically, or logs stop \ + being correlatable across lines"); + assert!(!tag.contains(&uuid.to_string()), "the uuid must not survive into the tag"); + assert!( + !tag.contains(&channel_id) && !channel_id.contains(&tag[1..]), + "the tag must not be a substring of the id or vice versa" + ); + assert_ne!(tag, redact_id(&format!("acp_{}", Uuid::new_v4())), "different ids differ"); + } + + /// No `info!`/`warn!`/`error!` in this file may carry a raw channel or session id. + /// + /// Written as a source scan because the defect is a *class*, not a line: the item named two + /// sites, and a multi-line-aware sweep found four — the two extra ones added by later items in + /// this same round, and missed by a line-oriented grep because the macro and the field sat on + /// different lines. Pinning the four known sites would pass while the next wrapped `info!` + /// reintroduced the leak. + #[test] + fn no_operator_log_line_carries_a_raw_acp_id() { + let src = include_str!("acp_server.rs"); + let mut offenders: Vec = Vec::new(); + for macro_open in ["info!(", "warn!(", "error!("] { + let mut from = 0; + while let Some(rel) = src[from..].find(macro_open) { + let start = from + rel; + // Balance from the macro's own opening paren, so a mention of `info!` in prose + // cannot send this scanning for a close paren that was never opened. + let open = start + macro_open.len() - 1; + from = open + 1; + let mut depth = 0usize; + let mut end = open; + for (i, c) in src[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = open + i; + break; + } + } + _ => {} + } + } + let body = &src[start..=end]; + // `%channel_id` / `%session_id` interpolate the raw value; `redact_id(..)` does not. + if body.contains("%channel_id") || body.contains("%session_id") { + let line = src[..start].matches('\n').count() + 1; + offenders.push(format!( + "{line}: {}", + body.split_whitespace().take(8).collect::>().join(" ") + )); + } + } + } + assert!( + offenders.is_empty(), + "these operator-visible log lines carry a raw resume credential: {offenders:#?}" + ); + } +} + #[cfg(test)] mod acp_teardown_ownership { use super::*; From 91563f28061d36dcf850c441a9e04539cf2f0353 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 23:09:49 +0800 Subject: [PATCH 096/138] fix(acp): complete the inner MCP lifecycle before using a tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP requires `initialize` → response → `notifications/initialized` before any other request. We sent `tools/list` and `tools/call` straight after `mcp/connect`. That works against today's single extension because it is lenient, which is not a defence: the deliverable is *generic* client-declared MCP servers, and a standards-compliant one may reject — or simply not answer — anything arriving before it has been initialized. The handshake now runs as part of establishing, and its failure fails the establish, so a server that cannot complete it never reaches the registry. Better no tunnel than one whose first real call is refused for a reason the operator cannot see. The test drives the socket and asserts two things: the frame order, and that the registry is still empty when `initialize` arrives — a server that has not answered it has not agreed to serve anything yet. Ordering is the whole point, so a unit test of the handshake function would not do: it could confirm the right frames are sent while saying nothing about whether `tools/list` can still arrive first. Mutation-checked twice. The first pass caught the regression only via a 30s timeout reporting "timed out waiting for a server frame" — a true failure that names the harness instead of the bug, which is how a regression becomes a suspected flake. The collection loop is now bounded with its own message. Nine mock clients needed updating for this one protocol step: four in the WebSocket harness, five unit-test mocks, across a shared helper, inline closures and one that hands its channel back. None was found by the compiler, and each failure named the wrong subsystem — "no tunnel registered" for an unread `initialize`, a mismatched frame for an unconsumed notification. Two incidental fixes. Inserting the new function by anchoring on `establish_and_register_tunnel` put it between that function and its `#[allow(clippy::too_many_arguments)]`, silently reparenting the attribute to the new 5-argument helper and leaving the 9-argument one unprotected. And the harness `recv` timeout goes 5s → 30s: it guards against a hang and measures nothing, and at 5s it produced two transient failures under suite load. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 244 +++++++++++++++++- 1 file changed, 238 insertions(+), 6 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 520ae9cdd..5321e0fcb 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -786,6 +786,59 @@ impl TunnelHandle { /// the client's response, which only that same read loop can deliver — awaiting it inline /// would deadlock. #[allow(dead_code)] +/// MCP protocol version the gateway speaks to a tunnelled client MCP server. +const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Perform the inner MCP handshake on a freshly connected tunnel. +/// +/// MCP requires `initialize` → response → `notifications/initialized` before any other request. We +/// were sending `tools/list` and `tools/call` straight after `mcp/connect`, which happens to work +/// against today's single extension because it is lenient. That is not a defence: the deliverable +/// is *generic* client-declared MCP servers, and a standards-compliant server is entitled to +/// reject — or simply not answer — anything that arrives before it has been initialized. +/// +/// Failure here fails the establish, so a server that cannot complete the handshake never reaches +/// the registry: better no tunnel than one whose first real call is rejected for a reason the +/// operator cannot see. +async fn inner_mcp_handshake( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + timeout_secs: u64, +) -> Result<(), String> { + let init = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: "initialize".to_string(), + params: Some(json!({ + "protocolVersion": INNER_MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "openab-gateway", "version": env!("CARGO_PKG_VERSION") } + })), + }) + .map_err(|e| e.to_string())?; + let frame = send_request(out_tx, pending, next_id, "mcp/message", init, timeout_secs).await?; + frame_result(frame)?; + + // `notifications/initialized` is a notification in both directions: an inner MCP notification, + // carried by an outer frame with no `id`, so nothing is awaited and no reply is owed. + let initialized = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: "notifications/initialized".to_string(), + params: None, + }) + .map_err(|e| e.to_string())?; + let notification = json!({ + "jsonrpc": "2.0", + "method": "mcp/message", + "params": initialized, + }); + out_tx + .send(notification.to_string()) + .map_err(|_| "connection closed before notifications/initialized".to_string())?; + Ok(()) +} + #[allow(clippy::too_many_arguments)] async fn establish_and_register_tunnel( out_tx: mpsc::UnboundedSender, @@ -802,6 +855,8 @@ async fn establish_and_register_tunnel( // line in the log answers "did the browser extension advertise itself?" for a live session. info!(acp_id = %acp_id, acp_name = %acp_name, channel = %redact_id(&channel_id), "ACP: opening MCP-over-ACP tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; + // MCP lifecycle before the tunnel is usable — see `inner_mcp_handshake`. + inner_mcp_handshake(&out_tx, &pending, &next_id, &connection_id, timeout_secs).await?; let handle = TunnelHandle { out_tx, pending, @@ -2372,6 +2427,32 @@ mod acp_requests { use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; + /// Answer the inner MCP `initialize` that `establish_and_register_tunnel` now sends after + /// `mcp/connect`, and swallow the `notifications/initialized` that follows. + /// + /// A mock extension that answers only `mcp/connect` leaves the establish waiting on the + /// handshake, so it never registers — surfacing as "no handle in the registry", which says + /// nothing about the handshake. Every mock driving a real establish needs this. + async fn answer_inner_handshake( + out_rx: &mut mpsc::UnboundedReceiver, + pending: &Arc>>>, + ) { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["params"]["method"], json!("initialize"), "expected the inner MCP initialize"); + route_client_response( + pending, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{ + "protocolVersion":"2025-06-18","capabilities":{"tools":{}}, + "serverInfo":{"name":"test-ext","version":"0"} + }}), + ) + .await; + // The notification owes no reply, but it must come off the channel so a later + // `out_rx.recv()` does not mistake it for the frame the test is waiting for. + let n: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(n["params"]["method"], json!("notifications/initialized")); + } + fn new_pending( ) -> Arc>>> { Arc::new(tokio::sync::Mutex::new(HashMap::new())) @@ -2538,6 +2619,7 @@ mod acp_requests { assert_eq!(f["params"]["acpId"], json!("srv-1")); route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}})) .await; + answer_inner_handshake(&mut out_rx, &pending2).await; }); super::establish_and_register_tunnel( @@ -2582,6 +2664,7 @@ mod acp_requests { &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-1"}}), ) .await; + answer_inner_handshake(&mut out_rx, &pending2).await; }); super::establish_and_register_tunnel( out_tx, @@ -2644,6 +2727,7 @@ mod acp_requests { &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-old"}}), ) .await; + answer_inner_handshake(&mut out_rx, &pending2).await; out_rx // hand the receiver back so the test can keep reading it }); super::establish_and_register_tunnel( @@ -3392,9 +3476,15 @@ mod acp_ws_integration { } /// Next JSON frame from the server, skipping anything that is not text (pings etc). + /// + /// The timeout guards against a hang, so it should be generous rather than tight: it is not + /// measuring anything. At 5s it produced two transient failures when these WebSocket tests ran + /// alongside the rest of the suite — a budget that depends on machine load is a flaky test, and + /// a flaky test in a security-adjacent area is worse than none, because the next person learns + /// to re-run it. async fn recv(ws: &mut Ws) -> Value { loop { - match tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + match tokio::time::timeout(std::time::Duration::from_secs(30), ws.next()) .await .expect("timed out waiting for a server frame") .expect("socket closed") @@ -3407,6 +3497,39 @@ mod acp_ws_integration { } } + /// Handle a frame belonging to the inner MCP lifecycle, if it is one. + /// + /// Returns `Some("initialize")` after answering the request, `Some("initialized")` after + /// consuming the notification that follows it, `None` for anything else. + /// + /// Deliberately a classifier, not a loop. The first version looped on `recv` until it saw the + /// initialize and dropped everything else on the way — including the `session/new` response + /// its caller was still waiting for, which hung. A helper that eats frames its caller needs is + /// worse than no helper: every call site is already a dispatch loop, so this handles one frame + /// and hands the rest back. + async fn handled_inner_lifecycle(ws: &mut Ws, frame: &Value) -> Option<&'static str> { + if frame.get("method").and_then(Value::as_str) != Some("mcp/message") { + return None; + } + match frame["params"]["method"].as_str() { + Some("initialize") => { + send(ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + })).await; + Some("initialize") + } + // A notification: no reply is owed, but it must still be taken off the socket or the + // next `recv` in a test mistakes it for the frame it was waiting for. + Some("notifications/initialized") => Some("initialized"), + _ => None, + } + } + /// Drive `initialize` + `session/new` declaring one `type:acp` server, then answer the /// `mcp/connect` the gateway sends back. Returns the session id. async fn handshake(ws: &mut Ws, acp_id: &str, name: &str, connection_id: &str) -> String { @@ -3427,10 +3550,20 @@ mod acp_ws_integration { // The gateway now does two things concurrently: answer session/new, and open the tunnel // by sending mcp/connect. Order is not guaranteed, so accept either first. + // Three things must land before this returns, and the third is easy to forget: the + // gateway registers the tunnel only after the inner MCP handshake completes, so returning + // once `mcp/connect` is answered leaves the `initialize` unread in the socket. Nobody is + // polling the socket after that, the gateway times out, and the establish fails — which + // shows up as "no tunnel registered" rather than anything about initialize. let mut session_id = None; let mut connected = false; - while session_id.is_none() || !connected { + let mut lifecycle = 0; + while session_id.is_none() || !connected || lifecycle < 2 { let frame = recv(ws).await; + if handled_inner_lifecycle(ws, &frame).await.is_some() { + lifecycle += 1; + continue; + } if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { assert_eq!( frame["params"]["acpId"], json!(acp_id), @@ -3471,6 +3604,87 @@ mod acp_ws_integration { } } + /// The tunnel is not usable until the inner MCP lifecycle has completed. + /// + /// MCP requires `initialize` → response → `notifications/initialized` before any other + /// request. Driven over the socket because the ordering is the whole assertion: a unit test of + /// the handshake function could confirm it sends the right frames while saying nothing about + /// whether `tools/list` can still arrive first. + #[tokio::test] + async fn the_inner_mcp_lifecycle_completes_before_the_tunnel_is_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Bounded explicitly, with its own message. Letting the generic `recv` timeout catch a + // missing lifecycle works, but it costs 30s and reports "timed out waiting for a server + // frame" — which names the harness rather than the regression. A test that catches a bug + // should also say which bug. + let collect = async { + let mut order: Vec = Vec::new(); + let mut connected = false; + let mut lifecycle = 0; + while !connected || lifecycle < 2 { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + order.push("mcp/connect".into()); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + connected = true; + } else if f.get("method").and_then(Value::as_str) == Some("mcp/message") { + let inner = f["params"]["method"].as_str().unwrap_or("").to_string(); + order.push(inner.clone()); + if inner == "initialize" { + // The registry must still be empty: a server that has not answered + // `initialize` has not agreed to serve anything yet. + assert!( + registry.lock().unwrap().is_empty(), + "the tunnel was registered before the MCP handshake completed" + ); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + })).await; + } + lifecycle += 1; + } + } + order + }; + let order = tokio::time::timeout(std::time::Duration::from_secs(10), collect) + .await + .expect( + "no inner MCP lifecycle on the tunnel: the gateway connected but never sent \ + `initialize`, so a standards-compliant client MCP server would be asked for tools \ + before it had been initialized", + ); + + assert_eq!( + order, + vec![ + "mcp/connect".to_string(), + "initialize".to_string(), + "notifications/initialized".to_string() + ], + "the gateway must connect, then initialize, then notify — in that order" + ); + wait_for_tunnels(®istry, 1).await; + } + /// A real `mcp/message` crosses the socket and its result comes back to the caller. #[tokio::test] async fn a_tool_call_crosses_the_real_socket_and_returns_its_result() { @@ -3643,10 +3857,17 @@ mod acp_ws_integration { {"type": "acp", "id": "drop-1", "name": "other"} ]} })).await; + // Two servers: two `mcp/connect`s AND two lifecycle pairs (initialize + notification). + // Exiting on the connects alone leaves the handshakes unread and both establishes fail. let mut session_id = None; let mut connects = 0; - while session_id.is_none() || connects < 2 { + let mut lifecycle = 0; + while session_id.is_none() || connects < 2 || lifecycle < 4 { let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await.is_some() { + lifecycle += 1; + continue; + } if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { let acp_id = f["params"]["acpId"].as_str().unwrap().to_string(); send(&mut ws, json!({ @@ -3677,8 +3898,13 @@ mod acp_ws_integration { // Asserting on whichever arrives first tests the scheduler, not the behaviour. let mut disconnected: Vec = Vec::new(); let mut reconnected = false; - while disconnected.len() < 2 || !reconnected { + let mut relifecycle = 0; + while disconnected.len() < 2 || !reconnected || relifecycle < 2 { let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await.is_some() { + relifecycle += 1; + continue; + } match f.get("method").and_then(Value::as_str) { Some("mcp/disconnect") => { disconnected.push(f["params"]["connectionId"].as_str().unwrap().to_string()); @@ -3745,14 +3971,20 @@ mod acp_ws_integration { "mcpServers": [{"type": "acp", "id": "uuid-new", "name": "katashiro"}] } })).await; - loop { + let mut connected_new = false; + let mut new_lifecycle = 0; + while !connected_new || new_lifecycle < 2 { let frame = recv(&mut second).await; + if handled_inner_lifecycle(&mut second, &frame).await.is_some() { + new_lifecycle += 1; + continue; + } if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { send(&mut second, json!({ "jsonrpc": "2.0", "id": frame["id"].clone(), "result": {"connectionId": "conn-new"} })).await; - break; + connected_new = true; } if frame.get("id") == Some(&json!(2)) { assert!( From 8b8f5505e3c422323295da04fddf129999b8e328 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Wed, 29 Jul 2026 23:39:31 +0800 Subject: [PATCH 097/138] fix(acp): restore the deadlock warning to the function it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same insertion that reparented `#[allow(clippy::too_many_arguments)]` took two more things with it, and I only sent the one back that clippy had complained about. `establish_and_register_tunnel`'s doc comment — including "MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits the client's response, which only that same read loop can deliver — awaiting it inline would deadlock" — and its `#[allow(dead_code)]` were both attached to `INNER_MCP_PROTOCOL_VERSION`, a version string. The function had no documentation at all, and the constant's own doc was pushed below an attribute (`///` is sugar for `#[doc]`, so interleaving them is legal and silent). The lint exemption was the least valuable of the three. The one that mattered is the deadlock warning: it is load-bearing safety documentation, and it was sitting on a `&'static str`. Found by the second reviewer reading the file, which is the only way to find it — nothing warns about a doc comment attached to the wrong item. Swept the file for other attribute/doc inversions; none remain. Co-Authored-By: Claude --- crates/openab-gateway/src/adapters/acp_server.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 5321e0fcb..57565726b 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -780,12 +780,6 @@ impl TunnelHandle { } } -/// Open the MCP-over-ACP tunnel to a session's declared `"type":"acp"` server and register a -/// `TunnelHandle` under the session's `channel_id` so the core MCP proxy can reach it (T5.3). -/// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits -/// the client's response, which only that same read loop can deliver — awaiting it inline -/// would deadlock. -#[allow(dead_code)] /// MCP protocol version the gateway speaks to a tunnelled client MCP server. const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; @@ -839,6 +833,12 @@ async fn inner_mcp_handshake( Ok(()) } +/// Open the MCP-over-ACP tunnel to a session's declared `"type":"acp"` server and register a +/// `TunnelHandle` under the session's `channel_id` so the core MCP proxy can reach it (T5.3). +/// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits +/// the client's response, which only that same read loop can deliver — awaiting it inline +/// would deadlock. +#[allow(dead_code)] #[allow(clippy::too_many_arguments)] async fn establish_and_register_tunnel( out_tx: mpsc::UnboundedSender, From 1e70a93453066c84bc77a52d6f5d4bf5aa2fc13b Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 00:47:28 +0800 Subject: [PATCH 098/138] fix(acp): test that a refused handshake blocks registration, and tell the extension side about the lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from the second reviewer, and both about claims item 12 made without backing them. `inner_mcp_handshake`'s doc promised "failure here fails the establish, so a server that cannot complete the handshake never reaches the registry" and nothing tested it. The ordering test cannot: change the call to `let _ = inner_mcp_handshake(...)` and the frames still go out in order, the registry is still empty when `initialize` arrives, and it stays green while servers that refused the handshake get registered. Verified — under that mutation the ordering test passes and the new test fails with "a server that refused `initialize` was registered anyway". That is a better mutation than the one I ran. I deleted the call, which the ordering test caught; this one leaves observable behaviour identical and removes only the guarantee. The tunnel contract — the only document an extension implementer reads — never mentioned `notifications/initialized`, listed only `initialize`, `tools/list` and `tools/call` as methods to handle, described notifications as travelling extension→gateway only, and stated no ordering requirement. An extension built to it has no reason to forward the notification, so its inner server stays un-initialized while the gateway believes the handshake completed. The gateway was made compliant and the other side of the wire was not told. Which is the same defence this item already rejected, moved one layer out: "today's extension is lenient" was not a reason for the gateway to skip the handshake, and it is not a reason to leave the contract silent either. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 58 +++++++++++++++++++ docs/mcp-over-acp-tunnel-contract.md | 27 +++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 57565726b..5e0e941f8 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -3685,6 +3685,64 @@ mod acp_ws_integration { wait_for_tunnels(®istry, 1).await; } + /// A server that refuses the handshake must not be registered. + /// + /// `inner_mcp_handshake`'s doc promises "failure here fails the establish, so a server that + /// cannot complete the handshake never reaches the registry" — and nothing tested it. The + /// ordering test cannot: change the call to `let _ = inner_mcp_handshake(...)` and the frame + /// order is unchanged, the registry is still empty when `initialize` arrives, and it stays + /// green while refusing servers get registered anyway. + #[tokio::test] + async fn a_server_that_refuses_initialize_is_not_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Answer `mcp/connect`, then REFUSE `initialize` the way a server that will not serve us + // does: a JSON-RPC error, per the contract's "an inner MCP-level error is returned as the + // outer JSON-RPC `error`". + let mut refused = false; + while !refused { + let f = recv(&mut ws).await; + match f.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + } + Some("mcp/message") if f["params"]["method"] == json!("initialize") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "error": {"code": -32603, "message": "not accepting connections"} + })).await; + refused = true; + } + _ => {} + } + } + + // The establish must fail. Poll rather than sleep: we are proving something stays absent, + // so give it real time to appear wrongly instead of checking once and declaring victory. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + assert!( + registry.lock().unwrap().is_empty(), + "a server that refused `initialize` was registered anyway — the handshake failure \ + did not fail the establish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + /// A real `mcp/message` crosses the socket and its result comes back to the caller. #[tokio::test] async fn a_tool_call_crosses_the_real_socket_and_returns_its_result() { diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 8132af2ee..ff257d5be 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -74,12 +74,31 @@ inner MCP `id`; correlation is by the outer ACP JSON-RPC id): { "jsonrpc":"2.0", "id":, "result": { ...inner MCP result... } } ``` An inner MCP-level error is returned as the outer JSON-RPC `error`. -- **Notification** (outer frame has no `id`): fire-and-forget inner MCP notification; no - reply. The **extension** sends these upward for server-originated MCP notifications (e.g. - `notifications/tools/list_changed` when its tool set changes). +- **Notification** (outer frame has no `id`): fire-and-forget inner MCP notification; no reply. + These travel in **both** directions. The extension sends them upward for server-originated MCP + notifications; the gateway sends them downward, and the extension must forward them to its inner + MCP server exactly as it forwards requests — see `notifications/initialized` below. + +### Lifecycle: the gateway initializes before it asks for anything + +Immediately after `mcp/connect` the gateway performs the MCP handshake on the new connection: + +1. `mcp/message` **request** carrying inner `initialize` — the extension forwards it to its MCP + server and returns the server's `InitializeResult`. +2. `mcp/message` **notification** (no outer `id`) carrying inner `notifications/initialized` — the + extension forwards it to the server and replies with nothing. + +Only then does the gateway send `tools/list` or `tools/call`. + +**A server that fails `initialize` is not registered**, so its tools never become reachable and no +later call is attempted against it. Forwarding the notification matters as much as answering the +request: MCP servers are entitled to reject work until they have received `initialized`, and an +extension that swallows it leaves its own server permanently un-initialized while the gateway +believes the handshake completed. Inner MCP methods the extension must handle as a server: -- `initialize` → advertise `capabilities.tools`. +- `initialize` → forward to the server; return its `InitializeResult`. +- `notifications/initialized` → forward to the server; no reply. - `tools/list` → return the browser tools (§6). - `tools/call` → execute the named tool in the active tab; return an MCP `CallToolResult`. From 3182b8210766dad6f37edd3f0b2f2d4cfe7af442 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 01:01:33 +0800 Subject: [PATCH 099/138] fix(acp): disconnect the connection a failed handshake leaves open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 12 introduced this. Before it, `mcp_connect` was immediately followed by registration, so every connection the client opened for us became a handle in the registry. Putting the MCP handshake between them means a `?` on failure drops `connection_id` with nothing holding it — and every cleanup path in this file goes `reg.remove(..)` then `handle.disconnect(..)`, so a connection that never entered the registry has no cleanup path at all. Twenty lines below, the eviction code states the rule this broke: the client still believes those connections are open, so each is owed an `mcp/disconnect`. Not a rare path. Handshake failure includes a 30s timeout, so a slow or temporarily unwilling server leaks one connection per attach — and last-attach-wins exists precisely because clients reconnect, so a retry leaks another. The disconnect is best-effort and spawned, matching the eviction path: a client that just failed a handshake may be in no state to answer, and waiting on it would delay the error the caller needs to log. The test I added for the refusal path asserted the leak as correct — it checked the registry stays empty, and the leak happens inside exactly that state. It now requires the `mcp/disconnect` naming that connection first, bounded with its own message so a regression does not surface as "timed out waiting for a server frame", which names the harness rather than the leak. Found by the second reviewer on its SECOND read of this code. Its first pass, an hour earlier, produced two different findings and never touched connect/disconnect pairing. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 5e0e941f8..75722f909 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -856,7 +856,34 @@ async fn establish_and_register_tunnel( info!(acp_id = %acp_id, acp_name = %acp_name, channel = %redact_id(&channel_id), "ACP: opening MCP-over-ACP tunnel"); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; // MCP lifecycle before the tunnel is usable — see `inner_mcp_handshake`. - inner_mcp_handshake(&out_tx, &pending, &next_id, &connection_id, timeout_secs).await?; + // + // On failure the client is still holding an open connection it opened for us, and we are about + // to return without registering a handle for it. Every other cleanup path in this file goes + // through the registry (`reg.remove(..)` then `handle.disconnect(..)`), so a connection that + // never got registered has none at all — it would leak for the life of the process, once per + // attach, and a handshake timeout against a slow server is enough to trigger it. Same + // obligation the eviction path documents: the client believes the connection is open, so it is + // owed an `mcp/disconnect`. + // + // Best-effort and off the failure path, matching the eviction disconnect: a client that just + // failed a handshake may be in no state to answer, and waiting on it would delay the error the + // caller needs to log. + if let Err(e) = + inner_mcp_handshake(&out_tx, &pending, &next_id, &connection_id, timeout_secs).await + { + let (tx, pend, nid, cid) = ( + out_tx.clone(), + pending.clone(), + next_id.clone(), + connection_id.clone(), + ); + tokio::spawn(async move { + if let Err(e) = mcp_disconnect(&tx, &pend, &nid, &cid, 5).await { + debug!(error = %e, "ACP: mcp/disconnect after a failed handshake did not complete"); + } + }); + return Err(e); + } let handle = TunnelHandle { out_tx, pending, @@ -3730,9 +3757,43 @@ mod acp_ws_integration { } } - // The establish must fail. Poll rather than sleep: we are proving something stays absent, - // so give it real time to appear wrongly instead of checking once and declaring victory. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + // The client opened a connection for us and we are not going to use it, so it is owed an + // `mcp/disconnect` naming that connection. Asserting only "not registered" is what let the + // leak through: the connection is leaked *inside* the not-registered state, so a test that + // checks the registry alone asserts the broken state as correct. + // Bounded with its own message: without it, a regression here waits out the generic + // `recv` timeout and reports "timed out waiting for a server frame" — which names the + // harness, not the leak, and reads like a flake. + let wait_disconnect = async { + loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = tokio::time::timeout( + std::time::Duration::from_secs(10), + wait_disconnect, + ) + .await + .map(Some) + .expect( + "no `mcp/disconnect` after a refused handshake: the connection the client opened for \ + us is leaked — it never entered the registry, and every cleanup path goes through the \ + registry", + ); + assert_eq!( + disconnected.as_deref(), + Some("conn-1"), + "the connection opened for a server that then refused the handshake must be closed, \ + naming that connection — nothing else can close it, since every other cleanup path \ + goes through the registry it never entered" + ); + + // And it must still not be registered. Poll rather than check once: we are proving + // something stays absent, so give it real time to appear wrongly. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < deadline { assert!( registry.lock().unwrap().is_empty(), From d98e55440473fbb307c537ff6376ed13fbecf2b2 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 01:10:58 +0800 Subject: [PATCH 100/138] fix(acp): warn when handshake cleanup fails, and stop duplicating the inner request path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both raised as non-blocking by the second reviewer; both cheap now and expensive later. The `mcp/disconnect` that runs after a failed handshake logged its own failure at `debug!`, while the handshake failure that triggered it logs at `warn!`. But the consequence of the disconnect failing *is* the leak the disconnect exists to prevent: the connection never entered the registry, so nothing else will ever close it, and at the default `info` filter an operator sees nothing. The quieter message described the worse outcome. Now `warn!`, with the connection id redacted like every other id on this path. `inner_mcp_handshake` was re-assembling the `mcp/message` frame by hand instead of calling `mcp_message_request`, which is what the tool path uses. Same struct, same method string, same `frame_result` — differing only in `map_err` versus `unwrap`. The reason to collapse it now rather than later: this handshake still does not validate the `protocolVersion` the server returns, and with two copies that check would have to be added twice. Co-Authored-By: Claude --- .../openab-gateway/src/adapters/acp_server.rs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 75722f909..0eabedb4d 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -801,18 +801,23 @@ async fn inner_mcp_handshake( connection_id: &str, timeout_secs: u64, ) -> Result<(), String> { - let init = serde_json::to_value(McpMessageParams { - connection_id: connection_id.to_string(), - method: "initialize".to_string(), - params: Some(json!({ + // Reuse `mcp_message_request` rather than re-assembling the frame: sending an inner MCP + // request and unwrapping its result should have exactly one implementation, or the + // `protocolVersion` check this handshake still lacks would have to be added in two places. + mcp_message_request( + out_tx, + pending, + next_id, + connection_id, + "initialize", + Some(json!({ "protocolVersion": INNER_MCP_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": { "name": "openab-gateway", "version": env!("CARGO_PKG_VERSION") } })), - }) - .map_err(|e| e.to_string())?; - let frame = send_request(out_tx, pending, next_id, "mcp/message", init, timeout_secs).await?; - frame_result(frame)?; + timeout_secs, + ) + .await?; // `notifications/initialized` is a notification in both directions: an inner MCP notification, // carried by an outer frame with no `id`, so nothing is awaited and no reply is owed. @@ -879,7 +884,15 @@ async fn establish_and_register_tunnel( ); tokio::spawn(async move { if let Err(e) = mcp_disconnect(&tx, &pend, &nid, &cid, 5).await { - debug!(error = %e, "ACP: mcp/disconnect after a failed handshake did not complete"); + // `warn!`, not `debug!`: reaching here means the connection we opened is now + // leaked for the life of the process — it never entered the registry, so nothing + // else will ever close it. Logging the cleanup failure more quietly than the + // handshake failure it cleans up after would hide the worse of the two. + warn!( + error = %e, connection = %redact_id(&cid), + "ACP: mcp/disconnect after a failed handshake did not complete — that \ + connection is now unreclaimable" + ); } }); return Err(e); From 5306541b2c2b9032828de26ff507d39925099aa4 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 01:22:52 +0800 Subject: [PATCH 101/138] chore(acp): drop six dead_code allows that had stopped being true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of them now has a production caller — including the allow on `mcp_message_request`, whose caller was added by the previous commit. Proven rather than assumed: converting them to `#[expect(dead_code)]` made all six fail as unfulfilled lint expectations, which is exactly the signal `#[allow]` cannot give. So the right move was deletion, not conversion. The general point, raised by the second reviewer on its fourth read of this file: `#[allow]` never tells you it has expired, `#[expect]` does. That is the one category of correction this round has been short of — the reparented deadlock warning, the stale doc claims and the credential logged on unchecked paths all survived because nothing complained, and "nothing complained" kept being read as "nothing is wrong". Co-Authored-By: Claude --- crates/openab-gateway/src/adapters/acp_server.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 0eabedb4d..b0429748d 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -610,7 +610,6 @@ async fn route_client_response( /// `pending`, writes the frame via the outbound channel, then awaits the correlated response /// (resolved by `route_client_response`) with a timeout. Wired to a caller by T1.4 (the /// core↔gateway MCP-over-ACP bridge); landed ahead of its caller as ready infrastructure. -#[allow(dead_code)] async fn send_request( out_tx: &mpsc::UnboundedSender, pending: &Arc>>>, @@ -644,7 +643,6 @@ async fn send_request( /// Extract the `result` from a JSON-RPC response frame, mapping an `error` member to `Err`. /// `send_request` yields the whole response frame; the tunnel helpers want just the payload. -#[allow(dead_code)] fn frame_result(frame: Value) -> Result { if let Some(err) = frame.get("error") { return Err(format!("remote error: {err}")); @@ -654,7 +652,6 @@ fn frame_result(frame: Value) -> Result { /// `mcp/connect` (T4): open a tunnelled MCP connection to the client-provided (`"type":"acp"`) /// MCP server identified by `acp_id`; returns the client-assigned `connectionId`. -#[allow(dead_code)] async fn mcp_connect( out_tx: &mpsc::UnboundedSender, pending: &Arc>>>, @@ -675,7 +672,6 @@ async fn mcp_connect( /// `mcp/message` REQUEST (T4): tunnel an inner MCP request over `connection_id`; returns the /// inner MCP result payload (the outer ACP id does the correlation; the inner MCP id is not /// carried on the wire). -#[allow(dead_code)] async fn mcp_message_request( out_tx: &mpsc::UnboundedSender, pending: &Arc>>>, @@ -696,7 +692,6 @@ async fn mcp_message_request( } /// `mcp/disconnect` (T4): close a tunnelled MCP connection. -#[allow(dead_code)] async fn mcp_disconnect( out_tx: &mpsc::UnboundedSender, pending: &Arc>>>, @@ -843,7 +838,6 @@ async fn inner_mcp_handshake( /// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits /// the client's response, which only that same read loop can deliver — awaiting it inline /// would deadlock. -#[allow(dead_code)] #[allow(clippy::too_many_arguments)] async fn establish_and_register_tunnel( out_tx: mpsc::UnboundedSender, From 87fac9c7e6144d2a7c729b10aa76ab53615d6898 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 02:37:30 +0800 Subject: [PATCH 102/138] chore(acp): drop the last six dead_code allows, and the doc claims that went stale with them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orca's sweep of the tunnel helper functions found six stale `#[allow(dead_code)]`; seven more sat on structs, which that sweep did not cover. Converting all seven to `#[expect]` reports six as unfulfilled — the same mechanism, run over the whole file rather than over one enumerated list. `McpDisconnectParams` keeps its allow: its expectation is fulfilled. The lint being stale is also evidence that two doc comments are stale, so they go with it: - `AcpSession::acp_mcp_servers` said "Unused until that wiring lands". It is read on resume to work out which declarations the client withdrew. - `JsonRpcRequestOut` said it "landed ahead of its caller as ready infrastructure". `send_request` now drives mcp/connect, mcp/message and mcp/disconnect. `acp_server` is entirely `#[cfg(feature = "acp")]`, so that feature is the only configuration in which any of this compiles and one check settles it. --- crates/openab-gateway/src/adapters/acp_server.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index b0429748d..6cd127001 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -293,14 +293,13 @@ struct AcpSession { cancel: Option>, /// Client-declared MCP-over-ACP servers (the RFD `{"type":"acp"}` mcpServers entries): /// the browser extension serves its MCP tools over this same /acp WS. Recorded so the - /// gateway can later `mcp/connect` to them (T5.3). Unused until that wiring lands. - #[allow(dead_code)] + /// gateway can later `mcp/connect` to them (T5.3). Read on resume to work out which + /// declarations the client withdrew. acp_mcp_servers: Vec, } /// A client-declared MCP-over-ACP server (the RFD `"type":"acp"` `mcpServers` entry). Not in /// the generated schema (the RFD is a proposal), so parsed from raw params. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] struct AcpMcpServer { id: String, @@ -437,9 +436,8 @@ struct JsonRpcNotification { /// A SERVER-INITIATED JSON-RPC request (the agent→client REQUEST direction, T1). The base /// only ever *received* requests, so `JsonRpcRequest` is deserialize-only; this is the -/// outbound counterpart used by `send_request`. Wired to a caller by T1.4 (core↔gateway -/// bridge) / the MCP-over-ACP tunnel; landed ahead of its caller as ready infrastructure. -#[allow(dead_code)] +/// outbound counterpart used by `send_request`, which the MCP-over-ACP tunnel drives for +/// `mcp/connect`, `mcp/message` and `mcp/disconnect`. #[derive(Debug, Serialize)] struct JsonRpcRequestOut { jsonrpc: &'static str, @@ -459,7 +457,6 @@ struct JsonRpcRequestOut { /// `mcp/connect` params — `acpId` matches the `id` of the client's `session/new` /// `mcpServers` entry with `"type":"acp"`. -#[allow(dead_code)] #[derive(Debug, Serialize)] struct McpConnectParams { #[serde(rename = "acpId")] @@ -467,7 +464,6 @@ struct McpConnectParams { } /// `mcp/connect` result — the client-assigned connection handle. -#[allow(dead_code)] #[derive(Debug, Deserialize)] struct McpConnectResult { #[serde(rename = "connectionId")] @@ -476,7 +472,6 @@ struct McpConnectResult { /// `mcp/message` params — the inner MCP `method`/`params` are flattened in (no inner id); /// `connectionId` selects the tunnelled MCP connection. -#[allow(dead_code)] #[derive(Debug, Serialize)] struct McpMessageParams { #[serde(rename = "connectionId")] From 7915db2e18dbf29bc32e12a34c0479beecd1ad93 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 02:53:47 +0800 Subject: [PATCH 103/138] chore(acp): drop the seventh dead_code allow, which was stale like the other six MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 87fac9c7 kept the allow on `McpDisconnectParams` on the grounds that its `#[expect]` was fulfilled while the other six were not. That reading was wrong, and the evidence to catch it was in the same output: the unfulfilled list was displayed through `head -40`, which cut it off at six, while a count in the same run reported eight matching lines — seven warnings plus the trailing `= note:`. The count disagreed with the list and the count was right. Removing all seven and forcing a real recompile of the gateway lib emits no dead_code warning at all, and clippy passes with `-D warnings`. The struct is live; nothing here needed an allow. --- crates/openab-gateway/src/adapters/acp_server.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 6cd127001..41b606cae 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -482,7 +482,6 @@ struct McpMessageParams { } /// `mcp/disconnect` params. -#[allow(dead_code)] #[derive(Debug, Serialize)] struct McpDisconnectParams { #[serde(rename = "connectionId")] From c1f4dac867ed186cd6e2d7156ec6adfe42ff11e7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 04:26:58 +0800 Subject: [PATCH 104/138] fix(acp): reject an inner MCP server that answers a protocol version we do not speak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inner_mcp_handshake` sent `initialize` and threw the result away: `mcp_message_request(...) .await?` proved only that a reply arrived, never what it said. A server answering a version we do not speak was registered and then failed on its first real `tools/call`, with nothing in the log to explain it — the exact opaque failure this handshake exists to prevent. The gap was not undiscovered. The comment above the call said the handshake "still lacks" the protocolVersion check, and it stayed that way. A comment admitting a missing check is a defect, not documentation. Capture the result, compare, and on mismatch fail the establish so the tunnel is never registered, naming both the version received and the one we require. A missing `protocolVersion` is treated the same way rather than defaulting to accept. The test answers `initialize` SUCCESSFULLY with 2024-11-05 — a real MCP revision, so a plausible peer rather than a nonsense one. That distinguishes it from the refusal test: with no JSON-RPC error anywhere, every check that only asks "did a reply arrive" passes, which is why this went unnoticed. Verified by deleting the check and re-running: the test fails and says the tunnel "was registered anyway — the version check did not fail the establish". The registry assertion comes first on purpose. Both obligations break when the check is missing, but only that one names it: without the check the tunnel is registered and in use, so leading with the disconnect assertion would report a leaked connection that is not actually present. A failing test that names the wrong cause costs more than one that names none. --- .../openab-gateway/src/adapters/acp_server.rs | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 41b606cae..6e793d16d 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -791,9 +791,9 @@ async fn inner_mcp_handshake( timeout_secs: u64, ) -> Result<(), String> { // Reuse `mcp_message_request` rather than re-assembling the frame: sending an inner MCP - // request and unwrapping its result should have exactly one implementation, or the - // `protocolVersion` check this handshake still lacks would have to be added in two places. - mcp_message_request( + // request and unwrapping its result has exactly one implementation, so the `protocolVersion` + // check below only has to exist in one place. + let init = mcp_message_request( out_tx, pending, next_id, @@ -808,6 +808,25 @@ async fn inner_mcp_handshake( ) .await?; + // A reply proves the server answered, not that it agreed. A server answering a version we do + // not speak would be registered here and then fail on its first real `tools/call`, for a + // reason nothing in the log explains — the exact opaque failure this handshake exists to + // prevent. Refuse the establish instead, and name both versions so the mismatch is readable. + match init.get("protocolVersion").and_then(Value::as_str) { + Some(INNER_MCP_PROTOCOL_VERSION) => {} + Some(other) => { + return Err(format!( + "inner MCP server answered protocolVersion {other}, but this gateway speaks \ + {INNER_MCP_PROTOCOL_VERSION}" + )); + } + None => { + return Err( + "inner MCP `initialize` result carried no `protocolVersion` string".to_string(), + ); + } + } + // `notifications/initialized` is a notification in both directions: an inner MCP notification, // carried by an outer frame with no `id`, so nothing is awaited and no reply is owed. let initialized = serde_json::to_value(McpMessageParams { @@ -3805,6 +3824,99 @@ mod acp_ws_integration { } } + /// A server that *succeeds* at `initialize` but answers a protocol version we do not speak + /// must not be registered either. + /// + /// Distinct from the refusal test in the one way that matters: there is no JSON-RPC `error` + /// here. The handshake completes, so every check that only asks "did a reply arrive" passes — + /// which is exactly why the version went unchecked. The gateway used to discard this result + /// entirely, register the tunnel, and fail on the first real `tools/call` with nothing in the + /// log to explain it. + /// + /// The mock answered the supported version everywhere, so the happy path was the only path the + /// suite could reach and an absent check passed. + #[tokio::test] + async fn a_server_answering_an_unsupported_protocol_version_is_not_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Answer `mcp/connect`, then answer `initialize` SUCCESSFULLY with an older spec version. + // 2024-11-05 is a real MCP revision, so this is a plausible peer rather than a nonsense one. + let mut answered = false; + while !answered { + let f = recv(&mut ws).await; + match f.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + } + Some("mcp/message") if f["params"]["method"] == json!("initialize") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "old-ext", "version": "0" } + } + })).await; + answered = true; + } + _ => {} + } + } + + // Registry first, deliberately. Both obligations below fail when the version check is + // missing, but only this one NAMES that: delete the check and the tunnel is registered and + // genuinely in use, so leading with the disconnect assertion reports a leaked connection — + // a different defect, which is not actually present. A failing test that names the wrong + // cause costs more than one that names none. + // Poll rather than check once: we are proving something stays absent. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert!( + registry.lock().unwrap().is_empty(), + "a server answering an unsupported protocolVersion was registered anyway — the \ + version check did not fail the establish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // The connection the client opened for us is still owed an `mcp/disconnect`: it never + // entered the registry, and every cleanup path goes through the registry. Bounded with its + // own message so a regression names the defect rather than the harness. + let wait_disconnect = async { + loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "no `mcp/disconnect` after a version mismatch: the connection opened for a \ + server we then rejected is leaked", + ); + assert_eq!( + disconnected, "conn-1", + "the connection opened for a server whose protocol version we rejected must be closed, \ + naming that connection" + ); + } + /// A real `mcp/message` crosses the socket and its result comes back to the caller. #[tokio::test] async fn a_tool_call_crosses_the_real_socket_and_returns_its_result() { From bbb53948dbb54e9d74a8d3fb9bebec8dbb7c6734 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 04:38:25 +0800 Subject: [PATCH 105/138] fix(acp): order last-attach-wins by a generation stamp, not by who registers first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eviction predicate was `channel == && server_name == && id !=` with no ordering term at all, and the registry lock is only held around evict+insert — the handshake completes outside it, necessarily, since `disconnect` is async and that is a std mutex. So two establishes racing for one declared name were resolved by whoever reached the lock first. That is last-to-REGISTER-wins, and it diverges from last-to-ATTACH-wins whenever handshake durations differ, which over a real socket is the normal case: a slow older establish would finish last, evict the newer tunnel that had already beaten it, and install the stale one over the live one. Stamp a process-wide monotonic generation at the START of each establish, before the first round trip, and carry it on the handle. Eviction now removes only strictly LOWER generations; an establish that finds a HIGHER generation already holding the name stands down without registering and disconnects the connection it opened. That connection is owed the same `mcp/disconnect` an evicted one is — the client believes it is open. Process-wide rather than per-connection because the racing establishes belong to different connections: a reconnect arrives on a new socket with a fresh `server_id`, so a per-connection sequence cannot order them, and cross-connection is precisely the failing case. A timestamp would add clock skew and resolution problems for no gain. This also closes "an aborted task inserts a dead handle after cleanup" without a separate guard: a lower-generation late insert cannot win the slot. The test drives the real race across two sockets — socket A declares the name and stalls with its `mcp/connect` unanswered while socket B resumes the same session, declares the same name and completes — then lets A finish last. Verified by reverting the predicate in the source: the registry ends up holding `srv-1`, the older late-finisher, instead of `srv-2`. --- .../openab-gateway/src/adapters/acp_server.rs | 192 ++++++++++++++++-- 1 file changed, 176 insertions(+), 16 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 6e793d16d..daf50ddfd 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -702,6 +702,19 @@ async fn mcp_disconnect( .map(|_| ()) } +/// Monotonic attach ordering for last-attach-wins (ADR §6.1). +/// +/// Stamped at the START of an establish, before the first round trip, because "which declaration +/// is newer" is decided by when the client asked — not by which handshake happened to finish +/// first. Those differ whenever handshake durations differ, which over a real socket is the +/// normal case: a slow older establish would otherwise complete last, evict the successor that +/// beat it, and leave the STALE tunnel installed. Registration order is not attach order. +/// +/// Process-wide rather than per-connection on purpose: the racing establishes belong to +/// *different* connections (a reconnect mints a fresh `server_id` on a new socket), so a +/// per-connection sequence cannot order them, and cross-connection is precisely the failing case. +static TUNNEL_GENERATION: AtomicU64 = AtomicU64::new(0); + /// A cloneable handle to one `/acp` connection's MCP-over-ACP tunnel (T5.3). Bundles the /// per-connection outbound channel + pending-request map + id counter + the `connectionId` /// from `mcp/connect`, so a holder can issue `mcp/message` requests to that browser and await @@ -727,6 +740,11 @@ pub struct TunnelHandle { /// connections by `session/resume`. Teardown therefore matches on this owner rather than on /// the key, so a late cleanup cannot remove a handle installed by a different connection. owner: String, + /// Attach ordering from [`TUNNEL_GENERATION`], stamped when this establish STARTED. + /// + /// Eviction compares generations instead of trusting arrival order, so a slow establish that + /// finishes after a newer one cannot evict its own successor. + generation: u64, } impl TunnelHandle { @@ -866,6 +884,9 @@ async fn establish_and_register_tunnel( // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. info!(acp_id = %acp_id, acp_name = %acp_name, channel = %redact_id(&channel_id), "ACP: opening MCP-over-ACP tunnel"); + // Stamp BEFORE the first round trip: this establish's place in attach order is fixed by when + // the client declared the server, not by how long its handshake takes. See `TUNNEL_GENERATION`. + let generation = TUNNEL_GENERATION.fetch_add(1, Ordering::Relaxed); let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; // MCP lifecycle before the tunnel is usable — see `inner_mcp_handshake`. // @@ -911,8 +932,15 @@ async fn establish_and_register_tunnel( connection_id, server_name: acp_name.clone(), owner: owner.clone(), + generation, }; - let replaced = { + // `Superseded` carries the handle back out of the lock: a losing establish still owes its + // client an `mcp/disconnect`, and `disconnect` is async while this is a std mutex. + enum Registered { + Done(Vec), + Superseded(TunnelHandle), + } + let outcome = { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); // Last-attach-wins (ADR §6.1). The client mints a fresh `id` on every connection, so a // reconnect would otherwise leave the dead tunnel registered beside the live one under @@ -924,22 +952,52 @@ async fn establish_and_register_tunnel( // connections are open, so each one is owed an `mcp/disconnect` (review R7). They are // collected here and disconnected after the lock is released — `disconnect` is async and // this is a std mutex, so awaiting under it is not an option. - let stale: Vec<(String, String)> = reg + // + // Deliberately NOT scoped to the attaching connection. A reconnecting client arrives on a + // NEW socket with a fresh `server_id`, so its predecessor's handle is owned by the old + // connection — restricting eviction to one owner would leave that dead tunnel registered + // beside the live one under the same declared name, which is the ambiguity + // last-attach-wins exists to prevent (ADR §6.1). Teardown is owner-scoped; eviction is + // not, and they are different questions. + let same_name = |c: &String, id: &String, h: &TunnelHandle| { + c == &channel_id && h.server_name == acp_name && id != &acp_id + }; + // Ordering is by generation, not by who reached this lock first. A slow establish that + // started EARLIER but finished later must not evict the newer tunnel that beat it here: + // it lost the race the client actually cares about, so it stands down and closes its own + // connection instead of installing a stale handle over a live one. + if reg .iter() - .filter(|((c, id), h)| { - // Deliberately NOT scoped to the attaching connection. A reconnecting client - // arrives on a NEW socket with a fresh `server_id`, so its predecessor's handle is - // owned by the old connection — restricting eviction to one owner would leave that - // dead tunnel registered beside the live one under the same declared name, which - // is the ambiguity last-attach-wins exists to prevent (ADR §6.1). Teardown is - // owner-scoped; eviction is not, and they are different questions. - c == &channel_id && h.server_name == acp_name && id != &acp_id - }) - .map(|(k, _)| k.clone()) - .collect(); - let replaced: Vec = stale.iter().filter_map(|k| reg.remove(k)).collect(); - reg.insert((channel_id.clone(), acp_id.clone()), handle); - replaced + .any(|((c, id), h)| same_name(c, id, h) && h.generation > generation) + { + Registered::Superseded(handle) + } else { + let stale: Vec<(String, String)> = reg + .iter() + .filter(|((c, id), h)| same_name(c, id, h) && h.generation < generation) + .map(|(k, _)| k.clone()) + .collect(); + let replaced: Vec = stale.iter().filter_map(|k| reg.remove(k)).collect(); + reg.insert((channel_id.clone(), acp_id.clone()), handle); + Registered::Done(replaced) + } + }; + let replaced = match outcome { + Registered::Done(replaced) => replaced, + Registered::Superseded(handle) => { + info!( + channel = %redact_id(&channel_id), server_name = %acp_name, generation, + "ACP: a newer attach already holds this name — standing down without registering" + ); + // Same obligation as eviction: the client opened this connection for us and we are not + // going to use it, so it is owed an `mcp/disconnect`. Best-effort, off the attach path. + tokio::spawn(async move { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a superseded establish did not complete"); + } + }); + return Ok(()); + } }; if !replaced.is_empty() { info!( @@ -2631,6 +2689,7 @@ mod acp_requests { next_id, connection_id: "conn-9".into(), server_name: "browser".into(), + generation: 0, }; let pending2 = pending.clone(); @@ -3824,6 +3883,106 @@ mod acp_ws_integration { } } + /// A slow establish that STARTED first must not evict the newer tunnel that beat it to the + /// registry. + /// + /// The failing case is cross-connection by construction, which is why a per-connection + /// sequence number could not have fixed it: a reconnecting client arrives on a NEW socket and + /// mints a fresh `server_id`, so the two establishes racing for one declared name belong to + /// different connections. Here socket A declares `katashiro` and then stalls — its + /// `mcp/connect` is deliberately left unanswered — while socket B resumes the same session, + /// declares the same name, and completes. When A finally finishes it is the OLDER attach, and + /// before the generation stamp it would have evicted B and installed the stale tunnel over the + /// live one. + /// + /// Ordering is stamped at establish start rather than at registration for exactly this reason: + /// registration order is finish order, and finish order inverts whenever handshake durations + /// differ. + #[tokio::test] + async fn a_late_finishing_older_establish_does_not_evict_its_successor() { + let (url, registry) = serve().await; + + // Socket A: declare `katashiro` as srv-1, then STALL — do not answer its `mcp/connect`. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut a).await; + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + let mut session_id = None; + let mut a_connect_id = None; + while session_id.is_none() || a_connect_id.is_none() { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!(f["params"]["acpId"], json!("srv-1")); + a_connect_id = Some(f["id"].clone()); + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + let session_id = session_id.unwrap(); + + // Socket B: resume the SAME session — same channel — declaring the same name as srv-2, and + // carry it all the way to registered. This is the newer attach. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now let the OLDER establish finish. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + })).await; + loop { + let f = recv(&mut a).await; + if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { + break; + } + } + + // The newer tunnel must still be the registered one. Poll: we are proving the late arrival + // never displaces it, and it would do so asynchronously. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + ids, + vec!["srv-2".to_string()], + "the older establish finished last and displaced its successor — attach order was \ + taken from registration order instead of the generation stamp" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + /// A server that *succeeds* at `initialize` but answers a protocol version we do not speak /// must not be registered either. /// @@ -4355,6 +4514,7 @@ mod acp_teardown_ownership { connection_id: connection_id.into(), server_name: "katashiro".into(), owner: owner.into(), + generation: 0, } } From 130a1f7b8bf8f692e7d68379dbb1fc5737d9ff94 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 04:55:13 +0800 Subject: [PATCH 106/138] fix(acp): derive withdrawn declarations from the registry, and delete the record that duplicated it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session/resume` retired tunnels by comparing the new declarations against `AcpSession::acp_mcp_servers`. That map is built inside `handle_acp_connection` — per connection — so a reconnect arrives on a fresh socket with an empty map, the lookup returns None, and the withdrawn set was ALWAYS empty. The feature was dead in exactly the case it was written for: katashiro reconnects via `session/resume`, which is the reason the code path exists. Its test passed because it drove a same-connection resume, where the map is populated. The registry is process-global and keyed `(channel_id, server_id)`, so "registered under this channel but absent from what the client just declared" IS the withdrawn set. A resume re-presents the whole declaration set, which is what makes absence meaningful. Deriving it needs no second record and is correct across a reconnect. With the derivation moved, `acp_mcp_servers` had no readers left, so it goes — along with the parameters that fed it and the test that asserted the record existed. That field was the duplicate copy this defect lived in: two records of the same fact, one of them unreachable on the path that mattered. The fix removes state rather than adding it. Test count is unchanged at 329: one test added for the reconnect path, one removed because the record it asserted no longer exists. The new test reconnects on a second socket and withdraws everything. Verified by restoring the per-connection derivation in the source: the tunnel stays registered and the test says so in those words. --- .../openab-gateway/src/adapters/acp_server.rs | 200 ++++++++++-------- 1 file changed, 116 insertions(+), 84 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index daf50ddfd..5cd858e0a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -291,11 +291,6 @@ struct AcpSession { /// "cancelled"` to the prompt's own request id (rather than hard-aborting /// the task and orphaning that id). cancel: Option>, - /// Client-declared MCP-over-ACP servers (the RFD `{"type":"acp"}` mcpServers entries): - /// the browser extension serves its MCP tools over this same /acp WS. Recorded so the - /// gateway can later `mcp/connect` to them (T5.3). Read on resume to work out which - /// declarations the client withdrew. - acp_mcp_servers: Vec, } /// A client-declared MCP-over-ACP server (the RFD `"type":"acp"` `mcpServers` entry). Not in @@ -1292,7 +1287,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } }; let (resp, channel_id) = - handle_session_new(&sessions, id.clone(), acp_mcp_servers.clone()).await; + handle_session_new(&sessions, id.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); // If the client declared "type":"acp" MCP servers, open + register a tunnel to @@ -1338,33 +1333,42 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { continue; } }; - let (resp, resumed_channel, retired) = handle_session_resume( - &sessions, - id.clone(), - req.params.as_ref(), - resumed_servers.clone(), - ) - .await; + let (resp, resumed_channel) = + handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); - // Retire tunnels for declarations this resume withdrew. Scoped to the channel the - // resume actually established and to the ids it dropped, so it cannot touch a - // server the client still offers. The handles are taken out under the lock and - // disconnected after it is released — `disconnect` is async and this is a std - // mutex — and each is owed that disconnect because the client still believes the - // connection is open (same obligation as a same-name replacement, R7). - if let (Some(registry), Some(ref channel_id)) = + // Retire tunnels for declarations this resume withdrew. + // + // Derived from the REGISTRY, not from a remembered declaration set. The previous + // version compared against `sessions`, which is built per connection — so on the + // real reconnect path that map is empty, the lookup returns None, and the withdrawn + // set was ALWAYS empty. The feature was dead in exactly the case it was written + // for, and its test passed because it drove a same-connection resume. + // + // The registry is process-global and keyed `(channel_id, server_id)`, so + // "registered under this channel but absent from what the client just declared" IS + // the withdrawn set — no second record to keep in sync, and correct across a + // reconnect. A resume re-presents the client's WHOLE declaration set, which is what + // makes absence meaningful. + // + // Handles come out under the lock and are disconnected after it is released + // (`disconnect` is async, this is a std mutex). Each is owed that disconnect + // because the client still believes the connection is open — same obligation as a + // same-name replacement (R7). + if let (Some(registry), Some(channel_id)) = (state.acp_tunnel_registry.clone(), resumed_channel.as_ref()) { - if !retired.is_empty() { + { + let keep: std::collections::HashSet<&str> = + resumed_servers.iter().map(|s| s.id.as_str()).collect(); let dropped: Vec = { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - retired - .iter() - .filter_map(|srv| { - reg.remove(&(channel_id.to_string(), srv.id.clone())) - }) - .collect() + let stale: Vec<(String, String)> = reg + .keys() + .filter(|(c, id)| c == channel_id && !keep.contains(id.as_str())) + .cloned() + .collect(); + stale.iter().filter_map(|k| reg.remove(k)).collect() }; if !dropped.is_empty() { info!( @@ -1626,7 +1630,6 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { async fn handle_session_new( sessions: &Arc>>, id: Value, - acp_mcp_servers: Vec, ) -> (JsonRpcResponse, String) { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). @@ -1640,7 +1643,6 @@ async fn handle_session_new( channel_id: channel_id.clone(), busy: false, cancel: None, - acp_mcp_servers, }, ); @@ -1691,15 +1693,13 @@ async fn handle_session_resume( sessions: &Arc>>, id: Value, params: Option<&Value>, - accepted: Vec, -) -> (JsonRpcResponse, Option, Vec) { +) -> (JsonRpcResponse, Option) { let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) { Some(s) => s.to_string(), None => { return ( JsonRpcResponse::error(id, -32602, "Missing sessionId"), None, - Vec::new(), ) } }; @@ -1714,7 +1714,6 @@ async fn handle_session_resume( "Invalid sessionId: expected the form sess_", ), None, - Vec::new(), ); } }; @@ -1731,7 +1730,6 @@ async fn handle_session_resume( format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), ), None, - Vec::new(), ); } // R16-F2: refuse to resume a session that currently has a prompt in flight. The insert @@ -1747,31 +1745,14 @@ async fn handle_session_resume( "Session busy: a prompt is in progress; cannot resume", ), None, - Vec::new(), ); } - // A resume re-presents the client's WHOLE declaration set, so anything the session had that - // is absent now has been withdrawn and its tunnel must be retired — otherwise a server the - // client has stopped offering stays reachable for the rest of the connection. - let retired: Vec = guard - .get(&session_id) - .map(|prev| { - prev.acp_mcp_servers - .iter() - .filter(|old| !accepted.iter().any(|new| new.id == old.id)) - .cloned() - .collect() - }) - .unwrap_or_default(); guard.insert( session_id.clone(), AcpSession { channel_id: channel_id.clone(), busy: false, cancel: None, - // Store the ACCEPTED list — deduplicated and capped — not a fresh raw parse. Storing - // the raw one let the session record disagree with the tunnels actually opened. - acp_mcp_servers: accepted, }, ); drop(guard); @@ -1784,7 +1765,6 @@ async fn handle_session_resume( ( JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), Some(channel_id), - retired, ) } @@ -2939,7 +2919,7 @@ mod acp_handlers { async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); let v = - serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await.0).unwrap(); + serde_json::to_value(handle_session_new(&sessions, json!(2)).await.0).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); @@ -2967,18 +2947,6 @@ mod acp_handlers { assert!(parse_acp_mcp_servers(None).is_empty()); } - #[tokio::test] - async fn session_new_records_declared_acp_servers() { - let sessions = new_sessions(); - let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; - let v = serde_json::to_value( - handle_session_new(&sessions, json!(2), servers.clone()).await.0, - ) - .unwrap(); - let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); - let guard = sessions.lock().await; - assert_eq!(guard.get(&sid).unwrap().acp_mcp_servers, servers); - } #[tokio::test] async fn session_resume_valid_stores_and_invalid_errors() { @@ -2986,18 +2954,18 @@ mod acp_handlers { // valid sess_ → {} and the session is (re)stored let sid = format!("sess_{}", Uuid::new_v4()); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms), Vec::new()).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await.0) .unwrap(); assert_eq!(v["result"], json!({})); assert!(sessions.lock().await.contains_key(&sid)); // malformed sessionId shape → -32602 let bad = json!({"sessionId": "not-a-session", "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad), Vec::new()).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing sessionId → -32602 let v = serde_json::to_value( - handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"})), Vec::new()).await.0, + handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await.0, ) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); @@ -3028,7 +2996,7 @@ mod acp_review_fixes { for _ in 0..MAX_SESSIONS_PER_CONNECTION { let sid = format!("sess_{}", Uuid::new_v4()); let p = json!({ "sessionId": sid }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p), Vec::new()).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "resume under cap should succeed"); ids.push(sid); @@ -3036,13 +3004,13 @@ mod acp_review_fixes { assert_eq!(sessions.lock().await.len(), MAX_SESSIONS_PER_CONNECTION); // A new distinct session over the cap is refused with ACP_OVERLOADED. let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over), Vec::new()).await.0) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(ACP_OVERLOADED), "over-cap resume must be refused"); // Re-resuming an already-present session is exempt (idempotent). let existing = json!({ "sessionId": ids[0] }); let v = - serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing), Vec::new()).await.0) + serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } @@ -3194,14 +3162,14 @@ mod acp_review_fixes { let sessions = sessions_map(); // 1. missing sessionId - let (resp, chan, _) = - handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"})), Vec::new()).await; + let (resp, chan) = + handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"}))).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); assert!(chan.is_none(), "missing sessionId must not yield a channel"); // 2. malformed sessionId let bad = json!({"sessionId": "not-a-session", "cwd": "/w"}); - let (resp, chan, _) = handle_session_resume(&sessions, json!(2), Some(&bad), Vec::new()).await; + let (resp, chan) = handle_session_resume(&sessions, json!(2), Some(&bad)).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); assert!(chan.is_none(), "malformed sessionId must not yield a channel"); @@ -3209,11 +3177,11 @@ mod acp_review_fixes { // derive-from-params guard would have happily produced a channel here. for _ in 0..MAX_SESSIONS_PER_CONNECTION { let p = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let (_r, chan, _) = handle_session_resume(&sessions, json!(3), Some(&p), Vec::new()).await; + let (_r, chan) = handle_session_resume(&sessions, json!(3), Some(&p)).await; assert!(chan.is_some(), "resume under the cap should succeed"); } let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let (resp, chan, _) = handle_session_resume(&sessions, json!(4), Some(&over), Vec::new()).await; + let (resp, chan) = handle_session_resume(&sessions, json!(4), Some(&over)).await; assert_eq!( serde_json::to_value(resp).unwrap()["error"]["code"], json!(ACP_OVERLOADED) @@ -3228,11 +3196,10 @@ mod acp_review_fixes { channel_id: derive_channel_id(&busy_sid).unwrap(), busy: true, cancel: Some(Arc::new(tokio::sync::Notify::new())), - acp_mcp_servers: Vec::new(), }, ); - let (resp, chan, _) = - handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid})), Vec::new()).await; + let (resp, chan) = + handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid}))).await; assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32001)); assert!(chan.is_none(), "a busy-rejected resume must not yield a channel"); } @@ -3360,7 +3327,6 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), - acp_mcp_servers: Vec::new(), }, ); // Cancel arrives before the handler's stream loop (reserved-then-immediate-cancel). @@ -3404,12 +3370,11 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), - acp_mcp_servers: Vec::new(), }, ); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let (resp, resumed, _) = handle_session_resume(&sessions, json!(9), Some(¶ms), Vec::new()).await; + let (resp, resumed) = handle_session_resume(&sessions, json!(9), Some(¶ms)).await; let v = serde_json::to_value(resp).unwrap(); assert_eq!(v["error"]["code"], json!(-32001), "resume while busy must be rejected"); assert!( @@ -3442,7 +3407,7 @@ mod acp_review_fixes { let cancel = Arc::new(tokio::sync::Notify::new()); sessions.lock().await.insert( sid.clone(), - AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()), acp_mcp_servers: Vec::new() }, + AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()) }, ); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -3518,7 +3483,6 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), - acp_mcp_servers: Vec::new(), }, ); let params = json!({"sessionId": sid}); @@ -3883,6 +3847,74 @@ mod acp_ws_integration { } } + /// A resume on a NEW connection that stops declaring a server retires its tunnel. + /// + /// This is the path the withdrawal feature was written for and the one it could never take. + /// The old implementation compared the new declarations against `sessions`, which is built + /// inside `handle_acp_connection` — per connection. A reconnect arrives on a fresh socket with + /// an empty map, so the lookup returned None and the withdrawn set was always empty. The + /// existing coverage drove a same-connection resume, where the map IS populated, so it passed + /// while the feature did nothing on the only path that matters. + /// + /// Deriving the set from the registry — process-global, keyed `(channel_id, server_id)` — is + /// what makes this case work, and it is why the fix removes state rather than adding it. + #[tokio::test] + async fn a_reconnect_that_withdraws_a_declaration_retires_its_tunnel() { + let (url, registry) = serve().await; + + // Connection 1: declare and fully establish one server. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + // `handshake` already drives the whole inner lifecycle, including `notifications/ + // initialized`, so nothing further is owed on this socket before the tunnel is registered. + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Connection 2 — a genuine reconnect — resumes the same session declaring NOTHING. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": []} + })).await; + let resumed = recv(&mut b).await; + assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); + + // The tunnel must go. Bounded with its own message: before the fix the registry simply + // kept the entry, and a bare `wait_for_tunnels(0)` would report only a count. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if registry.lock().unwrap().is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "a reconnect withdrew every declaration and the tunnel stayed registered — the \ + withdrawn set was derived from per-connection session state, which a reconnect \ + never populates" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // And the original connection is owed its `mcp/disconnect`, on ITS socket. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect("a withdrawn tunnel was dropped without disconnecting its connection"); + assert_eq!(disconnected, "conn-1"); + } + /// A slow establish that STARTED first must not evict the newer tunnel that beat it to the /// registry. /// From 09e5c4ded94397eb1196d9a9b2a444ad01e3b67f Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 05:06:04 +0800 Subject: [PATCH 107/138] fix(acp): order same-server_id re-attach too, and stop discarding the handle it displaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to bbb53948, from Orca's review. Two defects and one retraction. The generation stamp only ordered establishes with DIFFERENT `server_id`s. The same-name predicate deliberately excludes the attaching key (`id != &acp_id`), so when a client reuses its `server_id` across a reconnect both establishes land on one registry key and that predicate never sees the newer entry: the older arrival neither stood down nor evicted, and `insert` put a stale handle straight over a live one. Ordering has to hold per key, not just per declared name, so the own key is now checked separately. Worse, `insert`'s return value was discarded, so the displaced handle left the registry with no cleanup path while its client still believed the connection was open — the same leak shape as an un-disconnected eviction, introduced by the commit that added the Superseded arm. Whatever the insert displaces now joins the disconnect path. Reusing a stable id is not a client bug. Nothing in the protocol requires a fresh one; "the client mints a new id per connection" is an assumption that holds for our own extension, and the deliverable is a generic compliant peer. Same shape as strict `protocolVersion` equality: an assumption about our extension applied to arbitrary peers. RETRACTION: bbb53948's message claimed it closed "an aborted task inserts a dead handle after cleanup" without a separate guard. That is wrong, and the reasoning was Orca's: a lower generation cannot win a slot only while a higher generation still HOLDS it. After teardown's `retain` the slot is empty, so a late insert simply succeeds and nothing will ever remove it. `abort()` does not help — there is no await point between `inner_mcp_handshake().await` and `reg.insert`. That guard is still needed and is not in this commit. Both new assertions were verified by mutation of the source: reverting the own-key check makes the reconnect test report that the older establish overwrote the newer live handle, and deleting the Superseded arm's disconnect spawn now fails the ordering test, which it did not before — that test asserted only "the right tunnel is registered", the exact gap its neighbour's comment warns about. --- .../openab-gateway/src/adapters/acp_server.rs | 146 +++++++++++++++++- 1 file changed, 140 insertions(+), 6 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 5cd858e0a..f9162a414 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -954,6 +954,7 @@ async fn establish_and_register_tunnel( // beside the live one under the same declared name, which is the ambiguity // last-attach-wins exists to prevent (ADR §6.1). Teardown is owner-scoped; eviction is // not, and they are different questions. + let own_key = (channel_id.clone(), acp_id.clone()); let same_name = |c: &String, id: &String, h: &TunnelHandle| { c == &channel_id && h.server_name == acp_name && id != &acp_id }; @@ -961,10 +962,20 @@ async fn establish_and_register_tunnel( // started EARLIER but finished later must not evict the newer tunnel that beat it here: // it lost the race the client actually cares about, so it stands down and closes its own // connection instead of installing a stale handle over a live one. - if reg - .iter() - .any(|((c, id), h)| same_name(c, id, h) && h.generation > generation) - { + // + // OUR OWN KEY is checked separately and must be, because `same_name` excludes it: a client + // is free to reuse the same `server_id` across a reconnect — nothing in the protocol + // requires a fresh one, and a stable id is the more natural implementation for a generic + // peer. When it does, both establishes land on this one key, `same_name` never sees the + // newer entry, and the older arrival would `insert` straight over a live handle. Ordering + // has to hold per key, not just per declared name. + let superseded = reg + .get(&own_key) + .is_some_and(|h| h.generation > generation) + || reg + .iter() + .any(|((c, id), h)| same_name(c, id, h) && h.generation > generation); + if superseded { Registered::Superseded(handle) } else { let stale: Vec<(String, String)> = reg @@ -972,8 +983,12 @@ async fn establish_and_register_tunnel( .filter(|((c, id), h)| same_name(c, id, h) && h.generation < generation) .map(|(k, _)| k.clone()) .collect(); - let replaced: Vec = stale.iter().filter_map(|k| reg.remove(k)).collect(); - reg.insert((channel_id.clone(), acp_id.clone()), handle); + let mut replaced: Vec = + stale.iter().filter_map(|k| reg.remove(k)).collect(); + // Whatever this insert displaces is owed a disconnect too. Discarding the return value + // leaked the previous holder of this exact key: it left the registry, so no cleanup + // path could reach it, while its client still believed the connection was open. + replaced.extend(reg.insert(own_key, handle)); Registered::Done(replaced) } }; @@ -4013,6 +4028,125 @@ mod acp_ws_integration { ); tokio::time::sleep(std::time::Duration::from_millis(25)).await; } + + // And the establish that stood down owes ITS client a disconnect. Asserting only "the + // right tunnel is registered" is what let the equivalent leak through on the refusal path: + // the losing connection never enters the registry, so no cleanup path can ever reach it. + // Without this, deleting the whole `tokio::spawn(disconnect)` in the `Superseded` arm + // leaves this test green. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "the superseded establish never disconnected the connection it opened — it is \ + not in the registry, so nothing else can close it", + ); + assert_eq!(disconnected, "conn-old"); + } + + /// A client that REUSES its `server_id` across a reconnect must still be ordered. + /// + /// The same-name predicate deliberately excludes the attaching key (`id != &acp_id`), so when + /// both establishes carry the same `server_id` they land on one registry key and that + /// predicate never sees the newer entry. The older arrival would then `insert` straight over a + /// live handle — and because the old `insert` return value was discarded, the displaced + /// connection left the registry with no cleanup path while its client still believed it was + /// open. Ordering has to hold per key, not just per declared name. + /// + /// Reusing a stable id is not a client bug. Nothing in the protocol requires a fresh one; the + /// "mints a new id per connection" assumption holds for our own extension, and the deliverable + /// here is a GENERIC compliant peer. + #[tokio::test] + async fn a_reconnect_reusing_the_same_server_id_is_still_ordered() { + let (url, registry) = serve().await; + + // Socket A declares srv-1 and stalls with its `mcp/connect` unanswered. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut a).await; + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let mut session_id = None; + let mut a_connect_id = None; + while session_id.is_none() || a_connect_id.is_none() { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + a_connect_id = Some(f["id"].clone()); + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + + // Socket B reconnects and re-declares THE SAME id, completing fully. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.unwrap(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now let the OLDER establish finish, on the same key. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + })).await; + loop { + let f = recv(&mut a).await; + if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { + break; + } + } + + // It must stand down and close its own connection. Before the fix it silently overwrote + // the live handle instead, and `conn-old` was never disconnected because it believed it + // had won — so this wait is what distinguishes the two. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "an older establish reusing the same server_id overwrote the newer live handle \ + instead of standing down — same-key ordering is not enforced", + ); + assert_eq!(disconnected, "conn-old"); + assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must remain"); } /// A server that *succeeds* at `initialize` but answers a protocol version we do not speak From 111966bf12eef8c5b3268eb2075a50c42c8641eb Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 05:20:53 +0800 Subject: [PATCH 108/138] feat(acp)!: make the tunnel timeout operator-configurable, and cancel on the peer when it expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F7(b). The tunnel request timeout was hard-coded at 30s in `browser_tunnel.rs`, which is not a policy anyone chose — a heavy page can run silent for minutes. It becomes `[mcp] tunnel_timeout_seconds`, default 180s, enforced server-side, following the shape openab already uses for `request_timeout_secs` and `[exec] timeout_seconds`. Server-side because on this tunnel openab is the requester and the peer is an extension it neither ships nor controls, so there is nobody else to bound it. 180s matches the ACP idle timeout it sits beneath. Raising only the tunnel's bound would move the wall rather than remove it: a turn that outlives the idle timeout is cut short there instead. On expiry the gateway now sends `mcp/cancel` naming the outer request id. Removing the pending entry only ended OUR wait — the extension kept running the request and kept holding whatever it held, a tab or a navigation or a script, with no way to learn that nobody was listening. That was stranded work on the peer for every single timeout. Documented in the contract, which had no cancellation or limits section at all: the `mcp/cancel` frame, the limits table, and the `session/resume` withdrawal semantics that 130a1f7b made real and that were wire-visible but unwritten. The limits were read out of the source rather than from memory, which corrected one of them — 8 MiB is the cap on inbound RESPONSE frames, while method-bearing frames stay at 1 MiB precisely so the screenshot allowance cannot be reused for prompt text. The facade doc's circuit-breaking claim is deleted rather than implemented, in prose and in the diagram: a breaker protects a connection pool and a single in-process tunnel has none. The redaction claim beside it is left alone because it is true (facade.rs:153). Follow-up, not fixed here: the 180s ACP idle timeout at acp_server.rs:1861 is still hard-coded. Anyone who raises the tunnel timeout without knowing about it will hit it next. BREAKING CHANGE: the tunnel request timeout is 180s instead of 30s by default. Deployments relying on a fast failure should set `[mcp] tunnel_timeout_seconds`. --- crates/openab-core/src/config.rs | 15 +++++ .../openab-gateway/src/adapters/acp_server.rs | 56 +++++++++++++++++++ docs/mcp-over-acp-tunnel-contract.md | 44 ++++++++++++++- docs/oab-mcp-facade.md | 6 +- src/browser_tunnel.rs | 13 ++++- src/main.rs | 10 +++- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 743b6e44a..1bd963119 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -85,6 +85,21 @@ pub struct McpFacadeConfig { /// client-side service without a code change. #[serde(default)] pub acp_servers: Vec, + /// How long a single request tunnelled to a client-declared `type:acp` server may run + /// before openab gives up on it, in seconds. + /// + /// Enforced server-side because on this tunnel OPENAB is the requester and the peer is a + /// browser extension we neither ship nor control, so there is nobody else to bound it. + /// The default matches the ACP idle timeout it sits beneath: raising only this one would + /// move the wall rather than remove it, since a turn that outlives the idle timeout is cut + /// short there instead. A heavy page can run silent for minutes, which is why the old + /// hard-coded 30s was too low to be a policy. + #[serde(default = "default_tunnel_timeout_seconds")] + pub tunnel_timeout_seconds: u64, +} + +fn default_tunnel_timeout_seconds() -> u64 { + 180 } /// One entry of the §6.4 operator allowlist. diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index f9162a414..cfb119875 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -625,6 +625,17 @@ async fn send_request( Ok(Err(_)) => Err("connection closed before response".into()), Err(_) => { pending.lock().await.remove(&id); + // Tell the peer we gave up. Dropping the pending entry only ends OUR wait: the + // extension is still running the request and still holding whatever it holds — a tab, + // a navigation, a script — with no way to learn that nobody is listening any more. + // Best-effort by construction: this is a notification, so there is no reply to await, + // and a peer that has already gone away simply never reads it. + let cancel = JsonRpcNotification { + jsonrpc: "2.0", + method: "mcp/cancel".to_string(), + params: json!({ "requestId": id }), + }; + let _ = out_tx.send(serde_json::to_string(&cancel).unwrap()); Err("request timed out".into()) } } @@ -4302,6 +4313,51 @@ mod acp_ws_integration { ); } + /// A tunnelled request that times out tells the peer, instead of just giving up quietly. + /// + /// Dropping the pending entry ends only OUR wait. The extension is still running the request + /// and still holding whatever it holds — a tab, a navigation, a script — and without a + /// cancellation it has no way to learn that nobody is listening. That is work and state + /// stranded on the peer for every timeout. + #[tokio::test] + async fn a_timed_out_tunnel_request_cancels_itself_on_the_peer() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().unwrap().clone() + }; + // One second, and deliberately never answered. + let call = tokio::spawn(async move { handle.mcp_message("tools/call", None, 1).await }); + + let request = recv(&mut ws).await; + let request_id = request["id"].clone(); + assert_eq!(request["method"], json!("mcp/message")); + + let cancel = tokio::time::timeout(std::time::Duration::from_secs(10), recv(&mut ws)) + .await + .expect( + "no `mcp/cancel` after the request timed out — the peer is still working on a \ + request nobody is waiting for", + ); + assert_eq!(cancel["method"], json!("mcp/cancel")); + assert_eq!( + cancel["params"]["requestId"], request_id, + "the cancellation must name the request it cancels, or the peer cannot tell which of \ + several in-flight requests to abandon" + ); + assert!( + cancel.get("id").is_none(), + "a cancellation is a notification: giving it an `id` would oblige a reply nobody reads" + ); + + let err = call.await.unwrap().expect_err("a timed-out call must not read as success"); + assert!(err.contains("timed out"), "got: {err}"); + } + /// A prompt must not be refused because tunnels are still establishing. /// /// The two used to share `prompt_tasks` and `MAX_INFLIGHT_PROMPTS`, so a client with enough diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index ff257d5be..bd1bc809f 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -127,7 +127,49 @@ or an image content block for `screenshot`). On failure return an MCP tool error extension MAY expose additional tools beyond this baseline; they surface to the agent via `tools/list` + a `tools/list_changed` notification. -## 7. Permissions +## 7. Cancellation and limits + +The gateway bounds how long it will wait for any tunnelled request. When that bound is reached it +stops waiting **and tells you**, so the extension is never left working on a request nobody reads. + +### `mcp/cancel` (gateway → extension, notification) + +```json +{ "jsonrpc": "2.0", "method": "mcp/cancel", "params": { "requestId": 42 } } +``` + +`requestId` is the **outer ACP frame `id`** of the request being abandoned — the same id you would +have replied to. There is no `id` on this frame: it is a notification, so no reply is owed and none +is read. + +On receipt, stop the work and release whatever it holds (the tab, the navigation, the script). A +late reply to a cancelled `requestId` is discarded, so answering costs the gateway nothing and buys +you nothing. Cancellation is best-effort in one direction only: if the socket is already gone the +notification is simply never delivered, so do not treat its absence as "keep going". + +### Limits + +| Limit | Value | Meaning | +|---|---|---| +| Tunnel request timeout | `[mcp] tunnel_timeout_seconds`, default **180s** | one `mcp/message` request | +| Connect / handshake timeout | 30s | `mcp/connect` and the `initialize` that follows it | +| Servers per session | 8 (`MAX_ACP_SERVERS_PER_SESSION`) | `type:acp` entries accepted per `session/new` | +| In-flight establishes | 64 (`MAX_INFLIGHT_ESTABLISHES`) | concurrent tunnel setups per connection | +| Inbound response frame | 8 MiB (`MAX_FRAME_BYTES`) | a reply to `mcp/message` — `id`, no `method`; sized for screenshots | +| Inbound method-bearing frame | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | anything carrying a `method`; the 8 MiB allowance is deliberately not reusable for these | + +The request timeout is operator-configurable because openab is the requester here and the peer is +an extension it neither ships nor controls. It sits beneath the ACP idle timeout, so raising it +past that bound moves the wall rather than removing it. + +### `session/resume` withdraws what it does not re-declare + +A resume re-presents the client's **whole** declaration set. Any `type:acp` server registered for +that session and absent from the new set is treated as withdrawn: its tunnel is retired and its +connection receives an `mcp/disconnect`. Re-declare every server you still want, on every resume — +including across a reconnect. + +## 8. Permissions OpenAB core auto-approves tool permissions today (ADR D1); the extension does **not** need a per-call consent UX yet. Fine-grained consent is a later addition to this contract. diff --git a/docs/oab-mcp-facade.md b/docs/oab-mcp-facade.md index c5334e14c..8d5ca6c5f 100644 --- a/docs/oab-mcp-facade.md +++ b/docs/oab-mcp-facade.md @@ -21,7 +21,7 @@ flowchart LR end subgraph runtime["Shared MCP runtime (openab-mcp)"] - policy["tool_filter (least-privilege)
JSON-Schema arg validation
timeouts · circuit breaker
secret redaction · audit log"] + policy["tool_filter (least-privilege)
JSON-Schema arg validation
timeouts
secret redaction · audit log"] end adapter["gmail-native adapter
loopback :8850/mcp"] @@ -81,8 +81,8 @@ connections stay in `mcp.json` — `[mcp]` carries listener settings only by each server's `tool_filter` **before** anything reaches the agent. 2. `execute_capability` (`name` + `arguments`) — execute an exact capability returned by discovery. Arguments are validated against the capability's - JSON Schema before dispatch; timeouts, circuit breaking, and secret - redaction apply on the same path the native agent uses. + JSON Schema before dispatch; timeouts and secret redaction apply on the + same path the native agent uses. An audit line is logged for every dispatch (tool name + args SHA-256 — never plaintext arguments): diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index 2445a641c..e2ddc8f26 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -11,11 +11,18 @@ use serde_json::Value; pub struct RootBrowserTunnel { registry: AcpTunnelRegistry, + /// Operator-configurable ceiling for one tunnelled request (`[mcp] tunnel_timeout_seconds`). + /// Carried here rather than read per call so the value a session runs under is fixed when the + /// tunnel is wired, not re-resolved mid-flight. + timeout_secs: u64, } impl RootBrowserTunnel { - pub fn new(registry: AcpTunnelRegistry) -> Self { - Self { registry } + pub fn new(registry: AcpTunnelRegistry, timeout_secs: u64) -> Self { + Self { + registry, + timeout_secs, + } } } @@ -51,7 +58,7 @@ impl AcpMcpTunnel for RootBrowserTunnel { } }; match handle { - Some(h) => h.mcp_message(method, params, 30).await, + Some(h) => h.mcp_message(method, params, self.timeout_secs).await, None => Err(format!("no browser attached to session {channel_id}")), } } diff --git a/src/main.rs b/src/main.rs index c5ba10ea2..568a27779 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,7 +462,15 @@ async fn main() -> anyhow::Result<()> { let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); #[cfg(feature = "acp")] let browser_tunnel: Arc = Arc::new( - browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + browser_tunnel::RootBrowserTunnel::new( + acp_tunnel_registry.clone(), + // Browser control requires `[mcp]`, so the absent case is unreachable in practice; + // fall back to the same default rather than to the old 30s, which was the defect. + cfg.mcp + .as_ref() + .map(|m| m.tunnel_timeout_seconds) + .unwrap_or(180), + ), ); // OAB MCP Facade (`[mcp]` in config.toml — OAB MCP Adapter ADR §6.2): From b5f317bae51443a7e315bb28a3634a9b50d17e16 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 05:32:05 +0800 Subject: [PATCH 109/138] fix(acp): give each atomic write its own temp file, fsync the directory, and stop the concurrency test passing for the wrong reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four item-9 defects, fixed where they survive the pending redesign. The redesign (stop writing the user's MCP config at all) removes the merge path and with it the EACCES-as-absent overwrite and the mode/owner/symlink damage. These three do not depend on it, so they land now. The temp file is now unique per WRITE. A fixed name made concurrent writers share one temp file: both opened it with `truncate`, both wrote, and whichever renamed first left the other renaming a path that no longer existed. A pid alone would not have fixed it — the racing writers are two tasks in the same process — so a counter is what makes each attempt distinct. `sync_all` made the CONTENTS durable but the rename that publishes them is a directory operation it does not cover, so the directory is fsynced after the rename. "fsynced, then renamed" was not the same as "the rename survived". The concurrency test passed two writers the SAME url, so their outputs were byte-identical and a lost update was unobservable by construction — and since both merge from the same base, even a real one left both servers present. It now passes different urls, so the winner is identifiable, and asserts what is actually on offer: the published file is complete, valid, exactly one writer's, and still contains the user's own server. Reported rather than hidden: with unique temp names, removing the write lock no longer fails this test. Before, it failed with ENOENT from the filename collision — a collision reported as if it were a lost update, which is why the test looked like it proved the lock. It never did. With atomic rename and a static payload every writer publishes a complete file, so no observable damage occurs without the lock. The lock is kept because read-modify-write ordering is still the correct shape, but this test does not demonstrate it is load-bearing and should not be read as if it does. --- crates/openab-core/src/mcp_proxy.rs | 60 ++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 51d1a238b..da73e095c 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -174,9 +174,22 @@ async fn load_mergeable_config(path: &std::path::Path) -> Option { async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Result<()> { let bytes = serde_json::to_vec_pretty(value)?; let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + // Unique per WRITE, not per process. A fixed name made concurrent writers share one temp file: + // both opened it with `truncate`, both wrote, and whichever renamed first left the other + // renaming a path that no longer existed. A pid alone does not fix that — the racing writers + // are usually two tasks in the SAME process — so the counter is what makes each attempt + // distinct, and the pid keeps a respawn that overlaps its predecessor off the same paths. + // + // This separates two concerns that were tangled: the lock orders read-modify-write so a writer + // cannot publish over a state it never read, and the unique temp name stops writers colliding + // on the intermediate file. Previously the lock was doing both, which is why removing it + // failed with ENOENT — a filename collision reported as if it were a lost update. + static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let tmp = dir.join(format!( - ".{}.openab-tmp", - path.file_name().and_then(|n| n.to_str()).unwrap_or("mcp.json") + ".{}.openab-tmp.{}.{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("mcp.json"), + std::process::id(), + TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); { let mut opts = tokio::fs::OpenOptions::new(); @@ -195,7 +208,16 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re f.sync_all().await?; } match tokio::fs::rename(&tmp, path).await { - Ok(()) => Ok(()), + Ok(()) => { + // `sync_all` above made the CONTENTS durable; the rename that publishes them is a + // directory operation and is not covered by it. Without this a crash can leave the + // directory entry unwritten while the data it points at is safely on disk — "fsynced, + // then renamed" is not the same as "the rename survived". + if let Ok(d) = tokio::fs::File::open(dir).await { + let _ = d.sync_all().await; + } + Ok(()) + } Err(e) => { let _ = tokio::fs::remove_file(&tmp).await; Err(e) @@ -523,25 +545,45 @@ mod facade_config_writer { /// file replaces the winner's, silently. Both calls write the same entry, so what this pins is /// that the user's pre-existing server survives both — a lost update would drop it. #[tokio::test] - async fn concurrent_writers_do_not_lose_the_users_config() { + async fn concurrent_writers_never_publish_a_damaged_config() { let wd = tmp_dir("concurrent").await; let path = wd.join(".cursor").join("mcp.json"); tokio::fs::write(&path, r#"{"mcpServers":{"mine":{"command":"x"}}}"#) .await .unwrap(); + // DIFFERENT urls on purpose. The previous version passed the same url to both writers, so + // their outputs were byte-identical and a lost update was unobservable by construction — + // and because both merge from the same base, even a real lost update left `mine` and + // `openab` both present. The assertions could not fail. Removing the lock made it red for + // an unrelated reason: the two writers shared one fixed temp filename, so one renamed it + // away and the other hit ENOENT. It was reporting a filename collision as a lost update. + // + // With distinct urls the winner is identifiable, so this pins the guarantee that is really + // on offer: whichever writer lands last, the published file is complete and valid — never + // a merge of the two, never half-written, and never missing the user's own server. let w = wd.to_str().unwrap().to_string(); let (a, b) = tokio::join!( write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), - write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), + write_facade_mcp_config(&w, "http://127.0.0.1:9999/mcp"), ); a.unwrap(); b.unwrap(); - let v: Value = - serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); - assert_eq!(v["mcpServers"]["mine"]["command"], json!("x"), "user server survived both"); - assert!(v["mcpServers"]["openab"].is_object(), "ours is present"); + let v: Value = serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()) + .expect("a concurrent write published a file that is not valid JSON"); + assert_eq!( + v["mcpServers"]["mine"]["command"], + json!("x"), + "the user's own server must survive both writers" + ); + let url = v["mcpServers"]["openab"]["url"] + .as_str() + .expect("our entry must be present and complete"); + assert!( + url == "http://127.0.0.1:8848/mcp" || url == "http://127.0.0.1:9999/mcp", + "the published entry must be exactly one writer's, not a blend of both: {url}" + ); let _ = tokio::fs::remove_dir_all(&wd).await; } From 01607bd9c9d71f57173fd19c9eb9d7f13deeab94 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 05:42:24 +0800 Subject: [PATCH 110/138] fix(acp): an omitted mcpServers withdraws nothing, and pin the withdrawn set to what is declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Orca's review of 130a1f7b. That commit made withdrawal work, and in doing so made a pre-existing conflation destructive: `parse_acp_mcp_servers` returns an empty vec both when `mcpServers` is an empty array and when the field is absent entirely. While the withdrawn set was always empty this cost nothing. Deriving it from the registry turned "the client omitted an optional field" into "the client withdrew everything", so a compliant client that simply left it out would have every tunnel on its channel torn down. Absent and empty are different statements: omitting the field says nothing about the client's servers, an explicit `[]` says it now offers none. Only the latter withdraws. Absence was already the fail-closed reading elsewhere — a missing `protocolVersion` refuses the establish — so it should not be the most damaging reading here. Both existing withdrawal tests survive a mutation that sweeps the whole channel on every resume, which is how this got through: one declares `[]`, so its `keep` is empty either way, and the other re-declares under a NEW id, so the tunnel a sweep wrongly removes was going to be evicted by last-attach-wins anyway and the end state matches. Only re-declaring the SAME id makes the difference observable, so that test is added and it is what pins the withdrawn set to "registered minus declared". Verified: under the sweep-everything mutation the two older tests still pass and only the new one fails. Also corrects the eviction log. `replaced` can now include a same-key replacement whose declared name differs, so "evicted stale same-name tunnel(s)" would misdirect anyone reading the log during an incident. This is the third instance of one shape — strict protocolVersion equality, the fresh-id assumption, and now this — where behaviour correct for our own extension was applied to any compliant peer. Orca has asked for a general rule rather than three separate fixes; that is an operator decision and is not taken here. --- .../openab-gateway/src/adapters/acp_server.rs | 108 +++++++++++++++++- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index cfb119875..fd15de2aa 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -1022,8 +1022,8 @@ async fn establish_and_register_tunnel( }; if !replaced.is_empty() { info!( - channel = %redact_id(&channel_id), server_name = %acp_name, evicted = replaced.len(), - "ACP: last-attach-wins — evicted stale same-name tunnel(s)" + channel = %redact_id(&channel_id), server_name = %acp_name, replaced = replaced.len(), + "ACP: last-attach-wins — replaced stale tunnel(s)" ); // Best-effort and off the attach path: a replaced connection may already be dead, and // waiting on its response would stall the tunnel that just came up for no benefit. @@ -1381,10 +1381,25 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // (`disconnect` is async, this is a std mutex). Each is owed that disconnect // because the client still believes the connection is open — same obligation as a // same-name replacement (R7). + // + // ABSENT and EMPTY are not the same thing. `mcpServers` omitted entirely means the + // client said nothing about its servers, so nothing is withdrawn; an explicit `[]` + // is a client saying it now offers none, which withdraws everything. Treating the + // two alike was harmless while the withdrawn set was always empty — deriving it + // from the registry made it destructive, so a compliant client that simply left + // the optional field out would have every tunnel on its channel torn down. + // `session/new` has the same reading of absence, and D-06 treats a missing + // `protocolVersion` as fail-closed; absence should not be the most damaging + // interpretation here while it is the safest one there. + let declared_servers = req + .params + .as_ref() + .and_then(|p| p.get("mcpServers")) + .is_some(); if let (Some(registry), Some(channel_id)) = (state.acp_tunnel_registry.clone(), resumed_channel.as_ref()) { - { + if declared_servers { let keep: std::collections::HashSet<&str> = resumed_servers.iter().map(|s| s.id.as_str()).collect(); let dropped: Vec = { @@ -3873,6 +3888,93 @@ mod acp_ws_integration { } } + /// A resume that OMITS `mcpServers` withdraws nothing. + /// + /// Absent and empty are different statements. Omitting the optional field says nothing about + /// the client's servers; an explicit `[]` says it now offers none. Conflating them was + /// harmless while the withdrawn set was always empty, but deriving that set from the registry + /// made absence the most destructive reading available — a compliant client that simply left + /// the field out would have every tunnel on its channel torn down. Elsewhere absence is read + /// fail-closed (a missing `protocolVersion` refuses the establish); it should not be the most + /// damaging reading here. + #[tokio::test] + async fn a_resume_that_omits_mcp_servers_withdraws_nothing() { + let (url, registry) = serve().await; + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + // No `mcpServers` key at all. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w"} + })).await; + let resumed = recv(&mut b).await; + assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); + + // The tunnel must stay. Poll, because a wrongful teardown is asynchronous. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert_eq!( + registry.lock().unwrap().len(), + 1, + "a resume that never mentioned mcpServers tore down the session's tunnels — \ + absence was read as 'the client withdrew everything'" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// A resume that RE-DECLARES an already-registered id must leave that tunnel completely alone. + /// + /// This is what pins the withdrawn set to "registered MINUS declared". Both neighbouring + /// withdrawal tests survive a mutation that simply sweeps the whole channel on every resume: + /// one declares `[]` so its `keep` is empty either way, and the other re-declares under a NEW + /// id, so the tunnel a sweep would wrongly remove was going to be evicted by last-attach-wins + /// anyway and the end state matches. Only re-declaring the SAME id makes the difference + /// observable — the tunnel must not be disconnected and rebuilt, which for a client with + /// stable ids would mean a spurious `mcp/disconnect` and a gap on every single resume. + #[tokio::test] + async fn a_resume_redeclaring_the_same_id_leaves_its_tunnel_untouched() { + let (url, registry) = serve().await; + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Same connection, same id, re-declared. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Nothing may be disconnected. A sweep-everything implementation retires conn-1 here and + // then re-establishes it, which this catches; the correct implementation sends no + // `mcp/disconnect` at all, so a short quiet window is the assertion. + let quiet = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + } + } + }) + .await; + assert!( + quiet.is_err(), + "a resume that re-declared the SAME id disconnected its tunnel ({:?}) — the withdrawn \ + set is not 'registered minus declared', it is 'everything'", + quiet.ok() + ); + assert_eq!(registry.lock().unwrap().len(), 1, "the tunnel must still be registered"); + } + /// A resume on a NEW connection that stops declaring a server retires its tunnel. /// /// This is the path the withdrawal feature was written for and the one it could never take. From 0b4e4ed4640024acd1220195d8c43f6a8cb0fd56 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 05:51:01 +0800 Subject: [PATCH 111/138] fix(acp): put the tunnel timeout strictly beneath the idle timeout it was level with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Orca's review of 111966bf. The default was 180s and the ACP per-chunk idle timeout is also 180s, so "sits beneath" was false — they were level, and at the boundary which fires first is undecidable. That defeats the purpose of the commit that set it: only when the tunnel's own timeout wins does the peer receive `mcp/cancel` and the caller see a timeout. If the idle timeout wins, the turn just ends and the extension is left holding exactly the stranded work cancellation exists to prevent. Default is now 170s. The margin is the mechanism, not a rounding choice. Also from that review: - `default_tunnel_timeout_seconds` is public and `main.rs` calls it instead of repeating `180`. Two records of one fact, and the copy in the binary is the one that would have silently kept the old number — the same duplicate-record shape removed in 130a1f7b. - The directory fsync no longer swallows its error. A function whose entire purpose is durability had a silent failure mode for the step that provides it. Logged at debug because Windows cannot open a directory as a file at all, so failure there is expected. - A reply arriving after its request was abandoned is logged at debug, not warn. Now that the tunnel cancels on expiry, a late reply is what a correct peer does when the cancellation and the reply cross; warning about it made correct behaviour look like a defect. - The contract's limits table records the two frame caps' failure modes, which matter more than the numbers: oversize a response and the socket closes, oversize a request and you get an error back and may continue. Corrects the previous message's citation: the idle timeout is at acp_server.rs:1987; :1861 is `derive_channel_id` and has nothing to do with it. Not resolved here, and worth a reader's attention: Orca asks whether the in-flight tool call future is dropped when the surrounding turn ends, because if it is, `send_request`'s timeout branch never runs and `mcp/cancel` is never sent at all. The call is issued from the facade's request path rather than from the prompt loop, so the two are not obviously the same task tree, and I could not settle it by reading. It needs an execution test, not an argument. --- crates/openab-core/src/config.rs | 18 ++++++++++++------ crates/openab-core/src/mcp_proxy.rs | 16 ++++++++++++++-- .../openab-gateway/src/adapters/acp_server.rs | 5 ++++- docs/mcp-over-acp-tunnel-contract.md | 7 +++++-- src/main.rs | 4 ++-- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 1bd963119..67fcbc862 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -90,16 +90,22 @@ pub struct McpFacadeConfig { /// /// Enforced server-side because on this tunnel OPENAB is the requester and the peer is a /// browser extension we neither ship nor control, so there is nobody else to bound it. - /// The default matches the ACP idle timeout it sits beneath: raising only this one would - /// move the wall rather than remove it, since a turn that outlives the idle timeout is cut - /// short there instead. A heavy page can run silent for minutes, which is why the old - /// hard-coded 30s was too low to be a policy. + /// The default sits STRICTLY beneath the ACP per-chunk idle timeout (180s, hard-coded at + /// `acp_server.rs`), and the margin is the point. Setting the two equal makes which one fires + /// first undecidable at the boundary, and they do different things: only when this one wins + /// does the peer receive `mcp/cancel` and the caller see a timeout error. If the idle timeout + /// wins the turn simply ends, which leaves exactly the stranded work on the extension that + /// cancellation exists to prevent. Raising this past the idle timeout moves the wall rather + /// than removing it — raise both, in that order. #[serde(default = "default_tunnel_timeout_seconds")] pub tunnel_timeout_seconds: u64, } -fn default_tunnel_timeout_seconds() -> u64 { - 180 +/// Public so the binary can use the same value instead of repeating the literal. A private +/// default plus an `unwrap_or(180)` at the call site is two records of one fact, and the one in +/// the binary would silently keep the old number the day this changes. +pub fn default_tunnel_timeout_seconds() -> u64 { + 170 } /// One entry of the §6.4 operator allowlist. diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index da73e095c..9804b54f5 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -213,8 +213,20 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re // directory operation and is not covered by it. Without this a crash can leave the // directory entry unwritten while the data it points at is safely on disk — "fsynced, // then renamed" is not the same as "the rename survived". - if let Ok(d) = tokio::fs::File::open(dir).await { - let _ = d.sync_all().await; + // Report rather than swallow: this function's whole purpose is durability, so a + // silent failure of the step that provides it is the worst shape available. Not fatal + // — the data is written and visible, only the rename's survival across a crash is + // unproven. On Windows a directory cannot be opened as a file at all, so this is + // expected to fail there and is logged at debug rather than warn for that reason. + match tokio::fs::File::open(dir).await { + Ok(d) => { + if let Err(e) = d.sync_all().await { + tracing::debug!(dir = %dir.display(), error = %e, + "MCP config: directory fsync failed — the rename may not survive a crash"); + } + } + Err(e) => tracing::debug!(dir = %dir.display(), error = %e, + "MCP config: could not open the directory to fsync it (expected on Windows)"), } Ok(()) } diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index fd15de2aa..913c09e0e 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -589,7 +589,10 @@ async fn route_client_response( if let Some(tx) = pending.lock().await.remove(&id) { let _ = tx.send(raw.clone()); } else { - warn!(id, "acp: client response with no matching pending request"); + // `debug!`, not `warn!`: since the tunnel cancels on expiry, a reply arriving after we + // gave up is EXPECTED traffic from a well-behaved peer, not a client defect. Logging it at + // warn made a correct peer look broken. + debug!(id, "acp: client response arrived after its request was abandoned; discarding"); } true } diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index bd1bc809f..729fbff2d 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -155,8 +155,11 @@ notification is simply never delivered, so do not treat its absence as "keep goi | Connect / handshake timeout | 30s | `mcp/connect` and the `initialize` that follows it | | Servers per session | 8 (`MAX_ACP_SERVERS_PER_SESSION`) | `type:acp` entries accepted per `session/new` | | In-flight establishes | 64 (`MAX_INFLIGHT_ESTABLISHES`) | concurrent tunnel setups per connection | -| Inbound response frame | 8 MiB (`MAX_FRAME_BYTES`) | a reply to `mcp/message` — `id`, no `method`; sized for screenshots | -| Inbound method-bearing frame | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | anything carrying a `method`; the 8 MiB allowance is deliberately not reusable for these | +| Any inbound frame | 8 MiB (`MAX_FRAME_BYTES`) | checked before parsing. **Exceeding it closes the connection** — an unparseable frame has no recoverable `id` to answer | +| Inbound frame carrying a `method` | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | checked after parsing. **Answered with an error, connection kept.** The 8 MiB allowance exists for tool results, which arrive as responses (`id`, no `method`); capping method-bearing frames lower stops that allowance being reused to hold prompt text | + +The two differ in failure mode, which matters more than the numbers: oversize a response and the +socket closes under you, oversize a request and you get an error back and may continue. The request timeout is operator-configurable because openab is the requester here and the peer is an extension it neither ships nor controls. It sits beneath the ACP idle timeout, so raising it diff --git a/src/main.rs b/src/main.rs index 568a27779..981d4605e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -465,11 +465,11 @@ async fn main() -> anyhow::Result<()> { browser_tunnel::RootBrowserTunnel::new( acp_tunnel_registry.clone(), // Browser control requires `[mcp]`, so the absent case is unreachable in practice; - // fall back to the same default rather than to the old 30s, which was the defect. + // fall back through the SAME function serde uses rather than repeating the literal. cfg.mcp .as_ref() .map(|m| m.tunnel_timeout_seconds) - .unwrap_or(180), + .unwrap_or_else(openab_core::config::default_tunnel_timeout_seconds), ), ); From 45e5ab141483c4314fe93fcb675c932e42807a23 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 06:02:13 +0800 Subject: [PATCH 112/138] fix(acp): authorise the resume sweep by connection age, not by when the resume ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last of the withdrawal races. The withdrawn set is "registered under this channel, minus what the client just declared", which is only sound if the declaration is current. An older connection whose resume is processed late carries an out-of-date view: its silence about a server a newer connection established is not a withdrawal, it never knew about that server at all. Before this, that silence retired a live tunnel belonging to someone else. Connections are stamped from the same counter that orders attaches, once at accept, and a sweep may only retire tunnels installed by a connection no newer than its own. The design all three of us first proposed — stamp a generation when the RESUME is processed and retire only lower ones — does not work, and the test proves it rather than arguing it. It measures the wrong thing: the late resume takes the HIGHER number precisely because it ran last, so it still outranks the newer connection it must not touch. Both reviewers endorsed that shape and so did I; substituting it for the real fix leaves the new test failing with exactly the same message as having no guard at all. That is the second time this round a conclusion three of us agreed on turned out to be wrong, and the same thing settled it both times: following the actual path instead of reasoning about it. --- .../openab-gateway/src/adapters/acp_server.rs | 112 +++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 913c09e0e..4102db15a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -749,6 +749,15 @@ pub struct TunnelHandle { /// connections by `session/resume`. Teardown therefore matches on this owner rather than on /// the key, so a late cleanup cannot remove a handle installed by a different connection. owner: String, + /// Age of the CONNECTION that installed this tunnel, from [`TUNNEL_GENERATION`], stamped + /// when that connection was accepted. + /// + /// A resume sweep is authorised by connection age, not by when the resume happened to be + /// processed: an older connection's late resume must not retire a tunnel installed by a newer + /// connection, and "which resume ran first" cannot express that. Stamping the resume itself + /// measures the wrong thing — the late resume takes the HIGHER number precisely because it ran + /// last, so it would still outrank the newer connection it must not touch. + connection_generation: u64, /// Attach ordering from [`TUNNEL_GENERATION`], stamped when this establish STARTED. /// /// Eviction compares generations instead of trusting arrival order, so a slow establish that @@ -889,6 +898,7 @@ async fn establish_and_register_tunnel( registry: AcpTunnelRegistry, timeout_secs: u64, owner: String, + connection_generation: u64, ) -> Result<(), String> { // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. @@ -942,6 +952,7 @@ async fn establish_and_register_tunnel( server_name: acp_name.clone(), owner: owner.clone(), generation, + connection_generation, }; // `Superseded` carries the handle back out of the lock: a losing establish still owes its // client an `mcp/disconnect`, and `disconnect` is async while this is a std mutex. @@ -1060,6 +1071,7 @@ fn spawn_acp_tunnels( next_id: &Arc, establish_tasks: &mut Vec>, owner: &str, + connection_generation: u64, ) { // Drop finished handles first so a long-lived connection does not accumulate them, then bound // what is still running. Over the cap the declaration is dropped with a warning rather than @@ -1082,6 +1094,7 @@ fn spawn_acp_tunnels( establish_tasks.push(tokio::spawn(async move { if let Err(e) = establish_and_register_tunnel( out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, + connection_generation, ) .await { @@ -1094,6 +1107,10 @@ fn spawn_acp_tunnels( async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); + // Age of this connection, from the same counter that orders attaches. A resume's authority to + // retire someone else's tunnel is decided by which CONNECTION is newer, so it has to be stamped + // here — once, at accept — rather than per request. + let connection_generation = TUNNEL_GENERATION.fetch_add(1, Ordering::Relaxed); info!(connection = %connection_id, "ACP client connected"); @@ -1333,6 +1350,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &next_req_id, &mut establish_tasks, &connection_id, + connection_generation, ); } } @@ -1408,9 +1426,18 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let dropped: Vec = { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); let stale: Vec<(String, String)> = reg - .keys() - .filter(|(c, id)| c == channel_id && !keep.contains(id.as_str())) - .cloned() + .iter() + .filter(|((c, id), h)| { + c == channel_id + && !keep.contains(id.as_str()) + // Never retire a tunnel a NEWER connection installed. An + // older connection's late resume carries an out-of-date + // declaration set, so its silence about a newer + // connection's server is not a withdrawal — it simply + // never knew about it. + && h.connection_generation <= connection_generation + }) + .map(|(k, _)| k.clone()) .collect(); stale.iter().filter_map(|k| reg.remove(k)).collect() }; @@ -1452,6 +1479,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &next_req_id, &mut establish_tasks, &connection_id, + connection_generation, ); } } @@ -2714,6 +2742,7 @@ mod acp_requests { connection_id: "conn-9".into(), server_name: "browser".into(), generation: 0, + connection_generation: 0, }; let pending2 = pending.clone(); @@ -2762,6 +2791,7 @@ mod acp_requests { registry.clone(), 5, "conn-test".into(), + 0, ) .await .unwrap(); @@ -2806,6 +2836,7 @@ mod acp_requests { registry.clone(), 5, "conn-test".into(), + 0, ) .await .unwrap(); @@ -2870,6 +2901,7 @@ mod acp_requests { registry.clone(), 5, "conn-test".into(), + 0, ) .await .unwrap(); @@ -3137,6 +3169,7 @@ mod acp_review_fixes { &next_id, &mut tasks, "conn-test", + 0, ); assert_eq!(tasks.len(), 2, "one task per unique declaration"); @@ -3891,6 +3924,78 @@ mod acp_ws_integration { } } + /// An older connection's late resume must not retire a NEWER connection's tunnel. + /// + /// The withdrawn set is "registered under this channel, minus what the client just declared". + /// That is only sound if the declaration is current. An older connection whose resume is + /// processed late carries an out-of-date view: its silence about a server a newer connection + /// established is not a withdrawal, it simply never knew about it. + /// + /// Authorising the sweep by connection age is what expresses that. Stamping the RESUME instead + /// measures the wrong thing and cannot fix this case — the late resume takes the higher number + /// precisely because it ran last, so it would still outrank the newer connection it must not + /// touch. Both reviewers and I initially proposed exactly that, and it fails here. + #[tokio::test] + async fn an_older_connections_late_resume_does_not_retire_a_newer_connections_tunnel() { + let (url, registry) = serve().await; + + // Connection B (older) establishes srv-2 and stays open. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut b, "srv-2", "katashiro", "conn-b").await; + wait_for_tunnels(®istry, 1).await; + + // Connection C (newer) resumes the same session and adds srv-3 under a different name. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}, + {"type": "acp", "id": "srv-3", "name": "notes"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") + && f["params"]["acpId"] == json!("srv-3") + { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 2).await; + + // Now the OLDER connection resumes, still declaring only what it knew about. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + + // srv-3 belongs to a newer connection and must survive. Poll: a wrongful retire is async. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: std::collections::HashSet = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert!( + ids.contains("srv-3"), + "an older connection's late resume retired a tunnel a NEWER connection had \ + established — the sweep was authorised by resume order instead of connection age, \ + leaving: {ids:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + /// A resume that OMITS `mcpServers` withdraws nothing. /// /// Absent and empty are different statements. Omitting the optional field says nothing about @@ -4842,6 +4947,7 @@ mod acp_teardown_ownership { server_name: "katashiro".into(), owner: owner.into(), generation: 0, + connection_generation: 0, } } From 17e16ec863707a9a6118c1f4e05b3354b504e666 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 07:06:13 +0800 Subject: [PATCH 113/138] fix(acp): order every tunnel comparison by connection age, and close the teardown insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ordering defects and the review fixes from 0b4e4ed4. 45e5ab14 stamped connection age and used it in one place. The establish side still ordered by attach number alone, and that number is taken when an establish STARTS — so an older connection's late resume gets a HIGHER one, for the same reason its resume ran later, and then outranks the newer connection whose tunnel it must not take. Concretely: the newer connection holds a server, the older one re-declares that same id, resume re-spawns an establish for it unconditionally, and the incumbent is replaced and disconnected. All three comparison sites now order lexicographically on (connection age, attach order): connection age decides whose declaration set is current, attach order is the tiebreak within one connection, where last-attach-wins is right. Orca found this by grepping every use of the new field rather than by reasoning about the change: `TunnelHandle` had two ordering fields and one site read the newer one. When a new ordering dimension is added, sweep every place that orders the same objects. An establish that finishes after its connection closed no longer registers. `abort()` cannot prevent it — abort acts at an await point and there is none between the handshake completing and the insert — so a per-connection flag is set before teardown's `retain` and read inside the registry lock. Either lock order is then correct: insert first and the retain removes it, retain first and the insert never happens. A generation cannot do this job, because it can only lose to a handle that is still present and after teardown the slot is empty. From Orca's review of 0b4e4ed4: - The failure-mode column added to fix an inaccuracy was itself inaccurate. There are three modes, and the line is drawn by which limit was crossed, not by request versus response. The one worth planning for is an oversized notification: no error, no acknowledgement, silently dropped. Verified in the source rather than taken on report. - `config.rs` told operators to raise the tunnel timeout and then the idle timeout "in that order". There is no second knob — the idle timeout is a constant. It now states that 180s is the effective ceiling, and startup warns when a configured value cannot take effect. - That constant is named `ACP_PROMPT_IDLE_TIMEOUT_SECS` instead of sitting inline in a loop, where it was invisible to the one person who needed it. Docs reference it by name: the same claim was written as a line number twice and was wrong both times. - A directory fsync that runs and fails is now `warn!`. Failing to open a directory stays `debug!` because Windows cannot. Reporting a cleanup failure more quietly than the failure it cleans up after hides the worse of the two. - Records the one reachable way to arrive with an empty keep set and a present field: a resume declaring only non-acp servers. That withdraws every acp tunnel and is correct — the client re-presented its whole set and no acp server is in it. Also retracts a finding of my own that was never real: `mcpServers: null` cannot reach the sweep. `validate_params` runs first and refuses anything that is not a sequence, and serde's `default` applies only to an absent key, so null fails deserialization. The guard is kept as defence that does not depend on a distant validator, with both facts recorded beside it. That came out of mutation-testing a reported defect before believing it: the guard was reverted and the tests stayed green, which proved the inputs never reached it. Mutation caught a reviewer's premise, not an implementer's mistake. --- crates/openab-core/src/config.rs | 15 +- crates/openab-core/src/mcp_proxy.rs | 6 +- .../openab-gateway/src/adapters/acp_server.rs | 296 +++++++++++++++++- docs/mcp-over-acp-tunnel-contract.md | 15 +- src/main.rs | 25 +- 5 files changed, 329 insertions(+), 28 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 67fcbc862..90be4d453 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -90,13 +90,20 @@ pub struct McpFacadeConfig { /// /// Enforced server-side because on this tunnel OPENAB is the requester and the peer is a /// browser extension we neither ship nor control, so there is nobody else to bound it. - /// The default sits STRICTLY beneath the ACP per-chunk idle timeout (180s, hard-coded at - /// `acp_server.rs`), and the margin is the point. Setting the two equal makes which one fires + /// The default sits STRICTLY beneath the ACP per-chunk idle timeout in `handle_session_prompt` + /// (`ACP_PROMPT_IDLE_TIMEOUT_SECS`, 180s), and the margin is the point. Referenced by name, not + /// by line: the same claim was written as a line number twice and was wrong both times, because + /// every edit above it moves the target. Setting the two equal makes which one fires /// first undecidable at the boundary, and they do different things: only when this one wins /// does the peer receive `mcp/cancel` and the caller see a timeout error. If the idle timeout /// wins the turn simply ends, which leaves exactly the stranded work on the extension that - /// cancellation exists to prevent. Raising this past the idle timeout moves the wall rather - /// than removing it — raise both, in that order. + /// cancellation exists to prevent. + /// + /// **180s is therefore the effective ceiling.** A larger value here is not an error and is not + /// clamped, but it cannot take effect: the idle timeout is not operator-configurable, so the turn + /// ends there first and this setting stops mattering. Startup warns when it is set that high + /// rather than letting the number look effective. Earlier wording said to "raise both, in that + /// order" — there is no second knob to raise, so that instruction could not be followed. #[serde(default = "default_tunnel_timeout_seconds")] pub tunnel_timeout_seconds: u64, } diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 9804b54f5..9b36dc8d0 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -221,7 +221,11 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re match tokio::fs::File::open(dir).await { Ok(d) => { if let Err(e) = d.sync_all().await { - tracing::debug!(dir = %dir.display(), error = %e, + // `warn!`, not `debug!`: opening a directory can legitimately fail (Windows), + // but an fsync that runs and FAILS is an unexpected durability failure, and + // reporting it more quietly than the thing it protects would hide the worse + // of the two — the same reasoning as the failed-handshake disconnect. + tracing::warn!(dir = %dir.display(), error = %e, "MCP config: directory fsync failed — the rename may not survive a crash"); } } diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 4102db15a..42b1c99ae 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -44,6 +44,16 @@ const MAX_INFLIGHT_PROMPTS: usize = 32; /// client had not reached, for work it had not asked for. `MAX_ACP_SERVERS_PER_SESSION` bounds one /// session's declarations; this bounds every session's establishes on a connection at once. const MAX_INFLIGHT_ESTABLISHES: usize = 64; +/// Per-chunk idle timeout for a prompt turn, in `handle_session_prompt`. +/// +/// Named rather than left inline because it is the effective ceiling on anything a turn waits for: +/// the tunnel's own timeout has to stay strictly beneath it, and `[mcp] tunnel_timeout_seconds` +/// documents itself against this value. As a bare literal in the middle of a loop it was invisible +/// to exactly the person who needed it — the operator raising the tunnel timeout into it. +/// +/// Not operator-configurable today. Anything set above it is silently capped here, which is why the +/// config path warns rather than letting a larger value look effective. +pub const ACP_PROMPT_IDLE_TIMEOUT_SECS: u64 = 180; /// Cap on `type:acp` servers a single session may declare (review R3-F1). /// /// Every declaration costs a spawned task, a pending `mcp/connect` holding a 30s timeout, and an @@ -899,6 +909,7 @@ async fn establish_and_register_tunnel( timeout_secs: u64, owner: String, connection_generation: u64, + connection_closed: Arc, ) -> Result<(), String> { // Observability: reaching here means the client DID declare a "type":"acp" server, so this // line in the log answers "did the browser extension advertise itself?" for a live session. @@ -994,18 +1005,39 @@ async fn establish_and_register_tunnel( // peer. When it does, both establishes land on this one key, `same_name` never sees the // newer entry, and the older arrival would `insert` straight over a live handle. Ordering // has to hold per key, not just per declared name. - let superseded = reg - .get(&own_key) - .is_some_and(|h| h.generation > generation) + // The closed flag is read HERE, under the registry lock, because that is the only place it + // can be decisive. `abort()` cannot close this race: it takes effect at an await point and + // there is none between the handshake completing and the insert below, so on a + // multi-thread runtime this section runs concurrently with teardown's `retain`. The flag is + // set BEFORE that retain, so whichever takes the lock first the outcome is right — insert + // first and the retain removes it; retain first and we never insert at all. Without it a + // late establish drops a handle owned by a dead connection into an empty slot, where + // nothing will ever remove it. + // Ordering is LEXICOGRAPHIC on (connection age, attach order), and it has to be both. + // + // Attach order alone repeats the mistake the sweep already had to fix: it is stamped when an + // establish starts, so an older connection's LATE resume spawns an establish with a HIGHER + // number — precisely because it ran later — and then outranks the newer connection whose + // tunnel it must not take. Connection age is what says which declaration set is current. + // + // Attach order is still needed as the tiebreak: within ONE connection the ages are equal, and + // there last-attach-wins is exactly right. + let rank = (connection_generation, generation); + let superseded = connection_closed.load(std::sync::atomic::Ordering::Acquire) || reg - .iter() - .any(|((c, id), h)| same_name(c, id, h) && h.generation > generation); + .get(&own_key) + .is_some_and(|h| (h.connection_generation, h.generation) > rank) + || reg.iter().any(|((c, id), h)| { + same_name(c, id, h) && (h.connection_generation, h.generation) > rank + }); if superseded { Registered::Superseded(handle) } else { let stale: Vec<(String, String)> = reg .iter() - .filter(|((c, id), h)| same_name(c, id, h) && h.generation < generation) + .filter(|((c, id), h)| { + same_name(c, id, h) && (h.connection_generation, h.generation) < rank + }) .map(|(k, _)| k.clone()) .collect(); let mut replaced: Vec = @@ -1072,6 +1104,7 @@ fn spawn_acp_tunnels( establish_tasks: &mut Vec>, owner: &str, connection_generation: u64, + connection_closed: &Arc, ) { // Drop finished handles first so a long-lived connection does not accumulate them, then bound // what is still running. Over the cap the declaration is dropped with a warning rather than @@ -1084,6 +1117,7 @@ fn spawn_acp_tunnels( let registry = registry.clone(); let channel_id = channel_id.clone(); let owner = owner.to_string(); + let connection_closed = connection_closed.clone(); if establish_tasks.len() >= MAX_INFLIGHT_ESTABLISHES { warn!( max = MAX_INFLIGHT_ESTABLISHES, @@ -1094,7 +1128,7 @@ fn spawn_acp_tunnels( establish_tasks.push(tokio::spawn(async move { if let Err(e) = establish_and_register_tunnel( out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, - connection_generation, + connection_generation, connection_closed, ) .await { @@ -1111,6 +1145,9 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // retire someone else's tunnel is decided by which CONNECTION is newer, so it has to be stamped // here — once, at accept — rather than per request. let connection_generation = TUNNEL_GENERATION.fetch_add(1, Ordering::Relaxed); + // Set once, at teardown, and read by in-flight establishes under the registry lock. See the + // check in `establish_and_register_tunnel` for why `abort()` cannot do this job. + let connection_closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); info!(connection = %connection_id, "ACP client connected"); @@ -1351,6 +1388,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &mut establish_tasks, &connection_id, connection_generation, + &connection_closed, ); } } @@ -1412,11 +1450,25 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // `session/new` has the same reading of absence, and D-06 treats a missing // `protocolVersion` as fail-closed; absence should not be the most damaging // interpretation here while it is the safest one there. - let declared_servers = req - .params - .as_ref() - .and_then(|p| p.get("mcpServers")) - .is_some(); + // Only a real ARRAY withdraws. `is_some()` was wrong in the case this guard exists + // for: `null` is a third state, semantically next to absent, and it is the shape a + // serde `Option>` without `skip_serializing_if` produces for a field the + // client left unset — so the most likely real wire form of "omitted" landed on the + // destructive side. `{}` and `"x"` were swept too, silently, because + // `parse_acp_mcp_servers` reads through `as_array()` and yields an empty list for + // anything that is not one. A malformed declaration must not be the most damaging + // reading available. + // One reachable way to arrive with an empty `keep` and a present key: the client + // declared only NON-`type:acp` servers. `parse_acp_mcp_servers` filters by type, so + // the list empties while the field itself is a perfectly good array. That withdraws + // every acp tunnel on the channel and is the CORRECT reading — the client + // re-presented its whole set and there is no acp server in it. Noted because it + // looks identical to a defect that was reported here and turned out to be + // unreachable. + let declared_servers = matches!( + req.params.as_ref().and_then(|p| p.get("mcpServers")), + Some(Value::Array(_)) + ); if let (Some(registry), Some(channel_id)) = (state.acp_tunnel_registry.clone(), resumed_channel.as_ref()) { @@ -1480,6 +1532,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &mut establish_tasks, &connection_id, connection_generation, + &connection_closed, ); } } @@ -1605,6 +1658,10 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } // --- Disconnect cleanup --- + // Announce the close BEFORE anything is torn down. An establish that is past its last await + // point cannot be aborted, so the flag — not `abort()` — is what stops it installing a handle + // for a connection that no longer exists. + connection_closed.store(true, std::sync::atomic::Ordering::Release); // Abort any in-flight tasks to prevent registry leaks. Establishes are aborted too: a task // still waiting on `mcp/connect` would otherwise insert a handle into the registry AFTER the // teardown below has run, leaving a dead tunnel registered for a closed connection. @@ -2015,7 +2072,7 @@ async fn handle_session_prompt( // Stream replies back as ACP `session/update` notifications. let mut sent_len = 0usize; - let timeout = tokio::time::Duration::from_secs(180); + let timeout = tokio::time::Duration::from_secs(ACP_PROMPT_IDLE_TIMEOUT_SECS); // Typed StopReason (T2.1) so the final PromptResponse is constructed from acp_schema. let mut stop_reason = crate::adapters::acp_schema::StopReason::EndTurn; let mut timed_out = false; @@ -2763,6 +2820,85 @@ mod acp_requests { ext.await.unwrap(); } + /// An establish that finishes after its connection closed must not register. + /// + /// The connection's tasks are aborted at teardown, but `abort()` only takes effect at an await + /// point and there is none between the inner handshake completing and the registry insert. On + /// a multi-thread runtime that section runs concurrently with teardown's `retain`, so a late + /// establish could drop a handle owned by a dead connection into a slot the retain had already + /// emptied — where nothing would ever remove it, because every cleanup path is scoped to a + /// connection that no longer exists. + /// + /// The generation stamp does NOT cover this: it can only lose to a handle that is still there, + /// and after teardown the slot is empty. Both Mira and I claimed otherwise; Orca showed the + /// empty-slot case, and this is the guard that closes it. + #[tokio::test] + async fn an_establish_that_finishes_after_teardown_does_not_register() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let registry = super::new_tunnel_registry(); + let closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let pending2 = pending.clone(); + let closed2 = closed.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; + // Close BEFORE answering the handshake, not after. The establish cannot get past its + // handshake until this reply lands, so the flag is ordered by the reply channel itself + // rather than by timing. Setting it afterwards is a real race that the establish + // usually WINS — it goes straight from the handshake to the lock without waiting for + // this task, registers, sends no disconnect, and the `recv` below then blocks forever. + // The first version of this test did that and hung the whole suite. + closed2.store(true, std::sync::atomic::Ordering::Release); + answer_inner_handshake(&mut out_rx, &pending2).await; + // Bounded: a regression must fail, not hang. An unbounded `recv` turns "no disconnect + // was sent" into a stuck suite, which is strictly worse than a red test. + tokio::time::timeout(std::time::Duration::from_secs(10), out_rx.recv()) + .await + .expect("timed out waiting for the mcp/disconnect a stood-down establish owes") + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "browser".into(), + "acp_abc".into(), + registry.clone(), + 5, + "conn-test".into(), + 0, + closed, + ) + .await + .unwrap(); + + // Registry first. With the guard removed the establish does NOT stand down — it registers — + // so awaiting the mock here would fail on "no disconnect from a stood-down establish", + // naming a thing that did not happen and costing a 10s timeout to say it. This ordering + // rule has now been needed three times in this file: run the assertion whose truth differs + // between fixed and broken FIRST, and it is not the same assertion in every test. + assert!( + registry.lock().unwrap().is_empty(), + "an establish registered a tunnel for a connection that had already closed — nothing \ + else can remove it, because every cleanup path is scoped to that dead connection" + ); + let disconnect = ext.await.unwrap(); + let frame: serde_json::Value = serde_json::from_str(&disconnect.expect( + "the stood-down establish still owes its client an mcp/disconnect for the connection \ + it opened", + )) + .unwrap(); + assert_eq!(frame["method"], json!("mcp/disconnect")); + } + #[tokio::test] async fn establish_tunnel_registers_handle_under_channel_and_server_id() { let pending = new_pending(); @@ -2792,6 +2928,7 @@ mod acp_requests { 5, "conn-test".into(), 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .await .unwrap(); @@ -2837,6 +2974,7 @@ mod acp_requests { 5, "conn-test".into(), 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .await .unwrap(); @@ -2902,6 +3040,7 @@ mod acp_requests { 5, "conn-test".into(), 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .await .unwrap(); @@ -3170,6 +3309,7 @@ mod acp_review_fixes { &mut tasks, "conn-test", 0, + &Arc::new(std::sync::atomic::AtomicBool::new(false)), ); assert_eq!(tasks.len(), 2, "one task per unique declaration"); @@ -3924,6 +4064,107 @@ mod acp_ws_integration { } } + /// An older connection's late resume must not TAKE OVER a newer connection's tunnel either. + /// + /// The sibling test covers the sweep path, where the older resume withdraws what it never knew + /// about. This covers the establish path, which needed the same fix and did not get it: resume + /// re-spawns an establish for every declared server without checking whether that + /// `(channel, id)` is already live, and that establish is stamped when it STARTS — so the older + /// connection's late one carries the HIGHER attach number, for the same reason its resume ran + /// later. Ordering on attach alone therefore lets it replace the incumbent and disconnect a + /// connection that is newer than itself. + /// + /// The difference from the sibling is only which id the late resume names: there it declared + /// something the newer connection did not hold, so nothing collided. Here it declares exactly + /// what the newer connection holds, which is the case a stable-id client produces on every + /// reconnect. + #[tokio::test] + async fn an_older_connections_late_resume_does_not_take_over_a_newer_connections_tunnel() { + let (url, registry) = serve().await; + + // Connection B (older) opens the session but establishes nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // Connection C (newer) resumes and establishes srv-1. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // The OLDER connection now resumes declaring THE SAME id, and answers its handshake fully. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let mut answered = false; + while !answered { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + answered = true; + } + } + + // C must keep the slot: it is the newer connection. Before the fix B took it over and C was + // disconnected, so the discriminating check is that C — not B — is never told to disconnect. + let stolen = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + } + } + }) + .await; + assert!( + stolen.is_err(), + "an older connection's late resume replaced a NEWER connection's live tunnel and \ + disconnected it ({:?}) — the establish path orders by attach number alone, which the \ + late resume wins precisely because it ran late", + stolen.ok() + ); + assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must hold the slot"); + } + /// An older connection's late resume must not retire a NEWER connection's tunnel. /// /// The withdrawn set is "registered under this channel, minus what the client just declared". @@ -4026,6 +4267,22 @@ mod acp_ws_integration { let resumed = recv(&mut b).await; assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); + // The other shapes an "omitted" field takes on the wire, pinned as REJECTIONS rather than + // as sweeps. A serde `Option>` without `skip_serializing_if` emits `null`, and a + // guard written as `is_some()` would accept that as a declaration with an empty list — but + // schema validation runs first and refuses anything that is not a sequence, so the + // destructive path is unreachable from the wire. This records that, because the guard below + // reads as if it were the only thing standing between `null` and a full sweep, and the next + // person to relax the schema needs the two facts in one place. + for shape in [json!(null), json!({}), json!("nonsense")] { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": shape} + })).await; + let r = recv(&mut b).await; + assert!(r.get("result").is_some() || r.get("error").is_some(), "no reply: {r}"); + } + // The tunnel must stay. Poll, because a wrongful teardown is asynchronous. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < deadline { @@ -4039,7 +4296,18 @@ mod acp_ws_integration { } } - /// A resume that RE-DECLARES an already-registered id must leave that tunnel completely alone. + /// A resume that RE-DECLARES an already-registered id must not have that tunnel SWEPT. + /// + /// Scope deliberately narrow, because the system does not leave the tunnel alone: resume then + /// spawns an establish for every declared server without checking whether that + /// `(channel, id)` is already registered, so the re-declared one is replaced through the + /// own-key path and its predecessor is disconnected. The churn is real and is tracked + /// separately; what this test pins is only that the SWEEP does not take it. + /// + /// It is green for that reason and not because the tunnel survives end to end — the second + /// `mcp/connect` is never answered here, so the replacement cannot complete inside the window. + /// Worth stating plainly: withholding a trigger is exactly why the two tests this one was + /// written to supplement looked like they had coverage. /// /// This is what pins the withdrawn set to "registered MINUS declared". Both neighbouring /// withdrawal tests survive a mutation that simply sweeps the whole channel on every resume: diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 729fbff2d..3463aa528 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -155,11 +155,16 @@ notification is simply never delivered, so do not treat its absence as "keep goi | Connect / handshake timeout | 30s | `mcp/connect` and the `initialize` that follows it | | Servers per session | 8 (`MAX_ACP_SERVERS_PER_SESSION`) | `type:acp` entries accepted per `session/new` | | In-flight establishes | 64 (`MAX_INFLIGHT_ESTABLISHES`) | concurrent tunnel setups per connection | -| Any inbound frame | 8 MiB (`MAX_FRAME_BYTES`) | checked before parsing. **Exceeding it closes the connection** — an unparseable frame has no recoverable `id` to answer | -| Inbound frame carrying a `method` | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | checked after parsing. **Answered with an error, connection kept.** The 8 MiB allowance exists for tool results, which arrive as responses (`id`, no `method`); capping method-bearing frames lower stops that allowance being reused to hold prompt text | - -The two differ in failure mode, which matters more than the numbers: oversize a response and the -socket closes under you, oversize a request and you get an error back and may continue. +| Any inbound frame | 8 MiB (`MAX_FRAME_BYTES`) | checked **before** parsing. Exceeding it **closes the connection**, whatever the frame is — an unparseable frame has no recoverable `id` to answer | +| A `method` frame that is a **request** | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | checked **after** parsing. Answered with an error, **connection kept** | +| A `method` frame that is a **notification** | 1 MiB (same check) | **silently dropped** — a notification has no `id`, so there is nothing to answer, and inventing a reply would break the rule that notifications get none | + +Three failure modes, and the line is drawn by **which limit you crossed**, not by whether the frame +was a request or a response. The 8 MiB allowance exists for tool results, which arrive as responses +(`id`, no `method`); method-bearing frames are held at 1 MiB so that allowance cannot be reused to +hold prompt text. The case worth planning for is the third: an oversized notification produces no +error and no acknowledgement of any kind, so a sender that assumes silence means success will lose +data without noticing. The request timeout is operator-configurable because openab is the requester here and the peer is an extension it neither ships nor controls. It sits beneath the ACP idle timeout, so raising it diff --git a/src/main.rs b/src/main.rs index 981d4605e..2a1bddfa1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -466,10 +466,27 @@ async fn main() -> anyhow::Result<()> { acp_tunnel_registry.clone(), // Browser control requires `[mcp]`, so the absent case is unreachable in practice; // fall back through the SAME function serde uses rather than repeating the literal. - cfg.mcp - .as_ref() - .map(|m| m.tunnel_timeout_seconds) - .unwrap_or_else(openab_core::config::default_tunnel_timeout_seconds), + { + let t = cfg + .mcp + .as_ref() + .map(|m| m.tunnel_timeout_seconds) + .unwrap_or_else(openab_core::config::default_tunnel_timeout_seconds); + // Say so rather than let a larger number look effective. The prompt turn's idle + // timeout is not operator-configurable, so anything at or above it is overtaken + // there and this setting stops deciding the outcome — silence would leave the + // operator believing a value that cannot apply. + let ceiling = openab_gateway::adapters::acp_server::ACP_PROMPT_IDLE_TIMEOUT_SECS; + if t >= ceiling { + tracing::warn!( + configured = t, effective_ceiling = ceiling, + "[mcp] tunnel_timeout_seconds is at or above the ACP prompt idle timeout, \ + which is not configurable — the turn ends there first, so this value \ + cannot take effect" + ); + } + t + }, ), ); From c633e879a705a8fd7afb75b13fc3e79357fef026 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 07:27:17 +0800 Subject: [PATCH 114/138] refactor(acp): delete the eviction filter's rank comparison, and cover the two sites that had no test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Orca's review of 17e16ec8. Two of the three comparison sites had no test that could tell the orderings apart, and one claim I had written into the source was false. The eviction filter's rank comparison is gone; the predicate is now "same declared name, not me". Everything that should have won is already filtered out by the supersede check above, and ranks are unique, so the comparison was true every time it ran. It is deleted rather than annotated because it was only redundant while it MATCHED the check above: changed on its own to attach order it silently stopped evicting an incumbent that was older by connection but later by attach, leaving two tunnels under one declared name. A condition that looks like a check, is always true, and turns wrong when it drifts out of step with another one is worse than no condition. It misled me twice in two hours — first as a site needing repair, then as proof that nothing could observe it. That second reading was written into the source as "PROVABLY INERT — no input can distinguish the two". It is removed. I had mutated the site, seen the suite stay green, and concluded nothing could distinguish it; green only meant my tests did not. The distinguishing input needs connection age and attach order to disagree, which happens when the newer connection's establish starts first and stalls while the older connection's starts later and completes. Two tests now cover that, and both discriminate the surviving same-name comparison — verified by mutating it and watching both go red: - a_same_name_establish_from_an_older_connection_stands_down — same name, different id, incumbent on the newer connection. Attach order alone lets the arriving establish sit beside it: the failure is two tunnels under one name, not a take-over. - eviction_follows_connection_age_when_it_disagrees_with_attach_order — the interleaving above. Every pre-existing same-name test has the two dimensions agreeing, which is why none of them could reach either site. Also from that review: - The ceiling comparison moves into the gateway beside the constant it is about, as `warn_if_tunnel_timeout_is_ineffective`; the binary hands over the configured value and no longer needs to know the ceiling or the direction. This RELOCATES the invariant — it does not remove coupling. The edge is unchanged, and the value still has to be handed in because the gateway never sees config. Calling it decoupling would tell the next reader an edge had disappeared. - The contract's oversized-notification row says the reply is forbidden rather than impossible. The gateway can assemble one with a null id and deliberately does not, because answering a notification violates JSON-RPC. "Unable" and "not permitted" lead a contract reader to opposite conclusions. Corrects 17e16ec8's message, which said mutation "proved the inputs never reached it". It showed the tests did not discriminate. What proved unreachability was three measured replies: `invalid type: null|map|string, expected a sequence`, all -32602, from validation that runs before the code in question. --- crates/openab-core/src/config.rs | 10 +- .../openab-gateway/src/adapters/acp_server.rs | 252 +++++++++++++++++- docs/mcp-over-acp-tunnel-contract.md | 2 +- src/main.rs | 16 +- 4 files changed, 262 insertions(+), 18 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 90be4d453..fa81dfb47 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -102,7 +102,15 @@ pub struct McpFacadeConfig { /// **180s is therefore the effective ceiling.** A larger value here is not an error and is not /// clamped, but it cannot take effect: the idle timeout is not operator-configurable, so the turn /// ends there first and this setting stops mattering. Startup warns when it is set that high - /// rather than letting the number look effective. Earlier wording said to "raise both, in that + /// rather than letting the number look effective. + /// + /// The check lives in the gateway, beside the constant, as + /// `warn_if_tunnel_timeout_is_ineffective`; the binary only hands it this value. That keeps the + /// ceiling and the comparison in one place, so changing it — or making it configurable — is a + /// single edit. It does not remove coupling: this crate cannot see the constant, since + /// `openab-gateway` does not depend on `openab-core`, and the gateway never sees this value. The + /// binary is the only place both are visible, and it already depends on the gateway. Moving the + /// constant into this crate would ADD a dependency edge to save nothing. Earlier wording said to "raise both, in that /// order" — there is no second knob to raise, so that instruction could not be followed. #[serde(default = "default_tunnel_timeout_seconds")] pub tunnel_timeout_seconds: u64, diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 42b1c99ae..e05820c0e 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -54,6 +54,26 @@ const MAX_INFLIGHT_ESTABLISHES: usize = 64; /// Not operator-configurable today. Anything set above it is silently capped here, which is why the /// config path warns rather than letting a larger value look effective. pub const ACP_PROMPT_IDLE_TIMEOUT_SECS: u64 = 180; + +/// Warn when a configured tunnel timeout cannot take effect because the idle timeout above overtakes +/// it. +/// +/// Lives here, next to the number it is about, rather than in the binary that reads the config. The +/// edge is unchanged — the binary already depends on this crate — but the caller no longer has to +/// know what the ceiling is or which direction to compare, so when that constant changes or becomes +/// configurable there is one place to edit instead of two. This is a relocation of the invariant, not +/// a removal of coupling: the value still has to be handed in, because this crate never sees the +/// config. +pub fn warn_if_tunnel_timeout_is_ineffective(configured_secs: u64) { + if configured_secs >= ACP_PROMPT_IDLE_TIMEOUT_SECS { + warn!( + configured = configured_secs, + effective_ceiling = ACP_PROMPT_IDLE_TIMEOUT_SECS, + "[mcp] tunnel_timeout_seconds is at or above the ACP prompt idle timeout, which is not \ + configurable — the turn ends there first, so this value cannot take effect" + ); + } +} /// Cap on `type:acp` servers a single session may declare (review R3-F1). /// /// Every declaration costs a spawned task, a pending `mcp/connect` holding a 30s timeout, and an @@ -1033,11 +1053,23 @@ async fn establish_and_register_tunnel( if superseded { Registered::Superseded(handle) } else { + // No rank comparison here, deliberately. Everything ranking ABOVE this establish has + // already returned through `superseded`, and ranks are unique, so every surviving + // same-name entry necessarily ranks below — the comparison would be true every time. + // + // It was written out rather than left in place because a condition that looks like a + // check, is in fact always true, and becomes WRONG if it ever stops matching the + // comparison above is worse than no condition at all. Held as lexicographic it was + // redundant; changed on its own to attach-order it silently stopped evicting an incumbent + // that was older by connection but later by attach, leaving two tunnels under one + // declared name. Both readings of it misled me inside two hours. + // + // INVARIANT: the eviction set is "same declared name, not me". Anything that should have + // won is filtered out earlier by `superseded`; if that check is ever weakened, this is + // where the damage shows up, so change the two together. let stale: Vec<(String, String)> = reg .iter() - .filter(|((c, id), h)| { - same_name(c, id, h) && (h.connection_generation, h.generation) < rank - }) + .filter(|((c, id), h)| same_name(c, id, h)) .map(|(k, _)| k.clone()) .collect(); let mut replaced: Vec = @@ -4064,6 +4096,220 @@ mod acp_ws_integration { } } + /// When connection age and attach order DISAGREE, connection age decides who keeps the name. + /// + /// The construction is what matters: an incumbent that is older by connection but LATER by + /// attach, which happens when the newer connection's establish starts first and then stalls + /// while the older connection's starts later and completes. Every other same-name test has the + /// two dimensions agreeing — same-connection ones have equal ages, and the cross-connection one + /// has the older side also attaching earlier — so none of them can tell the orderings apart. + /// + /// Ordering on attach alone gets this backwards: it reads the arriving establish as the older + /// one, lets it stand down, and leaves the wrong connection holding the name. + /// + /// History worth keeping, because it cost two wrong turns. I first mutated the eviction filter, + /// saw the suite stay green, and wrote into the source that the comparison was "provably inert" + /// — reading green as "nothing can distinguish this" when it only meant "my tests do not". The + /// filter has since been simplified away entirely, since everything that should win is already + /// filtered by the supersede check above, so what this test now pins is that comparison. + #[tokio::test] + async fn eviction_follows_connection_age_when_it_disagrees_with_attach_order() { + let (url, registry) = serve().await; + + // B opens FIRST — older connection — and declares nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // C opens SECOND — newer connection — and starts its establish FIRST, then stalls: its + // `mcp/connect` is captured and deliberately left unanswered, so it takes the LOWER attach + // number while making no progress. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-c", "name": "katashiro"}]} + })).await; + let c_connect_id = loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + break f["id"].clone(); + } + }; + + // Now the OLDER connection declares the same name under a different id and completes, so it + // registers with the HIGHER attach number. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-b", "name": "katashiro"}]} + })).await; + let mut done = false; + while !done { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + done = true; + } + } + wait_for_tunnels(®istry, 1).await; + + // Finally let the stalled, newer-connection establish finish. + send(&mut c, json!({ + "jsonrpc": "2.0", "id": c_connect_id, + "result": {"connectionId": "conn-c"} + })).await; + loop { + let f = recv(&mut c).await; + if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + + // Exactly one tunnel, and it must be the newer connection's. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + if ids == vec!["srv-c".to_string()] { + break; + } + assert!( + std::time::Instant::now() < deadline, + "expected only the newer connection's tunnel, got {ids:?} — the incumbent was older \ + by connection but later by attach, and attach order alone reads that as 'not older' \ + and refuses to evict it" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// Same declared name, DIFFERENT id, incumbent on the newer connection: the arriving establish + /// must stand down rather than sit beside it. + /// + /// This is the same-name comparison, which the take-over test cannot reach: there the late + /// resume re-declares the SAME id, so it goes through the own-key check and `same_name` never + /// sees the incumbent (`id != &acp_id` excludes it). Here the ids differ, so this is the only + /// site that can refuse it. + /// + /// The failure is not a take-over — it is TWO tunnels under one declared name, which is exactly + /// the ambiguity last-attach-wins exists to remove (ADR §6.1). Ordering on attach alone permits + /// it: the older connection's late resume carries the higher attach number, so it is neither + /// superseded nor able to evict, and simply lands alongside. + #[tokio::test] + async fn a_same_name_establish_from_an_older_connection_stands_down() { + let (url, registry) = serve().await; + + // B opens first, so its connection is the OLDER one. It declares nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // C opens second (NEWER connection) and establishes srv-3 under the shared name. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-3", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // The OLDER connection now declares the same NAME under a different id, and completes. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-4", "name": "katashiro"}]} + })).await; + let mut done = false; + while !done { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + done = true; + } + } + + // Poll: the wrong outcome is an EXTRA entry appearing, so give it time to appear. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let names: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.server_name().to_string()).collect() + }; + assert_eq!( + names.len(), + 1, + "two tunnels are registered under one declared name ({names:?}) — an establish from \ + an OLDER connection neither stood down nor evicted, because attach order alone \ + ranks it above the incumbent it arrived after" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + // And it must be the newer connection's tunnel that survived. + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!(ids, vec!["srv-3".to_string()], "the newer connection's tunnel must hold the name"); + } + /// An older connection's late resume must not TAKE OVER a newer connection's tunnel either. /// /// The sibling test covers the sweep path, where the older resume withdraws what it never knew diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md index 3463aa528..b500980d3 100644 --- a/docs/mcp-over-acp-tunnel-contract.md +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -157,7 +157,7 @@ notification is simply never delivered, so do not treat its absence as "keep goi | In-flight establishes | 64 (`MAX_INFLIGHT_ESTABLISHES`) | concurrent tunnel setups per connection | | Any inbound frame | 8 MiB (`MAX_FRAME_BYTES`) | checked **before** parsing. Exceeding it **closes the connection**, whatever the frame is — an unparseable frame has no recoverable `id` to answer | | A `method` frame that is a **request** | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | checked **after** parsing. Answered with an error, **connection kept** | -| A `method` frame that is a **notification** | 1 MiB (same check) | **silently dropped** — a notification has no `id`, so there is nothing to answer, and inventing a reply would break the rule that notifications get none | +| A `method` frame that is a **notification** | 1 MiB (same check) | **silently dropped.** Not a limitation — the gateway could answer with a null id and deliberately does not, because replying to a notification violates JSON-RPC. The refusal is required, and the cost is that you get no signal | Three failure modes, and the line is drawn by **which limit you crossed**, not by whether the frame was a request or a response. The 8 MiB allowance exists for tool results, which arrive as responses diff --git a/src/main.rs b/src/main.rs index 2a1bddfa1..a01f19128 100644 --- a/src/main.rs +++ b/src/main.rs @@ -472,19 +472,9 @@ async fn main() -> anyhow::Result<()> { .as_ref() .map(|m| m.tunnel_timeout_seconds) .unwrap_or_else(openab_core::config::default_tunnel_timeout_seconds); - // Say so rather than let a larger number look effective. The prompt turn's idle - // timeout is not operator-configurable, so anything at or above it is overtaken - // there and this setting stops deciding the outcome — silence would leave the - // operator believing a value that cannot apply. - let ceiling = openab_gateway::adapters::acp_server::ACP_PROMPT_IDLE_TIMEOUT_SECS; - if t >= ceiling { - tracing::warn!( - configured = t, effective_ceiling = ceiling, - "[mcp] tunnel_timeout_seconds is at or above the ACP prompt idle timeout, \ - which is not configurable — the turn ends there first, so this value \ - cannot take effect" - ); - } + // The comparison and the ceiling both live beside the constant in the gateway; this + // only hands over the configured value. + openab_gateway::adapters::acp_server::warn_if_tunnel_timeout_is_ineffective(t); t }, ), From d15018e5c62426844327bbc20d3224de377a9e6d Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 07:42:32 +0800 Subject: [PATCH 115/138] test(acp): pin the attach tiebreak and the timeout ceiling, neither of which had a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both gaps came from Orca's review of c633e879, and both were measured before being believed. The `gen` half of `(connection age, attach order)` had no coverage. Comparing connection age alone leaves all three existing ordering tests green, including the one whose name is about a late-finishing establish — because every one of them is CROSS-connection, so the ages already give the right answer without consulting the tiebreak. The tiebreak only decides when ages are equal, which takes two establishes on ONE connection, and nothing exercised that. `within_one_connection_a_late_finishing_older_establish_loses_to_its_successor` covers it, using the construction Orca proposed: park the first establish by withholding its `mcp/connect`, drive the second to completion, then release the first. Which one started earlier is fixed by the order the resumes were sent, so no spawns are raced. Verified by comparing age alone: the registry ends up holding `srv-1`, the stale tunnel that replaced its own successor. The relationship the whole timeout margin rests on — the default being strictly beneath the ceiling that overtakes it — also had no test. 111966bf's original defect was 180 against 180, and it was fixed by editing one literal, leaving nothing to stop the next edit restoring it with a green gate. `the_default_tunnel_timeout_stays_beneath_the_idle_timeout` fails if the default returns to 180, naming the consequence: the turn ends at the idle timeout first and no `mcp/cancel` is ever sent. It lives in the binary because that is the only place both constants are visible — the gateway owns the ceiling and does not depend on the crate owning the default. `tunnel_timeout_is_ ineffective` is split out of the warning so the boundary is testable without capturing log output; an inverted `>=` would otherwise be silent in exactly the case the function exists to report. The warning itself is deliberately not tested: asserting on a log line costs a subscriber harness to catch almost nothing. Baseline-anchored, since a previous message got this wrong by comparing against my own last measurement instead of the parent commit: c633e879 → HEAD, gateway 338 → 340, root 39 → 40 (+3): within_one_connection_a_late_finishing_older_establish_loses_to_its_successor a_tunnel_timeout_at_or_above_the_idle_timeout_is_ineffective the_default_tunnel_timeout_stays_beneath_the_idle_timeout --- .../openab-gateway/src/adapters/acp_server.rs | 132 +++++++++++++++++- src/main.rs | 26 ++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index e05820c0e..c4aacc06b 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -64,8 +64,18 @@ pub const ACP_PROMPT_IDLE_TIMEOUT_SECS: u64 = 180; /// configurable there is one place to edit instead of two. This is a relocation of the invariant, not /// a removal of coupling: the value still has to be handed in, because this crate never sees the /// config. +/// Whether a configured tunnel timeout is overtaken by the idle timeout, and so cannot decide the +/// outcome. +/// +/// Split out from the warning so the boundary is testable without capturing log output: the +/// interesting part is one comparison, and an inverted `>=` would be silent in exactly the case it +/// exists to report. +pub fn tunnel_timeout_is_ineffective(configured_secs: u64) -> bool { + configured_secs >= ACP_PROMPT_IDLE_TIMEOUT_SECS +} + pub fn warn_if_tunnel_timeout_is_ineffective(configured_secs: u64) { - if configured_secs >= ACP_PROMPT_IDLE_TIMEOUT_SECS { + if tunnel_timeout_is_ineffective(configured_secs) { warn!( configured = configured_secs, effective_ceiling = ACP_PROMPT_IDLE_TIMEOUT_SECS, @@ -2852,6 +2862,28 @@ mod acp_requests { ext.await.unwrap(); } + /// The ineffective-timeout boundary is inclusive on the ceiling. + /// + /// Equal is the case that matters and the one an inverted comparison would drop: at exactly the + /// idle timeout the two clocks start together and which fires first is undecided, so the value + /// cannot be relied on to decide anything — that is the whole reason the margin exists. + #[test] + fn a_tunnel_timeout_at_or_above_the_idle_timeout_is_ineffective() { + let ceiling = super::ACP_PROMPT_IDLE_TIMEOUT_SECS; + assert!( + super::tunnel_timeout_is_ineffective(ceiling), + "equal to the ceiling must count as ineffective: the two clocks start together, so \ + neither reliably wins" + ); + assert!(super::tunnel_timeout_is_ineffective(ceiling + 1)); + assert!( + !super::tunnel_timeout_is_ineffective(ceiling - 1), + "one second beneath the ceiling is the intended configuration, not a warning" + ); + // The shipped default cannot be checked here: this crate does not depend on the one that + // owns it. That pairing is asserted in the binary, which is the only place both are visible. + } + /// An establish that finishes after its connection closed must not register. /// /// The connection's tasks are aborted at teardown, but `abort()` only takes effect at an await @@ -4208,6 +4240,104 @@ mod acp_ws_integration { } } + /// Within ONE connection, the later establish still wins — the attach tiebreak is load-bearing. + /// + /// Every other ordering test here is cross-connection, so all of them survive dropping the second + /// half of `(connection age, attach order)` and comparing only age: their connection ages already + /// give the right answer. What no other test reaches is two establishes on the SAME connection, + /// where the ages are equal and attach order is the only thing left to decide with. Compare age + /// alone and a late-finishing older establish is neither superseded nor blocked, so it evicts the + /// successor that already registered and installs the stale tunnel over it. + /// + /// Deterministic without racing two spawns: the first establish is parked by withholding its + /// `mcp/connect` answer, the second is driven to completion, and only then is the first released. + /// Which one started earlier is fixed by the order the resumes were sent. + #[tokio::test] + async fn within_one_connection_a_late_finishing_older_establish_loses_to_its_successor() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut ws).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // First resume: declare srv-1 and PARK it — its `mcp/connect` is captured, not answered, so + // it holds the lower attach number while making no progress. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let parked_connect_id = loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!(f["params"]["acpId"], json!("srv-1")); + break f["id"].clone(); + } + }; + + // Second resume on the SAME connection: same name, new id, driven to completion. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 4, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + let mut registered = false; + while !registered { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") + && f["params"]["acpId"] == json!("srv-2") + { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-2"} + })).await; + } else if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { + registered = true; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now release the parked, EARLIER establish. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": parked_connect_id, + "result": {"connectionId": "conn-1"} + })).await; + loop { + let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { + break; + } + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + ids, + vec!["srv-2".to_string()], + "the earlier establish finished last and took the name back ({ids:?}) — within one \ + connection the ages are equal, so attach order is the only thing that can decide, \ + and dropping it lets a stale tunnel replace its own successor" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + /// Same declared name, DIFFERENT id, incumbent on the newer connection: the arriving establish /// must stand down rather than sit beside it. /// diff --git a/src/main.rs b/src/main.rs index a01f19128..97f263350 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1719,6 +1719,32 @@ fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { #[cfg(test)] mod tests { + + /// The shipped tunnel-timeout default must stay strictly beneath the ceiling that overtakes it. + /// + /// This pairing can only be asserted here. The gateway owns the ceiling and cannot see the + /// default; the core crate owns the default and cannot see the ceiling, since the gateway does + /// not depend on it. The binary is the only place both are visible — which is also why the + /// warning that reports a violation is wired up here. + /// + /// Raising the default to or above the ceiling would silently restore the condition several + /// commits were spent removing: two clocks starting together, with the wrong one able to fire + /// first, and no cancellation reaching the peer when it does. + #[test] + fn the_default_tunnel_timeout_stays_beneath_the_idle_timeout() { + let default = openab_core::config::default_tunnel_timeout_seconds(); + let ceiling = openab_gateway::adapters::acp_server::ACP_PROMPT_IDLE_TIMEOUT_SECS; + assert!( + default < ceiling, + "the default tunnel timeout ({default}s) must be strictly beneath the ACP prompt idle \ + timeout ({ceiling}s); at or above it the turn ends there first and no `mcp/cancel` is \ + ever sent" + ); + assert!( + !openab_gateway::adapters::acp_server::tunnel_timeout_is_ineffective(default), + "the shipped default must not be a value the startup warning fires on" + ); + } use super::*; use clap::Parser; From 6a1004af2aa445f385bc9b66c0236abf0ca64d6e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 08:06:33 +0800 Subject: [PATCH 116/138] refactor(acp): resolve a declared name where the invariant lives, and give two docs back their functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `browser_source` resolved a declared server name by enumerating the channel's tunnels and taking the first name match. That is correct only while same-name entries cannot coexist — an invariant maintained by the eviction in this crate, out of that file's sight. A take-the-first caller does not fail when the invariant breaks; it silently routes to an arbitrary tunnel. Both of my proposed fixes were wrong, and Orca's reasons were checkable. Erroring on duplicates cannot work from the root crate: `TunnelHandle`'s ordering fields are private, so root cannot rank two tunnels and could only refuse — and refusing is the behaviour §6.1 exists to avoid, because "ambiguous, pass a server_id" locks a client out of its own tools on every reconnect. A registry test asserting uniqueness already exists (`reattaching_same_name_evicts_the_stale_ tunnel`, with `different_names_on_one_channel_coexist` as its complement), so adding another would restate a pinned fact. Neither addressed what was actually missing: the link between where the invariant is kept and where it is relied on, which no test can supply. So the resolution moves to the gateway, beside the eviction, and runs under the registry lock. `.find()` is gone from the routing path — the assumption is not documented or guarded, it no longer exists. If two entries ever do share a name, it picks the newest by the same ordering that established the invariant and warns, rather than erroring. The trait method is required, with no default. Giving it a `None` default compiled everywhere and then answered "not connected" for all routing: five routing tests failed that had nothing to do with the change. A missing implementation should be a compile error, not a behaviour change. Two doc comments are returned to the functions they describe. Inserting `tunnel_timeout_is_ineffective` above `warn_if_tunnel_timeout_is_ineffective` with no blank line merged the existing doc into the new one, so both landed on the predicate: its rustdoc opened "Warn when..." though it does not warn, the relocation rationale sat on the wrong function, and the warning had no doc at all. Doc merging is legal, so clippy cannot see it and a green gate proves nothing here. Third time this class has appeared in this branch, and every instance came from inserting an item directly above an existing one — the compiler objects when an attribute is reparented and never when a doc comment is. The tiebreak test's justification is narrowed. It claimed every other ordering test is cross-connection; `mod acp_requests` builds handles with a connection_generation of 0, so same-connection ordering tests exist. The real reason is directional: those exercise later-start/later-finish, where dropping the tiebreak still answers correctly. A universal that one counterexample disproves would have made the test look unnecessary. d15018e5 → HEAD, gateway 340 → 341, root 40 → 40 (+1): resolve_by_name_routes_to_the_newest_attach_if_a_name_is_ever_duplicated --- crates/openab-core/src/mcp_proxy.rs | 15 ++ .../openab-gateway/src/adapters/acp_server.rs | 138 ++++++++++++++++-- src/browser_source.rs | 24 ++- src/browser_tunnel.rs | 13 ++ src/main.rs | 5 +- 5 files changed, 170 insertions(+), 25 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 9b36dc8d0..d9ad13b68 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -57,6 +57,21 @@ pub trait AcpMcpTunnel: Send + Sync { fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { Vec::new() } + + /// Resolve a declared server NAME to the `server_id` the registry keys that tunnel by, for one + /// channel. + /// + /// Separate from [`Self::servers`] on purpose. Enumerating and picking the first name match is + /// only correct while same-name entries cannot coexist, and that uniqueness is maintained far + /// from any caller — so the choice belongs with the code that maintains it, not with each + /// consumer. `servers` stays for enumeration and discovery, where seeing everything is the point. + /// + /// Required, with no default. A default returning `None` compiles for every existing implementor + /// and then silently answers "not connected" for all routing — the failure surfaces at run time, + /// in tests belonging to whoever did NOT add the method. A missing implementation should be a + /// compile error, not a behaviour change. This was not hypothetical: adding it with a `None` + /// default broke five routing tests that had nothing to do with the change. + fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option; } /// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index c4aacc06b..708f1cf29 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -55,15 +55,6 @@ const MAX_INFLIGHT_ESTABLISHES: usize = 64; /// config path warns rather than letting a larger value look effective. pub const ACP_PROMPT_IDLE_TIMEOUT_SECS: u64 = 180; -/// Warn when a configured tunnel timeout cannot take effect because the idle timeout above overtakes -/// it. -/// -/// Lives here, next to the number it is about, rather than in the binary that reads the config. The -/// edge is unchanged — the binary already depends on this crate — but the caller no longer has to -/// know what the ceiling is or which direction to compare, so when that constant changes or becomes -/// configurable there is one place to edit instead of two. This is a relocation of the invariant, not -/// a removal of coupling: the value still has to be handed in, because this crate never sees the -/// config. /// Whether a configured tunnel timeout is overtaken by the idle timeout, and so cannot decide the /// outcome. /// @@ -74,6 +65,15 @@ pub fn tunnel_timeout_is_ineffective(configured_secs: u64) -> bool { configured_secs >= ACP_PROMPT_IDLE_TIMEOUT_SECS } +/// Warn when a configured tunnel timeout cannot take effect because the idle timeout above overtakes +/// it. +/// +/// Lives here, next to the number it is about, rather than in the binary that reads the config. The +/// edge is unchanged — the binary already depends on this crate — but the caller no longer has to +/// know what the ceiling is or which direction to compare, so when that constant changes or becomes +/// configurable there is one place to edit instead of two. This is a relocation of the invariant, not +/// a removal of coupling: the value still has to be handed in, because this crate never sees the +/// config. pub fn warn_if_tunnel_timeout_is_ineffective(configured_secs: u64) { if tunnel_timeout_is_ineffective(configured_secs) { warn!( @@ -411,6 +411,41 @@ pub struct ReplySink { pub owner: String, } +/// Resolve a client-declared server NAME to the registry key of its tunnel, for one channel. +/// +/// Lives here, beside the code that maintains the uniqueness it relies on, and that is the whole +/// point. The caller used to enumerate the channel's tunnels and take the first name match, which +/// was correct only because same-name entries cannot coexist — an invariant established by the +/// single insert site below, in another crate's line of sight, with nothing binding the two. Weaken +/// that eviction and a "take the first" caller does not fail, it silently picks an arbitrary tunnel. +/// +/// Moving the resolution next to the invariant removes the distance rather than documenting it. +/// +/// If two ever do coexist, this picks the newest by the SAME ordering that produced the invariant +/// and warns. It deliberately does not error: answering "ambiguous, pass a server_id" is the +/// behaviour ADR §6.1 exists to avoid, because it locks a client out of its own tools on every +/// reconnect. A hard stop is the wrong response to a soft inconsistency. +pub fn resolve_by_name( + registry: &AcpTunnelRegistry, + channel_id: &str, + server_name: &str, +) -> Option { + let reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + let mut matches: Vec<(&(String, String), &TunnelHandle)> = reg + .iter() + .filter(|((c, _), h)| c == channel_id && h.server_name == server_name) + .collect(); + if matches.len() > 1 { + warn!( + channel = %redact_id(channel_id), server_name, count = matches.len(), + "ACP: more than one tunnel is registered under one declared name — registry uniqueness \ + was broken upstream; routing to the newest attach" + ); + } + matches.sort_by_key(|(_, h)| (h.connection_generation, h.generation)); + matches.last().map(|((_, id), _)| id.clone()) +} + /// Registry of active ACP sessions: channel_id → reply sink. /// Uses std::sync::Mutex because all operations are fast CPU-bound /// (insert/remove/get) and never hold the lock across .await. @@ -2862,6 +2897,74 @@ mod acp_requests { ext.await.unwrap(); } + /// A handle with chosen ordering numbers, for asserting on resolution directly. + /// + /// The establish path cannot produce two tunnels under one name, so a state that only + /// `resolve_by_name` has to cope with has to be built by hand. + fn tunnel_ranked(owner: &str, conn_gen: u64, gen: u64) -> super::TunnelHandle { + let (out_tx, _rx) = mpsc::unbounded_channel::(); + super::TunnelHandle { + out_tx, + pending: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + next_id: Arc::new(AtomicU64::new(1)), + connection_id: format!("{owner}-conn"), + server_name: "katashiro".into(), + owner: owner.into(), + connection_generation: conn_gen, + generation: gen, + } + } + + /// `resolve_by_name` picks the newest attach when a name somehow has two tunnels. + /// + /// The registry keeps declared names unique, so this state should not arise — which is exactly + /// why the behaviour needs pinning. The caller this replaced enumerated and took the FIRST match, + /// and a first match is whatever the map iterator happened to yield: correct only while the + /// invariant holds, and silently arbitrary the moment it does not. Constructed directly here + /// because the establish path will not produce it. + /// + /// Newest-wins rather than an error, deliberately: refusing with "ambiguous, pass a server_id" is + /// the behaviour ADR §6.1 exists to avoid, since it locks a client out of its own tools on every + /// reconnect. A soft inconsistency should not become a hard stop. + #[test] + fn resolve_by_name_routes_to_the_newest_attach_if_a_name_is_ever_duplicated() { + let registry = super::new_tunnel_registry(); + { + let mut reg = registry.lock().unwrap(); + // Deliberately out of insertion order relative to rank, so a "first match" answer and a + // "newest" answer differ. + reg.insert(("acp_1".into(), "srv-new".into()), tunnel_ranked("conn-b", 7, 2)); + reg.insert(("acp_1".into(), "srv-old".into()), tunnel_ranked("conn-a", 3, 9)); + reg.insert(("acp_1".into(), "other".into()), { + let mut h = tunnel_ranked("conn-c", 9, 9); + h.server_name = "notes".into(); + h + }); + reg.insert(("acp_2".into(), "elsewhere".into()), tunnel_ranked("conn-d", 99, 99)); + } + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "katashiro").as_deref(), + Some("srv-new"), + "must pick the higher (connection_generation, generation), not whichever the map yields \ + first — note srv-old has the larger attach number, so attach order alone answers wrong" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "notes").as_deref(), + Some("other"), + "a different declared name on the same channel resolves independently" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "absent"), + None, + "an unknown name resolves to nothing rather than to an arbitrary tunnel" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_other", "katashiro"), + None, + "resolution is scoped to the channel — another channel's tunnel must not be reachable" + ); + } + /// The ineffective-timeout boundary is inclusive on the ceiling. /// /// Equal is the case that matters and the one an inverted comparison would drop: at exactly the @@ -4242,12 +4345,17 @@ mod acp_ws_integration { /// Within ONE connection, the later establish still wins — the attach tiebreak is load-bearing. /// - /// Every other ordering test here is cross-connection, so all of them survive dropping the second - /// half of `(connection age, attach order)` and comparing only age: their connection ages already - /// give the right answer. What no other test reaches is two establishes on the SAME connection, - /// where the ages are equal and attach order is the only thing left to decide with. Compare age - /// alone and a late-finishing older establish is neither superseded nor blocked, so it evicts the - /// successor that already registered and installs the stale tunnel over it. + /// Same-connection ordering tests DO exist — `mod acp_requests` builds handles with a + /// `connection_generation` of 0 throughout — but every one of them exercises the same direction: + /// the later establish also finishes later, and there dropping the tiebreak still answers + /// correctly (equal ages mean nothing supersedes, the same-name eviction is unconditional, and + /// the later arrival wins anyway). What nothing reaches is the REVERSE direction: started + /// earlier, finished later. Compare age alone there and the stale establish is neither superseded + /// nor blocked, so it evicts the successor that already registered and installs itself over it. + /// + /// Stated this way on purpose. An earlier draft claimed "every other ordering test is + /// cross-connection", which is a universal that one same-connection test disproves — and the case + /// for this test would have appeared to fall with it, though the gap is real either way. /// /// Deterministic without racing two spawns: the first establish is parked by withholding its /// `mcp/connect` answer, the second is driven to completion, and only then is the first released. diff --git a/src/browser_source.rs b/src/browser_source.rs index 2340b210a..463e5d959 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -334,14 +334,12 @@ impl CapabilitySource for AcpTunnelSource { ))); } - // Resolve the declared name to the tunnel's registry key (§6.1). Same-name - // duplicates cannot occur — attach evicts the stale entry (last-attach-wins). - let Some((_, server_id)) = self - .tunnel - .servers(&ctx.channel_id) - .into_iter() - .find(|(name, _)| name == server_name) - else { + // Resolve the declared name to the tunnel's registry key (§6.1). Delegated rather than + // done here: enumerating and taking the first name match is only correct while same-name + // entries cannot coexist, and that uniqueness is maintained in the gateway, out of this + // file's sight. A "take the first" caller does not fail when it breaks — it silently routes + // to an arbitrary tunnel. The resolution now lives beside the eviction that guarantees it. + let Some(server_id) = self.tunnel.resolve_by_name(&ctx.channel_id, server_name) else { return Ok(Self::error_result(format!( "{server_name} not connected: open the OpenAB side panel in your browser" ))); @@ -450,6 +448,16 @@ mod tests { #[async_trait::async_trait] impl AcpMcpTunnel for FakeTunnel { + /// The double holds a controlled list, so matching it directly is honest here — the + /// reason the real implementation delegates to the gateway is that IT cannot rank two + /// same-name tunnels, not that matching is wrong in principle. + fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option { + self.servers(channel_id) + .into_iter() + .find(|(name, _)| name == server_name) + .map(|(_, id)| id) + } + async fn call( &self, channel_id: &str, diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index e2ddc8f26..32535cedf 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -63,6 +63,19 @@ impl AcpMcpTunnel for RootBrowserTunnel { } } + /// Delegates to the gateway, which resolves under the registry lock and beside the eviction + /// that keeps declared names unique. Deliberately not implemented here by enumerating and + /// matching: this crate cannot rank two tunnels — the ordering fields are private to the gateway + /// — so a local implementation could only take an arbitrary match or refuse, and refusing is the + /// behaviour ADR §6.1 rejects. + fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option { + openab_gateway::adapters::acp_server::resolve_by_name( + &self.registry, + channel_id, + server_name, + ) + } + /// Enumerate this channel's registered tunnels as `(declared_name, server_id)` (ADR §6.1). /// The name is what a tool prefix and the §6.4 allowlist match on; the id is what the registry /// is keyed by. Same-name duplicates cannot appear here — `establish_and_register_tunnel` diff --git a/src/main.rs b/src/main.rs index 97f263350..eb00eabed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1720,6 +1720,9 @@ fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { #[cfg(test)] mod tests { + use super::*; + use clap::Parser; + /// The shipped tunnel-timeout default must stay strictly beneath the ceiling that overtakes it. /// /// This pairing can only be asserted here. The gateway owns the ceiling and cannot see the @@ -1745,8 +1748,6 @@ mod tests { "the shipped default must not be a value the startup warning fires on" ); } - use super::*; - use clap::Parser; #[test] fn cli_no_args_defaults_to_run() { From 39b42f87075dbb5ecd27b76a84c01e75a6deb830 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 08:17:41 +0800 Subject: [PATCH 117/138] refactor(acp): resolve in one pass, and give discovery the same collapse rule as routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from review, no new tests and no behaviour change on the reachable path. `resolve_by_name` no longer collects into a `Vec` and sorts. The cost was never the issue at one or two entries — Orca was explicit that performance is not the reason — but sorting to compute a maximum makes the reader re-derive whether the direction is right, and direction is what this file has got wrong repeatedly: the rank polarity, `<=` against `<`, which of the two numbers is compared first. One pass now tracks the count and the best, and `rank > current` says which way wins out loud. A shape that already holds a `Vec` under the lock also invites the next person to do more work in there; there is nothing to add to now. Direction is still pinned: flipping the comparison fails the existing test. Discovery used a different collapse rule from routing, which is worse than it sounds. It built a `name -> server_id` map with `collect::>()`, so duplicates resolved to whichever entry the iterator yielded last, over registry `HashMap` iteration order — arbitrary and unstable across runs, with no signal. Routing resolves newest-wins, deterministically, and warns. While names are unique the two agree; if that ever breaks, discovery would fetch its catalogue from one tunnel while calls went to another, advertising tools the serving tunnel does not have. That is harder to diagnose than either side simply picking wrong. Discovery now calls the same `resolve_by_name`, and the stale "duplicates cannot occur" comment goes with the snapshot. Reusing the one function rather than adding a bulk resolved-map helper is deliberate. A second helper would be a second implementation of the collapse rule, and one rule with two implementations is the shape that has broken most often here: the tunnel-timeout default recorded in two places, `connection_generation` adopted at one of three comparison sites, the eviction filter going from redundant to live-and-wrong once it stopped matching the check above it. The cost is one lock acquisition per policy name and a registry scan each time — O(names × entries), bounded 8 × 8 by `MAX_ACP_SERVERS_PER_SESSION`. If that ever matters, the bulk helper should be added then and written in terms of this comparison, not kept ahead of need as a second rule that has to agree. Left open for a reviewer rather than decided here: `servers()` now has no production caller — only the test double's own `resolve_by_name` uses it. It is a `pub` trait method with a default, so nothing warns. 6a1004af → HEAD, gateway 341 → 341, root 40 → 40 (+0): no functions added or removed. --- .../openab-gateway/src/adapters/acp_server.rs | 29 ++++++++++++++----- src/browser_source.rs | 18 ++++++------ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 708f1cf29..5d9097de9 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -430,20 +430,33 @@ pub fn resolve_by_name( channel_id: &str, server_name: &str, ) -> Option { + // One pass, no allocation, and `rank > best` says which direction wins out loud. The first + // version collected into a `Vec`, sorted it, and took the last element — the cost of that was + // never the problem at one or two entries, but it asked the reader to re-derive whether the + // direction was right, and direction is what this file has got wrong repeatedly: the rank + // polarity, `<=` against `<`, which of the two numbers is compared first. A shape that already + // holds a `Vec` under the lock also invites the next person to do more work in here. let reg = registry.lock().unwrap_or_else(|e| e.into_inner()); - let mut matches: Vec<(&(String, String), &TunnelHandle)> = reg - .iter() - .filter(|((c, _), h)| c == channel_id && h.server_name == server_name) - .collect(); - if matches.len() > 1 { + let mut count = 0usize; + let mut best: Option<(&String, (u64, u64))> = None; + for ((c, id), h) in reg.iter() { + if c != channel_id || h.server_name != server_name { + continue; + } + count += 1; + let rank = (h.connection_generation, h.generation); + if best.is_none_or(|(_, current)| rank > current) { + best = Some((id, rank)); + } + } + if count > 1 { warn!( - channel = %redact_id(channel_id), server_name, count = matches.len(), + channel = %redact_id(channel_id), server_name, count, "ACP: more than one tunnel is registered under one declared name — registry uniqueness \ was broken upstream; routing to the newest attach" ); } - matches.sort_by_key(|(_, h)| (h.connection_generation, h.generation)); - matches.last().map(|((_, id), _)| id.clone()) + best.map(|(id, _)| id.clone()) } /// Registry of active ACP sessions: channel_id → reply sink. diff --git a/src/browser_source.rs b/src/browser_source.rs index 463e5d959..b39edacdc 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -267,13 +267,6 @@ impl CapabilitySource for AcpTunnelSource { return sorted(self.policy.values().flat_map(|p| p.seed.iter().cloned())); }; - // name -> server_id for the tunnels attached to this session. Same-name - // duplicates cannot occur (last-attach-wins, §6.1). - let attached: HashMap = self - .tunnel - .servers(&ctx.channel_id) - .into_iter() - .collect(); let mut out: Vec = Vec::new(); for (name, policy) in &self.policy { @@ -295,8 +288,15 @@ impl CapabilitySource for AcpTunnelSource { // Nothing discovered yet: advertise the seed (never empty out a // seeded server) and kick off the fetch if it is attached. out.extend(policy.seed.iter().cloned()); - if let Some(server_id) = attached.get(name) { - self.spawn_discovery(&ctx.channel_id, name, server_id); + // Resolved through the same call routing uses, not through a snapshot of + // `servers()`. Collecting that into a map keeps whichever entry the iterator + // yields LAST, and registry iteration is not ordered by generation — so in a + // duplicate-name state discovery could fetch its catalog from one tunnel while + // calls went to another, advertising tools the serving tunnel does not have. + // Uniqueness makes that unreachable today; using one resolution makes it + // unreachable by construction. + if let Some(server_id) = self.tunnel.resolve_by_name(&ctx.channel_id, name) { + self.spawn_discovery(&ctx.channel_id, name, &server_id); } } } From 74315a60d0f9603cd8d43eb852388a7b68a82cd6 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 08:25:43 +0800 Subject: [PATCH 118/138] refactor(acp)!: remove the enumerating servers(), leaving one way to reach a tunnel by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AcpMcpTunnel::servers` had no production callers left. Both of them used to collapse `name -> server_id` themselves and by different rules — routing took the first match, discovery took whichever a `HashMap` happened to keep — and both now call `resolve_by_name`, which does it once, beside the eviction that makes the answer unique. Kept, it would be a `pub` trait method that nobody calls and that looks like the supported way to reach a tunnel by name. The next person to need routing would reasonably use it and rebuild the assumption the previous commits removed. Deleted, there is one route and it is the correct one. Same reasoning as dropping the seven stale `#[allow(dead_code)]` rather than annotating them. The implementor list came free: making `resolve_by_name` required rather than defaulted had already forced the compiler to name every implementation, so the blast radius of this removal was known before starting. That is a second argument for required-over-defaulted, beyond the one that motivated it. `FakeTunnel::resolve_by_name` now reads its own field instead of going through the trait. `tunnel_ranked` documents that every handle needs a distinct rank. Resolution compares with a strict `>`, so equal ranks mean the first one the registry iterator yields wins, over an order that is not stable. Real handles cannot tie because `fetch_add` makes the attach number unique; only handmade ones can. A test built on two equal ranks would fail intermittently for a reason with no visible connection to what it was testing. Corrects the cost figure in 39b42f87's message, which claimed one lock acquisition per policy name. The call sits in the `cached.is_none()` arm, so it runs only for names with no cached catalogue — usually none after the first discovery pass. The trade-off argument held; the number I quoted was worse than reality, and I had not checked it. BREAKING CHANGE: `AcpMcpTunnel::servers` is removed. Implementors of that trait outside this repository must drop it; `resolve_by_name` covers reaching a tunnel by declared name, and nothing in-tree needed enumeration once discovery stopped collapsing names itself. 39b42f87 → HEAD, gateway 341 → 341, root 40 → 40 (+0 tests; -1 fn in each of three files). --- crates/openab-core/src/mcp_proxy.rs | 25 ++++--------------- .../openab-gateway/src/adapters/acp_server.rs | 6 +++++ src/browser_source.rs | 14 +++++------ src/browser_tunnel.rs | 11 -------- 4 files changed, 17 insertions(+), 39 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index d9ad13b68..d6653f504 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -41,30 +41,15 @@ pub trait AcpMcpTunnel: Send + Sync { params: Option, ) -> Result; - /// The `type:acp` servers currently registered for `channel_id`, as `(declared_name, - /// server_id)` pairs. - /// - /// Both halves are needed and they are *not* interchangeable (ADR §6.1): the registry is keyed - /// by the client-minted `server_id`, which the reference client mints as a fresh UUID **per - /// connection**, while a tool name carries the stable declared **name** (`katashiro.click`) and - /// the §6.4 trust gate is keyed by that name too. Enumerating both is what lets a capability - /// source resolve a tool prefix back to a tunnel; matching a prefix against the registry key - /// alone can never work. - /// - /// Sync because implementations just read an in-memory registry. The default is empty, so - /// implementations that track no declarations (test doubles, single-target bridges) simply - /// advertise nothing. - fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { - Vec::new() - } /// Resolve a declared server NAME to the `server_id` the registry keys that tunnel by, for one /// channel. /// - /// Separate from [`Self::servers`] on purpose. Enumerating and picking the first name match is - /// only correct while same-name entries cannot coexist, and that uniqueness is maintained far - /// from any caller — so the choice belongs with the code that maintains it, not with each - /// consumer. `servers` stays for enumeration and discovery, where seeing everything is the point. + /// The only way to reach a tunnel by name. There was also an enumerating `servers()`, and both + /// its callers collapsed `name -> id` themselves: routing took the first match, discovery took + /// whichever a `HashMap` kept last. Two collapse rules for one fact, neither beside the eviction + /// that makes the fact true. Both now call this, and the enumerator is gone rather than left as a + /// second route someone would reasonably mistake for the supported one. /// /// Required, with no default. A default returning `None` compiles for every existing implementor /// and then silently answers "not connected" for all routing — the failure surfaces at run time, diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 5d9097de9..1010e7ead 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -2914,6 +2914,12 @@ mod acp_requests { /// /// The establish path cannot produce two tunnels under one name, so a state that only /// `resolve_by_name` has to cope with has to be built by hand. + /// + /// **Give every handle a distinct rank.** `resolve_by_name` compares with a strict `>`, so on a + /// tie the first one the registry iterator happens to yield wins — and that order is not stable. + /// Real handles cannot tie, because `fetch_add` makes the attach number unique; only handmade + /// ones can. A test built on two equal ranks would be intermittently wrong for a reason with no + /// visible connection to what it was testing. fn tunnel_ranked(owner: &str, conn_gen: u64, gen: u64) -> super::TunnelHandle { let (out_tx, _rx) = mpsc::unbounded_channel::(); super::TunnelHandle { diff --git a/src/browser_source.rs b/src/browser_source.rs index b39edacdc..28c0b1d2b 100644 --- a/src/browser_source.rs +++ b/src/browser_source.rs @@ -451,11 +451,13 @@ mod tests { /// The double holds a controlled list, so matching it directly is honest here — the /// reason the real implementation delegates to the gateway is that IT cannot rank two /// same-name tunnels, not that matching is wrong in principle. - fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option { - self.servers(channel_id) - .into_iter() + fn resolve_by_name(&self, _channel_id: &str, server_name: &str) -> Option { + self.servers + .lock() + .unwrap() + .iter() .find(|(name, _)| name == server_name) - .map(|(_, id)| id) + .map(|(_, id)| id.clone()) } async fn call( @@ -484,10 +486,6 @@ mod tests { )); Ok(json!({ "content": [{ "type": "text", "text": "ok" }] })) } - - fn servers(&self, _channel_id: &str) -> Vec<(String, String)> { - self.servers.lock().unwrap().clone() - } } fn ctx() -> SessionCtx { diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index 32535cedf..a5518a31d 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -76,15 +76,4 @@ impl AcpMcpTunnel for RootBrowserTunnel { ) } - /// Enumerate this channel's registered tunnels as `(declared_name, server_id)` (ADR §6.1). - /// The name is what a tool prefix and the §6.4 allowlist match on; the id is what the registry - /// is keyed by. Same-name duplicates cannot appear here — `establish_and_register_tunnel` - /// evicts the stale entry on attach (last-attach-wins). - fn servers(&self, channel_id: &str) -> Vec<(String, String)> { - let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); - reg.iter() - .filter(|((c, _), _)| c == channel_id) - .map(|((_, id), h)| (h.server_name().to_string(), id.clone())) - .collect() - } } From 24db1d103b8d2fbb52e0cee7e2480445bebc3925 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 08:36:12 +0800 Subject: [PATCH 119/138] docs(acp): point the registry's public doors at resolve_by_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 74315a60's message claimed that removing `servers()` left "one route and it is the correct one". That is true of the trait and false of the crate. `AcpTunnelRegistry` is a `pub type` over a bare `Arc>>`, `new_tunnel_registry` is `pub`, and `TunnelHandle::server_name` is a `pub` accessor — so any holder can iterate, match names itself, and rebuild the arbitrary-choice assumption the previous commits removed. Iteration is not something the module grants; it comes with the type. The accurate claim is narrower: `resolve_by_name` is the only route that resolves CORRECTLY. Other routes exist and are simply wrong. The difference matters because the original wording implies the door is shut, so a reader concludes no care is needed where care is exactly what is needed. Rather than restate that in a commit message nobody will read at the moment they need it, both public doors now carry it: the type's doc says to call `resolve_by_name` and why doing it yourself is wrong (the deciding rank is private, and the eviction that makes names unique lives beside the resolver, not beside the caller), and `server_name` is marked as exposed for reporting rather than routing. Two callers previously did their own matching, by two different rules, and neither noticed — which is the argument for putting this where they were standing. Not attempted: making the claim true by wrapping the registry in a newtype that exposes only `get` and `resolve_by_name`. That was raised and is more expensive than it first appears, because `RootBrowserTunnel::call` still iterates — its empty-`server_id` sentinel resolves "the sole tunnel on this channel" and errors when there are several. Making the strong claim true would mean relocating that resolution too, which is the same cleanup as the last three commits rather than a new idea, and is left as an open item. 74315a60 → HEAD, gateway 341 → 341, root 40 → 40 (+0): doc comments only. --- crates/openab-gateway/src/adapters/acp_server.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 1010e7ead..1f70c03c4 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -474,6 +474,13 @@ pub fn new_reply_registry() -> AcpReplyRegistry { /// Keyed by the compound `(channel_id, server_id)` (P1) so one session can carry several /// `type:acp` servers without collision; eviction drops all `(channel_id, *)` on teardown. Same /// std::sync::Mutex rationale as `AcpReplyRegistry`. +/// Shared map of live MCP-over-ACP tunnels, keyed `(channel_id, server_id)`. +/// +/// **To reach a tunnel by its declared NAME, call [`resolve_by_name`].** Holding this map and +/// matching `server_name()` yourself is possible and is the wrong thing: which entry wins when a +/// name appears twice is decided by rank, that rank is private, and the eviction keeping names +/// unique lives beside `resolve_by_name` rather than beside you. Two callers previously did their +/// own matching, by two different rules, and neither noticed. pub type AcpTunnelRegistry = Arc>>; pub fn new_tunnel_registry() -> AcpTunnelRegistry { @@ -856,6 +863,10 @@ pub struct TunnelHandle { impl TunnelHandle { /// The client-declared server name for this tunnel (see the field docs for why the declared /// name and the registry key are deliberately different things). + /// + /// Exposed for reporting, not for routing. Selecting a tunnel by comparing this against a + /// wanted name is what [`resolve_by_name`] exists to do — it also knows which entry wins if a + /// name ever appears twice, which this accessor cannot tell you. pub fn server_name(&self) -> &str { &self.server_name } From 4396cbe92998df3a649958c65737f3a7537f7794 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 08:43:05 +0800 Subject: [PATCH 120/138] docs(acp): integrate the registry doc instead of appending to it, and drop a false teardown claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems in the block 24db1d10 touched, both found by Orca reviewing that commit. The pointer I added was appended to the existing doc with no blank line, so it joined that paragraph. Its opening sentence restated "keyed by `(channel_id, server_id)`" for the third time in one block, and rustdoc takes the first paragraph as the summary — so the type index ended on a redundant restatement while the instruction that matters, call `resolve_by_name`, sat in the third paragraph where a reader scanning the index never reaches it. Now one summary sentence and separate paragraphs. This is the fourth time in this branch that writing next to existing text without reading it has gone wrong. The earlier three were insertions reparenting a doc comment or an attribute; this is an append diluting a doc. Same act, different symptom — and it happened in the commit whose whole purpose was correcting a doc inaccuracy, so intending to be careful about docs protected nothing. Reading the block properly also exposed a false statement inside it: "eviction drops all `(channel_id, *)` on teardown". Teardown matches on `owner`, not on the channel, and has since 197bb4de — dropping every `(channel_id, *)` would delete a successor's live entry, because a client that reconnects and resumes takes over the same `channel_id`. The doc now says what the code does and why. That sentence was already known: it is Orca's documentation finding #11, raised against the ADR. I triaged that as a pre-merge item and never swept for the same claim elsewhere, so this second copy survived a review that had already identified the wording. Swept now — two mentions remain and both are accurate, including `acp_server.rs`'s inline "this connection's tunnels only". The ADR line is still open as a pre-merge item. 24db1d10 → HEAD, gateway 341 → 341, root 40 → 40 (+0): doc comments only. --- .../openab-gateway/src/adapters/acp_server.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 1f70c03c4..892c463e0 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -468,13 +468,16 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } -/// Registry of open MCP-over-ACP tunnels: `(channel_id, server_id)` → `TunnelHandle`. The -/// gateway inserts a handle once it has `mcp/connect`ed to a session's declared `type:acp` -/// server; the core MCP proxy looks one up to route a tool call to the right server (T5.3). -/// Keyed by the compound `(channel_id, server_id)` (P1) so one session can carry several -/// `type:acp` servers without collision; eviction drops all `(channel_id, *)` on teardown. Same -/// std::sync::Mutex rationale as `AcpReplyRegistry`. -/// Shared map of live MCP-over-ACP tunnels, keyed `(channel_id, server_id)`. +/// Registry of open MCP-over-ACP tunnels: `(channel_id, server_id)` → `TunnelHandle`. +/// +/// The gateway inserts a handle once it has `mcp/connect`ed to a session's declared `type:acp` +/// server; the core MCP proxy looks one up to route a tool call to the right server (T5.3). Keyed +/// by the compound `(channel_id, server_id)` (P1) so one session can carry several `type:acp` +/// servers without collision. Same `std::sync::Mutex` rationale as `AcpReplyRegistry`. +/// +/// Teardown removes only the entries THIS connection owns — it matches on `owner`, not on the +/// channel. Removing every `(channel_id, *)` would delete a successor's live entry, because a +/// client that reconnects and resumes takes over the same `channel_id`. /// /// **To reach a tunnel by its declared NAME, call [`resolve_by_name`].** Holding this map and /// matching `server_name()` yourself is possible and is the wrong thing: which entry wins when a From 67bb5d7442e02cccd5bd29a98eb769d08d53337e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 09:40:21 +0800 Subject: [PATCH 121/138] fix(acp): gate the root timeout test on the acp feature so the crate compiles without it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red since d15018e5 — six consecutive failures — and the eight-step gate reported green for every one of them. `the_default_tunnel_timeout_stays_beneath_the_idle_timeout` names `openab_gateway::adapters::acp_server`, and `openab-gateway` is an OPTIONAL dependency. The surrounding `mod tests` in `src/main.rs` is `#[cfg(test)]` only, not feature-gated, so under default features the crate is absent and the whole `openab` test binary fails to compile: error[E0433]: cannot find module or crate `openab_gateway` in this scope CI's `check` job runs a plain `cargo test --workspace`, which is that configuration. The gate could not see it. Every test step passed `--features acp`, and `cargo clippy --workspace` does not build test targets — so no step compiled test targets under default features, which is the first thing CI compiles. The gate was green about the steps it ran and said nothing about the branch, while I attached a completion claim to it. Gated the single test on `#[cfg(feature = "acp")]`, with the reason recorded beside it because the next person adding a test here will hit the same wall. Deliberately NOT making openab-gateway non-optional: that would pull the gateway into the default binary to satisfy a test. Fourth instance of one family, and the runbook already names the other three: F5 never compiled, item 18 compiled but was never selected, ci.yml's trailing colon left the workflow unparseable while every local gate passed. Each is the same shape — the check that ran was not the check that mattered. Step 9 (`cargo test --workspace --no-run`, no features) now reproduces CI's first step locally and is in the gate. 4396cbe9 → HEAD, gateway 341 → 341, root 40 → 40 (+0): one test moved behind a feature gate. --- src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main.rs b/src/main.rs index eb00eabed..47756405d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1733,6 +1733,13 @@ mod tests { /// Raising the default to or above the ceiling would silently restore the condition several /// commits were spent removing: two clocks starting together, with the wrong one able to fire /// first, and no cancellation reaching the peer when it does. + /// + /// Feature-gated because it names `openab_gateway`, which is an OPTIONAL dependency: the crate + /// is absent under default features, so without this gate the whole `openab` test binary fails + /// to compile for anyone building without `acp` — including CI, whose `check` job runs a plain + /// `cargo test --workspace`. The surrounding `mod tests` is `#[cfg(test)]` only, so the gate has + /// to be here. + #[cfg(feature = "acp")] #[test] fn the_default_tunnel_timeout_stays_beneath_the_idle_timeout() { let default = openab_core::config::default_tunnel_timeout_seconds(); From 2a6c074a73fbf07331294a6df06bf12d5ea9485c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 12:05:37 +0800 Subject: [PATCH 122/138] ci(acp): fail the filtered test steps when the filter selects nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8 and R9 — instruments first, because this round's central failure was a gate reporting green while CI was red for six consecutive runs. `cargo test` exits 0 when a filter matches no tests, so "the filter selected nothing" and "everything passed" are indistinguishable from the outside. That is not hypothetical here: it is how a compiled-but-never-selected test shipped in this branch already. Both filtered steps now list the selection, print the count, and fail on zero. Confirmed rather than assumed, before writing the guard: `-- --list | grep -c ': test$'` reports 14 for `mcp_proxy::` and 0 for a deliberately bogus filter, and a zero-match `cargo test` run does exit 0. Two details that are easy to get wrong and were: - The commands live in block scalars. Written inline after `run:`, a filter ending in `::` reads as a YAML mapping indicator — that is the trailing-colon bug that once left this workflow unparseable for nine commits while every local gate stayed green. - `grep -c` exits 1 when the count is zero, so the count is captured with `|| true`. Without it `set -euo pipefail` aborts the step before the guard can say what was actually wrong, turning a precise message back into an unexplained failure. R9 adds `cargo build --features unified` to the local gate. CI runs it and clippy is not a substitute: clippy neither links nor codegens, so duplicate symbols, a missing native library, or a link-order problem pass every clippy step. The gate had no step that produced a binary under the unified feature set at all. The gate is ten steps now and green on all of them. That claim is worth exactly what its coverage is worth, which is the lesson that produced this commit. Not addressed here, deliberately: whether any CI job actually RUNS a given test. Listing proves selection for the two filtered steps; the general test-to-job mapping needs a designed list of supported feature combinations and belongs in its own PR. Neither mechanism can tell whether a test is any good — a tautological assertion still counts as selected. --- .github/workflows/ci.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3014a825..122ec6014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,15 +61,31 @@ jobs: # Filtered to `mcp_proxy::` for the same reason the gateway step is scoped to one package — # it keeps the flaky hooks::tests out of this job. - name: cargo test (acp-mcp core) - # Quoted: the trailing `::` of the filter would otherwise read as a YAML mapping - # indicator and make the whole workflow file unparseable. - run: "cargo test -p openab-core --features acp-mcp mcp_proxy::" + # `cargo test` exits 0 when a filter selects NOTHING, so "selected nothing" and "everything + # passed" are indistinguishable — that is precisely how a compiled-but-never-selected test + # shipped here before. Count the selection first and fail on zero. + # + # The filter lives in a variable inside a block scalar: written inline after `run:`, its + # trailing `::` reads as a YAML mapping indicator and makes the whole file unparseable. + run: | + set -euo pipefail + FILTER='mcp_proxy::' + n=$(cargo test -p openab-core --features acp-mcp "$FILTER" -- --list | grep -c ': test$' || true) + echo "filter '$FILTER' selected $n test(s)" + [ "$n" -gt 0 ] || { echo "::error::filter '$FILTER' selected ZERO tests — stale or mistyped"; exit 1; } + cargo test -p openab-core --features acp-mcp "$FILTER" # The pool's facade-session tests are `acp-mcp`-gated too but live outside `mcp_proxy::`, so # the filter above compiled them and then selected them away. Naming the module runs them # while still leaving the flaky hooks::tests out. - name: cargo test (acp-mcp pool) - # Quoted for the same reason as above — a trailing `::` reads as a YAML mapping indicator. - run: "cargo test -p openab-core --features acp-mcp acp::pool::" + # Same zero-match guard as above, and the same reason for the block scalar. + run: | + set -euo pipefail + FILTER='acp::pool::' + n=$(cargo test -p openab-core --features acp-mcp "$FILTER" -- --list | grep -c ': test$' || true) + echo "filter '$FILTER' selected $n test(s)" + [ "$n" -gt 0 ] || { echo "::error::filter '$FILTER' selected ZERO tests — stale or mistyped"; exit 1; } + cargo test -p openab-core --features acp-mcp "$FILTER" # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and # are `acp`-gated, so `--workspace` above skips # them too. This is where the source's routing and trust-gate coverage lives. From c0d424a5629f5620b53ecafaa2c1a3800523afc0 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 12:29:56 +0800 Subject: [PATCH 123/138] fix(acp)!: stop logging the resume credential in cleartext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1, the most severe item left. An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are mutually derivable: a channel id in a log IS a resume credential. Anyone who can read operator logs could take over a live session, and logs travel further than the sessions they describe. The item named three sites. There are seven. Sweeping the class found four more in the same file as one of the named ones — `core/gateway.rs` at the allowlist skip, the identity denial, the scope denial and the unified receive. Every one takes `event.platform` and `event.channel.id`, so every one carries the credential when the platform is acp. A verified list of sites is still a claim about where someone looked. The audit line in the facade is the sharpest of them: it hashes its arguments because they "could carry secrets" and printed the credential beside them in cleartext. The inconsistency was visible in six lines of one function. Only `acp_` ids are hashed. A Discord or Slack channel id is a public identifier that operators grep for, and blanket redaction would cost real debuggability to protect nothing. Hashed rather than dropped, because the same session must still tag identically on every line — that correlation is the only reason to keep an identifier in a log at all. The helper is duplicated in three crates rather than shared. These crates deliberately depend on none of each other; `browser_tunnel.rs` calls that independence intentional, and buying five shared lines with a new dependency edge is the wrong side of that trade. But duplication has a real failure mode here — three drifting hashes would split one session into three untraceable tags, which is worse than not redacting at all — so each copy is pinned to the same vector by its own test. Change one and its build fails. Deliberately unchanged, and reported instead: `acp_server.rs` logs session and channel ids raw at `debug!` in five places. That is a consistent existing convention, not an oversight — the F12 decision downgraded these rather than redacting them, on the grounds that a sessionId is a resume capability kept out of normal logs. Whether debug-level exposure is acceptable is a different question from this one and should be answered on its own. `core/ambient.rs` also logs a `channel_id`; I could not establish whether an ambient channel can be an ACP channel, so it is flagged rather than guessed at. BREAKING CHANGE: operator logs no longer contain raw ACP channel ids. Anything grepping for `acp_` in gateway reply, gateway event, or facade audit lines must match the `#<8hex>` tag instead. Non-ACP platforms are unaffected. --- crates/openab-core/src/gateway.rs | 66 ++++++++++++++++++++++++++--- crates/openab-gateway/src/lib.rs | 58 ++++++++++++++++++++++++- crates/openab-mcp/src/mcp/facade.rs | 60 +++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index caac2441e..128c646a9 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -86,7 +86,7 @@ fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { } // Channel allowlist if !filter.allow_all_channels && !filter.allowed_channels.contains(&event.channel.id) { - tracing::info!(channel = %event.channel.id, "gateway: channel not in allowed_channels, skipping"); + tracing::info!(channel = %redact_channel(&event.channel.id), "gateway: channel not in allowed_channels, skipping"); return true; } // User allowlist @@ -913,7 +913,7 @@ pub async fn run_gateway_adapter( info!( platform = %event.platform, sender = %event.sender.name, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event received" ); @@ -1315,7 +1315,7 @@ fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEve tracing::info!( platform = %event.platform, sender = %event.sender.id, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event denied (identity); echoing request-access" ); let throttle_key = format!("{}:{}", event.platform, event.sender.id); @@ -1343,7 +1343,7 @@ fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEve tracing::info!( platform = %event.platform, sender = %event.sender.id, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), ?decision, "gateway event denied (scope); silent" ); @@ -1393,7 +1393,7 @@ pub async fn process_gateway_event( tracing::info!( platform = %event.platform, sender = %event.sender.name, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event received (unified)" ); @@ -1876,3 +1876,59 @@ mod tests { assert!(!should_skip_event(&event, &filter)); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: the channel id printed here IS a resume credential. Anyone reading operator +/// logs could resume the session, and logs travel further than the sessions they describe. +/// +/// Only ACP ids are hashed. A Discord or Slack channel id is a public identifier that operators +/// legitimately grep for, and redacting it would cost real debuggability to protect nothing. +/// +/// Hashed rather than dropped so the same session still tags identically on every line — that +/// correlation is the whole reason the id is in the log. **The tag must match the one produced in +/// the other crates that log channel ids**, or a session cannot be followed across them; each copy +/// is pinned to the same vector by its own test. The copies exist because these crates deliberately +/// do not depend on one another, and adding an edge to share five lines would trade a documented +/// architectural boundary for a duplicate. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index b730c90f0..bc988b172 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -886,7 +886,7 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: Ok(reply) => { info!( platform = %reply.platform, - channel = %reply.channel.id, + channel = %redact_channel(&reply.channel.id), command = ?reply.command.as_deref(), "OAB → gateway reply" ); @@ -1178,3 +1178,59 @@ mod l1_audit_tests { assert!(flagged(&s).is_empty()); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: the channel id printed here IS a resume credential. Anyone reading operator +/// logs could resume the session, and logs travel further than the sessions they describe. +/// +/// Only ACP ids are hashed. A Discord or Slack channel id is a public identifier that operators +/// legitimately grep for, and redacting it would cost real debuggability to protect nothing. +/// +/// Hashed rather than dropped so the same session still tags identically on every line — that +/// correlation is the whole reason the id is in the log. **The tag must match the one produced in +/// the other crates that log channel ids**, or a session cannot be followed across them; each copy +/// is pinned to the same vector by its own test. The copies exist because these crates deliberately +/// do not depend on one another, and adding an edge to share five lines would trade a documented +/// architectural boundary for a duplicate. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} diff --git a/crates/openab-mcp/src/mcp/facade.rs b/crates/openab-mcp/src/mcp/facade.rs index bef76c6e6..b8af0aec3 100644 --- a/crates/openab-mcp/src/mcp/facade.rs +++ b/crates/openab-mcp/src/mcp/facade.rs @@ -278,7 +278,11 @@ impl McpFacade { // reason, never forwarded. meta_tool::validate_args(tool.input_schema.as_ref(), &args_map) .with_context(|| format!("execute_capability {name:?}"))?; - let channel = ctx.map(|c| c.channel_id.as_str()).unwrap_or("-"); + // Redacted for the same reason the arguments below are hashed, and the + // inconsistency was the tell: this line hashed the args because they "could carry + // secrets" while printing a resume credential beside them in cleartext. An ACP + // `channel_id` is `acp_` and the session id is `sess_`. + let channel = redact_channel(ctx.map(|c| c.channel_id.as_str()).unwrap_or("-")); // Same audit shape as the meta_tool dispatcher: hash of the // wire arguments, never plaintext (could carry secrets). let args_sha256 = { @@ -877,3 +881,57 @@ mod tests { assert!(err.to_string().contains("requires a `name`")); } } + +/// Render a channel id for the audit log, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: printed in full, this line hands out a resume credential. That sat directly +/// beside `args_sha256`, which exists because arguments "could carry secrets" — the audit line was +/// hashing the payload and publishing the capability. +/// +/// Only ACP ids are hashed; a Discord or Slack channel id is public and operators grep for it. +/// +/// **The tag must match the one produced in `openab-gateway` and `openab-core`**, or one session +/// cannot be followed across the reply log, the event log and this audit log — which is the only +/// reason to keep an identifier here at all. Each copy is pinned to the same vector by its own +/// test. The copies exist because these crates deliberately do not depend on one another. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} From 43ecb66ad6be656e820a5a7b7c0a3df5418a1460 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 13:01:15 +0800 Subject: [PATCH 124/138] fix(acp)!: redact both encodings of the session credential, and follow the value not the field name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass on R1, from Orca's review. The first pass fixed seven sites and missed twenty-five, because it was scoped by encoding rather than by secret. One uuid addresses a session two ways: the session id is `sess_` and the channel id is `acp_`. Either resumes it, and `sess_` is the more direct of the two — resume takes it without deriving anything. The first pass swept `acp_` only, and its scan patterns (`channel = %`, `channel_id = %`) cannot match `session = %` at all. That claim about an existing convention was wrong, and the file says so. The previous commit left three raw `session = %session_id` debug lines in `acp_server.rs` on the grounds that they followed a deliberate F12 decision to downgrade rather than redact. Twenty lines further down, the same value is logged as `warn!(session = %redact_id(&session_id), …)`. One file, one value, redacted in one place and not the other: an inconsistency, not a convention, and treating it as settled policy is how it survived a review that had already looked at that file. Following the value instead of the field name found twelve more sites in `pool.rs` — a file R1 never named — logging `thread_id` (a `:` composite) and `session_id` in cleartext at info level. `redact_session_ids` now matches on the value's shape: it splits on `:` and hashes any `acp_` or `sess_` segment, so bare ids and composite pool keys are handled the same way and both encodings of one uuid produce the same tag. A session still reads as one thing across every line. Applied to `ambient.rs` rather than investigated. The previous pass flagged it as unresolved and then proved by type that an ambient channel cannot be an ACP one — correct, and beside the point: the function is a no-op for non-ACP ids, so applying it costs nothing and removes the question. That is cheaper than an investigation and safer than a guess, and it generalises to any call site whose platform cannot be established. The single pinned vector is replaced by a table. Three copies can agree on one `acp_...` example and still diverge on the predicate — and the predicate is exactly what this commit changes, since adding `sess_` alters which inputs hash while leaving every `acp_` output identical. A vector pins a point; a table pins the branch structure. Out of scope, checked and classified rather than assumed: the remaining raw channel logs live in platform-specific adapters (`line`, `feishu`, `discord`) and in `cron.rs`, whose channels come from operator-written config. A per-session `acp_` cannot appear in either. R1 named three sites. This branch now redacts thirty-two. BREAKING CHANGE: operator logs no longer contain raw ACP session or channel ids. Anything grepping for `sess_` or `acp_` in gateway, pool, facade or ambient lines must match the `#<8hex>` tag instead. Both encodings of one session produce the same tag. --- crates/openab-core/src/acp/pool.rs | 24 +++--- crates/openab-core/src/ambient.rs | 20 ++--- crates/openab-core/src/lib.rs | 1 + crates/openab-core/src/redact.rs | 78 +++++++++++++++++++ .../openab-gateway/src/adapters/acp_server.rs | 6 +- 5 files changed, 104 insertions(+), 25 deletions(-) create mode 100644 crates/openab-core/src/redact.rs diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 1139d7828..d40e096a4 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -426,7 +426,7 @@ impl SessionPool { { Some(token) => { session_token = Some(token.clone()); - info!(thread_id, "session token minted for facade browser capabilities"); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session token minted for facade browser capabilities"); // The guard carries the TOKEN it minted, not the channel. A replaced // session's teardown runs after its successor has already re-minted for // the same channel, so revoking by channel would strip the live token and @@ -480,7 +480,7 @@ impl SessionPool { if new_conn.supports_load_session { match new_conn.session_load(sid, &effective_workdir).await { Ok(()) => { - info!(thread_id, session_id = %sid, "session resumed via session/load"); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), "session resumed via session/load"); resumed = true; } Err(e) => { @@ -488,7 +488,7 @@ impl SessionPool { let is_transient = TRANSIENT_LOAD_ERRORS.iter().any(|s| err_str.contains(s)); if is_transient { - warn!(thread_id, session_id = %sid, error = %e, + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), error = %e, "session/load failed transiently, preserving session ID for retry"); load_failed = Some(if err_str.contains("timeout waiting for") { "timeout" @@ -496,7 +496,7 @@ impl SessionPool { "connection lost" }); } else { - warn!(thread_id, session_id = %sid, error = %e, + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), error = %e, "session/load failed, creating new session"); } } @@ -552,7 +552,7 @@ impl SessionPool { if existing.alive() { return Ok(false); } - warn!(thread_id, "stale connection, rebuilding"); + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), "stale connection, rebuilding"); drop(existing); state.active.remove(thread_id); state.cancel_handles.remove(thread_id); @@ -566,7 +566,7 @@ impl SessionPool { state.cancel_handles.remove(&key); state.activity.remove(&key); state.pgids.remove(&key); - info!(evicted = %key, "pool full, suspending oldest idle session"); + info!(evicted = %crate::redact::redact_session_ids(&key), "pool full, suspending oldest idle session"); if let Some(sid) = sid { state.persisted.insert(key.clone(), sid.clone()); state.suspended.insert(key, sid); @@ -574,7 +574,7 @@ impl SessionPool { state.persisted.remove(&key); } } else { - warn!(evicted = %key, "pool full but eviction candidate changed before removal"); + warn!(evicted = %crate::redact::redact_session_ids(&key), "pool full but eviction candidate changed before removal"); } } else if skipped_locked_candidates > 0 { warn!( @@ -716,7 +716,7 @@ impl SessionPool { "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id, "sending session/cancel"); + tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "sending session/cancel"); use tokio::io::AsyncWriteExt; let mut w = stdin.lock().await; w.write_all(data.as_bytes()).await?; @@ -742,7 +742,7 @@ impl SessionPool { "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id, "reset: sending session/cancel"); + tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "reset: sending session/cancel"); use tokio::io::AsyncWriteExt; let mut w = stdin.lock().await; let _ = w.write_all(data.as_bytes()).await; @@ -760,7 +760,7 @@ impl SessionPool { self.save_mapping(&state.persisted); self.save_meta(&state.session_workdirs); if had_active { - info!(thread_id, "session reset"); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset"); Ok(()) } else { Err(anyhow!("no session for thread {thread_id}")) @@ -862,7 +862,7 @@ impl SessionPool { let mut state = self.state.write().await; for (key, expected_conn, sid) in stale { if remove_if_same_handle(&mut state.active, &key, &expected_conn).is_some() { - info!(thread_id = %key, "cleaning up idle session"); + info!(thread_id = %crate::redact::redact_session_ids(&key), "cleaning up idle session"); state.cancel_handles.remove(&key); state.activity.remove(&key); state.pgids.remove(&key); @@ -877,7 +877,7 @@ impl SessionPool { } for (key, expected_conn) in hung { if !apply_hung_eviction(&mut state, &key, &expected_conn) { - warn!(thread_id = %key, "hung session was replaced before eviction; maps untouched"); + warn!(thread_id = %crate::redact::redact_session_ids(&key), "hung session was replaced before eviction; maps untouched"); } } self.save_mapping(&state.persisted); diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index f0ef5d2ae..57f9e3213 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -482,14 +482,14 @@ async fn ambient_consumer_loop( target: Arc, instructions: String, ) { - info!(channel_id = %channel_id, "ambient consumer started"); + info!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient consumer started"); loop { // Wait for first message (blocks until one arrives or channel closes). let first = match rx.recv().await { Some(msg) => msg, None => { - info!(channel_id = %channel_id, "ambient consumer channel closed, exiting"); + info!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient consumer channel closed, exiting"); return; } }; @@ -535,7 +535,7 @@ async fn ambient_consumer_loop( let batch_size = batch.len(); debug!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), batch_size, "ambient flush triggered" ); @@ -544,7 +544,7 @@ async fn ambient_consumer_loop( let _permit = match flush_semaphore.acquire().await { Ok(permit) => permit, Err(_) => { - warn!(channel_id = %channel_id, "flush semaphore closed, exiting"); + warn!(channel_id = %crate::redact::redact_session_ids(&channel_id), "flush semaphore closed, exiting"); return; } }; @@ -558,7 +558,7 @@ async fn ambient_consumer_loop( // Don't drain — messages buffered after the mention are still valid // for the next batch cycle. The current batch is discarded but future // messages will be picked up when the loop restarts and reset() clears. - debug!(channel_id = %channel_id, "ambient flush cancelled by mention during accumulation"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush cancelled by mention during accumulation"); continue; } @@ -590,14 +590,14 @@ async fn ambient_consumer_loop( debug_msg.push_str("\n…(truncated)"); } if let Err(e) = adapter.send_message(&channel_ref, &debug_msg).await { - warn!(channel_id = %channel_id, error = %e, "ambient debug message send failed"); + warn!(channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "ambient debug message send failed"); } } // Ensure session exists. if let Err(e) = target.ensure_session(&session_key, None).await { warn!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "failed to create ambient session, discarding batch" ); @@ -630,7 +630,7 @@ async fn ambient_consumer_loop( // Check post_guard before dispatching (mention may have cancelled). if !post_guard.can_post() { - debug!(channel_id = %channel_id, "ambient flush cancelled by mention before dispatch"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush cancelled by mention before dispatch"); continue; } @@ -647,11 +647,11 @@ async fn ambient_consumer_loop( .await { Ok(()) => { - debug!(channel_id = %channel_id, "ambient flush dispatched"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush dispatched"); } Err(e) => { warn!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "ambient flush failed, discarding batch" ); diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 4e26a641d..8c4791e5e 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod acp; pub mod adapter; #[cfg(feature = "acp-mcp")] pub mod mcp_proxy; +pub mod redact; pub mod bot_turns; pub mod config; pub mod cron; diff --git a/crates/openab-core/src/redact.rs b/crates/openab-core/src/redact.rs new file mode 100644 index 000000000..85954da12 --- /dev/null +++ b/crates/openab-core/src/redact.rs @@ -0,0 +1,78 @@ +//! Rendering session-bearing identifiers in logs without handing out the session. +//! +//! An ACP session is addressed two ways and both carry the same uuid: the session id is +//! `sess_` and the channel id is `acp_`. Either one is enough to resume — `sess_` is +//! taken directly by resume, and `acp_` differs from it only by prefix — so both are credentials, +//! and a redaction that covers one of them covers nothing. +//! +//! Ids also travel embedded: a pool key is `:`, so scanning for a field +//! named `channel` misses it entirely. Redaction here matches on the VALUE's shape, which is why +//! it can be applied to a composite without the caller taking it apart. +//! +//! Applying this to a non-ACP identifier is a no-op, deliberately. A Discord or Slack channel id +//! is public and operators grep for it, and because the function leaves it untouched, a caller +//! that cannot tell whether a given id will ever be ACP does not have to find out — applying it +//! is free and removes the question. That is cheaper than an investigation and safer than a guess. + +/// Hash any `acp_`/`sess_` prefixed segment of `s`, leaving everything else exactly as it was. +/// +/// Segments are split on `:` so a `:` pool key redacts its id half and keeps +/// the platform readable. +/// +/// The same uuid tags identically whichever prefix carried it, so one session reads as one tag +/// across every log line that mentions it — that correlation is the only reason to keep an +/// identifier in a log at all. +pub fn redact_session_ids(s: &str) -> String { + s.split(':') + .map(|seg| match seg.strip_prefix("acp_").or_else(|| seg.strip_prefix("sess_")) { + Some(uuid) if !uuid.is_empty() => hash_tag(uuid), + _ => seg.to_string(), + }) + .collect::>() + .join(":") +} + +fn hash_tag(uuid: &str) -> String { + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(uuid.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod tests { + use super::redact_session_ids; + + /// A table, not a single vector — the branch structure is what needs pinning. + /// + /// One example only proves the hash. The predicate is the part most likely to move: this + /// function exists because a redaction covering `acp_` but not `sess_` was shipped, and that + /// edit changes which inputs hash without changing the output for any `acp_` input. A single + /// `acp_...` vector cannot see it. + #[test] + fn both_encodings_hash_alike_and_everything_else_passes_through() { + let u = "00000000-0000-0000-0000-000000000000"; + let tag = redact_session_ids(&format!("acp_{u}")); + assert!(tag.starts_with('#') && tag.len() == 9, "expected #<8hex>, got {tag}"); + assert_eq!( + redact_session_ids(&format!("sess_{u}")), + tag, + "both encodings carry the SAME uuid, so they must produce the same tag or one session \ + reads as two" + ); + assert_eq!( + redact_session_ids(&format!("acp:acp_{u}")), + format!("acp:{tag}"), + "a : pool key must redact the id half and keep the platform greppable" + ); + assert_eq!(redact_session_ids("1234567890"), "1234567890", "public ids stay greppable"); + assert_eq!(redact_session_ids("-"), "-", "the no-session sentinel is not a session"); + assert_eq!(redact_session_ids(""), "", "empty in, empty out"); + assert_eq!(redact_session_ids("acp_"), "acp_", "a bare prefix carries no uuid to hide"); + assert_eq!( + redact_session_ids("discord:1234567890"), + "discord:1234567890", + "a non-ACP composite is untouched, which is why applying this blindly is safe" + ); + } +} diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 892c463e0..00be63b28 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -1880,7 +1880,7 @@ async fn handle_session_new( ); // Downgraded from info! — sessionId is a resume capability; keep it out of normal logs (F12). - debug!(session = %session_id, "ACP session created"); + debug!(session = %redact_id(&session_id), "ACP session created"); // ACP session/new response is just { sessionId }. Constructed from the generated // NewSessionResponse (T2.1) so the wire shape is type-checked against acp_schema; the @@ -1990,7 +1990,7 @@ async fn handle_session_resume( ); drop(guard); - debug!(session = %session_id, "ACP session resumed"); + debug!(session = %redact_id(&session_id), "ACP session resumed"); // ACP session/resume response is an empty object (no history replay) — the generated // ResumeSessionResponse default serializes to {} (T2.1, type-checked against acp_schema). @@ -2172,7 +2172,7 @@ async fn handle_session_prompt( } } - debug!(session = %session_id, channel = %channel_id, "ACP: prompt dispatched"); + debug!(session = %redact_id(&session_id), channel = %redact_id(&channel_id), "ACP: prompt dispatched"); // Stream replies back as ACP `session/update` notifications. let mut sent_len = 0usize; From 19b2ae2800eb37d23cba8a41b3ac93f465fbf9a1 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 14:43:08 +0800 Subject: [PATCH 125/138] fix(acp): one session, one redaction tag; redact ids in returned errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third pass on R1. Two defects, both introduced or missed by the previous two passes rather than found in the original code. 1. Cross-crate tag divergence (corrects 43ecb66a) `openab-core`'s `redact_session_ids` strips `acp_`/`sess_` and hashes the uuid; `openab-gateway`'s `redact_id` hashed the WHOLE prefixed string. One session therefore produced three tags — `#12b9377c` (core), `#850414fa` (gateway, via `acp_`), `#57a483b3` (gateway, via `sess_`) — and `acp_server.rs:2175` emitted two of them on a single line. `redact_id` now strips the prefix before hashing, and the gateway carries its own cross-encoding assertion. Core already asserted exactly this property ("or one session reads as two"); the gateway had no such test, so the invariant was checked only on the side that upheld it. An invariant spanning two crates has to be asserted in both — the crates are deliberately independent siblings, so the gateway's test recomputes core's tag rather than importing it. CORRECTION to 43ecb66a's BREAKING CHANGE note. That note told operators "Both encodings of one session produce the same tag." That was false for every gateway line: an operator grepping the tag would have found core's `pool` / `ambient` / `facade` lines under one tag and `acp_server`'s under two others, and reasonably concluded the session was absent. It is true as of this commit. A migration note that is wrong is worse than none, because it is what an operator trusts instead of looking. 2. Credentials in RETURNED error strings `src/browser_tunnel.rs:62` and two `Unknown session:` sites in `acp_server.rs` embedded the id mid-sentence in an error that is RETURNED to a caller (and, for the latter two, sent over the wire as a JSON-RPC error). These never cross a logging boundary, so no logging-side redaction could ever catch them, and being mid-sentence they match no field-name pattern — the sweep that found the other 32 sites was structurally incapable of seeing them. Redacted at construction. The first of these was a reviewer finding; the other two came from sweeping the class it exposed, which is the part the previous two passes skipped. Gate: 12 steps green (the 10-step runbook gate plus `-p openab-mcp` and `-p openab-core` unfiltered). --- .../openab-gateway/src/adapters/acp_server.rs | 48 +++++++++++++++++-- docs/adr/acp-server-websocket-reverse-mcp.md | 7 ++- src/browser_tunnel.rs | 9 +++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 00be63b28..4c1a26e2a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -1685,7 +1685,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let resp = JsonRpcResponse::error( id, -32602, - format!("Unknown session: {session_id}"), + format!("Unknown session: {}", redact_id(&session_id)), ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; @@ -2045,12 +2045,50 @@ async fn handle_session_cancel( /// extension attach?"), so the id is hashed instead: the same session tags identically on every /// line, which is what makes a log readable, but the tag cannot be turned back into the id. fn redact_id(id: &str) -> String { + // Hash the UUID, not the prefixed string. One session is addressed as `sess_` and as + // `acp_`; hashing the whole string gives those two a different tag each, and a third + // different from `openab-core`, which strips the prefix. That is three tags for one session — + // and `prompt dispatched` prints two of them on a single line. Correlating a session across + // logs is the entire reason the tag exists, so producing several defeats the purpose more + // completely than not redacting would. + let uuid = id.strip_prefix("acp_").or_else(|| id.strip_prefix("sess_")).unwrap_or(id); use sha2::{Digest as _, Sha256}; - let digest = Sha256::digest(id.as_bytes()); + let digest = Sha256::digest(uuid.as_bytes()); let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); format!("#{short}") } +#[cfg(test)] +mod redact_id_cross_encoding { + /// Both encodings of one session must tag identically, and identically to `openab-core`. + /// + /// This assertion existed in `openab-core` and not here, so the gateway diverged silently while + /// core's own test asserted the property core alone upheld. An invariant that spans two crates + /// has to be asserted in both — checking it where it happens to hold proves nothing about the + /// side that breaks it. + #[test] + fn both_encodings_of_one_session_produce_one_tag() { + let u = "00000000-0000-0000-0000-000000000000"; + let a = super::redact_id(&format!("acp_{u}")); + let s = super::redact_id(&format!("sess_{u}")); + assert_eq!(a, s, "one session must not read as two"); + assert_eq!( + a, + openab_core_tag(u), + "the gateway's tag must equal openab-core's for the same session, or an operator \ + following one log to the other finds nothing" + ); + } + + /// Recomputed rather than imported: the crates do not depend on one another, so the shared + /// value has to be pinned on both sides independently. + fn openab_core_tag(uuid: &str) -> String { + use sha2::{Digest as _, Sha256}; + let d = Sha256::digest(uuid.as_bytes()); + format!("#{}", d.iter().take(4).map(|b| format!("{b:02x}")).collect::()) + } +} + fn derive_channel_id(session_id: &str) -> Option { let uuid = session_id.strip_prefix("sess_")?; Uuid::parse_str(uuid).ok()?; @@ -2102,7 +2140,11 @@ async fn handle_session_prompt( Some(s) => s.channel_id.clone(), None => { let resp = - JsonRpcResponse::error(id, -32602, format!("Unknown session: {session_id}")); + JsonRpcResponse::error( + id, + -32602, + format!("Unknown session: {}", redact_id(&session_id)), + ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); release_prompt(sessions, &session_id).await; return; diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 5f106d66b..1dd10a94b 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -156,7 +156,12 @@ Three pieces already generalize and are reused as-is: - `AcpTunnelRegistry` becomes keyed by `(channel_id, serverId)` instead of `channel_id` alone — the "one tunnel per session" collapse was a fan-out fix; the correct fix is a **compound key**. - Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. -- Evict all `(channel_id, *)` entries on session teardown. +- On session teardown, evict only the `(channel_id, *)` entries **this connection owns** — + matched on `owner`, not on the channel. Evicting every entry for the channel would delete a + successor's live tunnel, because a client that reconnects and resumes takes over the same + `channel_id`. (The unqualified form was this document's original wording and describes a + defect that was fixed in the implementation; left uncorrected it is the copy that could get + the code "restored" back into the bug.) **`id` and `name` are different things, and routing needs both.** A declaration is `{type:"acp", id, name}`, and the two fields have very different lifetimes — the reference client mints diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index a5518a31d..5765dd044 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -59,7 +59,14 @@ impl AcpMcpTunnel for RootBrowserTunnel { }; match handle { Some(h) => h.mcp_message(method, params, self.timeout_secs).await, - None => Err(format!("no browser attached to session {channel_id}")), + // Redacted here, at construction. This string is RETURNED, not logged — it reaches a + // caller that may log it, surface it to a model, or put it in an error response, so no + // logging-side redaction point can ever catch it. An id embedded mid-sentence also + // escapes every field-name scan: the pattern that found the other sites cannot see it. + None => Err(format!( + "no browser attached to session {}", + openab_core::redact::redact_session_ids(channel_id) + )), } } From 330e40c6ba6fc059aa897e1ed399ada530297b34 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 16:58:21 +0800 Subject: [PATCH 126/138] test(acp): correct what the replacement test covers, and exercise the survivor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6. The finding said the `connectionId` assertion in `a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced` is near-tautological because only `conn-old`'s `out_tx` writes to socket 1. Measuring it found something worse: the test never enters the eviction branch at all. Instrumenting all three `mcp/disconnect` emitters (superseded, last-attach-wins, withdrawal-retirement) and running the test against unmodified code prints one line — withdrawal-retirement. The resume drops `uuid-old` from the accepted set, retirement removes that registry entry, and the later establish for `uuid-new` finds no same-name predecessor: `replaced` is empty, so `!replaced.is_empty()` is false and the eviction path never runs. A mutation that redirects the eviction disconnect from the stale handles to the newly registered one — while preserving "empty unless something was replaced" — leaves the test GREEN. So the test is named for last-attach-wins eviction and is satisfied entirely by a different mechanism. The comment claiming "an implementation that disconnected the newly registered tunnel would pass too" was false, and the commit that introduced it (deb25777) reported the assertion as mutation-checked by flipping the expected `connectionId` — that mutates the TEST, not the code, and negating a tautology also goes red. Both claims are corrected in place rather than deleted, because the next reader would otherwise re-derive the same wrong conclusion from the same test name. What this commit adds is the `mcp/message` round trip over the SURVIVING tunnel that the review request for this trio announced and the test never performed. It proves the replacement left a working tunnel rather than a registry entry whose socket is gone, and it pins frame order on that socket. It is deliberately NOT described as making the disconnect assertion discriminating — it does not, while the eviction branch stays unreachable from this scenario. Filed for review, not patched here: whether `same_name` eviction is reachable through a resume at all. It requires a same-channel, same-name entry under a different `server_id` still registered when the new establish runs, and a resume that re-declares the name with a fresh id retires the old one first. If no path reaches it, that branch is dead code carrying a documented ADR rule — a design question, not a test fix. --- .../openab-gateway/src/adapters/acp_server.rs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 4c1a26e2a..1a8b0f68e 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -5644,8 +5644,27 @@ mod acp_ws_integration { } } - // The disconnect must name the REPLACED connection. Without this assertion an - // implementation that disconnected the newly registered tunnel would pass too. + // WHAT THIS TEST ACTUALLY COVERS — measured, not assumed. + // + // The name says last-attach-wins eviction (R7). It does not exercise it. Instrumenting + // every `mcp/disconnect` emitter and running this test against unmodified code shows one + // line: the WITHDRAWAL-RETIREMENT path (`resume withdrew declarations`). The eviction + // branch never runs, because the resume drops `uuid-old` from the accepted set, so + // retirement removes that registry entry BEFORE the establish for `uuid-new` looks for + // same-name predecessors — `replaced` is empty and `if !replaced.is_empty()` is false. + // + // A mutation confirms it: redirecting the eviction disconnect from the stale handles to + // the newly registered one (preserving "empty unless something was replaced") leaves this + // test GREEN. So the assertions below cannot distinguish disconnecting the right tunnel + // from disconnecting the wrong one — they are satisfied by a different mechanism. + // + // The previous comment here claimed the opposite ("an implementation that disconnected the + // newly registered tunnel would pass too"), and the commit that added it reported the + // assertion as mutation-checked by flipping the expected `connectionId`. That mutates the + // TEST, not the code; negating a tautology also goes red. Both claims were wrong. + // + // Whether `same_name` eviction is reachable through a resume AT ALL is an open question + // filed for review, not something this test should paper over. let framed = recv(&mut first).await; assert_eq!(framed["method"], json!("mcp/disconnect")); assert_eq!( @@ -5659,6 +5678,36 @@ mod acp_ws_integration { reg.values().map(|h| h.connection_id.clone()).collect() }; assert_eq!(names, vec!["conn-new".to_string()], "only the newest tunnel may remain"); + + // The survivor must WORK, not merely be present in the registry: a replacement that + // registers a handle whose socket is gone satisfies every assertion above. This is the + // `mcp/message` round trip the review request for this trio announced and this test never + // performed. + // + // It also pins frame ORDER on the surviving socket. Frames on one socket are ordered, so + // any implementation that wrote a disconnect to the replacement would have to write it + // before this response — asserting the next frame is the tool call fails fast and names + // the reason, where a missing-frame check could only time out. + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().expect("the survivor must be registered").clone() + }; + let call = tokio::spawn(async move { + handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + }); + + let request = recv(&mut second).await; + assert_eq!( + request["method"], json!("mcp/message"), + "the next frame owed to the surviving connection is the tool call, not a disconnect" + ); + assert_eq!(request["params"]["connectionId"], json!("conn-new")); + send(&mut second, json!({ + "jsonrpc": "2.0", "id": request["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + })).await; + let got = call.await.unwrap().expect("the surviving tunnel must carry a call"); + assert_eq!(got["content"][0]["text"], json!("clicked")); } } From 25dc1c8765612a52a0c60b767a3d09e4a896451c Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 16:58:31 +0800 Subject: [PATCH 127/138] test(acp): replace a tautological premise and an error-swallowing assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R7, both halves of the finding, in `pending_tunnel_establishes_do_not_consume_the_prompt_budget`. 1. The up-front `assert!(want_connects > MAX_INFLIGHT_PROMPTS)` restated an identity. `want_connects` is derived from the cap two lines above, so it expands to `(P/S + 1) * S = P - (P % S) + S > P`, true for every positive P and S. It read as a guard on the test's premise and could not fire for any value of either constant. Replaced by a check on the count the socket actually delivered, which can fail: what can really go wrong is the server parking fewer establishes than the test declared. The drain loop that produces that count is now bounded too. Unbounded, a short count surfaced as `recv`'s generic 30s timeout — a failure named after the harness rather than the defect, which reads as a flake and gets dismissed. 2. The final assertion was wrapped in `if let Some(err)`, checking only `!msg.contains("in-flight prompts")`. Measured, this harness ALWAYS errors — `No agent backend connected`, because no backend is attached — so the branch always runs and that substring was the test's entire discrimination. Any other error satisfied it, and so would the budget message itself once someone reworded it. The expected error is now pinned exactly. That specific backend error IS the evidence the prompt passed the budget gate, because the gate rejects earlier and with a different message. Mutation-checked against the CODE, not the test: making prompts and tunnel establishes share one budget again (the pre-147c094c behaviour this test exists to prevent regressing) turns it red, naming the parked count and both messages. The old assertion would also have caught that particular mutation — what it could not catch is any variant of it. --- .../openab-gateway/src/adapters/acp_server.rs | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 1a8b0f68e..0d48a3634 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -5416,12 +5416,16 @@ mod acp_ws_integration { let _ = recv(&mut ws).await; // Enough sessions to park strictly more than MAX_INFLIGHT_PROMPTS establishes. + // + // There used to be an `assert!(want_connects > MAX_INFLIGHT_PROMPTS)` here. `want_connects` + // is DERIVED from the cap two lines above, so that assertion restated the integer-division + // identity `(P/S + 1) * S = P - (P % S) + S > P`, which holds for every positive P and S. + // It could not fire for any value of either constant — it looked like a guard on the + // test's premise and guarded nothing. What can actually go wrong is the server not parking + // the establishes we asked for, so the premise is checked below against the count the + // socket really delivered. let sessions_needed = MAX_INFLIGHT_PROMPTS / MAX_ACP_SERVERS_PER_SESSION + 1; let want_connects = sessions_needed * MAX_ACP_SERVERS_PER_SESSION; - assert!( - want_connects > MAX_INFLIGHT_PROMPTS, - "the test must exceed the prompt cap or it cannot observe the old behaviour" - ); let mut last_session = None; let mut connects = 0; @@ -5452,12 +5456,22 @@ mod acp_ws_integration { assert_eq!(answered_new, sessions_needed); // Drain any remaining mcp/connect frames so the parked count is what we think it is. - while connects < want_connects { + // Bounded: if the server parks fewer than we declared, this must fail naming the count, + // not sit in `recv` until its 30s guard panics. A failure named after the harness reads + // as a flake and gets dismissed; one naming the count points at the defect. + let mut frames = 0; + while connects < want_connects && frames < want_connects * 4 { let f = recv(&mut ws).await; + frames += 1; if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { connects += 1; } } + assert!( + connects > MAX_INFLIGHT_PROMPTS, + "the budget defect is only observable with more than MAX_INFLIGHT_PROMPTS \ + ({MAX_INFLIGHT_PROMPTS}) establishes parked, but only {connects} were" + ); // With >32 establishes parked, a prompt must still be accepted. send(&mut ws, json!({ @@ -5474,13 +5488,23 @@ mod acp_ws_integration { } } let f = saw.expect("no response to the prompt"); - if let Some(err) = f.get("error") { - let msg = err["message"].as_str().unwrap_or(""); - assert!( - !msg.contains("in-flight prompts"), - "{connects} parked mcp/connects must not spend the prompt budget, got: {msg}" - ); - } + + // The prompt is expected to pass the in-flight budget gate and then fail at the backend, + // because this harness attaches none. That specific error IS the evidence: the budget gate + // rejects earlier and with a different message. + // + // Pinned exactly, not asserted as `!msg.contains("in-flight prompts")`. A negative + // substring check is satisfied by every OTHER error too — a session-not-found, a params + // error, or the budget message itself once someone rewords it — so it could only ever fail + // for one spelling of one regression, and would pass silently through the rest. + let err = f + .get("error") + .unwrap_or_else(|| panic!("the prompt must reach the backend and fail there, got: {f}")); + assert_eq!( + err["message"], json!("No agent backend connected"), + "{connects} parked mcp/connects must not spend the prompt budget; the prompt must \ + reach the backend and fail only for the missing backend, got: {f}" + ); } /// A resume that stops declaring a server must retire its tunnel. From 6adc4703621a5386ffddc9009521600e031243b0 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 17:33:53 +0800 Subject: [PATCH 128/138] fix(mcp): make `[mcp]` reject unknown keys instead of widening the tool set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10. `[[mcp.acp_server]]` — one character off `[[mcp.acp_servers]]` — parsed clean, left `acp_servers` empty, and `browser_source.rs:159` reads an empty list as "keep the built-in default": all five browser tools. An operator writing a one-tool allowlist and mistyping the section gets five. Tightening the config widened it. `#[serde(deny_unknown_fields)]` on `McpFacadeConfig` and `AcpServerPolicy`. Config parse errors are already a hard startup failure (`parse_config_str` → `?` at `main.rs:375`), so a typo now stops the process with a message naming the key, which is the right outcome for a permissions control. Both attributes are covered by their own test and each was verified to fail without the attribute it exercises — removing the one on `McpFacadeConfig` fails only the section test, removing the one on `AcpServerPolicy` fails only the field test. The negative tests are paired with a positive one asserting the documented spelling still parses, so "rejects everything" cannot pass for "rejects the typo". Note on the wrong file, since the item pointed at it: this section deserializes into `McpFacadeConfig` in `openab-core/src/config.rs`, NOT `McpConfig` in `openab-mcp/src/mcp/config.rs` — that one parses `.openab/agent/mcp.json`, provider connections, a different file entirely. Hardening `McpConfig` would have left the defect in place under a commit claiming to fix it. What this does NOT close, deliberately: the widening itself. The `entries.is_empty() → default_policy()` fallback at `src/browser_source.rs:155` is documented as intentional ("omitting the section leaves browser control working as before"), and it still means any future path that empties the list re-opens the same hole. `deny_unknown_fields` closes the typo, not the fallback. The ADR already files the related silent-refusal gap as follow-up F7; the fallback belongs with it rather than in a serde attribute. --- crates/openab-core/src/config.rs | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index fa81dfb47..6293d6c24 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -71,7 +71,16 @@ impl<'de> Deserialize<'de> for AllowBots { /// same host connects to `http:///mcp`. Provider connections stay in /// `~/.openab/agent/mcp.json` (the facade has no provider config here — /// single source of truth, ADR §6.3 / Alternative E). +/// **Strict.** An unknown key here is a hard startup failure, not a warning. +/// +/// This section is a permissions control, and the failure mode of a silently-ignored key runs the +/// wrong way: `[[mcp.acp_server]]` (singular) leaves `acp_servers` empty, and an empty list keeps +/// the built-in default of five browser tools. So an operator writing a one-tool allowlist and +/// mistyping the section gets five tools — tightening the config actually widens it. Every other +/// misconfiguration in this section fails closed; only the typo fails open, which is why the parser +/// has to catch it rather than the policy layer. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct McpFacadeConfig { /// Loopback listen address. Non-loopback addresses are refused at /// startup — the endpoint has no authentication layer, so the host @@ -131,7 +140,13 @@ pub fn default_tunnel_timeout_seconds() -> u64 { /// so admitting a name grants nothing by itself — `tools` is the second, /// independent gate that stops a client publishing extra tools under a name the /// operator trusts. +/// **Strict**, for the same reason as [`McpFacadeConfig`]: a mistyped `tools` key would leave the +/// list empty, and an entry with no tools is accepted-but-publishes-nothing. That direction fails +/// closed, so it is the milder case — but an operator who cannot tell a typo from an intentional +/// deny-all has no way to debug a server that went quiet, and the same keystroke in the parent +/// section fails open. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AcpServerPolicy { /// Declared server name to accept — matches `name` in the client's /// `{type:"acp", id, name}` entry and the `` prefix of its tools. @@ -2367,6 +2382,53 @@ mod tests { assert_eq!(mcp.listen, "127.0.0.1:8848"); } + /// The typo that motivated `deny_unknown_fields`, asserted as a REJECTION. + /// + /// `[[mcp.acp_server]]` is one character from `[[mcp.acp_servers]]`. Before this, it parsed + /// clean, left `acp_servers` empty, and `browser_source.rs` read an empty list as "keep the + /// built-in default" — five tools, from a config asking for one. + #[test] + fn a_mistyped_acp_servers_section_is_refused_not_ignored() { + let err = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_server]]\nname = \"katashiro\"\n\ + tools = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect_err("a mistyped section must fail the parse, not widen the tool set"); + let msg = err.to_string(); + assert!( + msg.contains("acp_server"), + "the error must name the offending key so the operator can find it, got: {msg}" + ); + } + + /// The correctly spelled section still parses — otherwise the test above would pass for a + /// parser that rejects everything. + #[test] + fn the_correctly_spelled_acp_servers_section_still_parses() { + let cfg = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_servers]]\nname = \"katashiro\"\n\ + tools = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect("the documented spelling must keep working"); + let mcp = cfg.mcp.expect("[mcp] present"); + assert_eq!(mcp.acp_servers.len(), 1); + assert_eq!(mcp.acp_servers[0].name, "katashiro"); + assert_eq!(mcp.acp_servers[0].tools, vec!["katashiro.read_dom".to_string()]); + } + + #[test] + fn a_mistyped_key_inside_an_acp_server_entry_is_refused() { + let err = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_servers]]\nname = \"katashiro\"\n\ + tool = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect_err("`tool` is not `tools`; silently dropping it makes the entry deny-all"); + assert!(err.to_string().contains("tool")); + } + #[test] fn mcp_facade_listen_override() { let cfg = parse_config_str( From 53bce9cf46b3ee9b107ce04f181fde1406f182ce Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 17:55:46 +0800 Subject: [PATCH 129/138] test(acp): cover the eviction branch, which had no test and is not dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 330e40c6, which recorded that `a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced` never enters `if !replaced.is_empty()` and left open whether `same_name` eviction is reachable at all. It is — through a single `session/new`, not through a resume. `accept_acp_servers` dedups on `id` alone (`seen.insert(s.id.clone())`), so one declaration may legally carry two servers sharing a NAME with different ids, and `session/new` runs no sweep because the channel is a freshly minted uuid that cannot collide. The two establishes race; whichever takes the registry lock second sees its same-name predecessor and evicts it. Two same-name declarations are a client error, but the gateway accepts them, so the path exists and has to behave. On the resume path the sweep runs before `spawn_acp_tunnels`, which is why the predecessor is already retired there and `replaced` is empty. Both statements are true and they are about different paths. `two_same_name_servers_in_one_session_evict_down_to_one` is the branch's only coverage. Which of the two wins is a real race, so it asserts the RELATION — exactly one tunnel survives and the disconnect names the one that did not — rather than a winner. Verified discriminating against the code, not the test: with the eviction disconnect redirected from the stale handles to the newly registered one (preserving "empty unless something was replaced"), this test FAILS while the resume test and 18 others still pass. That is the gap it was written to close. Also annotates the pinned assertion in `pending_tunnel_establishes_do_not_consume_the_prompt_budget`. The string pins two different things — the incidental harness artifact (`No agent backend connected`) and the property under test (separate budgets) — and only the second is a contract. Without saying so, a harness change reddens the test for a reason unrelated to what it protects, and the cheap reaction is to loosen it back to the substring form it replaced. The comment says to re-derive the expected value instead. --- .../openab-gateway/src/adapters/acp_server.rs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 0d48a3634..7c17ddd5e 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -5493,6 +5493,20 @@ mod acp_ws_integration { // because this harness attaches none. That specific error IS the evidence: the budget gate // rejects earlier and with a different message. // + // READ THIS BEFORE LOOSENING THE ASSERTION. The pinned string carries two different + // things, and only one of them is a contract: + // + // (a) `No agent backend connected` is INCIDENTAL — an artifact of this harness having no + // backend. It is not what the test protects. + // (b) That the prompt got far enough to fail there AT ALL is the property: establish + // tasks and prompt tasks hold separate budgets. + // + // So if a harness change makes this red, the fix is to RE-DERIVE what "reached the + // backend" now looks like and pin that — not to relax the check back toward + // `!msg.contains("in-flight prompts")`. That substring form is what this replaced, and it + // would pass for every other error and for any rewording of the budget message: it could + // only ever fail for one spelling of one regression. + // // Pinned exactly, not asserted as `!msg.contains("in-flight prompts")`. A negative // substring check is satisfied by every OTHER error too — a session-not-found, a params // error, or the budget message itself once someone rewords it — so it could only ever fail @@ -5614,6 +5628,106 @@ mod acp_ws_integration { ); } + /// The eviction branch's ONLY coverage — and the scenario that proves it is not dead code. + /// + /// `a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced` is named for + /// last-attach-wins but never reaches it: on the resume path the sweep runs before + /// `spawn_acp_tunnels`, so the same-name predecessor is already retired and `replaced` is + /// empty. That left `if !replaced.is_empty()` with no test at all. + /// + /// It IS reachable through a single `session/new`. `accept_acp_servers` dedups on `id` alone + /// (`seen.insert(s.id.clone())`), so one declaration may legally carry two servers sharing a + /// NAME with different ids, and `session/new` runs no sweep — the channel is a freshly minted + /// uuid that cannot collide. The two establishes race; whichever takes the registry lock + /// second sees its same-name predecessor, `same_name` matches, and the eviction disconnect + /// fires. Two same-name declarations are a client error, but the gateway accepts them, so the + /// path has to exist and has to behave. + /// + /// Which of the two wins is a genuine race, so this asserts the RELATION rather than a + /// winner: exactly one tunnel survives, and the disconnect names the one that did not. + #[tokio::test] + async fn two_same_name_servers_in_one_session_evict_down_to_one() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let init = recv(&mut ws).await; + assert!(init.get("result").is_some(), "initialize failed: {init}"); + + // Same name, different ids — accepted, because the dedup key is the id. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "uuid-a", "name": "katashiro"}, + {"type": "acp", "id": "uuid-b", "name": "katashiro"} + ] + } + })).await; + + // Answer both connects, absorb both inner lifecycles, and wait for the disconnect the + // loser is owed. Bounded so a regression fails naming what was missing rather than + // sitting in `recv` until its 30s guard trips — a failure named after the harness reads + // as a flake. + let mut connected = std::collections::HashMap::new(); + let mut disconnected: Option = None; + let mut session_seen = false; + for _ in 0..40 { + if disconnected.is_some() && session_seen && connected.len() == 2 { + break; + } + let frame = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &frame).await.is_some() { + continue; + } + match frame.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + let acp_id = frame["params"]["acpId"].as_str().expect("acpId").to_string(); + let conn = format!("conn-{}", acp_id.trim_start_matches("uuid-")); + connected.insert(acp_id, conn.clone()); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": conn} + })).await; + } + Some("mcp/disconnect") => { + disconnected = Some( + frame["params"]["connectionId"].as_str().expect("connectionId").to_string(), + ); + } + _ => { + if frame.get("id") == Some(&json!(2)) { + assert!(frame.get("result").is_some(), "session/new refused: {frame}"); + session_seen = true; + } + } + } + } + assert_eq!(connected.len(), 2, "both declared servers must be asked to connect"); + let evicted = disconnected.expect( + "the evicted tunnel is owed an mcp/disconnect — if this is missing, the eviction \ + branch did not run and this scenario no longer covers it", + ); + + wait_for_tunnels(®istry, 1).await; + let survivors: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.connection_id.clone()).collect() + }; + assert_eq!(survivors.len(), 1, "last-attach-wins must collapse the name to one tunnel"); + assert_ne!( + survivors[0], evicted, + "the disconnect must name the tunnel that LOST, not the one still registered" + ); + assert!( + connected.values().any(|c| c == &evicted), + "the disconnected connection must be one of the two we opened, got {evicted}" + ); + } + /// A reconnect that **resumes** the same session re-declares the same NAME with a fresh id. /// That evicts the stale tunnel, and the client is owed an `mcp/disconnect` for the connection /// it still believes is open (review R7). From 543249bf544de925db92e909adea35f92b0cd0bb Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 18:16:49 +0800 Subject: [PATCH 130/138] test(acp): bound the wait in the eviction test, and state what it does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to 53bce9cf, both from review. 1. The loop bounded the frame COUNT but not the WAIT `for _ in 0..40 { recv(..) }` with a blocking `recv` does not fail with the collected state when a frame never arrives — it sits on frame 9 until `recv`'s own 30s guard fires, reporting a harness timeout instead of the missing disconnect. That is precisely the failure shape the loop's comment claimed to avoid: a failure named after the harness reads as a flake and gets dismissed. Each iteration now waits at most 2s and breaks, so the assertions below report what was actually collected. Verified by mutating the code to never send the eviction disconnect: the test now fails in 2.11s with "the evicted tunnel is owed an mcp/disconnect", instead of after 30s with a timeout. 2. The coverage claim in 53bce9cf was stronger than the evidence That commit said this test is the eviction branch's coverage. It is not guaranteed to be. `generation` is stamped inside `establish_and_register_tunnel` — within the spawned task — so declaration order does not fix generation order. Both establishes share a `connection_generation`, so which arm runs is up to the scheduler: lower-generation-registers-first evicts (the arm we want), and higher-generation-registers-first supersedes instead, standing the earlier one down without eviction ever running. Both yield "one survivor, loser disconnected", which is why the assertions cannot distinguish them and the test is stable either way. So the mutation result reported in 53bce9cf was one observation of a scheduler-dependent path. Measured since: 20 of 20 runs on this machine take the eviction arm, so the coverage is real in practice — but it is not structural, and a change breaking eviction could pass on a schedule that favours the other arm. The test now says this rather than implying guaranteed coverage. The structural fix is to stamp `generation` in the declaration loop of `spawn_acp_tunnels` instead of inside the task: within one request, declaration order is the only order the client ever expressed, which is arguably more faithful than task-scheduling order, and it would make the eviction arm deterministic here. Not done in this commit — it changes the ordering semantics that last-attach-wins rests on, and that deserves its own review rather than riding along with a test change. --- .../openab-gateway/src/adapters/acp_server.rs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 7c17ddd5e..4ad0d665a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -5644,7 +5644,31 @@ mod acp_ws_integration { /// path has to exist and has to behave. /// /// Which of the two wins is a genuine race, so this asserts the RELATION rather than a - /// winner: exactly one tunnel survives, and the disconnect names the one that did not. + /// winner: exactly one tunnel survives, and the disconnect names the one that did not. That + /// holds under either interleaving, so the test itself is stable. + /// + /// WHAT IT DOES NOT GUARANTEE — read before relying on this as eviction coverage. + /// + /// `generation` is stamped inside `establish_and_register_tunnel`, i.e. within the spawned + /// task, so declaration order does NOT determine generation order. Both establishes share a + /// `connection_generation`, so ordering falls to `generation`, and which arm runs depends on + /// the scheduler: + /// + /// - lower-generation task registers first → the later one evicts it → EVICTION arm; + /// - higher-generation task registers first → the earlier one is superseded and stands down, + /// closing its own connection → SUPERSEDED arm, and eviction never runs. + /// + /// Both produce "one survivor, loser gets the disconnect", which is why the assertions cannot + /// tell them apart. Measured on this machine: 20 of 20 runs took the EVICTION arm, so the + /// coverage is real in practice — but it is not guaranteed by construction, and a change that + /// broke eviction could pass on an unlucky-for-us schedule. + /// + /// The fix is to stamp `generation` in the declaration loop of `spawn_acp_tunnels` rather than + /// inside the task. Declaration order within one request is the only order the client ever + /// expressed, so that is arguably more faithful than task-scheduling order, and it would make + /// this test cover the eviction arm deterministically. Filed as a follow-up rather than done + /// here: it changes the ordering semantics the whole last-attach-wins design rests on, and + /// that deserves its own review rather than riding along with a test. #[tokio::test] async fn two_same_name_servers_in_one_session_evict_down_to_one() { let (url, registry) = serve().await; @@ -5679,7 +5703,16 @@ mod acp_ws_integration { if disconnected.is_some() && session_seen && connected.len() == 2 { break; } - let frame = recv(&mut ws).await; + // The 40 bounds the frame COUNT; this bounds the WAIT, and only together do they do + // what the comment claims. `recv` blocks, so a regression that never sends the + // disconnect would not run out the loop and fail with the state below — it would sit + // on frame 9 until `recv`'s own 30s guard fired, reporting a harness timeout instead + // of the missing disconnect. Breaking out here keeps the failure attributable. + let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), recv(&mut ws)).await + else { + break; + }; if handled_inner_lifecycle(&mut ws, &frame).await.is_some() { continue; } From b6a2f2c7283f27ec7becd0a73361961f698455c3 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 18:56:55 +0800 Subject: [PATCH 131/138] fix(acp): negotiate the inner MCP revision against a set, not by equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5, per D-2026-07-30-10 option (b). Supersedes the strict equality c1f4dac8 implemented. MCP says a server that does not support the requested revision answers with one it does support. Strict equality against `INNER_MCP_PROTOCOL_VERSION` refused a peer for doing exactly that — and the point of client-declared `type:acp` servers is to work with peers we did not write, so a check added for genericity was the thing blocking it. `INNER_MCP_PROTOCOL_VERSION` stays the revision we REQUEST. Acceptance moves to `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS` = 2025-06-18, 2025-03-26, 2024-11-05. The `None` branch is unchanged and still rejects: a result carrying no `protocolVersion` string is not a compliant answer, and that was never in dispute. The basis is recorded in the code, next to the set, because a bare list of version strings with no stated reason is how this becomes silently wrong later. The set is safe only because the entire inner surface this gateway drives is four methods whose shapes are compatible across all three revisions — `initialize` and `notifications/initialized` in `inner_mcp_handshake`, `tools/list` at `src/browser_source.rs:200`, and `tools/call` at `:351`. Verified against the tree: those are the only methods that reach an inner server. A fifth would have to be re-checked, because the compatibility claim is about the methods we actually send, not about the revisions in the abstract. The error message now names what the peer answered, what we requested, and what we accept. Naming only the first two tells an operator there is a mismatch without telling them what would fix it. Tests assert each revision in the set individually rather than "the set is non-empty": a membership test that only ever exercises the requested version passes for the strict-equality code this replaces. Verified — reverting the match to `Some(INNER_MCP_PROTOCOL_VERSION)` fails `every_supported_inner_revision_is_accepted` on 2025-03-26, while both rejection tests stay green, which is the correct split. One existing test had to move with the decision. `a_server_answering_an_unsupported_protocol_version_is_not_registered` used 2024-11-05 as its "unsupported" example — picked because a real MCP revision makes a more plausible peer than a nonsense string. D-10 adds that revision to the accepted set, so the literal became a SUPPORTED version and the test started asserting the opposite of the decided behaviour. The realism that made it a good example is exactly what coupled it to the policy it was meant to be independent of. It now answers 2019-01-01: well-formed, deliberately not a real revision, so extending the set cannot silently invert it again. Worth recording how that surfaced. Three targeted `cargo test` filters for the new tests all passed and none of them selected this one — the full `-p openab-gateway --features acp` step is what caught it. The filter chose what I learned, which is the same reason the unfiltered steps exist in the gate. --- .../openab-gateway/src/adapters/acp_server.rs | 128 +++++++++++++++++- 1 file changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 4ad0d665a..790d4f6f1 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -910,6 +910,30 @@ impl TunnelHandle { /// MCP protocol version the gateway speaks to a tunnelled client MCP server. const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; +/// Revisions this gateway ACCEPTS in an inner `initialize` result (R5, D-2026-07-30-10). +/// +/// Negotiation, not equality. MCP says a server that does not support the requested revision +/// answers with one it does support, so strict equality against +/// [`INNER_MCP_PROTOCOL_VERSION`] rejected a peer for behaving exactly as specified. That matters +/// here because the point of client-declared `type:acp` servers is to work with peers we did not +/// write. +/// +/// WHY THIS SET IS SAFE — re-check this before adding a fifth inner method. +/// +/// The whole inner surface this gateway drives is four methods, and their shapes are compatible +/// across all three revisions: +/// +/// - `initialize` and `notifications/initialized` — `inner_mcp_handshake`, below; +/// - `tools/list` — `src/browser_source.rs:200`; +/// - `tools/call` — `src/browser_source.rs:351`. +/// +/// Verified against the tree: those are the only methods reaching an inner server. A fifth would +/// have to be re-checked across the set, because compatibility is a property of the methods we +/// actually send, not of the revisions in the abstract. A bare list of version strings with no +/// stated reason is how this becomes silently wrong later. +const SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS: [&str; 3] = + ["2025-06-18", "2025-03-26", "2024-11-05"]; + /// Perform the inner MCP handshake on a freshly connected tunnel. /// /// MCP requires `initialize` → response → `notifications/initialized` before any other request. We @@ -951,11 +975,12 @@ async fn inner_mcp_handshake( // reason nothing in the log explains — the exact opaque failure this handshake exists to // prevent. Refuse the establish instead, and name both versions so the mismatch is readable. match init.get("protocolVersion").and_then(Value::as_str) { - Some(INNER_MCP_PROTOCOL_VERSION) => {} + Some(v) if SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS.contains(&v) => {} Some(other) => { return Err(format!( - "inner MCP server answered protocolVersion {other}, but this gateway speaks \ - {INNER_MCP_PROTOCOL_VERSION}" + "inner MCP server answered protocolVersion {other}, which this gateway does not \ + speak (requested {INNER_MCP_PROTOCOL_VERSION}, accepts {})", + SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS.join(", ") )); } None => { @@ -2818,6 +2843,84 @@ mod acp_requests { Arc::new(tokio::sync::Mutex::new(HashMap::new())) } + /// Drive `inner_mcp_handshake` against a peer answering `version`, and report the outcome. + /// + /// The handshake awaits a reply, so the answer has to be routed from a second task; doing it + /// inline deadlocks on the `pending` entry that has not been created yet. + async fn handshake_answering(version: Option<&str>) -> Result<(), String> { + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let p2 = pending.clone(); + let version = version.map(str::to_string); + let responder = tokio::spawn(async move { + let f: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + let result = match version { + Some(v) => json!({ + "protocolVersion": v, "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-ext", "version": "0"} + }), + // A result with no `protocolVersion` at all — the branch that was never in + // dispute and must keep rejecting. + None => json!({ + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-ext", "version": "0"} + }), + }; + route_client_response(&p2, &json!({"jsonrpc":"2.0","id":f["id"],"result":result})) + .await; + // Drain `notifications/initialized` if the handshake got far enough to send it. + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), out_rx.recv()).await; + }); + let r = super::inner_mcp_handshake(&out_tx, &pending, &next_id, "conn-1", 5).await; + let _ = responder.await; + r + } + + /// Every revision in the accepted set must be accepted (R5, D-2026-07-30-10). + /// + /// Asserted per-revision rather than "the set is non-empty": a membership test that only ever + /// exercises the requested version would pass for the strict-equality code this replaced. + #[tokio::test] + async fn every_supported_inner_revision_is_accepted() { + for v in super::SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS { + assert!( + handshake_answering(Some(v)).await.is_ok(), + "{v} is in the supported set, so a peer answering it must be accepted" + ); + } + } + + /// A peer answering with a revision outside the set is still refused, and the error names both + /// what we asked for and what we accept — otherwise the operator cannot tell a version + /// mismatch from an unreachable peer. + #[tokio::test] + async fn a_revision_outside_the_set_is_still_refused() { + let err = handshake_answering(Some("1999-01-01")) + .await + .expect_err("an unknown revision must not be negotiated into"); + assert!(err.contains("1999-01-01"), "the error must name what the peer answered: {err}"); + assert!( + err.contains(super::INNER_MCP_PROTOCOL_VERSION), + "the error must name what we requested: {err}" + ); + assert!( + err.contains("2024-11-05"), + "the error must name what we accept, or the operator cannot tell what to change: {err}" + ); + } + + /// The `None` branch was never in dispute: a result carrying no `protocolVersion` string is + /// not a compliant answer, and widening acceptance must not have widened this. + #[tokio::test] + async fn a_result_with_no_protocol_version_is_refused() { + let err = handshake_answering(None) + .await + .expect_err("no protocolVersion is not a compliant initialize result"); + assert!(err.contains("no `protocolVersion`"), "unexpected error: {err}"); + } + #[tokio::test] async fn route_client_response_resolves_pending() { let pending = new_pending(); @@ -5208,6 +5311,9 @@ mod acp_ws_integration { /// /// The mock answered the supported version everywhere, so the happy path was the only path the /// suite could reach and an absent check passed. + /// + /// "Unsupported" means outside `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS`, which since R5 holds + /// three revisions rather than one — being older than what we request is no longer sufficient. #[tokio::test] async fn a_server_answering_an_unsupported_protocol_version_is_not_registered() { let (url, registry) = serve().await; @@ -5222,8 +5328,18 @@ mod acp_ws_integration { "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} })).await; - // Answer `mcp/connect`, then answer `initialize` SUCCESSFULLY with an older spec version. - // 2024-11-05 is a real MCP revision, so this is a plausible peer rather than a nonsense one. + // Answer `mcp/connect`, then answer `initialize` SUCCESSFULLY with a revision outside the + // accepted set. + // + // This used to be `2024-11-05`, chosen because a real MCP revision makes a more plausible + // peer than a nonsense string. R5 then ADDED that revision to + // `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS`, so the literal quietly became a SUPPORTED + // version and this test began asserting the opposite of the decided behaviour. Picking a + // realistic example coupled the test to the policy it was meant to be independent of. + // + // `2019-01-01` is well-formed and deliberately not a real revision, so extending the set + // cannot silently invert this test again. If it is ever added, that is a decision someone + // has to make explicitly. let mut answered = false; while !answered { let f = recv(&mut ws).await; @@ -5238,7 +5354,7 @@ mod acp_ws_integration { send(&mut ws, json!({ "jsonrpc": "2.0", "id": f["id"].clone(), "result": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2019-01-01", "capabilities": { "tools": {} }, "serverInfo": { "name": "old-ext", "version": "0" } } From fcfb424206746647d6c1f0b3efeba561d0929bc6 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 19:35:33 +0800 Subject: [PATCH 132/138] docs(acp): the revision set depends on transport and framing, not just the methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review correction to b6a2f2c7. The recorded basis said a fifth inner method would require re-checking `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS`. That is necessary but not sufficient: compatibility is a property of what we send AND how we send it, so a transport change or a framing change can invalidate the set while the method list stays identical. The caveat now names all three triggers. This matters more than the wording suggests — D-2026-07-30-10 required the basis be written down precisely because a version list whose reason is missing goes silently wrong later, and a reason that is written down but incomplete fails the same way while looking discharged. --- crates/openab-gateway/src/adapters/acp_server.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 790d4f6f1..ace95b62a 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -927,10 +927,18 @@ const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; /// - `tools/list` — `src/browser_source.rs:200`; /// - `tools/call` — `src/browser_source.rs:351`. /// -/// Verified against the tree: those are the only methods reaching an inner server. A fifth would -/// have to be re-checked across the set, because compatibility is a property of the methods we -/// actually send, not of the revisions in the abstract. A bare list of version strings with no -/// stated reason is how this becomes silently wrong later. +/// Verified against the tree: those are the only methods reaching an inner server. +/// +/// RE-CHECK THIS SET when any of three things changes, not just the first: +/// +/// - a fifth inner method is added; +/// - the transport changes; +/// - the framing changes. +/// +/// Compatibility is a property of what we actually send and how we send it, not of the revisions +/// in the abstract, so a transport or framing change can invalidate the set while the method list +/// stays identical. A bare list of version strings with no stated reason is how this becomes +/// silently wrong later. const SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; From 30e047581f2185fbd3b647287ce25fd9e6de443a Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 20:07:45 +0800 Subject: [PATCH 133/138] feat(mcp)!: author one file we own instead of editing the operator's MCP config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 stage 1, per D-2026-07-30-02/03/15. BREAKING for kiro and cursor deployments — see below. openab no longer reads, merges into, or writes any vendor's MCP config. It authors `/.openab/mcp-facade.json` and stops. Putting that entry in place is an external step, except for Claude Code, which will be pointed at the file with `--mcp-config` at spawn (stage 2). openab does not invoke vendor CLIs. Deleted with the merge path: `load_mergeable_config`, `merge_kiro_agent_facade_configs`, `strip_direct_browser_entry`, `is_openab_direct_browser_entry`, `config_write_lock`. This closes R2 rather than fixing it. `load_mergeable_config` turned ANY read error — including EACCES — into `Some({})`, after which the file was rewritten from empty; both of its call sites were in the merge path. Repairing it first would have been repairing code being deleted, which is why D-13 ordered R3 before R2. `write_json_atomic` stays. Its known weaknesses — replaces a symlink, does not preserve mode/owner — become acceptable rather than fixed, because the file is now ours: 0600 owned by the openab process is the intended end state. The per-path lock goes because there is no read-modify-write left to serialise; the content is a pure function of `facade_url`, so concurrent sessions write identical bytes. BREAKING CHANGE: openab no longer writes browser control into `.cursor/mcp.json`, `.kiro/settings/mcp.json`, or kiro agent files. - Claude Code keeps zero-config onboarding (stage 2). - kiro: the operator runs `kiro-cli mcp import --file .openab/mcp-facade.json workspace` (no `--force`). Verified there is no launch-time equivalent. - cursor: no write and no flag exists; the operator adds the entry by hand. That is WIDER than D-03 recorded. D-03 said "Cursor deployments lose zero-config onboarding"; with no invocation, kiro loses it too. SECURITY-RELEVANT CONSEQUENCE, stated because it is a policy bypass rather than a convenience regression: `strip_direct_browser_entry` was what retired the old direct bridge entry (`mcpServers["openab-browser"]`) and its `@openab-browser` grant from kiro agent files. Its stated reason was that leaving them lets the model reach the browser WITHOUT passing through facade policy and audit. That cleanup was performed by editing the operator's file, so under the new boundary openab cannot do it. Any deployment that ever ran bridge mode keeps a working facade bypass until the operator clears it. The `jq` snippet at `docs/browser-mcp-agent-setup.md:38-46` is the remedy, and it moves from automatic to operator-performed. Tests: the merge-semantics tests are deleted with the code they covered. The replacement that matters is `an_operators_own_mcp_config_is_never_touched` — it pre-creates `.cursor/mcp.json` (with a `//` comment, the shape the old path destroyed), `.kiro/settings/mcp.json` and a kiro agent file, and asserts all three are byte-identical afterwards. A reintroduced merge has to modify one of them to be useful, so it fails there rather than in a deployment. `no_vendor_directory_is_created` covers the other half: absence of a file is not permission to author one. One existing test had to be repointed, and it is worth naming. `no_token_is_minted_when_the_facade_config_write_fails` forced the write to fail by making `/.cursor` a file. openab no longer creates that directory, so the write would have SUCCEEDED — a test named "no mint on failure" passing against a call that never failed. It now blocks `.openab`. Docs are stage 4 and are NOT in this commit; the in-code descriptions that contradicted the change are corrected here, including the module doc and the comparison to the removed Option C bridge entry. --- crates/openab-core/src/acp/pool.rs | 10 +- crates/openab-core/src/mcp_proxy.rs | 571 +++++----------------------- 2 files changed, 109 insertions(+), 472 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index d40e096a4..55b6e8037 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -955,8 +955,14 @@ mod tests { #[tokio::test] async fn no_token_is_minted_when_the_facade_config_write_fails() { let dir = tempfile::tempdir().unwrap(); - // Make `/.cursor` a FILE, so create_dir_all inside the writer fails. - std::fs::write(dir.path().join(".cursor"), b"not a directory").unwrap(); + // Make `/.openab` a FILE, so `create_dir_all` inside the writer fails. + // + // This used to block on `.cursor`, which openab no longer creates: since D-15 it authors + // only `.openab/mcp-facade.json` and never touches a vendor directory. Left pointing at + // `.cursor` the write would SUCCEED, the test would fail, and — worse if it had been + // written the other way round — a test asserting "no mint on failure" would have been + // passing against a call that never failed. + std::fs::write(dir.path().join(".openab"), b"not a directory").unwrap(); let counting = Arc::new(CountingRegistrar::default()); let registrar: Arc = counting.clone(); diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index d6653f504..cefbef339 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -11,8 +11,10 @@ //! attached; a call while disconnected reports "browser not connected" instead of the tools //! silently disappearing. //! - [`AcpMcpTunnel`] — the trait core calls to reach a session's tunnel, implemented in the root. -//! - [`write_facade_mcp_config`] — writes the one static facade entry into each colocated CLI's -//! config, and retires the bridge entry it replaces. +//! - [`write_facade_mcp_config`] — authors `.openab/mcp-facade.json`, the ONE file openab owns. +//! It does not write, merge into, or read any vendor's MCP config, and it does not invoke a +//! vendor CLI (D-2026-07-30-15). Putting the entry in place is the operator's step, except for +//! Claude Code, which is pointed at the file with `--mcp-config` at spawn. //! - [`report_browser_control`] — startup report of whether browser control is on, plus the //! migration notice for the removed `OPENAB_BROWSER_MODE`. @@ -110,60 +112,7 @@ pub fn browser_tools() -> Vec { } -/// Serialise writers per path. -/// -/// Two sessions starting at once write the same `mcp.json`. Read-modify-write without this lets -/// the later read see the earlier state and drop the other's entry — and with `rename` below the -/// loser's whole file wins, so the interleaving is silent rather than merely partial. -fn config_write_lock(path: &std::path::Path) -> std::sync::Arc> { - use std::collections::HashMap; - use std::sync::{Mutex, OnceLock}; - static LOCKS: OnceLock< - Mutex>>>, - > = - OnceLock::new(); - let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); - let mut guard = map.lock().unwrap_or_else(|e| e.into_inner()); - guard - .entry(path.to_path_buf()) - .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) - .clone() -} -/// Read a JSON config we intend to merge into, or `None` when it must be left alone. -/// -/// `None` means **skip this file**, never "start from empty". Returning `{}` on a parse failure and -/// then writing is how a config with a comment in it, or any file this parser does not accept, gets -/// replaced by ours — destroying configuration we did not write and cannot reconstruct. A missing -/// file is different and returns `Some({})`: there is nothing to lose. -/// -/// A non-object root is also `None`. It cannot be merged into, and indexing a `Value::Array` with a -/// string key **panics** rather than failing, so this guard is what stops a `[]`-rooted file from -/// taking the process down. -async fn load_mergeable_config(path: &std::path::Path) -> Option { - match tokio::fs::read(path).await { - Err(_) => Some(json!({})), - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(v) if v.is_object() => Some(v), - Ok(_) => { - tracing::warn!( - path = %path.display(), - "MCP config root is not a JSON object — leaving it untouched; browser tools \ - will not be configured here" - ); - None - } - Err(e) => { - tracing::warn!( - path = %path.display(), error = %e, - "MCP config is not parseable JSON — leaving it untouched rather than replacing \ - it; browser tools will not be configured here" - ); - None - } - }, - } -} /// Write `value` to `path` atomically, owner-only. /// @@ -242,150 +191,48 @@ async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Re } -/// True when an `openab-browser` entry is one we can **prove** we wrote, and so may be removed. -/// -/// Only the removed bridge entry qualifies. It was byte-identical every session -/// (`{"command":"openab","args":["browser-bridge"]}`) and names our own binary and a subcommand -/// that no longer exists, so matching that exact shape is itself the proof — and leaving it would -/// have the agent's MCP client fail to start it on every session. -/// -/// The per-session proxy entry deliberately does **not** qualify. Its url and bearer were minted -/// per session and never recorded anywhere, so "loopback url plus some `Bearer` header" is a -/// description rather than an identity: it matches any local MCP server an operator configured -/// under this key. An earlier version of this function claimed to recognise "a bearer we minted" -/// while comparing against nothing we had kept. With no way to prove ownership we fail closed and -/// preserve — deleting an operator's configuration is worse than leaving a dead entry, and with -/// the proxy gone the entry it names is dead configuration rather than a live bypass. -fn is_openab_direct_browser_entry(entry: &Value) -> bool { - entry.get("command").and_then(Value::as_str) == Some("openab") - && entry.get("args") == Some(&json!(["browser-bridge"])) -} -/// Drop a stale direct-transport `openab-browser` entry from an `mcpServers` map, returning -/// whether anything was removed. Both entries otherwise load side by side and the model may pick -/// the direct one, bypassing the facade's policy and audit. -fn strip_direct_browser_entry(cfg: &mut Value) -> bool { - let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) else { - return false; - }; - match servers.get("openab-browser") { - Some(entry) if is_openab_direct_browser_entry(entry) => { - servers.remove("openab-browser"); - true - } - _ => false, - } -} -/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config -/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — -/// the per-session secret is NOT in the file: the entry references the +/// Author the static `openab` facade entry into the one file openab owns +/// (Facade mode). The entry is byte-identical for every session — the bridge it used to be +/// compared against here was removed with the rest of Option C, so the comparison is gone +/// rather than left pointing at code that no longer exists. +/// +/// The per-session secret is NOT in the file: the entry references the /// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned /// agent process (config-var expansion is exactly how deployed agents already reference /// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token /// dies with the agent process and its registry entry. pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { - let entry = json!({ - "url": facade_url, - "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } - }); - let cfg_paths = [ - std::path::Path::new(workdir).join(".cursor").join("mcp.json"), - std::path::Path::new(workdir) - .join(".kiro") - .join("settings") - .join("mcp.json"), - ]; - for cfg_path in &cfg_paths { - if let Some(dir) = cfg_path.parent() { - tokio::fs::create_dir_all(dir).await?; - } - // Held across the read-modify-write so a concurrent session cannot read pre-write state - // and then rename its copy over ours. - let lock = config_write_lock(cfg_path); - let _guard = lock.lock().await; - - let Some(mut cfg) = load_mergeable_config(cfg_path).await else { - // Unparseable or non-object root: already logged. Skip rather than replace — this is - // the user's file and we cannot merge into it safely. - continue; - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } - // Publish under "openab" (the facade), not "openab-browser": the agent - // reaches ALL facade capabilities through this one entry. - let mut changed = false; - if cfg["mcpServers"]["openab"] != entry { - cfg["mcpServers"]["openab"] = entry.clone(); - changed = true; - } - // Retire the direct transport we previously wrote here. Leaving it means both entries - // load and the model can reach the browser without passing through facade policy/audit. - changed |= strip_direct_browser_entry(&mut cfg); - if changed { - write_json_atomic(cfg_path, &cfg).await?; + let cfg = json!({ + "mcpServers": { + // Published as "openab" (the facade), not "openab-browser": the agent reaches ALL + // facade capabilities through this one entry. + "openab": { + "url": facade_url, + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + } } + }); + let path = facade_config_path(workdir); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; } - // kiro `--agent` deployments read agent files, not settings/mcp.json. - merge_kiro_agent_facade_configs(workdir, &entry).await?; - Ok(()) + // No read-modify-write and no per-path lock. The content is a pure function of `facade_url`, + // so concurrent sessions write identical bytes and the rename in `write_json_atomic` makes + // last-one-wins harmless: there is no prior state to lose, because we own the file. + write_json_atomic(&path, &cfg).await } -/// Facade-mode sibling of [`merge_kiro_agent_configs`]: merges the static -/// `openab` facade entry + `@openab` allowlist grant into every -/// `.kiro/agents/*.json`. Same never-clobber rules; nothing to clean up on -/// evict (the entry is static and the token lives in the process env). -async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { - let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); - let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { - return Ok(()); - }; - while let Ok(Some(f)) = rd.next_entry().await { - let path = f.path(); - let name = f.file_name(); - let name = name.to_string_lossy(); - if !name.ends_with(".json") || name.starts_with("._") { - continue; - } - let lock = config_write_lock(&path); - let _guard = lock.lock().await; - // Same fail-closed rule as the settings files: unparseable, or a root we cannot merge - // into, means leave the agent file alone. These carry model, description and allowlists - // that are none of our business to rewrite. - let Some(mut cfg) = load_mergeable_config(&path).await else { - continue; - }; - if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { - cfg["mcpServers"] = json!({}); - } - let mut changed = false; - if cfg["mcpServers"]["openab"] != *entry { - cfg["mcpServers"]["openab"] = entry.clone(); - changed = true; - } - // Same retirement as the settings files, plus the agent-file allowlist grant that made - // the direct server callable — `allowedTools` is default-deny, so a leftover - // `@openab-browser` is what keeps the bypass reachable here. - if strip_direct_browser_entry(&mut cfg) { - changed = true; - if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { - allowed.retain(|v| v.as_str() != Some("@openab-browser")); - } - } - if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { - if !allowed.iter().any(|v| v.as_str() == Some("@openab")) { - allowed.push(json!("@openab")); - changed = true; - } - } - if changed { - write_json_atomic(&path, &cfg).await?; - } - } - Ok(()) +/// The one file openab authors: `/.openab/mcp-facade.json`. +/// +/// Source for Claude Code's `--mcp-config` at spawn time and for the operator's +/// `kiro-cli mcp import --file … workspace`. openab never puts it in place itself (D-15). +pub fn facade_config_path(workdir: &str) -> std::path::PathBuf { + std::path::Path::new(workdir).join(".openab").join("mcp-facade.json") } + /// Broker-side session credential hook (Facade mode). Implemented by the root /// (closing over the facade's `SessionTokens` registry — core stays free of /// the openab-mcp dependency); the pool calls it at session spawn/evict. @@ -481,125 +328,107 @@ mod facade_config_writer { async fn tmp_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("openab-cfg-{tag}-{}", std::process::id())); let _ = tokio::fs::remove_dir_all(&d).await; - tokio::fs::create_dir_all(d.join(".cursor")).await.unwrap(); + tokio::fs::create_dir_all(&d).await.unwrap(); d } - /// A config this parser cannot read must be left EXACTLY as it was. - /// - /// The old code parsed with `unwrap_or_else(|_| json!({}))` and then wrote, so a file with a - /// `//` comment — which plenty of editors and humans put in `mcp.json` — came back containing - /// only our entry. Everything the user had configured was gone, unrecoverably. + /// We author exactly one file, in our own directory, with the facade entry. #[tokio::test] - async fn an_unparseable_config_is_left_untouched() { - let wd = tmp_dir("unparseable").await; - let path = wd.join(".cursor").join("mcp.json"); - let original = "{\n // my servers\n \"mcpServers\": { \"mine\": { \"command\": \"x\" } }\n}"; - tokio::fs::write(&path, original).await.unwrap(); - + async fn the_facade_entry_is_written_to_the_file_we_own() { + let wd = tmp_dir("authored").await; write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") .await .unwrap(); - let after = tokio::fs::read_to_string(&path).await.unwrap(); + let path = facade_config_path(wd.to_str().unwrap()); + assert_eq!(path, wd.join(".openab").join("mcp-facade.json")); + let v: Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); + // The token is never in the file — the literal is, and the agent's env supplies the value. assert_eq!( - after, original, - "an unparseable config must survive byte-for-byte — replacing it destroys work we \ - cannot reconstruct" + v["mcpServers"]["openab"]["headers"]["Authorization"], + json!("Bearer ${OPENAB_SESSION_TOKEN}") ); let _ = tokio::fs::remove_dir_all(&wd).await; } - /// A non-object root must not panic. + /// THE BOUNDARY (D-2026-07-30-03, D-2026-07-30-15): openab never modifies a file it did not + /// create, and never creates one in someone else's directory. /// - /// `cfg["mcpServers"] = ...` on a `Value::Array` does not return an error — `IndexMut` panics, - /// taking the process down. The guard has to run before any indexing. + /// Every defect in canonical item 9 — EACCES read as "file absent" then overwritten, no + /// directory fsync, rename dropping mode/owner and replacing symlinks, a concurrency test that + /// passed for the wrong reason — was a consequence of editing the operator's `mcp.json`. + /// Deleting that path deleted all four, and this test is what stops it coming back: a + /// reintroduced merge would have to modify one of these files to be useful, and that fails + /// here rather than in someone's deployment. #[tokio::test] - async fn an_array_root_does_not_panic_and_is_left_untouched() { - let wd = tmp_dir("arrayroot").await; - let path = wd.join(".cursor").join("mcp.json"); - tokio::fs::write(&path, "[]").await.unwrap(); + async fn an_operators_own_mcp_config_is_never_touched() { + let wd = tmp_dir("boundary").await; + let cursor = wd.join(".cursor").join("mcp.json"); + let kiro = wd.join(".kiro").join("settings").join("mcp.json"); + let agent = wd.join(".kiro").join("agents").join("terra.json"); + for f in [&cursor, &kiro, &agent] { + tokio::fs::create_dir_all(f.parent().unwrap()).await.unwrap(); + } + // Deliberately including a `//` comment: the shape that the old merge path destroyed. + let cursor_body = "{\n // mine\n \"mcpServers\": {\"mine\": {\"command\": \"x\"}}\n}"; + let kiro_body = "{\"mcpServers\":{\"openab-browser\":{\"command\":\"openab\",\"args\":[\"browser-bridge\"]}}}"; + let agent_body = "{\"allowedTools\":[\"@openab-browser\",\"@github\"]}"; + tokio::fs::write(&cursor, cursor_body).await.unwrap(); + tokio::fs::write(&kiro, kiro_body).await.unwrap(); + tokio::fs::write(&agent, agent_body).await.unwrap(); - let r = write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp").await; - assert!(r.is_ok(), "a `[]` root must be skipped, not fatal: {r:?}"); - assert_eq!( - tokio::fs::read_to_string(&path).await.unwrap(), - "[]", - "we cannot merge into an array root, so it is left alone" - ); + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + // Byte-for-byte, including the stale `openab-browser` entry and its grant. openab no + // longer retires those — see the PR body: that cleanup became operator-performed, and it + // is a policy bypass rather than a convenience, so it is stated rather than silently + // dropped. + assert_eq!(tokio::fs::read_to_string(&cursor).await.unwrap(), cursor_body); + assert_eq!(tokio::fs::read_to_string(&kiro).await.unwrap(), kiro_body); + assert_eq!(tokio::fs::read_to_string(&agent).await.unwrap(), agent_body); let _ = tokio::fs::remove_dir_all(&wd).await; } - /// A user's own servers survive, and ours is added beside them. + /// A vendor directory that does not exist must not be created either — absence of a file is + /// not permission to author one. #[tokio::test] - async fn a_valid_config_keeps_the_users_servers() { - let wd = tmp_dir("merge").await; - let path = wd.join(".cursor").join("mcp.json"); - tokio::fs::write(&path, r#"{"mcpServers":{"mine":{"command":"x"}},"other":42}"#) - .await - .unwrap(); - + async fn no_vendor_directory_is_created() { + let wd = tmp_dir("novendor").await; write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") .await .unwrap(); - - let v: Value = - serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); - assert_eq!(v["mcpServers"]["mine"]["command"], json!("x"), "user server preserved"); - assert_eq!(v["other"], json!(42), "unrelated top-level keys preserved"); - assert_eq!( - v["mcpServers"]["openab"]["headers"]["Authorization"], - json!("Bearer ${OPENAB_SESSION_TOKEN}"), - "and ours is added by reference, never with the token value" - ); + for d in [".cursor", ".kiro"] { + assert!( + !wd.join(d).exists(), + "{d} was created — openab may only author inside .openab/" + ); + } let _ = tokio::fs::remove_dir_all(&wd).await; } - /// Concurrent writers must not lose each other's work. + /// Concurrent sessions must still publish a readable file. /// - /// With an atomic rename and no lock this is *worse* than a torn write: the loser's entire - /// file replaces the winner's, silently. Both calls write the same entry, so what this pins is - /// that the user's pre-existing server survives both — a lost update would drop it. + /// Weaker than the merge-path version by design: with no read-modify-write there is no lost + /// update to prevent, because both writers produce identical bytes from the same `facade_url`. + /// What remains worth pinning is that the rename never exposes a partial file. #[tokio::test] - async fn concurrent_writers_never_publish_a_damaged_config() { + async fn concurrent_writers_publish_a_readable_config() { let wd = tmp_dir("concurrent").await; - let path = wd.join(".cursor").join("mcp.json"); - tokio::fs::write(&path, r#"{"mcpServers":{"mine":{"command":"x"}}}"#) - .await - .unwrap(); - - // DIFFERENT urls on purpose. The previous version passed the same url to both writers, so - // their outputs were byte-identical and a lost update was unobservable by construction — - // and because both merge from the same base, even a real lost update left `mine` and - // `openab` both present. The assertions could not fail. Removing the lock made it red for - // an unrelated reason: the two writers shared one fixed temp filename, so one renamed it - // away and the other hit ENOENT. It was reporting a filename collision as a lost update. - // - // With distinct urls the winner is identifiable, so this pins the guarantee that is really - // on offer: whichever writer lands last, the published file is complete and valid — never - // a merge of the two, never half-written, and never missing the user's own server. let w = wd.to_str().unwrap().to_string(); let (a, b) = tokio::join!( write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), - write_facade_mcp_config(&w, "http://127.0.0.1:9999/mcp"), + write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), ); a.unwrap(); b.unwrap(); - - let v: Value = serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()) - .expect("a concurrent write published a file that is not valid JSON"); - assert_eq!( - v["mcpServers"]["mine"]["command"], - json!("x"), - "the user's own server must survive both writers" - ); - let url = v["mcpServers"]["openab"]["url"] - .as_str() - .expect("our entry must be present and complete"); - assert!( - url == "http://127.0.0.1:8848/mcp" || url == "http://127.0.0.1:9999/mcp", - "the published entry must be exactly one writer's, not a blend of both: {url}" - ); + let v: Value = + serde_json::from_slice(&tokio::fs::read(facade_config_path(&w)).await.unwrap()) + .unwrap(); + assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); let _ = tokio::fs::remove_dir_all(&wd).await; } @@ -612,7 +441,7 @@ mod facade_config_writer { write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") .await .unwrap(); - let path = wd.join(".cursor").join("mcp.json"); + let path = facade_config_path(wd.to_str().unwrap()); let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "0600 must be set at creation, not chmod'd after"); let _ = tokio::fs::remove_dir_all(&wd).await; @@ -622,8 +451,7 @@ mod facade_config_writer { #[cfg(test)] mod tests { use super::{ - browser_mode_migration_notice, browser_tools, is_openab_direct_browser_entry, - write_facade_mcp_config, + browser_mode_migration_notice, browser_tools, }; /// The variable is inert now, so the notice must fire for every value an operator could have @@ -666,207 +494,10 @@ mod tests { // --- F4: facade setup retires the direct transport it replaces --- - /// The bridge and per-session-proxy entries we wrote are recognised; anything else under the - /// same key is not ours to delete. - #[test] - fn only_our_own_direct_browser_shapes_are_recognised() { - let bridge = serde_json::json!({ "command": "openab", "args": ["browser-bridge"] }); - assert!(is_openab_direct_browser_entry(&bridge)); - - // Not provably ours. The loopback+bearer shapes are the important ones: they describe our - // old proxy entry, but they equally describe an operator's own local MCP server, and the - // per-session url/bearer were never recorded, so ownership cannot be established. - for foreign in [ - serde_json::json!({ "url": "http://127.0.0.1:45678/mcp", "headers": { "Authorization": "Bearer abc" } }), - serde_json::json!({ "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer x" } }), - serde_json::json!({ "url": "http://127.0.0.1:45678/mcp" }), - serde_json::json!({ "command": "openab", "args": ["something-else"] }), - serde_json::json!({ "command": "my-browser-tool", "args": ["browser-bridge"] }), - serde_json::json!({ "url": "http://127.0.0.1:/mcp", "headers": { "Authorization": "Bearer x" } }), - ] { - assert!( - !is_openab_direct_browser_entry(&foreign), - "must not claim ownership of {foreign}" - ); - } - } - - #[tokio::test] - async fn facade_setup_removes_the_stale_direct_entry_but_keeps_user_servers() { - let dir = tempfile::tempdir().unwrap(); - let cursor = dir.path().join(".cursor"); - std::fs::create_dir_all(&cursor).unwrap(); - std::fs::write( - cursor.join("mcp.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "mcpServers": { - // ours, the bridge transport facade mode replaces - "openab-browser": { "command": "openab", "args": ["browser-bridge"] }, - // the operator's own servers must survive untouched - "github": { "url": "http://ghpool:8080/mcp" }, - "notes": { "command": "notes-mcp", "args": ["--stdio"] } - }, - "someUnrelatedKey": 42 - })) - .unwrap(), - ) - .unwrap(); - - write_facade_mcp_config(dir.path().to_str().unwrap(), "http://127.0.0.1:8848/mcp") - .await - .unwrap(); - - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); - let servers = cfg["mcpServers"].as_object().unwrap(); - assert!( - !servers.contains_key("openab-browser"), - "the direct transport must not load alongside the facade — that is the bypass" - ); - assert_eq!(servers["openab"]["url"], "http://127.0.0.1:8848/mcp"); - assert_eq!(servers["github"]["url"], "http://ghpool:8080/mcp"); - assert_eq!(servers["notes"]["command"], "notes-mcp"); - assert_eq!(cfg["someUnrelatedKey"], 42, "unrelated config must survive"); - } - - /// An operator's own local MCP server under this key survives facade setup — the entry **and** - /// its allowlist grant (review R3-F2). - /// - /// The previous matcher treated any loopback url carrying any `Bearer` header as ours, which - /// is precisely the shape a locally-run MCP server takes, so that configuration was deleted. - /// Ownership of that shape cannot be proven — the per-session url and bearer were never - /// recorded — so it is preserved now. - #[tokio::test] - async fn a_local_mcp_server_under_our_key_is_not_deleted() { - let wd = tmp_workdir("r3f2").await; - let cursor = wd.join(".cursor"); - tokio::fs::create_dir_all(&cursor).await.unwrap(); - // Indistinguishable from our retired proxy entry by shape alone. - let theirs = serde_json::json!({ - "url": "http://127.0.0.1:45678/mcp", - "headers": { "Authorization": "Bearer their-own-token" } - }); - tokio::fs::write( - cursor.join("mcp.json"), - serde_json::to_vec_pretty( - &serde_json::json!({ "mcpServers": { "openab-browser": theirs } }), - ) - .unwrap(), - ) - .await - .unwrap(); - - let agent = wd.join(".kiro/agents/terra.json"); - tokio::fs::write( - &agent, - serde_json::to_vec_pretty(&serde_json::json!({ - "name": "terra", - "mcpServers": { "openab-browser": theirs }, - "allowedTools": ["@builtin", "@openab-browser"] - })) - .unwrap(), - ) - .await - .unwrap(); - - write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") - .await - .unwrap(); - - let cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(cursor.join("mcp.json")).await.unwrap()) - .unwrap(); - assert_eq!( - cfg["mcpServers"]["openab-browser"], theirs, - "an entry we cannot prove we wrote must be preserved verbatim" - ); - - let agent_cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); - assert_eq!(agent_cfg["mcpServers"]["openab-browser"], theirs); - let allowed: Vec<&str> = agent_cfg["allowedTools"] - .as_array() - .unwrap() - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!( - allowed.contains(&"@openab-browser"), - "the grant must survive too — revoking it silently disables the operator's own server" - ); - let _ = tokio::fs::remove_dir_all(&wd).await; - } - - #[tokio::test] - async fn facade_setup_leaves_a_foreign_openab_browser_entry_alone() { - // Same key, but a shape we never wrote: it belongs to the operator, so removing it would - // destroy their configuration to fix a bypass that entry does not create. - let dir = tempfile::tempdir().unwrap(); - let cursor = dir.path().join(".cursor"); - std::fs::create_dir_all(&cursor).unwrap(); - let foreign = serde_json::json!({ "url": "https://my-own-browser.example/mcp" }); - std::fs::write( - cursor.join("mcp.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "mcpServers": { "openab-browser": foreign } - })) - .unwrap(), - ) - .unwrap(); - - write_facade_mcp_config(dir.path().to_str().unwrap(), "http://127.0.0.1:8848/mcp") - .await - .unwrap(); - let cfg: serde_json::Value = - serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); - assert_eq!( - cfg["mcpServers"]["openab-browser"], foreign, - "an entry we did not write must be preserved verbatim" - ); - } - #[tokio::test] - async fn facade_setup_retires_the_direct_entry_and_its_grant_in_kiro_agent_files() { - let wd = tmp_workdir("f4-agent").await; - let agent = wd.join(".kiro/agents/terra.json"); - tokio::fs::write( - &agent, - serde_json::to_vec_pretty(&serde_json::json!({ - "name": "terra", - "mcpServers": { - "openab-browser": { "command": "openab", "args": ["browser-bridge"] }, - "github": { "url": "http://ghpool:8080/mcp" } - }, - "allowedTools": ["@builtin", "@openab-browser", "@github"] - })) - .unwrap(), - ) - .await - .unwrap(); - write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") - .await - .unwrap(); - let cfg: serde_json::Value = - serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); - assert!(!cfg["mcpServers"].as_object().unwrap().contains_key("openab-browser")); - assert_eq!(cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp"); - let allowed: Vec<&str> = cfg["allowedTools"] - .as_array() - .unwrap() - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!( - !allowed.contains(&"@openab-browser"), - "allowedTools is default-deny — a leftover grant is what keeps the bypass reachable" - ); - assert!(allowed.contains(&"@openab"), "the facade must be granted"); - assert!(allowed.contains(&"@github"), "unrelated grants must survive"); - let _ = tokio::fs::remove_dir_all(&wd).await; - } /// Unique throwaway workdir with a `.kiro/agents/` tree. async fn tmp_workdir(tag: &str) -> std::path::PathBuf { From 7c68506835a8ab99001e0a66e089e3f777f43eb7 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 21:20:08 +0800 Subject: [PATCH 134/138] docs(mcp): stop claiming a --mcp-config spawn flag that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction to 30e04758, which wrote two doc comments describing Claude Code as already being pointed at `.openab/mcp-facade.json` with `--mcp-config` at spawn. That behaviour is decided (D-2026-07-30-15) but not implemented, and the commit that failed to implement it is the same one that wrote it down as fact. The runbook names this class explicitly — "a promise written into the docs by the same commit that failed to implement it" — and records eight instances of stale-description defects this round. It is worth noting this one runs the other way: every other instance was a sentence describing behaviour that had been REMOVED, and a vocabulary sweep finds those. A sentence describing behaviour that does not exist YET matches no removed term, so the sweep that caught the two stale bridge references in the same commit was structurally incapable of catching this one. Both comments now describe what the code does — the operator places the entry for every vendor today — and mark the spawn flag as pending, with the reason it is pending: it needs a way to identify the vendor at spawn time, and this codebase has deliberately never had one. --- crates/openab-core/src/mcp_proxy.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index cefbef339..7acc17b16 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -13,8 +13,10 @@ //! - [`AcpMcpTunnel`] — the trait core calls to reach a session's tunnel, implemented in the root. //! - [`write_facade_mcp_config`] — authors `.openab/mcp-facade.json`, the ONE file openab owns. //! It does not write, merge into, or read any vendor's MCP config, and it does not invoke a -//! vendor CLI (D-2026-07-30-15). Putting the entry in place is the operator's step, except for -//! Claude Code, which is pointed at the file with `--mcp-config` at spawn. +//! vendor CLI (D-2026-07-30-15). Putting the entry in place is the operator's step for EVERY +//! vendor today. Pointing Claude Code at it with `--mcp-config` at spawn is decided but NOT +//! implemented — it needs a way to identify the vendor at spawn time, which this codebase has +//! deliberately never had. //! - [`report_browser_control`] — startup report of whether browser control is on, plus the //! migration notice for the removed `OPENAB_BROWSER_MODE`. @@ -226,8 +228,9 @@ pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io /// The one file openab authors: `/.openab/mcp-facade.json`. /// -/// Source for Claude Code's `--mcp-config` at spawn time and for the operator's -/// `kiro-cli mcp import --file … workspace`. openab never puts it in place itself (D-15). +/// Source for the operator's `kiro-cli mcp import --file … workspace`, and the intended source for +/// Claude Code's `--mcp-config` once spawn-time vendor identification is decided. openab never puts +/// it in place itself (D-15). pub fn facade_config_path(workdir: &str) -> std::path::PathBuf { std::path::Path::new(workdir).join(".openab").join("mcp-facade.json") } From 38bef5dee0065fcc0469638036f4e466fe091080 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 21:50:53 +0800 Subject: [PATCH 135/138] test(acp): cover both frame ceilings, in the gate rather than only in a script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R11. `scripts/acp-ws-smoke.py` asserted "oversized frame closes the connection" by sending 1 MiB + 64 bytes, against a comment reading `# > MAX_FRAME_BYTES (1 MiB)`. `MAX_FRAME_BYTES` is 8 MiB. That payload stopped reaching the ceiling it was named for, and — being a bare `x` string rather than JSON — the close it still observed came from the parse path. The assertion kept passing the whole time, which is the problem: it asserted the right OUTCOME through the wrong MECHANISM, so the coverage was gone while the signal stayed green. There are two ceilings and they behave differently. The script covered neither: - over `MAX_FRAME_BYTES` (8 MiB): the frame cannot be parsed, so the gateway cannot tell a request from a notification or recover an id. Answering could mean answering a notification, so it closes the connection instead. - over `MAX_NON_TUNNEL_FRAME_BYTES` (1 MiB) for anything carrying a `method`: answered with an error, connection KEPT. The 8 MiB allowance exists for tunnel results, which arrive as responses; letting method frames use it would turn that allowance into a way to park `MAX_INFLIGHT_PROMPTS` × 8 MiB of prompt text per connection. Both are now Rust WS integration tests against the real `/acp` route, so the gate runs them. That is the substantive change: the assertion moved from a script nothing executes locally into the suite, and a script that needs a deployed server can no longer be the only thing standing between this behaviour and a regression. Each was mutation-checked against the code and the split is clean: disabling `oversized_for_its_kind` fails only the per-kind test, and raising the transport ceiling out of reach fails only the transport test. Neither mutation moved the other test, which is what shows the two ceilings are independently covered rather than one test passing for both reasons. The per-kind test also sends a second, normal request afterwards and asserts it succeeds. Without that, a regression that closed the connection on a per-kind refusal would satisfy every other assertion in the test. The smoke script is updated to match, as the end-to-end version against a real deployment rather than the only version. --- .../openab-gateway/src/adapters/acp_server.rs | 76 +++++++++++++++++++ scripts/acp-ws-smoke.py | 57 ++++++++++++-- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index ace95b62a..f0c1476d3 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -5752,6 +5752,82 @@ mod acp_ws_integration { ); } + /// The transport ceiling: a frame over `MAX_FRAME_BYTES` closes the connection. + /// + /// Deliberately paired with the test below, because the two ceilings have DIFFERENT outcomes + /// and a single test cannot show that. Over 8 MiB the frame cannot be parsed at all, so the + /// gateway cannot tell a request from a notification or recover an id — fabricating a response + /// would risk answering a notification, so it closes instead. + /// + /// `scripts/acp-ws-smoke.py` asserted this with a 1 MiB + 64 byte payload and a comment + /// reading `> MAX_FRAME_BYTES (1 MiB)`. The ceiling moved to 8 MiB, so that payload stopped + /// reaching it — and because the payload was a bare `x` string rather than JSON, the close it + /// still observed came from the parse path. The assertion kept passing while covering a + /// different mechanism, which is why this now lives here where the gate runs it. + #[tokio::test] + async fn a_frame_over_the_transport_ceiling_closes_the_connection() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let oversized = "x".repeat(super::MAX_FRAME_BYTES + 64); + send(&mut ws, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "pad": oversized})) + .await; + + // The connection must go away rather than answer. Bounded so a regression that keeps it + // open fails here instead of hanging. + let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + match ws.next().await { + None => return true, + Some(Err(_)) => return true, + Some(Ok(_)) => return false, + } + } + }) + .await; + assert_eq!( + closed, + Ok(true), + "a frame over the transport ceiling must close the connection, not answer it" + ); + } + + /// The per-kind ceiling: a METHOD-bearing frame over `MAX_NON_TUNNEL_FRAME_BYTES` is refused + /// with an error and the connection SURVIVES. + /// + /// This is the half the smoke script never covered. The 8 MiB allowance exists for tunnel + /// results, which arrive as client responses; letting a method-bearing frame use it would make + /// the allowance a way to park `MAX_INFLIGHT_PROMPTS` × 8 MiB of prompt text per connection. + #[tokio::test] + async fn a_method_frame_over_its_ceiling_is_refused_but_keeps_the_connection() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + // Over 1 MiB, comfortably under 8 MiB — so it reaches the per-kind check, not the + // transport one. Asserting the gap between the two ceilings is the point. + let pad = "y".repeat(super::MAX_NON_TUNNEL_FRAME_BYTES + 4096); + assert!(pad.len() < super::MAX_FRAME_BYTES, "must not trip the transport ceiling"); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 7, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}, "pad": pad} + })).await; + + let resp = recv(&mut ws).await; + assert_eq!(resp["id"], json!(7)); + assert!(resp.get("error").is_some(), "an oversized request must be answered with an error: {resp}"); + + // And the connection still works — the discriminating half. A regression that closed here + // would satisfy every assertion above. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 8, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let after = recv(&mut ws).await; + assert_eq!(after["id"], json!(8)); + assert!( + after.get("result").is_some(), + "the connection must survive a per-kind refusal: {after}" + ); + } + /// The eviction branch's ONLY coverage — and the scenario that proves it is not dead code. /// /// `a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced` is named for diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index d55f3cc01..2851c3ead 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -277,16 +277,63 @@ async def section_lifecycle(): except Exception as e: # noqa: BLE001 record("life", False, "valid token via Authorization: Bearer header accepted", repr(e)) - # oversized frame → the server closes the connection (no fabricated JSON-RPC response) + # There are TWO ceilings with DIFFERENT outcomes, and this used to cover neither. + # + # It sent 1 MiB + 64 bytes against a comment reading "> MAX_FRAME_BYTES (1 MiB)". The transport + # ceiling is 8 MiB, so that payload stopped reaching it — and being a bare "x" string rather + # than JSON, the close it still saw came from the parse path. Green, wrong mechanism. + # + # Both cases are also covered by Rust WS integration tests, which the gate runs; these are the + # end-to-end versions against a real deployment. + + # 1. Over the TRANSPORT ceiling (8 MiB) → connection closes, no JSON-RPC response. The frame + # cannot be parsed, so the server cannot tell request from notification or recover an id, + # and answering could mean answering a notification. async with await try_connect(TOKEN) as ws: - await ws.send("x" * ((1 << 20) + 64)) # > MAX_FRAME_BYTES (1 MiB) + oversized = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "pad": "x" * ((8 << 20) + 64)}) + await ws.send(oversized) try: await asyncio.wait_for(ws.recv(), timeout=8) - record("life", False, "oversized frame closes the connection", "got a frame back") + record("life", False, "frame over the transport ceiling closes the connection", + "got a frame back") except ConnectionClosed: - record("life", True, "oversized frame closes the connection") + record("life", True, "frame over the transport ceiling closes the connection") except asyncio.TimeoutError: - record("life", False, "oversized frame closes the connection", "no close within 8s") + record("life", False, "frame over the transport ceiling closes the connection", + "no close within 8s") + + # 2. Over the PER-KIND ceiling (1 MiB for anything carrying a `method`) but under the transport + # ceiling → answered with an error, and the connection SURVIVES. The 8 MiB allowance is for + # tunnel results, which are responses; letting method frames use it would turn the allowance + # into a way to park MAX_INFLIGHT_PROMPTS x 8 MiB of prompt text per connection. + async with await try_connect(TOKEN) as ws: + big_method = json.dumps({"jsonrpc": "2.0", "id": 7, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}, + "pad": "y" * ((1 << 20) + 4096)}}) + await ws.send(big_method) + try: + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=8)) + if resp.get("error") is None: + record("life", False, "oversized method frame is refused with an error", + f"no error in {resp}") + else: + record("life", True, "oversized method frame is refused with an error") + # The discriminating half: a server that closed instead would pass the check above + # only by never getting here. + await ws.send(json.dumps({"jsonrpc": "2.0", "id": 8, "method": "initialize", + "params": {"protocolVersion": 1, + "clientCapabilities": {}}})) + after = json.loads(await asyncio.wait_for(ws.recv(), timeout=8)) + record("life", after.get("result") is not None, + "connection survives a per-kind refusal", + "" if after.get("result") is not None else f"got {after}") + except ConnectionClosed: + record("life", False, "oversized method frame is refused with an error", + "connection closed — that is the transport-ceiling behaviour, not this one") + except asyncio.TimeoutError: + record("life", False, "oversized method frame is refused with an error", + "no response within 8s") # session/cancel → the in-flight prompt ends with stopReason:"cancelled" async with await try_connect(TOKEN) as ws: From 53dee2bc72da65be13afbdea846e679525055efc Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 23:13:45 +0800 Subject: [PATCH 136/138] fix(mcp): the startup line said "enabled" when the operator still has a step left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 stage 3. `report_browser_control` reported "browser control: enabled via the OAB MCP Facade ([mcp] configured)". After 30e04758 that is no longer true at startup: the facade runs, but openab no longer writes the entry into any vendor's MCP config, so no agent can reach it until the operator places it. An operator reading "enabled" and seeing no browser tools would go looking for a bug instead of doing the remaining step. This is the stale-description class again, arriving a third way. The other instances were sentences describing behaviour that had been REMOVED, which a vocabulary sweep finds, and one describing behaviour that did not exist YET, which a sweep cannot find because it matches no removed term. This one contains no removed term either: it went stale because something else changed underneath it while every word in it stayed the same. The line now reports the facade AND names the remaining step with the exact commands — `kiro-cli mcp import --file workspace` (no `--force`) for kiro, and for cursor the fact that no import mechanism exists, so the entry has to be pasted into `.cursor/mcp.json` by hand. Claude Code is deliberately absent. Whether openab points it at the file with `--mcp-config` at spawn is undecided (it needs spawn-time vendor identification, which this codebase has never had), and naming it either way would state something that is not settled. The path reported comes from the CONFIGURED `working_dir`. A session can resolve a different `effective_workdir` — a stored per-session value or an explicit override — and the file is written under whichever that session used. Startup cannot know those, so the code says the path is the default rather than a promise about every session. With the deployed `working_dir == $HOME` the two coincide, which is the same scope collapse D-11 asked to be documented rather than fixed. --- crates/openab-core/src/mcp_proxy.rs | 27 +++++++++++++++++++++++++-- src/main.rs | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs index 7acc17b16..49870746e 100644 --- a/crates/openab-core/src/mcp_proxy.rs +++ b/crates/openab-core/src/mcp_proxy.rs @@ -269,9 +269,32 @@ pub trait SessionTokenRegistrar: Send + Sync { /// to prevent, one level up. So the message says the value is ignored *and* reports what is /// actually in force, since "ignored" alone does not tell them whether they still have browser /// control at all. -pub fn report_browser_control(mcp_configured: bool) { +pub fn report_browser_control(mcp_configured: bool, workdir: &str) { if mcp_configured { - tracing::info!("browser control: enabled via the OAB MCP Facade ([mcp] configured)"); + // "enabled" alone became false when openab stopped wiring vendor configs (D-15). The + // facade IS running, but no agent can reach it until the entry is placed, and an operator + // reading "enabled" would go looking for a bug instead of doing the remaining step. So the + // line reports the facade AND names the step, with the exact commands. + // + // `workdir` here is the CONFIGURED default. A session may resolve a different one + // (`effective_workdir`: a stored per-session value, or an explicit override), and the file + // is written under whichever that session used. Startup cannot know those, so the path + // below is the default rather than a promise about every session — which is also why the + // deployed default matters: with `working_dir == $HOME` the two coincide. + let path = facade_config_path(workdir); + tracing::info!( + facade_config = %path.display(), + "browser control: the OAB MCP Facade is running ([mcp] configured), and openab has \ + written its entry to the file above. openab does NOT modify your agent's MCP config, \ + so browser tools stay unavailable until that entry is in place." + ); + tracing::info!( + "browser control — to finish wiring, run ONE of these for your agent: \ + kiro: kiro-cli mcp import --file {path} workspace (do not pass --force) | \ + cursor: no import mechanism exists — paste the contents of {path} into the \ + \"mcpServers\" object of .cursor/mcp.json yourself", + path = path.display() + ); } else { // Unconditional, and the whole point of the change: with the proxy fallback gone, an // unconfigured deployment has NO browser control. Saying nothing would leave that to be diff --git a/src/main.rs b/src/main.rs index 47756405d..3047ae6d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -499,7 +499,7 @@ async fn main() -> anyhow::Result<()> { // Gated on `acp` (the root feature that pulls in core's `acp-mcp`), not on `acp-mcp` itself — // that is a core feature and naming it here is an unknown-cfg error. #[cfg(feature = "acp")] - openab_core::mcp_proxy::report_browser_control(cfg.mcp.is_some()); + openab_core::mcp_proxy::report_browser_control(cfg.mcp.is_some(), &cfg.agent.working_dir); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); let tokens = facade_sessions.clone(); From e22c9ef81e7538a1bf3b240318b620af946ff4a9 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 23:24:30 +0800 Subject: [PATCH 137/138] docs(mcp): the setup guide promised a cleanup openab no longer performs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 stage 4, partial — everything that does not depend on the undecided Claude Code spawn flag. `docs/browser-mcp-agent-setup.md` told operators the leftover bridge entry "is deleted for you on the next session — in `.cursor/mcp.json`, `.kiro/settings/mcp.json` and the kiro agent files, including its `@openab-browser` grant." That cleanup was performed by editing the operator's file, and 30e04758 stopped openab doing that. The sentence became a promise the code no longer keeps, and it is the worst one to leave standing: an operator who believes it does nothing, and a leftover `openab-browser` entry is a WORKING route to the browser that skips facade policy and the audit trail. So the allowlist in `[[mcp.acp_servers]]` is not the only way in until they remove it. The guide now says removal is theirs, says why it is a policy question rather than tidiness, and extends the `jq` snippet to cover the kiro agent-file `@openab-browser` grant — which the old automatic path also handled and the old snippet did not. Also corrected there: "the MCP config entry openab writes is static and write-once" (openab writes one file, `.openab/mcp-facade.json`, and it is not the operator's), the "Any MCP-capable CLI works" section (rewritten as "Wiring it up", with the per-vendor step), and the Verify block, which told operators to `cat` the vendor configs as "the static entry openab wrote". ADR `acp-server-websocket-reverse-mcp.md`: - the claim that facade setup deletes the leftover static entry is struck, with the consequence stated; - "writes a static, write-once `openab` entry" corrected to naming the file it authors; - **§6.6's recorded divergence is CLOSED.** It asked the owner of the facade contract to confirm whether config-file injection was an accepted exception to the adapter ADR's "do not fall back to editing the CLI's config files". D-15 answers it in favour of the adapter ADR. Leaving it open would have described a settled question as unsettled — the same defect as describing a removed behaviour as present, and this PR is what settled it. Judged and deliberately left: `docs/cursor.md:116,125`, `docs/gmail-native.md:107` and `docs/refarch/gbrain.md:115` name `.cursor/mcp.json` and `~/.kiro/settings/mcp.json` as the places those VENDORS read their own config. That is still true and has nothing to do with who writes them. `docs/oab-mcp-facade.md:108-127` documents manual registration of the facade under a different key, which was always manual. Not in this commit: anything describing Claude Code's `--mcp-config` at spawn. That is undecided, and the last time I wrote a doc ahead of the decision it shipped as a false claim (fixed in 7c685068). --- docs/adr/acp-server-websocket-reverse-mcp.md | 26 ++++++-- docs/browser-mcp-agent-setup.md | 62 ++++++++++++++------ 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md index 1dd10a94b..93e0dc188 100644 --- a/docs/adr/acp-server-websocket-reverse-mcp.md +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -350,8 +350,14 @@ below. Both legacy transports are gone; the facade is the only downstream path. **Update — the operator call was made on 2026-07-28: bridge mode is removed.** The stdio bridge (`OPENAB_BROWSER_MODE=bridge`, `openab browser-bridge`, the per-pod unix socket and its process-ancestry channel resolver) existed because some CLIs preferred a stdio entry. The facade is -a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. Facade setup -deletes the leftover static entry, which is the only one whose exact shape proves we wrote it. +a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. ~~Facade setup +deletes the leftover static entry, which is the only one whose exact shape proves we wrote it.~~ +That deletion was performed by editing the operator's file, and openab stopped doing that on +2026-07-30 (D-15): it authors `.openab/mcp-facade.json` and touches nothing else. **Removing a +leftover bridge entry is now the operator's step, and it is a policy question rather than tidiness +— while it is present there is a route to the browser that bypasses facade policy and audit.** The +`jq` snippet in `docs/browser-mcp-agent-setup.md` covers it, including the kiro agent-file +`@openab-browser` grant. This paragraph said `bridge` "degrades to `facade`", which was true for one commit. The per-session proxy was removed hours later, taking `BrowserMode` and the whole `OPENAB_BROWSER_MODE` mechanism @@ -368,7 +374,8 @@ revisit a per-provider "expose directly" option only if interactive browser late implements `CapabilitySource` over the existing `AcpMcpTunnel` — `requires_session()`, static-advertise per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegistrar` adapts the facade's `SessionTokens` to a `SessionTokenRegistrar` hook in core, so `openab-core` stays free of an -`openab-mcp` dependency. `write_facade_mcp_config` writes a **static, write-once `openab` entry** whose +`openab-mcp` dependency. `write_facade_mcp_config` authors `.openab/mcp-facade.json` — the one file openab owns — containing +a **static `openab` entry** whose `Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's process environment rather than a config file — which also removes the shared-workdir exposure of the old per-session `mcp.json` write. Capabilities publish under the provider name `openab-browser` (`openab` is the mcp.json entry key, @@ -383,9 +390,18 @@ facade is unavailable for that CLI in the MVP **rather than falling back to edit files**". The as-built `write_facade_mcp_config` does write a static entry into the CLI's config — deliberately, because the browser path's D2 established that Cursor ignores ACP-passed `mcpServers` (**§7.2** D2, [zed#50924](https://github.com/zed-industries/zed/issues/50924)). -Both positions are defensible; recording the conflict rather than silently picking a side. Owner of the +~~Both positions are defensible; recording the conflict rather than silently picking a side. Owner of the facade contract should confirm whether config-file injection is an accepted exception for CLIs that -ignore `mcpServers`, or whether Facade mode should be unavailable for them. +ignore `mcpServers`, or whether Facade mode should be unavailable for them.~~ + +**RESOLVED 2026-07-30 (D-15), in favour of the adapter ADR.** openab does not edit a CLI's config +files, and does not invoke a vendor CLI to do it either. It authors `.openab/mcp-facade.json`; the +operator puts that entry in place (`kiro-cli mcp import --file … workspace` for kiro, by hand for +cursor, which has no include/extends and no launch flag). The cost is stated rather than hidden: +kiro and cursor both lose zero-config onboarding, which is wider than the cursor-only regression +first recorded. Whether Claude Code is pointed at the file with `--mcp-config` at spawn is a +separate open question — it needs spawn-time vendor identification, which this codebase has +deliberately never had. **Remaining to fulfil this section** — F1′, F3′, F4 and F5 all landed in #1447 and are struck through; **F6 genuinely remains**: diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index a1ab0fce1..0e29bd7f3 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -26,22 +26,32 @@ INFO browser control: unconfigured — no [mcp] section in config.toml, so brows > ⚠️ **A leftover `openab-browser` entry from either old transport can still sit in your agent's > `mcp.json`,** and the two are handled differently. > -> The **bridge** entry is deleted for you on the next session — in `.cursor/mcp.json`, -> `.kiro/settings/mcp.json` and the kiro agent files, including its `@openab-browser` grant. Left -> in place it names a subcommand that no longer exists, so the agent's MCP client would try and -> fail to start it every session. It is removed only when byte-identical to the entry we wrote -> (`{"command":"openab","args":["browser-bridge"]}`), because that exact shape is the only proof we -> have that it is ours rather than a server you configured under the same key. +> **You have to remove it yourself. openab no longer edits your MCP config at all**, so neither +> entry is cleaned up for you. > -> The **proxy** entry is deliberately *not* removed: its url and bearer were minted per session and -> never recorded anywhere, so under that key we cannot tell your server from ours. It is dead -> configuration — the port died with its session — but if you want it gone, remove it yourself: +> Earlier versions deleted the **bridge** entry on the next session. That cleanup worked by +> editing your file, and openab no longer does that — it authors `.openab/mcp-facade.json` and +> nothing else. **This is worth acting on rather than ignoring:** a leftover `openab-browser` +> entry is a *working* path to the browser that does not pass through facade policy or the audit +> trail, so until you remove it the allowlist in `[[mcp.acp_servers]]` is not the only way in. The +> bridge entry also names a subcommand that no longer exists, so the agent's MCP client will fail +> to start it every session. +> +> The **proxy** entry was never removed automatically either: its url and bearer were minted per +> session and never recorded, so under that key openab cannot tell your server from its own. +> +> Remove both, and the kiro agent-file grant that made the bridge entry callable: > > ```sh > # edits in place; check the diff before trusting it > for f in "$HOME/.cursor/mcp.json" "$HOME/.kiro/settings/mcp.json"; do > [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"])' "$f" > "$f.tmp" && mv "$f.tmp" "$f" > done +> # kiro agent files carry a separate default-deny grant; the entry stays reachable while it is listed +> for f in "$HOME"/.kiro/agents/*.json; do +> [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"]) | .allowedTools = ((.allowedTools // []) - ["@openab-browser"])' \ +> "$f" > "$f.tmp" && mv "$f.tmp" "$f" +> done > ``` --- @@ -65,9 +75,13 @@ tools = ["katashiro.read_dom","katashiro.screenshot","katashiro.navigate","katas ``` - **One listener** — the facade's. No per-session ports and no per-session config rewrites. +- **openab writes ONE file, and it is not yours.** It authors `/.openab/mcp-facade.json` + and never reads, merges into, or writes `.cursor/mcp.json`, `.kiro/settings/mcp.json` or a kiro + agent file. Putting that entry in front of your agent is your step — see + [Wiring it up](#wiring-it-up) below. - **Identity** — the pool mints one token per chat session and injects it into the agent process as - `OPENAB_SESSION_TOKEN`. The MCP config entry openab writes is **static and write-once**, and - references the variable rather than embedding a secret: + `OPENAB_SESSION_TOKEN`. The entry is **static**, and references the variable rather than + embedding a secret: ```json { @@ -88,12 +102,19 @@ tools = ["katashiro.read_dom","katashiro.screenshot","katashiro.navigate","katas `execute_capability`, alongside every other facade capability. A session-bound source is invisible to anonymous facade clients — no token, no discovery, no execution. -### Any MCP-capable CLI works +### Wiring it up -Because the entry is static, **hand-configuring a variant openab does not auto-write is viable**: -point the CLI at `http://127.0.0.1:8848/mcp` with the bearer header above. This is the practical -difference from proxy mode, where the endpoint was per-session ephemeral and a hand-written entry -went stale on the next session. +openab authors `/.openab/mcp-facade.json` and stops there. It does not run your agent's +CLI for you either — configuring your own agent stays your decision. + +| Agent | What you do | +|---|---| +| **kiro** | `kiro-cli mcp import --file /.openab/mcp-facade.json workspace` — the vendor performs the merge with its own semantics. Do **not** pass `--force`: it would overwrite a same-named server of yours. | +| **cursor** | No import mechanism exists — there is no include/extends and no launch flag. Paste the `mcpServers` object below into `.cursor/mcp.json` yourself. | +| **any other MCP-capable CLI** | Point it at `http://127.0.0.1:8848/mcp` with the bearer header above. Because the entry is static, a hand-written one keeps working — the practical difference from proxy mode, where the endpoint was per-session ephemeral and a hand-written entry went stale on the next session. | + +The startup log prints the resolved path and these commands, so the value is not guessed from this +page. ### Verify @@ -101,9 +122,12 @@ went stale on the next session. # facade listening? grep "OAB MCP facade listening" -# the static entry openab wrote -cat "$HOME/.cursor/mcp.json" # Cursor -cat "$HOME/.kiro/settings/mcp.json" # Kiro +# the file openab authored (the only one it writes) +cat "$HOME/.openab/mcp-facade.json" + +# and whether YOU have put it in front of the agent yet +cat "$HOME/.cursor/mcp.json" # Cursor — you paste it here +cat "$HOME/.kiro/settings/mcp.json" # Kiro — written by `kiro-cli mcp import` # does the catalog contain the browser capabilities for a session-bound client? # -> call search_capabilities from the agent; expect provider "openab-browser" From 88ab8ce85b2f78ae61ee8c47bb55c7889001ad5f Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Thu, 30 Jul 2026 23:58:30 +0800 Subject: [PATCH 138/138] fix(acp): redact the credential in five more returned errors, found by fixing the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-2026-07-30-16 item 1. `src/browser_tunnel.rs:50` put the raw `channel_id` in a returned error — twelve lines above the site R1 fixed, under a comment I wrote saying "an id embedded mid-sentence escapes every field-name scan: the pattern that found the other sites cannot see it." I identified the blind spot and then searched the same way anyway. The fix landed on the site a reviewer cited instead of on the file it was in. The directive's point is the useful part: two misses on one pattern means the scan dimension is wrong, not that we were unlucky. R1 scanned by FIELD NAME (`channel_id = %x` in a log macro), which by construction cannot see an id interpolated into a sentence. Rescanning for **any string literal interpolating an id-shaped binding**, restricted to the files where an id can be an `acp_`/`sess_` credential, found five more: - `pool.rs:649`, `:681`, `:697` — `anyhow!("no connection for thread {thread_id}")` - `pool.rs:712`, `:766` — `anyhow!("no session for thread {thread_id}")` An ACP `thread_id` is `acp:` and the channel is `acp_`, so the credential is inside the string; `anyhow!` RETURNS it rather than logging it, so no logging-side redaction could ever reach it. `redact_session_ids` splits on `:` and tags the credential segment, leaving the `acp:` prefix readable. Two known sites became six. The hits in `acp_server.rs` are test fixtures with fabricated ids and are deliberately untouched. Also, from review of e22c9ef8: the `jq` remedy in `docs/browser-mcp-agent-setup.md` deleted `mcpServers["openab-browser"]` by NAME, while the automation it replaces (`is_openab_direct_browser_entry`) only deleted it when byte-identical to the bridge entry openab wrote — `{"command":"openab","args":["browser-bridge"]}`. That shape was the only proof the entry was ours rather than a server the operator configured under the same key, and a test existed for exactly that case. Telling an operator to run the destructive version makes the manual step worse than the automation it stands in for, which is the opposite of the intent. The snippet now matches on the shape and leaves anything else alone, and says so. --- crates/openab-core/src/acp/pool.rs | 10 +++++----- docs/browser-mcp-agent-setup.md | 19 +++++++++++++++++-- src/browser_tunnel.rs | 8 +++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 55b6e8037..29c4d158c 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -646,7 +646,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; @@ -678,7 +678,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; conn.set_config_option(config_id, value).await @@ -694,7 +694,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; conn.get_usage().await @@ -709,7 +709,7 @@ impl SessionPool { .cancel_handles .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let data = serde_json::to_string(&serde_json::json!({ "jsonrpc": "2.0", @@ -763,7 +763,7 @@ impl SessionPool { info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset"); Ok(()) } else { - Err(anyhow!("no session for thread {thread_id}")) + Err(anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id))) } } diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md index 0e29bd7f3..e7b68bd82 100644 --- a/docs/browser-mcp-agent-setup.md +++ b/docs/browser-mcp-agent-setup.md @@ -42,17 +42,32 @@ INFO browser control: unconfigured — no [mcp] section in config.toml, so brows > > Remove both, and the kiro agent-file grant that made the bridge entry callable: > +> These delete the entry only when it is **byte-identical to the bridge entry openab wrote** +> (`{"command":"openab","args":["browser-bridge"]}`). That exact shape is the only proof it is ours +> rather than a server you configured under the same key — the automation this replaces used the +> same test, and a manual step should not be more destructive than the automation it stands in for. +> > ```sh > # edits in place; check the diff before trusting it +> BRIDGE='{"command":"openab","args":["browser-bridge"]}' > for f in "$HOME/.cursor/mcp.json" "$HOME/.kiro/settings/mcp.json"; do -> [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"])' "$f" > "$f.tmp" && mv "$f.tmp" "$f" +> [ -f "$f" ] && jq --argjson bridge "$BRIDGE" \ +> 'if .mcpServers["openab-browser"] == $bridge then del(.mcpServers["openab-browser"]) else . end' \ +> "$f" > "$f.tmp" && mv "$f.tmp" "$f" > done > # kiro agent files carry a separate default-deny grant; the entry stays reachable while it is listed > for f in "$HOME"/.kiro/agents/*.json; do -> [ -f "$f" ] && jq 'del(.mcpServers["openab-browser"]) | .allowedTools = ((.allowedTools // []) - ["@openab-browser"])' \ +> [ -f "$f" ] && jq --argjson bridge "$BRIDGE" \ +> 'if .mcpServers["openab-browser"] == $bridge +> then del(.mcpServers["openab-browser"]) +> | .allowedTools = ((.allowedTools // []) - ["@openab-browser"]) +> else . end' \ > "$f" > "$f.tmp" && mv "$f.tmp" "$f" > done > ``` +> +> If your entry under that key is a *different* shape, these leave it alone — openab cannot tell it +> from a server of yours, which is why the proxy entry was never removed automatically either. --- diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs index 5765dd044..805597029 100644 --- a/src/browser_tunnel.rs +++ b/src/browser_tunnel.rs @@ -47,8 +47,14 @@ impl AcpMcpTunnel for RootBrowserTunnel { (Some((_, h)), None) => Some(h.clone()), (None, _) => None, (Some(_), Some(_)) => { + // Redacted at construction, same as the `None` arm below and for the same + // reason. This one was MISSED when that one was fixed: the fix landed on + // the site a reviewer cited instead of on every site in the file, twelve + // lines away. return Err(format!( - "multiple MCP servers attached to session {channel_id}; a server_id is required to disambiguate" + "multiple MCP servers attached to session {}; a server_id is \ + required to disambiguate", + openab_core::redact::redact_session_ids(channel_id) )); } }