feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2) - #1447
feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2)#1447brettchien wants to merge 104 commits into
Conversation
…(§7) 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 <noreply@anthropic.com>
… diagrams 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 openabdev#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 <noreply@anthropic.com>
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<Mutex<HashMap<u64, oneshot::Sender<Value>>>> 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 <noreply@anthropic.com>
…es (T2.1)
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::<T> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <token> 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:<port>, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… (T4)
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…5.3) 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 <noreply@anthropic.com>
…5.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 <noreply@anthropic.com>
Add acp_tunnel_registry: Option<AcpTunnelRegistry> 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 <noreply@anthropic.com>
Wire the tunnel producer into the connection read loop: - handle_acp_connection mints a per-connection next_req_id (Arc<AtomicU64>) 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 <noreply@anthropic.com>
…5.3, D6-a')
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<Arc<dyn
BrowserTunnel>> (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 <noreply@anthropic.com>
…son (T5.2/D5-a) 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 <workdir>/.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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=<key> uv run scripts/acp-ws-smoke.py ws://<host>/acp Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g-number id
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 <noreply@anthropic.com>
…t session/new 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_<uuid>" 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 <noreply@anthropic.com>
…ervability 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…orphan (M-B1) 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 <noreply@anthropic.com>
start_session_server wrote <workdir>/.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ust 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 <noreply@anthropic.com>
|
Correction to my previous reply first, because it matters more than the fixes. I wrote that Round 3R3-F2 — prove ownership before deleting ( Fails closed now. Only the bridge entry qualifies: it is byte-identical every session and names our Preserving costs little, which is what makes failing closed the right trade rather than a cop-out: a R3-F1 — bound the declaration fan-out (
Over the cap the whole request is refused rather than truncated: truncating would silently honour |
Self-review findings on the current head — posting before you spend time on themSeparately from your three rounds, I ran an internal review pass over Fixes are in flight; this is the list, not a request for a re-review. The three repeats — these are the ones worth your attention1. 2. This is round-1 F1's shape exactly — a second, unchecked derivation living beside the checked one — 3. Two new tests are compiled but never executed ( Config-rewriting defects (the code that edits an operator's
|
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Answering "Three Reasons We Might Not Need This PR" — we are removing both legacy transportsYou raised this in all three rounds and we never answered it. Answering it now: you were right,
The reason is stronger than "fewer paths". The design record shows bridge was chosen because a Breaking change, stated plainly: browser control now requires an Four of the defects below leave by deletion rather than by fix: bridge authentication being On re-scoping mid-review: narrowing a PR after a review round can look like shrinking the Your other two points are answered at the end of this comment. Correction to an earlier security claim, and the current consolidated defect listTwo things in this comment: a retraction that should not wait, and the full list as it now stands so Retraction — R2 (bridge socket authentication) was not closed(The code discussed here is being deleted, but the retraction stands on its own: we told you something was fixed when it was not.) In my round-2 reply I wrote that the bridge channel "is now derived server-side from the So R2 moved the decision to a place that looks authoritative while leaving the input The replacement mechanism is not settled, and I overstated it above. An earlier revision of The alternatives on the table are server-side: a Two smaller corrections to my previous comment while I am here:
Current list — 11 defects, all independently confirmedEvery item below was verified against the source by at least two reviewers working independently,
The one open disagreement is now closed — toward the stricter reading. Item 6's severity was Item 11 was added after the list above was first published, and is worse than first described: the Items 2 and 9 are not done when the teardown filter is fixed. A fix is only acceptable if it Also being fixed here, though it is a performance defect rather than a correctness one: the Fixed before merge, though not blocking on their own — these are drift this PR itself Known and deliberately deferred: the discovery cache having no eviction, consolidation of the The PR stays in draft until items 1–11 land. I would rather post a list this long than have you find What we are actually doing, in orderDeletions first, because they remove work rather than add it — three of the defects below stop
Then the PR comes out of draft. We would rather show you the plan than have you infer it from a |
…tics 55eab82 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 55eab82. Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 6ea4b44. Co-Authored-By: Claude <noreply@anthropic.com>
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: <mode>" 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 <noreply@anthropic.com>
…tovers 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…e ADR
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 <noreply@anthropic.com>
…ansports 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…d present 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 <noreply@anthropic.com>
…wser` name 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 <noreply@anthropic.com>
…iding them 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`channel_id` is `acp_<uuid>` and `session_id` is `sess_<same uuid>` — 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… the extension side about the lifecycle 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… inner request path 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
What problem does this solve?
The ACP-over-WebSocket base (#1418) ships a 1:1 streaming chat surface at
GET /acp: a browser side-panel extension connects as an ACP client and drives an OpenAB agent. It deliberately stops at chat — no tool calls, no way for the agent's LLM to act on the page it is talking about.This PR delivers the base ADR §6 north-star: let the agent's LLM autonomously operate the user's real, logged-in Chrome (read the DOM, screenshot, navigate, click, type) by exposing the browser as MCP tools and routing them MCP-over-ACP over the
/acpWebSocket the extension already holds. No sandbox VM, no second connection, no per-frontend adapter.It also generalizes that mechanism: any ACP WS client may declare one or more
type:acpMCP servers, and they reach the agent through the OAB MCP Facade (#1448/#1453) as session-aware capability sources (#1454) — not through a bespoke per-session MCP server. Design:acp-server-websocket-reverse-mcp.md— the mechanism, the §6 multi-server generalization, and §7's worked example (browser control, incl. the contract the extension implements).Closes # — None; tracked by the base ADR §6 "Critical path" roadmap, not a separate issue.
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1527521703255605338
At a Glance
Architecture — the extension (a can't-listen MV3 client) is the MCP server, serving tools over its own outbound
/acpWS; OpenAB is the MCP proxy; the agent LLM is the MCP client. Only the extension hop leaves the pod. Downstream, browser capabilities are delivered through the OAB MCP Facade (default); the pre-facadeproxyandbridgetransports remain as explicitOPENAB_BROWSER_MODEopt-outs.flowchart LR EXT["<b>Side-panel MV3 extension</b> = MCP SERVER<br/>(cannot open a listening socket → serves MCP<br/>over the outbound /acp WS it already holds)<br/>declares {type:acp, id, name:'katashiro'}<br/>tools: katashiro.read_dom · screenshot · navigate · click · type"] subgraph POD["OPENAB POD — 'openab run', one process tree"] direction LR GW["<b>openab-gateway</b><br/>/acp WS server<br/>AcpTunnelRegistry<br/>keyed (channel_id, server_id)"] SRC["<b>AcpTunnelSource</b><br/>CapabilitySource<br/>requires_session<br/>allowlist + tool pin"] FAC["<b>OAB MCP Facade</b><br/>127.0.0.1:8848/mcp<br/>search_capabilities<br/>execute_capability"] AGENT["<b>agent CLI</b><br/>Cursor · Kiro · Claude · Codex<br/>LLM = MCP CLIENT"] GW <--> SRC SRC --> FAC FAC ==>|"Authorization: Bearer ${OPENAB_SESSION_TOKEN}<br/>(broker-minted per session, revoked on evict)"| AGENT end EXT <==>|"UPSTREAM — only remote hop<br/>MCP-over-ACP · mcp/message framing<br/>multiplexed with ACP chat on ONE /acp WSS<br/>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,SRC,FAC,AGENT pod;MCP usage sequence (
katashiro.clickexample) — Phase 1 connect + discovery, Phase 2 one autonomous action:sequenceDiagram autonumber participant Tab as Chrome tab participant Ext as extension<br/>MCP SERVER participant GW as gateway /acp participant Src as AcpTunnelSource participant LLM as agent LLM<br/>MCP client Note over Ext,LLM: PHASE 1 — connect & discovery Ext->>GW: initialize · mcpServers=[{type:acp, id, name:"katashiro"}] Ext->>GW: session/new (or resume) GW->>GW: register TunnelHandle at (channel_id, server_id) GW->>Src: broker mints session token → agent MCP config LLM->>Src: search_capabilities (via facade) Src->>GW: tools/list (mcp/message) — once per declared server GW->>Ext: mcp/message → tools/list Ext-->>Src: real tool list → cached per (channel_id, server_id) Src-->>LLM: capabilities: katashiro.* (fetched ∩ allowed) Note over Tab,LLM: PHASE 2 — one autonomous action LLM->>Src: execute_capability katashiro.click(selector) Src->>GW: tools/call (mcp/message, same /acp WS) GW->>Ext: mcp/message → tools/call Ext->>Tab: chrome.scripting / tabs API Tab-->>Ext: DOM mutated / pixels Ext-->>LLM: tool result (JPEG screenshots, frame <= 8 MiB) Note over GW,Ext: only the gateway-to-extension hop leaves the podPrior Art & Industry Research
The problem: give the agent's LLM tools that drive the user's own remote, logged-in browser, where the tool provider is an MV3 extension that cannot open a listening socket.
OpenClaw — browser control is either an OpenClaw-managed, isolated Chrome profile driven by a local control service inside the Gateway (Control UI, managed browser), or the Chrome DevTools MCP server against a locally-reachable Chrome (MCP docs).
openclaw mcp serveis a stdio bridge that keeps a stdio MCP session open and forwards to a local/remote Gateway over WebSocket — but it exposes routed channel conversations as MCP, not a remote browser. In every case the MCP server is colocated with the browser; OpenClaw does not route tools from a remote, can't-listen extension back to a colocated agent.Hermes Agent — built-in browser tools work over accessibility-tree snapshots + stable refs + screenshots, with pluggable backends (Browserbase / Browser Use / Firecrawl cloud, or local Chromium / Camofox) (browser feature).
hermes-computer-useis a pixel-level MCP server (screenshot +xdotoolon an Xvfb display) that any MCP client connects to (repo). Here too the MCP server runs where the browser runs.Takeaway — both projects colocate the browser-tool MCP server with the browser and let the agent be a normal MCP client. Neither tunnels MCP from a remote user's own can't-listen extension back to a colocated agent over the client's existing protocol connection. That inversion is the novel part. (ACP itself, and the general OpenClaw/Hermes ACP comparison, are covered in #1418.)
Proposed Solution
Three layers, behind the existing
acpfeature.1. Protocol gap — agent→client REQUEST direction. The base only did client→agent prompts and agent→client notifications. Browser control needs the agent to ask the client and await a result, so
acp_server's dispatch loop gains a server-initiated request path. Wire types are the generated serde-only v1 types from #1418.2. Upstream hop — MCP-over-ACP tunnel (extension ↔ gateway). A tunnel frame API multiplexes MCP
tools/list/tools/call/ results over the same/acpWS using the officialmcp/messageframing; the client declarestype:acpmcpServers oninitializeand the gateway establishes aTunnelHandleper(channel_id, server_id)— a compound key, so one session can hold several declared servers (re-attach under the same name is last-write-wins). Opened onsession/newandsession/resume, torn down on cleanup. Wire contract:docs/mcp-over-acp-tunnel-contract.md.3. Downstream hop — one
CapabilitySourcebehind the OAB MCP Facade. Every client-declared server is exposed through a single in-processAcpTunnelSource(src/browser_source.rs) registered with the facade:requires_session()— anonymous facade clients neither discover nor can execute these tools. Identity is the facade'sSessionTokens: the broker mints one opaque bearer per agent session, hands it to the agent as${OPENAB_SESSION_TOKEN}(process env, not a config-file secret) and revokes it on evict.tools(ctx)returns the tools of every server that session's client declared;callroutes on the<server>.<tool>prefix back to the right tunnel. No browser-specific branch in the source: admitting another client-side MCP service is config work, not a code change.tools/listis fetched once on attach and cached per(channel_id, server_id); the catalog is served from cache regardless of current attach state, and backend unavailability surfaces as a call error, never a vanishing capability.notifications/tools/list_changedis deliberately not implemented: facade discovery is pull-based (search_capabilitiesre-reads per call), so nothing caches a tool list to invalidate.[[mcp.acp_servers]], default:katashiroonly) gates admitted server names, and each entry carries a deny-alltoolspin. The name allowlist alone grants nothing — the catalog isfetched ∩ allowed, so a client declaring a trusted name still cannot publish extra tools.proxy(per-session loopback HTTP MCP) andbridge(per-pod socket +openab browser-bridgestdio relay) are retained as explicitOPENAB_BROWSER_MODEopt-outs for CLIs or rollouts that need them.Toolset — five DOM-semantic tools (
katashiro.read_dom,katashiro.screenshot,katashiro.navigate,katashiro.click,katashiro.type). The ACP frame cap is raised 1→8 MiB to carry screenshot results (JPEG).Why this approach?
/acpWS it already holds is the only way a can't-listen remote provider can be a full MCP server, and it reuses the one connection.ExtRequest— only tools appear in the LLM's tool list, so only tools let the model autonomously act.computertools —click(selector)/read_domare cheaper, more reliable and model-agnostic.list_changed— keeps a stable catalog across reconnects without fabricating tools.Known limitations — the browser tunnel binds to the ACP session, so the agent must be driven through the extension's ACP session (no Discord↔browser bridge). Under the facade the LLM reaches an action via
search_capabilities→execute_capability, one hop more per turn than a direct tool; a per-provider "expose directly" option is deliberately deferred until interactive latency proves it necessary.Alternatives Considered
ExtRequestper browser action — rejected: not surfaced to the LLM as a tool.computertool (screenshot + pixel coords) — subsumed; DOM-semantic tools are cheaper and model-agnostic.mcp.jsonentries (this PR's own earlier design) — superseded by the facade integration; it would have reinvented the catalog, discovery and policy runtime the facade already provides, and violated the adapter ADR's "one aggregation point".Validation
Rust changes, gated behind
--features acp(openab-gateway/acp+openab-core/acp-mcp):cargo build --features acp— cleancargo clippy --workspace -- -D warningsandcargo clippy --workspace --features unified -- -D warnings— both cleancargo test -p openab-gateway --features acp— green (theacp-gated test target CI runs)<server>.<tool>, allowlist + tool-pin refusal (unpinned tool denied, not "unknown server"), discovery cache narrowing (fetched ∩ allowed, cache can never widen past policy), and two client-declared servers in one sessionscripts/acp-ws-smoke.py(producer section:tools/list/tools/call, fan-out + filtering)127.0.0.1:8848,search_capabilitiesreturns provideropenab-browserwith exactly the five pinnedkatashiro.*capabilities alongside host-levelmcp.jsonproviders, and anonymous probes of the facade correctly see only the two meta-tools (session-bound source hidden without a token)execute_capabilityround-trip driving the real tab — the browser extension, rebuilt on the renamed surface, declares{type:acp, name:"katashiro"}; the gateway registers the tunnel at(channel_id, server_id), the broker mints the session token, and the agent'sexecute_capability katashiro.read_domreaches the user's actual tab and returns its DOM. The facade's audit trail brackets the dispatch:Review Contract
Goal
Let a colocated agent's LLM autonomously operate the user's real remote browser via DOM-semantic MCP tools tunnelled MCP-over-ACP over the existing
/acpWebSocket, and do it through the OAB MCP Facade as a session-awareCapabilitySourcerather than a second agent-facing MCP server. Concretely: the agent→client request direction; themcp/messagetunnel keyed by(channel_id, server_id);AcpTunnelSourcewith multi-server fan-out, per-server discovery cache and an operator allowlist with deny-all tool pinning; broker-minted per-session tokens for identity.Non-goals
proxy/bridgetransports (kept asOPENAB_BROWSER_MODEopt-outs; see Follow-ups).notifications/tools/list_changed— deliberately dropped, not deferred (pull-based facade discovery has no consumer for it).Accepted Residual Risks
127.0.0.1and trusts the host boundary; the per-session bearer rides the agent's process env. A hostile in-pod process shares the agent subprocess's trust boundary already.bridgemode resolves its channel by process ancestry — correct for the normal spawn tree; a relay started outside it fails closed.Acceptance Criteria
cargo build --features acp,cargo clippy --workspace [--features unified] -- -D warnings, andcargo test -p openab-gateway --features acpall green.search_capabilitieslists exactly the pinned capabilities for a session-bound client, and anonymous clients see none of them.Follow-ups
bridgemode once Facade mode has soaked. Beyond the adapter ADR's "one aggregation point", live testing surfaced a concrete hazard: each transport writer only adds its ownmcp.jsonentry and never removes the other's, so an agent that has run in more than one mode loads both servers and exposes the same tools twice — once through the facade (policy + audit) and once straight through the old transport (neither). The model picks the direct one and the call leaves no audit trail at all while working perfectly; auditing looks dead with nothing indicating why. Documented inbrowser-mcp-agent-setup.mdas an operational warning, but the durable fix is one transport (and, until then, cleanup-on-mode-switch).mcpServersand explicitly not by writing CLI config files, while the as-built writes a staticopenabentry because Cursor ignores ACP-passedmcpServers(browser ADR D2 / zed#50924). Owner of the facade contract to decide.initializedeclaration so the catalog is known without a first tunnel round-trip.