Skip to content

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

Open
brettchien wants to merge 75 commits into
openabdev:mainfrom
brettchien:feat/acp-mcp-browser
Open

feat(acp): browser control via MCP-over-ACP + stdio bridge (Phase 2)#1447
brettchien wants to merge 75 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.

It also generalizes that mechanism: any ACP WS client may declare one or more type:acp MCP 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 /acp WS; 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-facade proxy and bridge transports remain as explicit OPENAB_BROWSER_MODE opt-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;
Loading

MCP usage sequence (katashiro.click example) — 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 pod
Loading

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; 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.

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 acp feature.

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 /acp WS using the official mcp/message framing; the client declares type:acp mcpServers on initialize and the gateway establishes a TunnelHandle per (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 on session/new and session/resume, torn down on cleanup. Wire contract: docs/mcp-over-acp-tunnel-contract.md.

3. Downstream hop — one CapabilitySource behind the OAB MCP Facade. Every client-declared server is exposed through a single in-process AcpTunnelSource (src/browser_source.rs) registered with the facade:

  • requires_session() — anonymous facade clients neither discover nor can execute these tools. Identity is the facade's SessionTokens: 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.
  • Multi-server fan-outtools(ctx) returns the tools of every server that session's client declared; call routes 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.
  • Discovery — each declared server's real tools/list is 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_changed is deliberately not implemented: facade discovery is pull-based (search_capabilities re-reads per call), so nothing caches a tool list to invalidate.
  • Trust gatefeat(facade): session-aware in-process capability sources #1454 assumes a source's tool set is the operator's grant, but a tunnel source's tools are client-declared. So an operator allowlist ([[mcp.acp_servers]], default: katashiro only) gates admitted server names, and each entry carries a deny-all tools pin. The name allowlist alone grants nothing — the catalog is fetched ∩ allowed, so a client declaring a trusted name still cannot publish extra tools.

proxy (per-session loopback HTTP MCP) and bridge (per-pod socket + openab browser-bridge stdio relay) are retained as explicit OPENAB_BROWSER_MODE opt-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?

  • Extension-as-MCP-server over its own outbound WS — an MV3 extension cannot listen, and the user's browser is remote. Serving MCP over the /acp WS 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.
  • MCP tools, not a custom ACP ExtRequest — only tools appear in the LLM's tool list, so only tools let the model autonomously act.
  • DOM-semantic, not pixel computer toolsclick(selector) / read_dom are cheaper, more reliable and model-agnostic.
  • Behind the facade rather than a second inbound MCP server — the OAB MCP Adapter ADR (docs(adr): OAB MCP adapter #1446) §6.2 assigns the facade the same aggregator role and its Alternative C rejects "a second generic inbound MCP server". Reusing the facade also inherits its policy runtime (schema validation, timeouts, circuit breaking, redaction, audit) instead of reimplementing it.
  • Static-advertise from a cache, not 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_capabilitiesexecute_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

  • Custom ExtRequest per browser action — rejected: not surfaced to the LLM as a tool.
  • Extension hosts a standalone HTTP/SSE MCP server — rejected: MV3 extensions cannot listen.
  • Anthropic-style computer tool (screenshot + pixel coords) — subsumed; 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.
  • Bespoke per-session MCP server + N mcp.json entries (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".
  • One source per declared server — impossible by construction: facade sources are registered once at startup, so a single source fans out internally instead.

Validation

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

  • cargo build --features acp — clean
  • cargo clippy --workspace -- -D warnings and cargo clippy --workspace --features unified -- -D warnings — both clean
  • cargo test -p openab-gateway --features acp — green (the acp-gated test target CI runs)
  • Unit/integration coverage for the new behaviour: compound-key registry + last-attach-wins, multi-server routing by <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 session
  • MCP-over-ACP tunnel e2e in scripts/acp-ws-smoke.py (producer section: tools/list / tools/call, fan-out + filtering)
  • Live deployment — Facade mode running on a real agent (OrbStack, Cursor CLI): facade listener up on 127.0.0.1:8848, search_capabilities returns provider openab-browser with exactly the five pinned katashiro.* capabilities alongside host-level mcp.json providers, and anonymous probes of the facade correctly see only the two meta-tools (session-bound source hidden without a token)
  • Live execute_capability round-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's execute_capability katashiro.read_dom reaches the user's actual tab and returns its DOM. The facade's audit trail brackets the dispatch:
    mcp.audit: facade source call      provider="openab-browser" tool=katashiro.read_dom channel="acp_27da364b…" args_sha256=44136fa3…
    mcp.audit: facade source call exit provider="openab-browser" tool=katashiro.read_dom channel="acp_27da364b…" args_sha256=44136fa3…
    
  • Two-window session isolation (each window's agent reaching only its own browser) — covered by unit tests, not yet re-verified live on the renamed surface

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 /acp WebSocket, and do it through the OAB MCP Facade as a session-aware CapabilitySource rather than a second agent-facing MCP server. Concretely: the agent→client request direction; the mcp/message tunnel keyed by (channel_id, server_id); AcpTunnelSource with 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

  • Retiring the pre-facade proxy / bridge transports (kept as OPENAB_BROWSER_MODE opt-outs; see Follow-ups).
  • notifications/tools/list_changed — deliberately dropped, not deferred (pull-based facade discovery has no consumer for it).
  • A per-provider "expose tools directly" bypass of the meta-tool path.
  • Any Discord↔browser bridge: the tunnel is bound to the extension's ACP session by design.
  • Fine-grained per-tool consent UX (core still auto-approves permissions, ADR D1).

Accepted Residual Risks

  • Downstream MCP is loopback + bearer only. The facade binds 127.0.0.1 and 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.
  • Client-declared tool sets are gated, not verified. The allowlist + deny-all pin stop a client publishing extra tools under a trusted name, but a compromised extension can still misuse the five pinned tools within the user's logged-in session — which is inherent to driving the user's real browser.
  • 8 MiB frame cap — screenshots must be JPEG; a pathological result over the cap closes the frame rather than truncating.
  • Static-advertise from cache can briefly show a capability whose backend has just detached; the call then returns an MCP error rather than the capability disappearing mid-turn. This is the deliberate trade (ADR §6.3).
  • bridge mode 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, and cargo test -p openab-gateway --features acp all green.
  • Multi-server behaviour proven by test: two declared servers in one session are both discoverable and callable, routed by prefix, with no name collision.
  • Trust gate proven by test: an unpinned tool from an allowlisted server is refused as a tool-pin refusal, and the cache can never widen the catalog past policy.
  • Live: facade serving, search_capabilities lists exactly the pinned capabilities for a session-bound client, and anonymous clients see none of them.

Follow-ups

  • Retire the bespoke per-session proxy and bridge mode 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 own mcp.json entry 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 in browser-mcp-agent-setup.md as an operational warning, but the durable fix is one transport (and, until then, cleanup-on-mode-switch).
  • Reconcile the flagged divergence with the adapter ADR: it specifies delivery via ACP mcpServers and explicitly not by writing CLI config files, while the as-built writes a static openab entry because Cursor ignores ACP-passed mcpServers (browser ADR D2 / zed#50924). Owner of the facade contract to decide.
  • Re-verify two-window session isolation live on the renamed surface (unit-tested; the live check predates the rename).
  • Optional: carry a tool manifest in the client's initialize declaration so the catalog is known without a first tunnel round-trip.

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 11 commits July 26, 2026 13:20
…sets

Second half of §6 F1' — the consumer of the tunnel plumbing added in 5dc45de.
BrowserSource becomes AcpTunnelSource: it fans out to every client-declared
MCP server instead of assuming one implicit browser, and enforces the §6.4
trust gate. Behaviour for today's single-browser client is unchanged.

Routing (§6.1/§6.2): the `<server>` prefix of a published tool name is the
declared *name*, while the registry is keyed by the per-connection `id`, so
`call` resolves name -> (channel_id, id) via the registry enumeration. The
full published name is forwarded (`browser.click`), not the suffix — the
prefix selects the tunnel, it is not stripped, because the server's own
tools/call expects the name it published.

Trust gate (§6.4), two independent checks:
- the declared name must be in the operator allowlist (default: browser only);
- the tool must be one that server is pinned to.
The second is not redundant with the first. The name is chosen by the same
remote client that declares the tools, so a client can re-declare the trusted
name `browser` and publish `browser.exec`; the pin is what refuses it. Denied
calls never reach the tunnel. Both cases are covered by tests.

`browser` appears only as an entry in the default policy table — deliberately
data, not a branch — so the routing code stays generic and admitting another
client-side MCP service is a table entry (§6.2: no browser-specific branch).

tools() serves the policy table statically and is deliberately NOT intersected
with the tunnels currently attached: intersecting would make the catalog flap
as a tab detaches, which §6.3 forbids, and would lose the pre-attach discovery
D4 already provided. Availability is reported by call, never by a shrinking
catalog. Session *scope* — restricting to the servers a given client declared —
is a separate axis needing F3's declaration cache, so tools() ignores ctx for
now; an allowlisted server's pinned tools are advertised to every session,
which is the status quo for the browser.

Co-Authored-By: Claude <noreply@anthropic.com>
…er grants

Found while scoping the discovery cache: §6.3 said an un-cached declared server
"contributes an empty set", which contradicts the static-advertise posture that
§6.4's pinned sets and D4 both rely on, and which the source implemented in
ba94efe (browser's pinned tools are advertised before the extension attaches —
confirmed correct on review).

Record the layering review settled instead:
- a server's §6.4 policy entry is its pre-attach SEED as well as its filter, so
  a pinned server never drops to empty just because nothing has attached;
- the per-(channel_id, server_id) cache holds fetched ∩ allowed and replaces the
  seed once a fetch succeeds, narrowing the catalog to what the server really
  publishes without ever widening past the policy;
- a declared server with no policy entry contributes nothing because §6.4 is
  deny-all, not because it is un-cached. Caching is never itself a grant.

Also record the ordering consequence: deny-all plus pinned entries that already
carry full Tool schemas means fetching cannot surface anything the operator has
not already permitted, so the discovery cache is invisible until the
operator-facing config surface exists. The config surface lands first; the cache
then supplies real schemas once operators may list tools by name alone.

Co-Authored-By: Claude <noreply@anthropic.com>
…owlist

Completes the §6.4 gate: enforcement landed in ba94efe, but the allowlist and
per-server tool filter were a hardcoded table with no way for an operator to
change them. Adds [[mcp.acp_servers]] entries of {name, tools}.

Keyed by the declared name, never the id — the reference client mints its id as
a fresh UUID per connection, so an allowlist of ids could not match twice.

ServerPolicy now separates the two jobs that were conflated in one field, which
is the layering §6.3 settled:
- `allowed` is the deny-all gate over tool NAMES;
- `seed` is the pre-attach advertisement (full Tool values), always a subset of
  `allowed`, so narrowing the policy narrows the catalog and can never widen it.

Operators may list tools by name alone. For a server with a built-in catalog the
schemas are taken from it and narrowed to what was permitted, so restricting the
browser to read_dom needs no restated JSON schema. A server admitted by name
with no built-in catalog has no seed yet: it dispatches, but advertises nothing
until discovery caching can fetch its real schemas — which is precisely the job
that gives the cache a non-redundant purpose.

Two deliberate behaviours, both tested:
- an ABSENT/empty section keeps the built-in browser default, so omitting the
  config cannot silently break existing browser control;
- writing ANY entry takes over the allowlist wholesale — browser is not retained
  alongside an operator's list, so the config never grants more than it states.

Co-Authored-By: Claude <noreply@anthropic.com>
…list

Implements §6 F3'. A server an operator admitted by name alone had no schemas to
advertise; it could dispatch but was invisible. Discovery fills that gap, which
is the job that gives the cache a non-redundant purpose.

Two deviations from §6.3 as written, both applied to the ADR in this commit:

1. The cache is keyed by (channel_id, NAME), not (channel_id, server_id). Ids
   are minted per connection, so an id-keyed entry would be orphaned by exactly
   the reconnect the cache exists to survive — it could never outlive the attach
   that populated it, which is the opposite of "serve regardless of current
   attach state". This is a corollary of the id-vs-name distinction §6.1 already
   records. Same-name collisions are impossible under last-attach-wins, so the
   name is a safe key. Covered by a test that reconnects under a fresh id.

2. Discovery is pull-triggered, not attach-triggered: a declared server with no
   cache entry has its fetch started from the next tools() call and its real set
   appears one discovery round later. The facade re-reads the catalog on every
   call, so one round of staleness is the whole cost, and it avoids threading an
   attach hook from the gateway (which owns attach) into the root (which owns
   the source).

The cache stores what the server PUBLISHED, unfiltered, and the policy is
applied on read. Filtering on read means tightening the policy takes effect
immediately rather than waiting for an entry to be invalidated, and it keeps the
invariant that caching is never itself a grant: a server that publishes a tool
the operator never permitted stays both invisible and uncallable. Tested.

A failed fetch leaves the seed in place — a seeded server never drops to empty —
and clears its in-flight marker so the next round retries. Repeated discovery
rounds do not pile up duplicate tools/list requests on one tunnel.

Co-Authored-By: Claude <noreply@anthropic.com>
Covers the multi-server claim §6.2 makes, through the real source: one session
declares `browser` and a second, non-browser server; both are discovered and
callable, tool names do not collide, and each server's policy is enforced
independently.

- both servers contribute to one catalog, each under its own prefix, with no
  duplicate names (the case a naive un-prefixed catalog would collapse);
- each tool reaches the tunnel of the server that declared it, by name -> id;
- a permission granted to one server does not leak to another: browser.click is
  permitted while notes.click is refused, proving the gate is per-server rather
  than a global tool-name allowlist — an easy thing to regress in a refactor and
  invisible with only one server configured;
- one server detaching leaves its neighbour callable.

SCOPE: this is the source-side half of F6, not a full end-to-end. F6 asks that
"the agent discovers + calls tools from BOTH", and the agent-side leg runs
through the facade's meta-tools — which cannot be exercised while facade mode is
not live anywhere (no [mcp] configured; the browser deployment runs
OPENAB_BROWSER_MODE=bridge). That is the same precondition F5 is blocked on. The
gateway-side half — two type:acp servers each getting their own mcp/connect — is
already covered by section_tunnel in scripts/acp-ws-smoke.py. What remains
genuinely unproven is the facade <-> source seam.

Co-Authored-By: Claude <noreply@anthropic.com>
Counterpart to the katashiro-side rename. `browser` / `browser.*` collided
with Playwright MCP's `browser_*` tools; the declared name and tool prefix
become `katashiro` / `katashiro.*`.

- mcp_proxy::browser_tools(): the five seed tools (D4 static-advertise)
- browser_source::builtin_catalogs(): the catalog key, and every test that
  rides the default policy
- config: the acp_servers doc comment naming the built-in default
- docs: tunnel contract declaration + tool table, agent-setup tool list

Both injection-regression tests (`unpinned_tool_on_an_allowlisted_server_is
_refused`, `caching_is_never_itself_a_grant`) called `browser.exec`. Left
unrenamed they still assert is_err, but for the wrong reason — an unknown
server name rather than an unpinned tool on a trusted one — quietly gutting
the check. They now call `katashiro.exec`.

Names only; policy semantics, routing and schemas unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
270a2ff renamed the client-declared surface (`browser.*` -> `katashiro.*`) in code,
config and the tunnel contract, but the two ADRs still described the old tool names —
including the §6.4 pinned-tool list and the runtime sequence diagrams, which readers
would otherwise copy verbatim into a policy that no longer matches.

The one remaining `browser.click` is inside a verbatim quote of upstream openabdev#1454's
sources.rs doc comment and is left as written.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/bridge

The guide still opened with the per-session loopback proxy as "how it reaches
the agent" and only mentioned the facade in a trailing section, so a reader
took the superseded design as current. Facade mode has been the default since
bf37d25.

- Lead with a mode table (facade default; proxy/bridge as explicit opt-outs)
  and the `[mcp]` + `[[mcp.acp_servers]]` config needed to enable it.
- Document what actually changes under the facade: one listener, a static
  write-once entry referencing `${OPENAB_SESSION_TOKEN}` (secret rides the
  process env, not a file), and discovery via search_capabilities rather than
  the agent's own tools/list.
- Note the consequence the old text got backwards: because the entry is static,
  hand-configuring a variant openab doesn't auto-write is now viable. The old
  "gated on a stable browser-MCP endpoint" caveat is resolved, not pending.
- Keep proxy's per-variant config table under an explicit "Legacy" heading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d example

Consolidates the ACP ADR family from four documents to three (base, the original
@pahud proposal, and this one). The browser design was split out earlier in this
PR, but with the facade integration the two documents had grown overlapping
diagrams and the browser ADR had accumulated content that no longer described
anything shipping.

Folded in as §7 "Worked example — browser control": the toolset (incl. why the
declared name moved to `katashiro`), D1-D6 with their supersession notice, and
the message-level round-trip with the two id spaces.

Dropped rather than carried over:
- "Execution flow (bootstrap)" — described the pre-facade per-session-proxy boot
  path and duplicated D2/D3/D5.
- "Tasks (as executed)" — project-management history; the commits are the record.
- The separate context/references sections, which duplicated §1 and §11.

Net ~90 lines smaller than the two documents were. Referrers updated, including
the two links in the merged OAB MCP Adapter ADR; no dangling references remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…is silently off

`mcp.audit` is a bare tracing target, not under the `openab` prefix, so the
filter the deployment docs and our own fleet use —
`RUST_LOG=openab=debug,openab_agent=debug` — matches none of the audit events
and drops every audit line. Nothing indicates that auditing is disabled, so a
deployment can believe it has a tool-call audit trail and have none.

Found while verifying a live facade dispatch: the call demonstrably executed
(the tool result reached the agent) with zero audit output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each transport writer only adds its own mcp.json entry — facade writes `openab`,
bridge writes `openab-browser` — and neither removes the other's. An agent that
has run in both modes therefore loads both servers, exposing the same
`katashiro.*` tools twice: once through the facade (policy + audit) and once
straight through the old transport (neither). The model calls the direct one and
the call leaves no audit trail at all, while appearing to work perfectly.

Corrects the evidence in 1c1919c: that commit attributed the missing audit
lines to `RUST_LOG` alone, having assumed the observed call was a facade
dispatch. It was not — a stale bridge entry was carrying it. The RUST_LOG note
there is still correct and still required; it was simply not the reason auditing
looked dead in that instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien marked this pull request as ready for review July 26, 2026 15:54
@chaodu-obk

chaodu-obk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ - The MCP-over-ACP design is promising, but several failure and rollout paths can leave browser capability delivery inconsistent or bypass the facade's policy boundary.

What This PR Does

This PR lets an ACP client expose MCP servers over the existing /acp WebSocket, so a colocated agent can operate the user's browser through MCP-over-ACP. It adds server-initiated ACP requests, a compound-key tunnel registry, facade-backed session-aware capability discovery, operator allowlists, and legacy proxy/bridge transports.

How It Works

The gateway parses client-declared type:acp servers, opens an MCP connection for each server, and registers each tunnel by (channel_id, server_id). The root binary adapts that registry into the MCP facade's session-aware capability source. Facade sessions receive broker-minted bearer tokens; proxy and bridge remain explicit transport modes. Discovery is policy-filtered and cached, while tool calls resolve the declared server name to the current tunnel id.

Findings

# Severity Finding Location
1 🟡 Rejected session/resume requests still spawn MCP tunnels crates/openab-gateway/src/adapters/acp_server.rs:1022-1039
2 🟡 The 8 MiB frame cap increases per-connection memory exposure without a matching budget or backpressure limit crates/openab-gateway/src/adapters/acp_server.rs:39
3 🟡 Facade config-write failures are logged but the session continues with a minted, unusable token crates/openab-core/src/acp/pool.rs:416-422
4 🟡 Mode switching leaves the direct openab-browser entry, allowing calls to bypass facade policy and audit crates/openab-core/src/mcp_proxy.rs:521-529
5 🟡 New acp-mcp and root browser tests are not exercised by the repository CI test jobs .github/workflows/ci.yml:51-60
6 🟢 The compound-key registry and last-attach-wins behavior make reconnect routing deterministic crates/openab-gateway/src/adapters/acp_server.rs:741-752
7 🟢 The facade source applies both server-name allowlisting and per-tool pinning, with deny-all behavior for unlisted tools src/browser_source.rs:322-335
8 🟢 Session-token revocation and proxy cleanup are tied to connection/session lifetime guards crates/openab-core/src/acp/pool.rs:423-433
Finding Details

🟡 F1: Do not open tunnels after a rejected resume

The loop sends the response from handle_session_resume, but then derives a channel id and calls spawn_acp_tunnels without checking resp.error. A resume rejected for a busy session or the per-connection session cap can therefore still emit mcp/connect requests and register handles for a session that was not accepted. This can replace a live same-name tunnel and sends protocol traffic after an error response.

Requested change: Only spawn tunnels when the resume response is successful, or have the handler return an explicit success result that gates tunnel creation. Add a regression test for busy and over-cap resume responses asserting that no tunnel-open side effect occurs.

🟡 F2: Bound the larger frame allowance

Raising the inbound text-frame limit from 1 MiB to 8 MiB is needed for screenshots, but the connection still has an unbounded outbound channel and permits up to 32 concurrent prompt tasks. Large prompt payloads can therefore be retained by multiple in-flight tasks, while multiple sessions can share a connection. The existing comment acknowledges that full backpressure and global limits are deferred.

Requested change: Add a per-connection byte/in-flight budget or apply the larger limit only to bounded MCP result traffic, and add a test covering the rejection boundary and the chosen resource bound. Document the resulting worst-case memory budget.

🟡 F3: Fail the session setup or surface an explicit degraded state

When write_facade_mcp_config fails, the pool only logs a warning, mints OPENAB_SESSION_TOKEN, and continues spawning the agent. The agent then has no openab config entry and cannot reach the facade, but the session appears to start normally and the token remains registered until eviction.

Requested change: Propagate the config-write error and abort session creation, or explicitly fall back to a working transport. At minimum, log an error that browser tools are unavailable and avoid minting a token that cannot be used.

🟡 F4: Remove stale direct entries during facade rollout

Facade mode writes the static openab entry but does not remove a previous openab-browser proxy/bridge entry. The setup guide documents that both entries can then be loaded and that the model may choose the direct path, bypassing facade policy and audit. Documentation is useful mitigation but does not prevent the security-relevant misconfiguration.

Requested change: On facade-mode setup, remove only entries that are demonstrably owned by the old transport, including the corresponding Kiro agent-file entry, or make mode switching mutually exclusive and fail closed when a stale entry is detected. Add a migration test preserving unrelated user servers.

🟡 F5: Exercise the new feature tests in CI

The PR adds substantial feature-gated test coverage in openab-core/src/mcp_proxy.rs, src/browser_source.rs, and src/browser_bridge.rs, but the existing CI runs default workspace tests plus gateway ACP tests. It does not run cargo test -p openab-core --features acp-mcp or the root package tests with --features acp. The new tests can pass locally while regressions remain invisible to the merge gate.

Requested change: Add explicit CI jobs or steps for the core acp-mcp tests and the root ACP browser tests, keeping the existing gateway ACP test job as-is.

Baseline Check
  • PR opened: 2026-07-24
  • Base branch: main
  • Merge-base: 53061d696148106b2b7529f9d6c5dd802dff4545
  • Reviewed head: 19f983127dacc6aa132f1bc400603ef8fda76a57
  • Diff: 24 files, 4559 additions, 141 deletions
  • Main already has: the base ACP WebSocket, MCP facade primitives, and session-pool lifecycle; it does not have this reverse-MCP tunnel, browser source, or transport wiring.
  • Net-new value: server-initiated ACP requests, MCP-over-ACP fan-out, session-aware browser capability routing, policy pinning, and browser transport integration.
Validation and External Feedback
  • Exact head and merge-base were verified from the local clone.
  • git diff --check passed.
  • GitHub reports the visible PR check runs completed without a failed conclusion; the CI gap above is about feature-specific tests not being scheduled.
  • Local Rust validation could not run because cargo and rustc are not installed in this review environment.
  • No existing GitHub review threads were present. Existing top-level notes were considered as context; no external inline concerns were omitted.
What's Good (🟢)
  • The registry's (channel_id, server_id) key and same-name last-attach-wins rule avoid reconnect ambiguity and unbounded stale entries.
  • The source's independent name allowlist and tool pin prevent a client-declared server from widening the operator grant.
  • Constant-time bearer checks, loopback binding, private proxy config permissions, and session-lifetime revocation are strong defaults.
  • The ADR and tunnel contract explain the protocol direction, capability boundaries, and accepted residual risks clearly.

5. Three Reasons We Might Not Need This PR

  1. Facade-only may be enough - Keeping proxy and bridge as supported opt-outs multiplies config and security paths after the facade becomes the default.
  2. The extension contract is still a deployment dependency - Without a continuously exercised client-side browser extension, server-side tunnel correctness can regress at the wire boundary.
  3. The capability surface is broad for one consumer - A single browser provider now brings multi-server fan-out, discovery caching, three transports, and policy plumbing that could be reduced until a second MCP-over-ACP provider exists.

@chaodu-obk chaodu-obk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ - Five important correctness, resource-boundary, rollout, and CI issues are detailed in the consolidated review.

Consolidated review: #1447 (comment)

// "type":"acp" browser server each time. Without this, a resumed session records the
// server but never opens a tunnel, so the core proxy reports "no browser attached".
// channel_id is derived deterministically from the sessionId, matching the handler.
if let Some(registry) = state.acp_tunnel_registry.clone() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F1 - Gate tunnel creation on resume success

This block runs after handle_session_resume sends its response, but it does not check resp.error. A busy or over-cap resume can therefore still emit mcp/connect and register a tunnel for a session that was rejected.

Requested change: Only call spawn_acp_tunnels when the resume response is successful, and add a regression test for rejected resume requests.

const MAX_SESSIONS_PER_CONNECTION: usize = 128;
const MAX_INFLIGHT_PROMPTS: usize = 32;
const MAX_FRAME_BYTES: usize = 1 << 20; // 1 MiB per inbound JSON-RPC frame
const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 - Bound the 8 MiB frame increase

The larger limit is needed for screenshots, but it applies to every inbound text frame while up to 32 prompt tasks may retain request payloads and the outbound channel is unbounded. Please add a per-connection byte or in-flight budget, or restrict the larger allowance to bounded MCP result traffic, and test the resulting memory boundary.

Comment thread crates/openab-core/src/acp/pool.rs Outdated
let registrar = self.session_registrar.as_ref().unwrap();
let facade_url = self.facade_url.as_ref().unwrap();
if let Err(e) =
crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 - Do not continue with an unusable facade session

If write_facade_mcp_config fails, this path only warns, then mints OPENAB_SESSION_TOKEN and starts the agent. The agent has no openab entry and cannot reach the facade, but the session appears healthy and the token remains registered until eviction.

Requested change: Propagate the config error or explicitly fall back to a working transport; at minimum report a degraded session and avoid minting an unusable token.

}
// Publish under "openab" (the facade), not "openab-browser": the agent
// reaches ALL facade capabilities through this one entry.
if cfg["mcpServers"]["openab"] != entry {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 - Remove or reject stale direct transport entries

Facade mode adds the openab entry but leaves any old openab-browser entry in place. The setup guide documents that an agent may then select the direct path and bypass facade policy and audit.

Requested change: Remove only entries owned by the old transport, including Kiro agent-file entries, or fail closed when a stale entry is detected. Preserve unrelated user servers and add a migration test.

brettchien and others added 2 commits July 28, 2026 10:20
`cargo test --workspace` builds with default features, so two sets of tests this
PR added never compiled in CI: openab-core's `acp-mcp`-gated mcp_proxy tests and
the root package's `acp`-gated browser_source / browser_bridge tests. 67 tests
between them — including the capability source's routing and trust-gate
coverage — so the fixes they back could not gate a merge.

Add two steps mirroring the existing acp-gateway one.

The core step is filtered to `mcp_proxy::` on purpose. `acp-mcp` gates exactly
one module, so the filter loses no coverage, and an unfiltered `-p openab-core`
would pull in hooks::tests — the parallel flake the gateway step's comment
already documents avoiding.

Co-Authored-By: Claude <noreply@anthropic.com>
The read loop derived its own channel_id from the requested sessionId and used
that as the condition for spawning tunnels. A well-formed `sess_<uuid>` derives
successfully on all four of the handler's rejection paths — missing sessionId,
malformed sessionId, per-connection cap, busy — so the guard was not checking
what it appeared to check. Combined with last-write-wins same-name re-attach,
a refused resume could evict the live tunnel it had just been refused in favour
of: a client mid-prompt (busy) or over the cap would knock out the browser
control of the session that legitimately held it.

handle_session_resume now returns (JsonRpcResponse, Option<String>), handing
back the channel only when the resume actually succeeded, and the loop spawns
only on that Some. This mirrors handle_session_new's (resp, channel_id) and
deletes the independent derivation rather than adding a second check beside it —
leaving the derive in place would keep a misleading guard available for reuse.

Regression coverage asserts a None channel on each of the four rejections. The
over-cap and busy cases are the load-bearing ones: their sessionIds are well
formed and their sessions really exist, so the old guard produced a channel and
spawned.

Co-Authored-By: Claude <noreply@anthropic.com>

@chaodu-obk chaodu-obk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ - The resume fix is correct, but the exact head still has session-isolation, resource-boundary, lifecycle, configuration, and CI blockers.

What This PR Does

This PR lets an ACP WebSocket client expose client-side MCP servers, including a browser extension, to a colocated agent. It adds server-initiated ACP requests, MCP-over-ACP tunnel registration, a session-aware facade source, operator tool pinning, and legacy proxy/bridge transports.

How It Works

The gateway multiplexes mcp/connect and mcp/message requests over the existing /acp WebSocket and indexes tunnels by (channel_id, server_id). The root adapts that registry to an AcpTunnelSource, which resolves declared server names, filters tools by operator policy, and routes calls through facade session tokens. Proxy and bridge remain explicit downstream modes.

Findings

# Severity Finding Location
1 🔴 A stale facade guard can revoke a replacement session token for the same channel crates/openab-core/src/acp/pool.rs:421-433
2 🔴 Bridge requests can impersonate any same-UID session by supplying its channel_id crates/openab-core/src/mcp_proxy.rs:654-680
3 🟡 The 8 MiB frame cap still has no matching byte budget or bounded outbound queue crates/openab-gateway/src/adapters/acp_server.rs:39,826
4 🟡 Unix bridge line framing has no maximum line length crates/openab-core/src/mcp_proxy.rs:747
5 🟡 Facade config-write errors are logged while an unusable tokenized session continues crates/openab-core/src/acp/pool.rs:416-422
6 🟡 Switching to facade mode leaves a stale direct openab-browser entry crates/openab-core/src/mcp_proxy.rs:523-529
7 🟡 Last-attach-wins drops replaced tunnel handles without sending mcp/disconnect crates/openab-gateway/src/adapters/acp_server.rs:749-751
8 🟡 The current unified Pi smoke check is failing on this head .github/workflows/ci.yml / smoke-test-unified (pi, pi-acp)
Finding Details

🔴 F1: Do not revoke a newer facade token from an older guard

Facade setup mints a token for channel_id, then installs a drop guard whose cleanup calls revoke(channel_id). SessionTokens::mint intentionally replaces the prior token for that channel. If a connection is replaced while an older connection/task still holds its guard, the old guard can run after the replacement mint and revoke the new token, making the new agent lose facade access unexpectedly.

Requested change: Make revocation token-specific (or conditional on the token still being current), and add a replacement/eviction regression test that proves an old guard cannot revoke the newer token.

🔴 F2: Authenticate bridge channel selection

The Unix socket is mode 0600, but every same-UID client can submit a frame containing an arbitrary channel_id; the server trusts that field before dispatching tools/call. A compromised or mistaken agent process can therefore select another live session's channel and invoke its browser tunnel. Process ancestry is used by the shim to discover a channel, but the socket server does not verify that the submitted identity came from that shim.

Requested change: Bind authentication to the socket connection or a per-session credential, and derive/verify the channel server-side. Do not treat a caller-supplied channel_id as the session-authentication boundary.

🟡 F3: Bound frame memory and outbound backpressure

Raising MAX_FRAME_BYTES to 8 MiB is reasonable for screenshots, but the connection still uses an unbounded outbound channel and allows 32 concurrent prompt tasks. Multiple large results and queued frames can be retained without a byte budget, so the transport can amplify memory well beyond the nominal frame cap.

Requested change: Add a bounded byte/in-flight budget and bounded outbound queue, or restrict the larger limit to a bounded result path; add a test and document the worst-case memory bound.

🟡 F4: Bound Unix bridge frames

BufReader::lines() can grow its buffer until a newline arrives. A same-UID client that sends an unterminated line can retain an arbitrarily large allocation in the socket task.

Requested change: Read with a hard maximum (for example, reject frames over the selected MCP limit) and add a regression test for an overlong unterminated frame.

🟡 F5: Fail closed when facade config setup fails

If write_facade_mcp_config fails, the pool only warns, then mints OPENAB_SESSION_TOKEN and starts the agent. The agent has no usable facade entry, while the broker registers a token and reports a seemingly healthy session.

Requested change: Propagate the config error and abort setup, or explicitly choose a verified working fallback before minting the token. Do not create a tokenized session that cannot reach the facade.

🟡 F6: Remove or reject stale direct transport entries

Facade mode writes openab but does not remove a prior openab-browser proxy/bridge entry. The documented mixed configuration exposes the same browser tools both through the audited facade and directly through the legacy transport, allowing the model to bypass facade policy and audit.

Requested change: Remove only entries demonstrably owned by the legacy transport, including Kiro agent-file entries, or fail closed when a stale entry is detected; preserve unrelated user servers and test migration.

🟡 F7: Close replaced MCP connections

The LWW registry path removes older same-name handles with retain, but the only mcp/disconnect implementation is never called. Repeated reconnect/resume cycles can leave the extension holding old MCP connection state even though OpenAB no longer routes to it.

Requested change: Collect replaced handles while holding the registry lock, then issue mcp/disconnect after releasing the lock; also cover repeated same-name reattach in a lifecycle test.

🟡 F8: Resolve the failing unified smoke check

GitHub reports smoke-test-unified (pi, pi-acp) with conclusion failure for this exact head, while the other unified jobs and the main check are successful. The failure must be diagnosed and fixed or explicitly rerun and explained before merge.

Requested change: Make the exact-head unified Pi smoke job pass, or provide a reproducible justification and CI fix if this is a flaky/environmental failure.

Addressing External Reviewer Feedback

Reviewer A (Round 1)

Rejected session/resume requests still spawn MCP tunnels.

Addressed in 08a60532db94e8919b1777204c467764603c00a8: handle_session_resume now returns a channel only on success, and tunnel spawning is gated on that result. Regression coverage covers malformed, over-cap, and busy rejection paths.

The new feature tests are not exercised by repository CI.

Addressed in 08a60532db94e8919b1777204c467764603c00a8: CI now runs the openab-core acp-mcp tests and root ACP tests in addition to gateway ACP tests.

The frame budget, facade setup failure, and stale transport-entry concerns remain.

Still open: These are retained as F3, F5, and F6 above because the current head does not change their behavior.

Baseline Check
  • PR opened: 2026-07-24
  • Base branch: main
  • Merge-base: 53061d696148106b2b7529f9d6c5dd802dff4545
  • Reviewed head: 08a60532db94e8919b1777204c467764603c00a8
  • Diff: 25 files, 4670 additions, 166 deletions
  • Main already has: the base ACP WebSocket, MCP facade primitives, and session-pool lifecycle.
  • Net-new value: reverse MCP-over-ACP request routing, multi-server tunnel registration, session-aware browser capability routing, policy pinning, and browser transport integration.
Validation
  • Exact-head clone verified at 08a60532db94e8919b1777204c467764603c00a8.
  • Declared base and merge-base were verified locally; git diff --check passed.
  • GitHub check and validate jobs passed, and most smoke jobs passed.
  • GitHub smoke-test-unified (pi, pi-acp) failed on the reviewed head; see F8.
  • Local Rust and Python runtimes are unavailable, so local Rust tests and Python syntax validation could not run.
What's Good (🟢)
  • The rejected-resume fix gates all tunnel side effects on successful resume and includes focused regression tests.
  • The compound (channel_id, server_id) registry and same-name last-attach-wins rule make reconnect routing deterministic.
  • Constant-time bearer checks, loopback binding, private proxy config permissions, and session-lifetime token handling are strong defaults.
  • The facade source applies independent server-name and tool-pin gates, with deny-all behavior for unlisted tools and cache filtering that cannot widen policy.
  • The added feature-gated CI steps and broad unit coverage materially improve regression detection.

5. Three Reasons We Might Not Need This PR

  1. Facade-only may be sufficient - Retaining proxy and bridge as opt-outs multiplies configuration and security paths after facade mode becomes the default.
  2. The extension contract remains a deployment dependency - Without a continuously exercised client-side browser extension, server-side tunnel behavior can regress at the wire boundary.
  3. The capability surface is broad for one consumer - Multi-server fan-out, discovery caching, three transports, and policy plumbing may be more scope than needed until a second MCP-over-ACP provider exists.

@chaodu-obk

chaodu-obk Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ - The resume fix is correct, but the exact head still has session-isolation, resource-boundary, lifecycle, configuration, and CI blockers.

What This PR Does

This PR lets an ACP WebSocket client expose client-side MCP servers, including a browser extension, to a colocated agent. It adds server-initiated ACP requests, MCP-over-ACP tunnel registration, a session-aware facade source, operator tool pinning, and legacy proxy/bridge transports.

How It Works

The gateway multiplexes mcp/connect and mcp/message requests over the existing /acp WebSocket and indexes tunnels by (channel_id, server_id). The root adapts that registry to an AcpTunnelSource, which resolves declared server names, filters tools by operator policy, and routes calls through facade session tokens. Proxy and bridge remain explicit downstream modes.

Findings

# Severity Finding Location
1 🔴 A stale facade guard can revoke a replacement session token for the same channel crates/openab-core/src/acp/pool.rs:421-433
2 🔴 Bridge requests can impersonate any same-UID session by supplying its channel_id crates/openab-core/src/mcp_proxy.rs:654-680
3 🟡 The 8 MiB frame cap still has no matching byte budget or bounded outbound queue crates/openab-gateway/src/adapters/acp_server.rs:39,826
4 🟡 Unix bridge line framing has no maximum line length crates/openab-core/src/mcp_proxy.rs:747
5 🟡 Facade config-write errors are logged while an unusable tokenized session continues crates/openab-core/src/acp/pool.rs:416-422
6 🟡 Switching to facade mode leaves a stale direct openab-browser entry crates/openab-core/src/mcp_proxy.rs:523-529
7 🟡 Last-attach-wins drops replaced tunnel handles without sending mcp/disconnect crates/openab-gateway/src/adapters/acp_server.rs:749-751
8 🟡 The current unified Pi smoke check is failing on this head .github/workflows/ci.yml / smoke-test-unified (pi, pi-acp)
Finding Details

🔴 F1: Do not revoke a newer facade token from an older guard

Facade setup mints a token for channel_id, then installs a drop guard whose cleanup calls revoke(channel_id). SessionTokens::mint intentionally replaces the prior token for that channel. If a connection is replaced while an older connection/task still holds its guard, the old guard can run after the replacement mint and revoke the new token, making the new agent lose facade access unexpectedly.

Requested change: Make revocation token-specific (or conditional on the token still being current), and add a replacement/eviction regression test that proves an old guard cannot revoke the newer token.

🔴 F2: Authenticate bridge channel selection

The Unix socket is mode 0600, but every same-UID client can submit a frame containing an arbitrary channel_id; the server trusts that field before dispatching tools/call. A compromised or mistaken agent process can therefore select another live session's channel and invoke its browser tunnel. Process ancestry is used by the shim to discover a channel, but the socket server does not verify that the submitted identity came from that shim.

Requested change: Bind authentication to the socket connection or a per-session credential, and derive/verify the channel server-side. Do not treat a caller-supplied channel_id as the session-authentication boundary.

🟡 F3: Bound frame memory and outbound backpressure

Raising MAX_FRAME_BYTES to 8 MiB is reasonable for screenshots, but the connection still uses an unbounded outbound channel and allows 32 concurrent prompt tasks. Multiple large results and queued frames can be retained without a byte budget, so the transport can amplify memory well beyond the nominal frame cap.

Requested change: Add a bounded byte/in-flight budget and bounded outbound queue, or restrict the larger limit to a bounded result path; add a test and document the worst-case memory bound.

🟡 F4: Bound Unix bridge frames

BufReader::lines() can grow its buffer until a newline arrives. A same-UID client that sends an unterminated line can retain an arbitrarily large allocation in the socket task.

Requested change: Read with a hard maximum (for example, reject frames over the selected MCP limit) and add a regression test for an overlong unterminated frame.

🟡 F5: Fail closed when facade config setup fails

If write_facade_mcp_config fails, the pool only warns, then mints OPENAB_SESSION_TOKEN and starts the agent. The agent has no usable facade entry, while the broker registers a token and reports a seemingly healthy session.

Requested change: Propagate the config error and abort setup, or explicitly choose a verified working fallback before minting the token. Do not create a tokenized session that cannot reach the facade.

🟡 F6: Remove or reject stale direct transport entries

Facade mode writes openab but does not remove a prior openab-browser proxy/bridge entry. The documented mixed configuration exposes the same browser tools both through the audited facade and directly through the legacy transport, allowing the model to bypass facade policy and audit.

Requested change: Remove only entries demonstrably owned by the legacy transport, including Kiro agent-file entries, or fail closed when a stale entry is detected; preserve unrelated user servers and test migration.

🟡 F7: Close replaced MCP connections

The LWW registry path removes older same-name handles with retain, but the only mcp/disconnect implementation is never called. Repeated reconnect/resume cycles can leave the extension holding old MCP connection state even though OpenAB no longer routes to it.

Requested change: Collect replaced handles while holding the registry lock, then issue mcp/disconnect after releasing the lock; also cover repeated same-name reattach in a lifecycle test.

🟡 F8: Resolve the failing unified smoke check

GitHub reports smoke-test-unified (pi, pi-acp) with conclusion failure for this exact head, while the other unified jobs and the main check are successful. The failure must be diagnosed and fixed or explicitly rerun and explained before merge.

Requested change: Make the exact-head unified Pi smoke job pass, or provide a reproducible justification and CI fix if this is a flaky/environmental failure.

Addressing External Reviewer Feedback

Reviewer A (Round 1)

Rejected session/resume requests still spawn MCP tunnels.

Addressed in 08a60532db94e8919b1777204c467764603c00a8: handle_session_resume now returns a channel only on success, and tunnel spawning is gated on that result. Regression coverage covers malformed, over-cap, and busy rejection paths.

The new feature tests are not exercised by repository CI.

Addressed in 08a60532db94e8919b1777204c467764603c00a8: CI now runs the openab-core acp-mcp tests and root ACP tests in addition to gateway ACP tests.

The frame budget, facade setup failure, and stale transport-entry concerns remain.

Still open: These are retained as F3, F5, and F6 above because the current head does not change their behavior.

Baseline Check
  • PR opened: 2026-07-24
  • Base branch: main
  • Merge-base: 53061d696148106b2b7529f9d6c5dd802dff4545
  • Reviewed head: 08a60532db94e8919b1777204c467764603c00a8
  • Diff: 25 files, 4670 additions, 166 deletions
  • Main already has: the base ACP WebSocket, MCP facade primitives, and session-pool lifecycle.
  • Net-new value: reverse MCP-over-ACP request routing, multi-server tunnel registration, session-aware browser capability routing, policy pinning, and browser transport integration.
Validation
  • Exact-head clone verified at 08a60532db94e8919b1777204c467764603c00a8.
  • Declared base and merge-base were verified locally; git diff --check passed.
  • GitHub check and validate jobs passed, and most smoke jobs passed.
  • GitHub smoke-test-unified (pi, pi-acp) failed on the reviewed head; see F8.
  • Local Rust and Python runtimes are unavailable, so local Rust tests and Python syntax validation could not run.
What's Good (🟢)
  • The rejected-resume fix gates all tunnel side effects on successful resume and includes focused regression tests.
  • The compound (channel_id, server_id) registry and same-name last-attach-wins rule make reconnect routing deterministic.
  • Constant-time bearer checks, loopback binding, private proxy config permissions, and session-lifetime token handling are strong defaults.
  • The facade source applies independent server-name and tool-pin gates, with deny-all behavior for unlisted tools and cache filtering that cannot widen policy.
  • The added feature-gated CI steps and broad unit coverage materially improve regression detection.

5. Three Reasons We Might Not Need This PR

  1. Facade-only may be sufficient - Retaining proxy and bridge as opt-outs multiplies configuration and security paths after facade mode becomes the default.
  2. The extension contract remains a deployment dependency - Without a continuously exercised client-side browser extension, server-side tunnel behavior can regress at the wire boundary.
  3. The capability surface is broad for one consumer - Multi-server fan-out, discovery caching, three transports, and policy plumbing may be more scope than needed until a second MCP-over-ACP provider exists.

@brettchien
brettchien marked this pull request as draft July 28, 2026 04:07
brettchien and others added 8 commits July 28, 2026 13:03
When write_facade_mcp_config failed, the code warned and carried on: it minted
OPENAB_SESSION_TOKEN and spawned the agent anyway. The agent then had no
`openab` entry, so it could not reach the facade at all, while a live credential
stayed registered for that channel until eviction — and the only trace was a
warning.

Mint only when the write succeeded. The session still starts; it simply has no
browser capabilities, which is the honest description of what happened. The
failure is logged at ERROR, and with no token there is no revoke guard to arm.

Two alternatives were considered and rejected. Aborting session setup lets a
config-write failure kill an otherwise working agent, and browser control is one
capability among many. Falling back to a direct transport is worse than it
looks: proxy and bridge write into the same workdir, so a failure there is
likely to repeat, and silently switching to a direct entry re-creates the
facade-bypass this PR's other fix exists to remove.

Extracted setup_facade_session so the invariant is testable — the pool's tests
are pure-function units and driving the real path spawns an agent. A counting
registrar proves mint is never called when the write fails, forced by making
<workdir>/.cursor a file so create_dir_all errors.

Co-Authored-By: Claude <noreply@anthropic.com>
The facade writer added its `openab` entry but left any previous
`openab-browser` entry in place, so both loaded and the model could take the
direct path — reaching the browser without passing through facade policy and
audit. Observed live 2026-07-26 and until now only documented.

Remove the stale entry from all three places the direct transports wrote:
.cursor/mcp.json, .kiro/settings/mcp.json, and the kiro per-agent files. For the
agent files the `@openab-browser` grant goes too — `allowedTools` is default
deny, so a leftover grant is what keeps the bypass reachable even once the
server entry is gone; removing one without the other is a half fix.

Ownership is decided by exact shape, never by the key. `openab-browser` is not
proof we wrote it, and an operator may have configured their own server there.
Only the two shapes we ever wrote are removable: the bridge entry
{command:"openab",args:["browser-bridge"]}, and the per-session proxy entry —
a loopback http://127.0.0.1:<port>/mcp url carrying a bearer header. A remote
url, a bearer-less loopback, a different command or an empty port are treated as
operator-owned and preserved verbatim.

The matcher deliberately errs toward under-removal: a leftover entry only
preserves the bypass, while deleting an operator's configuration destroys work.

Tests cover shape recognition against five foreign shapes, removal alongside
untouched user servers and unrelated top-level keys, a foreign `openab-browser`
preserved verbatim, and the agent-file case where @github survives while
@openab-browser goes.

Co-Authored-By: Claude <noreply@anthropic.com>
Raising MAX_FRAME_BYTES to 8 MiB for browser tool results also raised it for
every other inbound frame, so one connection could hold MAX_INFLIGHT_PROMPTS
(32) x 8 MiB of prompt text — the ~256 MiB worst case the review flagged.

Bound the raise to the traffic it was for. Browser results arrive as client
RESPONSES to our server-initiated `mcp/message` requests — id present, no
`method` — so responses keep the 8 MiB ceiling, while every method-bearing
frame (session/prompt included) is held to MAX_NON_TUNNEL_FRAME_BYTES, the
pre-existing 1 MiB. That is what removes the exposure: the worst case came
from prompts, which are method-bearing.

Note this is deliberately not a `method == "mcp/message"` test, even though
that is the obvious reading. `mcp/message` is only ever sent outbound; there is
no inbound frame carrying that method, so matching on it would cap the
screenshot responses at 1 MiB and break the case the raise exists for.

The 8 MiB check stays pre-parse and still closes the connection: an oversized
frame cannot be parsed back to its id, so no response can be fabricated for it.
The per-kind check runs after parsing, where the id is available — oversized
requests get ACP_OVERLOADED with their id, and oversized notifications are
dropped without a reply, since answering a notification is a protocol
violation.

The unbounded outbound channel is untouched and remains a documented follow-up
inherited from openabdev#1418 F6; this change bounds only what this PR added.

Co-Authored-By: Claude <noreply@anthropic.com>
The unix socket authenticated nothing. 0600 proves the peer shares our uid, but
says nothing about which session it belongs to, and the channel_id in each frame
is a value the caller picks — so any same-uid process could connect and drive
another live session's browser.

Derive the channel server-side instead. The shim already walked its own /proc
ancestry for OPENAB_BROWSER_CHANNEL; that logic was right but ran on the wrong
side, because a caller can always lie about its own answer. The server now takes
the peer pid from SO_PEERCRED and runs the same walk itself, so the peer cannot
choose. A connection whose channel cannot be established is refused outright
rather than given a default session.

Frames may still carry channel_id — the shim sends it — but it is only ever
compared against the authenticated value, never used to select a session; a
mismatch is dropped and logged.

The ancestry helpers move from the shim into openab-core, next to the server
that now authenticates with them, so there is one implementation rather than two
that can drift. The shim delegates to it and its frame value is advisory.

serve_browser_socket keeps its signature; serve_browser_socket_with_resolver
takes an injectable peer->channel mapping because a test binary's ancestry
carries no channel, so the real resolver would refuse every test connection.
The regression test proves a frame naming another session gets no reply at all
while the next legitimate frame is answered.

Co-Authored-By: Claude <noreply@anthropic.com>
Session lifetimes overlap. `mint` replaces whatever token a channel holds, so a
replaced session's drop guard runs after its successor has already minted — and
the guard revoked by channel, which removed the live token. The new agent lost
facade access with nothing pointing at the cause.

Revocation is now token-specific end to end: SessionTokenRegistrar::revoke takes
the token, SessionTokens gains revoke_token alongside the existing
revoke_channel, and the pool's drop guard carries the token it minted instead of
the channel it minted for. A late teardown is then a no-op rather than a
silent outage.

revoke_channel stays — a deliberate channel-wide evict still wants it — but both
methods now document which case they are for, so the distinction is visible at
the call site rather than being a trap.

This is the same shape as the bridge finding: authorising on an identifier too
coarse for the decision. There it was "same uid" standing in for "same session";
here it is "same channel" standing in for "same session instance".

Co-Authored-By: Claude <noreply@anthropic.com>
Last-attach-wins dropped stale handles through retain(), so the only
mcp_disconnect implementation was never called. The client went on believing
those connections were open, accumulating state across every reconnect.

Take the stale handles out of the registry instead of dropping them — collect
the keys, remove them — then disconnect after the lock is released. That
ordering is forced rather than merely preferred: disconnect is async and the
registry is a std Mutex, so awaiting under it would not compile.

The disconnects run in a spawned task. A replaced connection is often already
dead — that is usually why it is being replaced — so waiting on its response
would stall the tunnel that just came up in exchange for a notification that
only tidies the client's bookkeeping. Failures log at debug rather than
propagating: a best-effort courtesy must not fail an attach that has already
succeeded.

The test asserts the replaced connection receives mcp/disconnect naming its own
connectionId. Without pinning that, an implementation which disconnected the
freshly registered tunnel instead would pass just as happily.

Co-Authored-By: Claude <noreply@anthropic.com>
BufReader::lines() grows until it sees a newline, so a peer that never sends one
pins an arbitrarily large allocation. The review frames this as a same-uid
attacker, but no malice is required — a shim that dies mid-line does the same
thing, which is the likelier trigger and is why the bound matters even though
authenticating the connection already narrowed who can reach this socket.

read_frame_bounded copies out of fill_buf before consuming, returns Ok(None) at
EOF, and errors InvalidData once the pending frame would exceed the cap. Over
the cap the connection is dropped: a stream cannot be resynchronised mid-frame,
so continuing would only leave a partial frame buffered.

The cap matches the ACP tunnel's own frame ceiling, so the bridge is never the
tighter bottleneck for legitimate MCP traffic.

Framing is now byte-oriented, so a non-UTF8 frame is skipped like any other
malformed one instead of ending the read loop as lines() would have.

Tested against the helper at a 16-byte cap rather than by driving the socket at
8 MiB: the assertion is that the bound holds, and it covers terminated reads,
exactly-at-cap (inclusive), unterminated past the cap, terminated but too long,
and EOF as None rather than an error.

Co-Authored-By: Claude <noreply@anthropic.com>
The step added to run the acp-mcp core tests ended in `mcp_proxy::`. YAML reads
a colon at the end of a plain scalar as a mapping indicator, so ci.yml stopped
parsing and GitHub failed the whole workflow with "workflow file issue" — every
job in it, not just the new step. CI has been red on this branch since that
commit while local gates passed, because the gate runs cargo directly and never
parses the workflow.

Quote the command. The three acp steps now parse with the filter intact.

Co-Authored-By: Claude <noreply@anthropic.com>
@brettchien

Copy link
Copy Markdown
Contributor Author

Thanks for both passes. Every finding from both rounds was reproducible against the code, and all
nine are addressed — each in its own commit with regression coverage. Two judgement calls, one
correction to a fix of my own, and one push-back are called out below.

Round 2

R1 — an older guard could revoke a newer facade token (1970bae5)

Revocation is now token-specific end to end: the registrar's revoke takes the token,
SessionTokens gains revoke_token beside the existing revoke_channel, and the pool's drop
guard carries the token it minted rather than the channel it minted for. Session lifetimes overlap
— the successor mints while the predecessor's guard is still pending — so a late teardown is now a
no-op instead of a silent outage. revoke_channel stays for deliberate channel-wide eviction, and
both methods document which case they serve.

R2 — bridge channel selection was unauthenticated (63fb63a7)

The socket authenticated nothing: 0600 proves the peer shares our uid, not which session it
belongs to, and the channel_id in each frame is a value the caller picks. The channel is now
derived server-side from the SO_PEERCRED peer pid using the same /proc ancestry walk the shim
already performed — the logic was right but ran on the side that can lie about its own answer. A
connection whose channel cannot be established is refused outright rather than given a default
session. Frames may still carry channel_id, but it is only compared against the authenticated
value; a mismatch is dropped and logged, so a misconfigured shim is visible rather than silently
honoured. The ancestry helpers moved into core beside the server that authenticates with them, so
there is one implementation rather than two that can drift.

R1 and R2 are the same shape: authorising on an identifier too coarse for the decision. One had
"same uid" standing in for "same session"; the other had "same channel" standing in for "same
session instance".

R7 — replaced tunnels were dropped without mcp/disconnect (3aa8f6a1)

Stale handles are now taken out of the registry rather than dropped by retain, and disconnected
after the lock is released — forced rather than preferred, since disconnect is async and the
registry is a std::sync::Mutex. The disconnects run in a spawned task: a replaced connection is
often already dead, which is usually why it is being replaced, so waiting on its response would
stall the tunnel that just came up. The test pins the connectionId of the replaced connection;
without that, an implementation that disconnected the freshly registered tunnel would pass equally.

R4 — unbounded bridge line framing (d9a2679a)

BufReader::lines() is replaced by a bounded reader that errors once a pending frame would exceed
the cap, with the connection dropped because a stream cannot be resynchronised mid-frame. The cap
matches the ACP tunnel's own frame ceiling so the bridge is never the tighter bottleneck for
legitimate traffic. Worth noting the trigger is broader than the finding states: no malice is
required, since a shim that dies mid-line pins the same allocation — which is why the bound still
matters even though R2 narrowed who can reach the socket. Framing is now byte-oriented, so a
non-UTF8 frame is skipped like any other malformed one instead of ending the read loop.

F8 — the failing unified Pi smoke check

This one is infrastructure, and I would ask for a rerun rather than a code change. In run
30324392489, job smoke-test-unified (pi, pi-acp) failed inside docker/setup-buildx-action@v3
while booting buildkit: pulling moby/buildkit:buildx-stable-1 timed out against
registry-1.docker.io (context deadline exceeded while awaiting headers). That is before any
repository code is fetched, built or executed, so no change on this head could have caused it or
can fix it. The same workflow succeeded on an earlier head of this branch and has been skipped on
subsequent ones. Happy to be told otherwise if you are seeing a different failure mode.

Round 1

F5 — feature-gated tests never ran in CI (b6fdeb4a, corrected in 34b5f2a2)

cargo test --workspace builds with default features, so the acp-mcp tests in
openab-core/src/mcp_proxy.rs and the acp tests in the root package were never compiled. Two
steps were added mirroring the existing acp-gateway one. The core step is filtered to mcp_proxy::
acp-mcp gates exactly one module, so the filter loses no coverage, and an unfiltered
-p openab-core would pull in the hooks::tests flake the gateway step already documents avoiding.

I got this wrong on the first attempt and it is worth flagging rather than quietly fixing. The
filter ends in mcp_proxy::, and a trailing colon in a YAML plain scalar reads as a mapping
indicator, so ci.yml stopped parsing and GitHub failed the entire workflow with a workflow-file
error. CI was red on this branch from that commit until 34b5f2a2 quoted the command. The local
gates stayed green throughout because they run cargo directly and never parse the workflow, and the
broken runs show up under the file path rather than the workflow's name — when a workflow cannot be
parsed, its name: cannot be read either. On 34b5f2a2 the added steps run and pass: 30 tests for
the core filter and 42 for the root package.

F1 — tunnels opened after a rejected session/resume (08a60532)

The loop derived its own channel_id from the requested sessionId and used that as the spawn
condition, but a well-formed sess_<uuid> derives successfully on all four rejection paths, so the
guard was not checking what it appeared to. With last-write-wins re-attach, a refused resume could
evict the live tunnel it had just been refused in favour of. handle_session_resume now returns
(JsonRpcResponse, Option<String>), handing back the channel only on success, and the independent
derivation is deleted rather than left beside a second check. Coverage asserts a None channel on
each rejection; over-cap and busy are the load-bearing cases, since their ids are well formed and
their sessions really exist.

F3 — continuing with an unusable facade session (a3068b3a)

Mints only when the config write succeeded; the session still starts, without browser capabilities,
which is the honest description of what happened. Aborting session setup would let a config-write
failure kill an otherwise working agent, and falling back to a direct transport would write into the
same workdir — likely failing too — while silently re-creating the bypass F4 removes.

F4 — stale direct entry bypassing facade policy (16df6de2)

Facade setup retires the entry it replaces in all three locations, including the kiro agent files
and their @openab-browser grant — allowedTools is default-deny, so a leftover grant is what
keeps the bypass reachable once the server entry is gone. Ownership is decided by exact shape, never
by the key: only the bridge entry and the loopback proxy entry we wrote are removable, and anything
else under that name is preserved verbatim. The matcher errs toward under-removal, because a
leftover entry only preserves the bypass while deleting an operator's configuration destroys work.

F2 — the 8 MiB frame allowance (28cad689)

Took the suggested direction. One note, because the obvious reading does not work: mcp/message is
only ever sent outbound, so no inbound frame carries that method — browser results arrive as client
responses, id present and no method. Matching on method == "mcp/message" would have capped
the screenshot responses at 1 MiB and broken the case the raise exists for. Responses therefore keep
the 8 MiB ceiling and every method-bearing frame, session/prompt included, is held to the
pre-existing 1 MiB — which is what removes the exposure, since the worst case came from in-flight
prompts. The 8 MiB check stays pre-parse and still closes the connection, as an oversized frame
cannot be parsed back to its id; the per-kind check runs after parsing, where oversized requests get
ACP_OVERLOADED with their id and oversized notifications are dropped without a reply.

Deliberately not changed

The unbounded outbound channel. It predates this PR — the comment above the caps already defers a
bounded outbound channel, idle eviction and global connection limits to the F6 follow-up — and the
F2 change bounds only the exposure this PR introduced. Happy to pull it forward if you would rather
not carry it.

@brettchien
brettchien marked this pull request as ready for review July 28, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants