Skip to content

feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2)#1447

Draft
brettchien wants to merge 39 commits into
openabdev:mainfrom
brettchien:feat/acp-mcp-browser
Draft

feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2)#1447
brettchien wants to merge 39 commits into
openabdev:mainfrom
brettchien:feat/acp-mcp-browser

Conversation

@brettchien

@brettchien brettchien commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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 /acp WebSocket the extension already holds. No sandbox VM, no second connection, no per-frontend adapter.

The design was recorded design-only in #1418 as acp-server-websocket-mcp-browser.md (Status: Proposed); this PR is its as-built implementation.

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

   REMOTE (user's real Chrome)          OPENAB POD (`openab run` — one process tree)
 ┌──────────────────┐        ┌────────────────────────────────────────────────────┐
 │ side-panel ext.  │        │  ┌─────────┐   ┌───────────┐   ┌──────────────────┐ │
 │  = MCP SERVER    │◀─/acp─▶│  │ gateway │──▶│  core     │──▶│  agent CLI       │ │
 │  5 browser tools │  WSS   │  │ /acp srv│   │ MCP proxy │   │  LLM (MCP client)│ │
 └──────────────────┘ (only  │  └─────────┘   └───────────┘   └──────────────────┘ │
                      remote  │       ▲ upstream: MCP-over-ACP    ▲ downstream:      │
                       hop)   │       └── mcp/message framing     └ HTTP MCP (proxy) │
                             │                                     or stdio (bridge) │
                             └────────────────────────────────────────────────────┘

 one tool call (LLM clicks a button); only the extension hop leaves the pod:
   LLM ─tools/call browser.click─▶ core(MCP proxy) ─▶ gateway ─MCP-over-ACP─▶ extension
   extension runs it in the active tab ─▶ result ─▶ gateway ─▶ core ─▶ LLM continues

Prior 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 serve is 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 (local CDP or an in-Gateway managed profile); 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-use is a pixel-level MCP server (screenshot + xdotool on an Xvfb display) that any MCP client connects to (repo). Here too the MCP server runs where the browser runs (a display/Xvfb the server owns, or a cloud backend); the agent is a straight MCP client of a colocated/hosted server, not of a remote user's extension.

Takeaway — both projects colocate the browser-tool MCP server with the browser (local CDP, managed profile, Xvfb, or cloud backend) 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 — extension = MCP server over its outbound /acp WS; OpenAB = MCP proxy/aggregator; agent = in-pod MCP client — is the novel part and is what this PR builds. (ACP itself, and the general OpenClaw/Hermes ACP comparison, are covered in #1418.)

Proposed Solution

Two hops, one wire contract, behind the existing acp feature.

1. Protocol gap — agent→client REQUEST direction. The base only did client→agent prompts and agent→client notifications (streaming text). 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 (T1.2/1.3, T2.1). Wire types for the expanded surface are the generated serde-only v1 types already landed in #1418.

2. Upstream hop — MCP-over-ACP tunnel (extension ↔ gateway). A tunnel frame API multiplexes MCP tools/list / tools/call / results over the same /acp WS using the official mcp/message framing (T4, T4.1); the extension declares type:acp mcpServers on initialize and the gateway records + establishes a per-session TunnelHandle via an AcpTunnelRegistry, opened on session/new and session/resume and torn down on cleanup (T5.3). The wire contract is pinned in docs/mcp-over-acp-tunnel-contract.md.

3. Downstream hop — how the colocated agent sees the tools. Core hosts a normal in-process MCP server (the extension, not the agent, is the one that can't listen). Two delivery modes, selected by OPENAB_BROWSER_MODE:

  • proxy (default, HTTP MCP) — a per-session loopback HTTP MCP server (mcp_proxy::start_session_server), bearer-gated (constant-time compare), that forwards tools/call down the tunnel via a BrowserTunnel trait + ProxyHandler. openab writes a fresh {url, headers} entry (openab-browser) into the CLI's native MCP config (.cursor/mcp.json, .kiro/settings/mcp.json) per session, 0600, stripped on evict. Suits HTTP-MCP CLIs.
  • bridge (stdio MCP, "Option C") — a per-pod browser-bridge Unix-socket server plus an openab browser-bridge stdio-MCP relay subcommand. The CLI config becomes a static {"command":"openab","args":["browser-bridge"]} (no per-session url/token to mint), written once; the relay resolves its session channel by process ancestry (inherits the agent's OPENAB_BROWSER_CHANNEL) rather than a config value, so N windows → N agent processes → N bridges, each bound to its own browser with no clobber. Suits stdio-MCP CLIs.

Toolset — five DOM-semantic tools (browser.read_dom, browser.screenshot, browser.navigate, browser.click, browser.type). The ACP frame cap is raised 1→8 MiB to carry screenshot results (JPEG). Per-session isolation is enforced: one tunnel per session, fixing an earlier fan-out overwrite/orphan.

Why this approach?

  • Extension-as-MCP-server over its own outbound WS — an MV3 extension cannot open a listening socket, and the user's browser is remote. Serving MCP over the /acp WS the extension already holds is the only way a can't-listen remote provider can be a full MCP server, and it reuses the one connection (no second transport, no inbound firewall hole).
  • MCP tools, not a custom ACP ExtRequest — only tools appear in the LLM's tools/list, so only tools let the model autonomously act. An ExtRequest would fit OpenAB-driven ops but the LLM would never call it.
  • DOM-semantic tools, not model-specific computer (pixel) toolsclick(selector) / read_dom are cheaper, more reliable, and model-agnostic; screenshot+coords remain expressible if wanted.
  • Two downstream modes — HTTP-MCP CLIs (Cursor, Kiro) take the proxy directly; stdio-MCP CLIs get the static bridge, which also removes per-session secret minting and makes multi-window isolation fall out of process ancestry. Trade-off: bridge adds a per-pod socket + a relay subcommand; proxy mints a port/token per session.

Known limitations — auto-written CLI config today covers Cursor + Kiro only (Claude Code / Codex / Gemini paths are documented in docs/browser-mcp-agent-setup.md but not yet auto-written); the browser tunnel binds to the ACP session, so the agent must be driven through the extension's ACP session (no Discord↔browser bridge).

Alternatives Considered

  • Custom ExtRequest per browser action — rejected: not surfaced to the LLM as a tool, so the model can't autonomously call it.
  • Extension hosts a standalone HTTP/SSE MCP server — rejected: MV3 extensions cannot open a listening socket.
  • Anthropic-style computer tool (screenshot + pixel coords) — subsumed: expressible as screenshot+click(x,y), but DOM-semantic tools are cheaper and model-agnostic.
  • Colocated/sandbox browser (OpenClaw-managed / Hermes Xvfb) — rejected: the goal is the user's real, logged-in Chrome, not a sandbox VM.

Validation

Rust changes, gated behind --features acp (openab-gateway/acp + openab-core/acp-mcp):

  • cargo build --features acp — clean (only pre-existing dead_code warnings, unrelated)
  • MCP-over-ACP tunnel e2e suite in scripts/acp-ws-smoke.py (producer section: tools/list / tools/call, fan-out + filtering)
  • Handler-level + streaming tests from the base remain green
  • Live manual test — full loop against a real deployment with an MV3 side-panel extension driving Chrome: all five tools (read_dom / screenshot / navigate / click / type), connection status pill, and stable reconnect (tunnel re-established on session/resume)
  • cargo clippy --workspace -- -D warnings (default) and cargo clippy --workspace --features unified -- -D warnings (includes acp) — both clean
  • bridge-mode two-window per-session isolation — manual acceptance checklist in the Option C validation notes (each window's agent sees only its own browser)

Review Contract

Goal

Let a colocated agent's LLM autonomously operate the user's real remote Chrome via five DOM-semantic MCP tools (read_dom/screenshot/navigate/click/type), routed MCP-over-ACP over the existing /acp WebSocket: extension = MCP server, OpenAB core = MCP proxy/aggregator, agent = in-pod MCP client. Ship both downstream delivery modes (proxy HTTP-MCP default; bridge stdio-MCP via OPENAB_BROWSER_MODE) with per-session tunnel isolation and the mcp/message wire contract.

Non-goals

  • Auto-writing MCP config for Claude Code / Codex / Gemini CLIs (documented, not wired — Cursor + Kiro only today).
  • Any Discord↔browser bridge: the tunnel is bound to the extension's ACP session by design.
  • New browser tools beyond the five DOM-semantic ones; pixel/computer-style tooling.
  • Backpressure / global connection limits beyond the base's per-connection caps.

Accepted Residual Risks

  • Downstream MCP is loopback + bearer only — the per-session HTTP proxy binds 127.0.0.1 with a fresh constant-time-compared bearer, 0600 config stripped on evict; it trusts in-pod locality. A hostile in-pod process could reach it (same trust boundary as the agent subprocess itself).
  • bridge mode resolves channel by process ancestry — correct for the normal agent→bridge spawn tree; a bridge started outside that tree would not inherit OPENAB_BROWSER_CHANNEL and would fail closed (no browser attached) rather than cross-wire sessions.
  • 8 MiB frame cap — screenshots must be JPEG; a pathological DOM/screenshot over 8 MiB closes the frame rather than truncating.
  • Tool execution trust — the extension executes DOM actions in the user's logged-in tab; safety relies on the user driving their own session and the tools being DOM-semantic (no arbitrary JS eval tool).

Acceptance Criteria

  • cargo build --features acp green; base handler/streaming/conformance tests green; cargo clippy --features acp -- -D warnings clean before merge.
  • scripts/acp-ws-smoke.py MCP-over-ACP producer section passes (tools/list, tools/call, fan-out + filtering).
  • Live e2e: all five tools drive a real Chrome via the extension over /acp; tunnel re-establishes on session/resume.
  • bridge mode: two windows on the same endpoint each drive their own browser with no clobber.

Follow-ups

  • Auto-write MCP config for Claude Code / Codex (TOML) / Gemini variants.
  • Progressive/streamed tool results and richer error surfaces.
  • Bounded outbound / idle eviction inherited from the base's Follow-ups.
  • CI: add a headless tunnel smoke to canary-tests so the producer path is gated, not just manual.

brettchien and others added 30 commits July 24, 2026 09:27
…(§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>
brettchien and others added 8 commits July 24, 2026 09:27
…penabdev#8)

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 (openabdev#9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…browser socket (Option C, P2)

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 <noreply@anthropic.com>
…, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ption C, b2 B1)

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/<pid>/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 <noreply@anthropic.com>
…C, b2 B2)

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 <noreply@anthropic.com>
@brettchien
brettchien requested a review from thepagent as a code owner July 24, 2026 02:30
@brettchien
brettchien marked this pull request as draft July 24, 2026 02:50
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant