Skip to content

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

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

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

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
@chaodu-obk

chaodu-obk Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ - The prior lifecycle, authentication, framing, cleanup, and CI findings are addressed, but two resource and ownership boundaries remain open.

What This PR Does

This PR lets an ACP WebSocket client expose one or more 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 explicit proxy and bridge transports.

How It Works

The gateway parses client-declared type:acp servers, opens one MCP tunnel per declaration, and indexes handles by (channel_id, server_id) while resolving tool prefixes by the declared server name. The root adapts that registry to an AcpTunnelSource; facade sessions receive broker-minted bearer tokens, and the source applies server-name and tool-pin policy before routing calls to the current tunnel.

Findings

# Severity Finding Location
1 🟡 Client-declared ACP MCP server fan-out is unbounded crates/openab-gateway/src/adapters/acp_server.rs:298-310,807-830
2 🟡 Facade cleanup cannot prove ownership of loopback bearer entries crates/openab-core/src/mcp_proxy.rs:511-529
3 🟢 Prior review findings are covered by targeted fixes and CI crates/openab-gateway/src/adapters/acp_server.rs, crates/openab-core/src/mcp_proxy.rs
Finding Details

🟡 F1: Bound client-declared ACP MCP server fan-out

parse_acp_mcp_servers accepts every array element with type: "acp", and spawn_acp_tunnels creates one Tokio task and one pending mcp/connect request for every returned element. The 1 MiB method-frame limit bounds the serialized declaration, but it still permits thousands of small declarations; the existing 128-session cap does not limit the number of servers in one session. A client can therefore cause a large burst of tasks, pending entries, outbound frames, and 30-second timeouts from one session/new or accepted session/resume, amplifying the unbounded outbound channel and bypassing the intended per-connection resource boundary.

Requested change: Enforce a documented per-session maximum for type:acp declarations before inserting the session or spawning tasks, and reject or truncate duplicates/invalid entries deterministically. Add a regression test that submits more than the limit and proves no excess mcp/connect tasks are created.

🟡 F2: Use an ownership proof for facade cleanup

is_openab_direct_browser_entry treats any openab-browser entry with a numeric loopback http://127.0.0.1:<port>/mcp URL and any Authorization: Bearer ... value as an entry written by OpenAB. The bearer is not compared with a token or other state that OpenAB recorded, so a user-owned local MCP server using the same key and ordinary bearer authentication is deleted during facade setup. This contradicts the stated "demonstrably owned" and "preserve unrelated user servers" contract; the current test only protects a remote URL and does not cover a foreign loopback bearer entry.

Requested change: Add an ownership marker or a persisted exact-entry identity that survives the mode transition, or fail closed and preserve entries when ownership cannot be proven. Add a regression test for a foreign loopback URL with a bearer header and verify the entry and its allowlist grant remain unchanged.

🟢 F3: Earlier findings resolved

The exact head includes the requested fixes for token-specific facade revocation (1970bae5), peer/process-derived bridge authentication (63fb63a7), bounded bridge framing (d9a2679a), replaced-tunnel disconnects (3aa8f6a1), facade setup without an unusable token (a3068b3a), direct-entry retirement (16df6de2), method-versus-result frame limits (28cad689), rejected-resume tunnel gating (08a60532), and feature-gated CI coverage (b6fdeb4a, corrected by 34b5f2a2). The exact-head checks also show the previously failing Pi smoke job passing. The unbounded outbound channel remains explicitly documented as a follow-up; the current frame fix correctly limits the larger allowance to tunnel results and keeps method-bearing frames at 1 MiB.

Addressing External Reviewer Feedback

Reviewer A (Rounds 1-2)

Rejected session/resume requests still spawn MCP tunnels, and the new feature tests were not exercised by CI.

Addressed in 08a60532 and b6fdeb4a / 34b5f2a2: Tunnel creation is gated on successful resume, and the gateway, core acp-mcp, and root ACP test suites are now explicit CI steps. The final workflow YAML is quoted correctly and the exact-head checks pass.

An older facade guard could revoke a replacement token; bridge channel selection was caller-controlled; bridge framing and replaced-tunnel cleanup were unbounded or incomplete.

Addressed in 1970bae5, 63fb63a7, d9a2679a, and 3aa8f6a1: Teardown is token-specific, bridge identity is derived from peer process ancestry and frame claims are only checked, frames have a hard 8 MiB cap, and replaced handles receive best-effort mcp/disconnect after registry removal.

Facade setup could mint an unusable token and mode switching could leave a direct browser entry.

Addressed in a3068b3a and 16df6de2: Failed config setup does not mint a token, and facade setup retires recognized direct entries in settings and Kiro agent files. F2 above narrows the remaining ownership-proof gap in that cleanup.

PR Author (Round 2)

All nine earlier findings are addressed, while the unbounded outbound channel is deliberately deferred.

Partially accepted: The nine fixes are reproducible at this head, and the outbound-channel deferral is documented. This round adds F1 because the new multi-server fan-out introduces a separate client-controlled cardinality path, and F2 because the cleanup matcher is broader than an ownership proof.

Baseline Check
  • PR opened: 2026-07-24
  • Base branch: main
  • Merge-base: 53061d696148106b2b7529f9d6c5dd802dff4545
  • Reviewed head: 34b5f2a22b1cac2888c53a6ca3bf36fbd705066b
  • Diff: 26 files, 5414 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
  • The local detached worktree is exactly 34b5f2a22b1cac2888c53a6ca3bf36fbd705066b.
  • git diff --check origin/main...pr-1447 passed.
  • GitHub reports 45 completed check runs on this exact head, including check, validate, all unified smoke jobs, and smoke-test-unified (pi, pi-acp), with successful conclusions.
  • The exact OpenAB PR Review status was initialized as pending for this round.
  • Local cargo, rustc, and python executables are unavailable in this review environment; validation therefore relies on the exact-head source inspection, repository tests present in the diff, and GitHub check runs.
What's Good (🟢)
  • Token-specific revocation prevents a late predecessor teardown from removing a successor's live session credential.
  • Peer-credential plus process-ancestry authentication is materially stronger than trusting a caller-supplied channel id.
  • The compound (channel_id, server_id) registry, same-name last-attach-wins rule, and post-lock disconnect path make reconnect lifecycle behavior explicit.
  • The per-kind frame limit preserves large screenshot responses while bounding method-bearing request frames.
  • The facade source applies independent server-name and deny-all tool-pin gates, and the new tests cover cache narrowing and multi-server routing.

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 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 ⚠️ - Two important findings remain in the canonical aggregate review.

Consolidated review: #1447 (comment)

@brettchien
brettchien marked this pull request as draft July 28, 2026 12:25
brettchien and others added 2 commits July 28, 2026 20:39
The ownership check claimed to recognise "a bearer we minted" while comparing
against nothing we had kept. Any openab-browser entry with a loopback url and
any Bearer header matched — which is exactly the shape of a local MCP server an
operator runs themselves, so facade setup deleted their configuration. It also
made the reply on this PR wrong: exact-shape matching preserved everything else
for the bridge entry only, not for the proxy entry.

Fail closed. Only the bridge entry qualifies now: it is byte-identical every
session and names our own binary and subcommand, so matching it is itself the
proof. The per-session proxy url and bearer were never recorded, so ownership of
that shape cannot be established and it is preserved.

Preserving costs little. A leftover proxy entry names an ephemeral port from a
session that is gone — start_session_server binds 127.0.0.1:0 and drops the
listener with the session — so it is dead configuration rather than a live
bypass. The bridge entry is the one that would still resolve and run, and it is
still removed.

Tests: the loopback+bearer shape moves to the not-ours list, and a new case
proves an operator's own entry survives verbatim in both .cursor/mcp.json and a
kiro agent file, along with its @openab-browser grant — revoking the grant would
silently disable their server even with the entry intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Every type:acp declaration buys a spawned task, a pending mcp/connect holding a
30s timeout, and an outbound frame, and nothing limited how many a session could
declare. Declarations are small enough that thousands fit inside the 1 MiB limit
that applies to a session/new frame, so one accepted request could burst all of
that at once.

accept_acp_servers dedupes by id and enforces MAX_ACP_SERVERS_PER_SESSION,
running in both session/new and session/resume before the session is inserted or
any task spawned — the point is that an over-declaring request never reaches the
work. Resume gets the same guard because the client re-presents its declarations
every time, which makes it an equally good burst vector.

Over the cap the whole request is refused rather than truncated. Truncating
would silently honour part of a request the client cannot know was clipped; it
would then believe it had servers that never got tunnels and debug that as a
mysterious "not connected".

Dedup runs before the cap, so repeats of one id are one server rather than a cap
violation — the reverse order would reject merely sloppy clients. A repeated id
would otherwise spawn two tunnels racing for one registry key, where the loser
exists only to be overwritten.

Tests cover the cap boundary, a five-thousand entry flood refused rather than
clipped, order-preserving duplicate collapse, and that exactly one task and one
mcp/connect are produced per unique declaration with no excess frame.

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

Copy link
Copy Markdown
Contributor Author

Correction to my previous reply first, because it matters more than the fixes. I wrote that
ownership of a stale openab-browser entry was decided by exact shape and that "anything else
under that name is preserved verbatim". That was true for the bridge entry and false for the
proxy entry
. The check claimed to recognise "a bearer we minted" while comparing against nothing
we had kept, so any loopback url carrying any Bearer header matched — which is exactly the shape
of a local MCP server an operator runs themselves. Facade setup would have deleted their
configuration. That is fixed in e308e753 (below), but the wrong claim was in the reply, not just
in the code, and I would rather retract it explicitly than let it stand as something you had been
told and could reasonably rely on.

Round 3

R3-F2 — prove ownership before deleting (e308e753)

Fails closed now. Only the bridge entry qualifies: it is byte-identical every session and names our
own binary and subcommand, so matching it is itself the proof. The per-session proxy url and bearer
were never recorded anywhere, so ownership of that shape cannot be established and it is preserved.

Preserving costs little, which is what makes failing closed the right trade rather than a cop-out: a
leftover proxy entry names an ephemeral port belonging to a session that is gone —
start_session_server binds 127.0.0.1:0 and drops the listener with the session — so it is dead
configuration rather than a live bypass. The bridge entry is the one that would still resolve and
run, and it is still removed. The regression test proves an operator's own entry survives verbatim
in both .cursor/mcp.json and a kiro agent file, along with its @openab-browser grant; revoking
the grant alone would silently disable their server even with the entry intact.

R3-F1 — bound the declaration fan-out (399d892d)

accept_acp_servers dedupes by id and enforces a documented per-session maximum, in both
session/new and session/resume, before the session is inserted or any task is spawned — an
over-declaring request never reaches the work. Resume is guarded too because the client re-presents
its declarations every time, which makes it an equally good burst vector.

Over the cap the whole request is refused rather than truncated: truncating would silently honour
part of a request the client cannot know was clipped, leaving it to debug servers that never got
tunnels as a mysterious "not connected". Dedup runs before the cap, so repeats of one id are one
server rather than a cap violation. Tests cover the boundary, a five-thousand entry flood refused
rather than clipped, order-preserving duplicate collapse, and that exactly one task and one
mcp/connect result per unique declaration.

@brettchien

Copy link
Copy Markdown
Contributor Author

Self-review findings on the current head — posting before you spend time on them

Separately from your three rounds, I ran an internal review pass over 399d892d — five independent
passes, one per surface (config rewriting, the ACP server, the capability source and bridge, token
lifecycle and CI wiring, docs-vs-code) — and re-read every candidate finding against the source
before writing it down. It turned up defects your rounds had not reached, including three that are
earlier fixes reappearing one room over. Posting them rather than quietly fixing them, since
some bear directly on claims I made in earlier replies.

Fixes are in flight; this is the list, not a request for a re-review.

The three repeats — these are the ones worth your attention

1. SessionTokens::mint still clobbers by channel (crates/openab-mcp/src/mcp/sources.rs:99).
Round 2's R1 made revocation token-specific, and I explained in that reply why the guard has to
carry the token rather than the channel. I never revisited the mint side, which still does
map.retain(|_, ctx| ctx.channel_id != channel_id). The connection pool explicitly supports racing
builders (pool.rs:611-620): A mints t1 and wins the insert; B mints t2, whose retain deletes
t1; B then sees A healthy, returns Ok(false), and drops the connection object holding its own
revoke guard, which revokes t2. Both tokens are gone while A's agent keeps presenting t1 — every
facade call fails auth and all session-gated tools silently disappear from its catalog. R1's fix was
correct and insufficient: I hardened the revocation path and left the same
one-entry-per-channel assumption living in mint.

2. session/resume stores the raw, uncapped declaration list
(crates/openab-gateway/src/adapters/acp_server.rs:1488). R3-F1 added accept_acp_servers and I
told you it runs "before the session is inserted or any task is spawned" on both paths. That is true
of the spawn path. The resume handler independently re-parses the raw params and stores those
on the session record, so the cap governs what gets spawned but not what gets retained. Since the
cap applies to the deduped count, thousands of duplicate declarations fit inside the 1 MiB frame,
pass the gate, and are held per session.

This is round-1 F1's shape exactly — a second, unchecked derivation living beside the checked one —
and F1's lesson was to delete the independent derivation rather than add a check next to it. I
applied that lesson to one path and recreated the pattern on the other in the same commit.

3. Two new tests are compiled but never executed (.github/workflows/ci.yml:66). The step added
for F5 filters on mcp_proxy::; the pool facade tests are acp::pool::tests::*, and the
--workspace step compiles them out under default features. F5 fixed "is the test compiled"; this
is "is it selected". So the token-lifecycle tests I cited as coverage in the round-2 reply have
never actually run in CI.

Config-rewriting defects (the code that edits an operator's mcp.json)

4. A malformed config file is silently replaced wholesale (crates/openab-core/src/mcp_proxy.rs:290,
also :474, :555): from_slice(&bytes).unwrap_or_else(|_| json!({})). Any file that fails to
parse — JSONC comments, a trailing comma, or a file truncated by finding 6 — is replaced by a fresh
object containing only our entry, destroying every other server the operator configured. The kiro
agent-file writer in the same file refuses to do exactly this (:381, "never clobber"), so the
correct policy is already present and applied inconsistently.

5. A valid-JSON non-object root panics session setup (:293-296): for a root of [],
cfg["mcpServers"] = json!({}) is IndexMut on an array.

6. Config writes are neither atomic nor serialized (:298, :334, :401, :483, :572):
truncate-then-write, no temp+rename, no lock. A crash mid-write leaves truncated JSON, which finding
4 then clobbers; two sessions sharing a workdir — the case the code documents at :308 — can
interleave read/modify/write and lose each other's entries.

7. The bearer-carrying file is briefly world-readable (write_private, :344-351): it writes
first and chmod 0600 after, leaving the token at umask permissions in between.

Trust surface

8. The ADR instructs operators to write an allowlist entry that disables browser control.
docs/adr/acp-server-websocket-reverse-mcp.md:290, :300, :355 say the allowlist defaults to
browser; the built-in default is katashiro, and any operator entry replaces the default
wholesale. Following the ADR yields deny-all for the real server, with nothing obviously wrong in
the config. §7.1 records the rename; §6.1, §6.4 and F4 were never updated.

9. Proxy and bridge modes forward any tool name to the tunnel (mcp_proxy.rs:148, :740);
browser_tools() gates only tools/list. The facade path is sound here — the call path re-checks
membership independently of the catalog, with tests that fail if either check is removed — so this
is confined to the two transports F5 retires, but the "static advertised set" property does not hold
for them today.

10. A typo'd config key silently widens access: no deny_unknown_fields, and an empty
acp_servers reverts to the built-in default, so [[mcp.acp_server]] (missing s) is ignored and
the operator who meant to narrow gets the full default set. Deny-all is also currently
inexpressible. A typo in a per-entry tools list fails safe, so the two directions disagree.

11. The bridge still has the unbounded-line bug R4 fixed server-side
(src/browser_bridge.rs:85, :99 use bare read_line, while read_frame_bounded exists in core
for precisely this reason). Same-uid peers, so low severity — but it is the same defect twice.

Test quality — the reason finding 2 survived

Nothing drives the WebSocket read loop. declaration_fan_out_is_capped_and_deduplicated and
only_accepted_declarations_spawn_tunnels exercise accept_acp_servers in isolation, so deleting
its call site entirely leaves the suite green
— the "enforced before any allocation, on both
paths" property is asserted only by comments. Also: the frame-cap test feeds std::io::Cursor,
which yields everything in one fill_buf, so the multi-fill accumulation branch never executes and
an implementation bounding only per-chunk length passes; the tools/call routing fake ignores
params, so dropping arguments passes; channel isolation is untested because the fake tunnel
ignores channel_id, and browser_tunnel.rs has no tests at all.

Follow-ups, not being fixed in this PR

The discovery cache is insert-only and never evicted, and caches the extension's published tool list
unfiltered. Tunnel tasks share the in-flight prompt budget, so pending mcp/connects can block
prompts on the connection. start_session_server's error paths drop the cancellation token without
cancelling, leaving the loopback server running. Cross-connection teardown can evict a live tunnel —
the reply-registry twin of this is already an accepted F5 residual; the tunnel registry inherits it
un-annotated. Invalid declarations are dropped silently rather than rejected, and duplicate
[[mcp.acp_servers]] names last-wins without a warning.

Confirmed sound, for what it is worth

The facade trust gate is enforced on both the catalog and the call path, with tests that fail if
either check is removed; tool-name matching is exact and fail-closed, with no alias, case-fold or
dot-collapse escape; channel_id is never caller-supplied; tokens are 32-byte getrandom,
constant-time compared, never logged, and the on-disk config carries only a placeholder; the revoke
guard covers every exit path including panic unwind, and revoke_channel has no production call
sites left; no .await is held across a std::sync::Mutex; pending requests are drained on close
and timeouts remove their pending entry; notification-versus-request semantics are correct and
unsolicited responses are never answered; the wire contract matches the code field for field.

brettchien and others added 2 commits July 28, 2026 21:41
F5 fixed whether these tests are compiled. This is the second half: whether they
are selected. The acp-mcp core step filters on `mcp_proxy::`, but the pool's
facade-session tests live in acp::pool::tests, so the filter compiled them and
then selected them away — the two tests added for the config-write fix have
never executed in CI.

I chose that filter to keep the flaky hooks::tests out of the job, and in doing
so re-created for my own tests exactly the gap F5 existed to close.

Name the module instead of widening the filter: `acp::pool::` runs the facade
tests (17 selected, including both config-write cases) and still selects zero
hooks tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Round 2 made revocation token-specific but left mint evicting by channel, so
the race it was meant to close survived on the other side. Two builders can
reach one channel — the pool supports racing builders, and a reset can start a
replacement while the predecessor is still serving. The second mint deleted the
first agent's live token. That agent holds OPENAB_SESSION_TOKEN in its
environment and cannot observe the change: facade calls simply begin failing
auth and its requires_session tools vanish from discovery, with nothing pointing
at the cause.

mint no longer evicts. Every mint is paired with a token-specific revoke on its
own drop guard, so the map stays bounded without eviction, and revoke_channel
remains for deliberate channel-wide teardown.

Second half: reset_session no longer removes the creating gate.
purge_session_entries documents precisely why that must not happen — the gate is
concurrency control, not session state, and dropping it while a builder still
holds the old Arc lets a concurrent get_or_create mint a fresh gate and run a
second creation for the same key. reset did it anyway, which is how two builders
reach one channel in the first place.

Note an existing test asserted the old behaviour as correct ("old token must be
dead"). It is rewritten to assert coexistence. The suite was green for as long
as it was defending the defect, which is worth remembering when a green gate is
offered as evidence.

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

brettchien commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Answering "Three Reasons We Might Not Need This PR" — we are removing both legacy transports

You raised this in all three rounds and we never answered it. Answering it now: you were right,
and the change is being made in this PR, not promised as a follow-up.

proxy and bridge are both removed. Facade becomes the only transport. Concretely that deletes
the stdio shim and the openab browser-bridge subcommand, the per-pod unix socket server and its
/proc ancestry channel resolver, the OPENAB_BROWSER_CHANNEL injection, the per-session loopback
HTTP server with its bearer and per-session config write/cleanup, and the bridge and proxy arms of
the mode switch.

The reason is stronger than "fewer paths". The design record shows bridge was chosen because a
fixed HTTP endpoint could not do per-session browser control — one shared mcp.json, one
endpoint, no way to tell which session a call came from. The facade is a fixed endpoint that solves
exactly that, with a per-session bearer in the agent's environment referenced from the shared file.
So the requirement that justified bridge no longer exists. The one open risk on that argument —
whether a vendor CLI expands ${VAR} inside a header value — has now been checked against a live
deployment rather than assumed: the config on disk carries the literal placeholder and the facade
path works end to end.

Breaking change, stated plainly: browser control now requires an [mcp] block. A deployment
without one previously fell through to the per-session proxy silently — no opt-in, and that
fallback path is the one with no tool allowlist. It will now simply have no browser control. We
chose that over auto-starting a listener, because opening a port and rewriting an agent workdir on
upgrade is the very coupling concern raised separately in round 3. Nothing starts implicitly any
more.

Four of the defects below leave by deletion rather than by fix: bridge authentication being
forgeable, tool names forwarded ungated on proxy and bridge, the orphan listener in
start_session_server, and the unbounded line reads in the shim.

On re-scoping mid-review: narrowing a PR after a review round can look like shrinking the
findings list instead of answering it. So, to be explicit — the full list stays below, every
remaining item is still being fixed, and the four above are named as deletions rather than quietly
dropped. Note also that the config-writer defect (item 7) is not rescued by this: the same
clobber-on-parse-failure and non-atomic write live in the facade's own writer, which is now the only
code that touches a user's mcp.json — so it matters more after this change, not less.

Your other two points are answered at the end of this comment.

Correction to an earlier security claim, and the current consolidated defect list

Two things in this comment: a retraction that should not wait, and the full list as it now stands so
this thread is the single source of truth rather than three partial ones.

Retraction — R2 (bridge socket authentication) was not closed

(The code discussed here is being deleted, but the retraction stands on its own: we told you something was fixed when it was not.)

In my round-2 reply I wrote that the bridge channel "is now derived server-side from the
SO_PEERCRED peer pid using the same /proc ancestry walk the shim already performed", and that a
connection whose channel cannot be established is refused. The first half is what makes the claim
wrong in a way that matters: the derivation runs server-side, but the data it reads is still
supplied by the peer
. channel_from_process_ancestry (crates/openab-core/src/mcp_proxy.rs:787)
begins its walk at the connecting pid itself and reads /proc/<peer>/environ on the first
iteration, so a same-uid process that sets the variable in its own environment is believed before
any ancestor is consulted. The legitimate shim does not set it, which is why this was not visible in
testing.

So R2 moved the decision to a place that looks authoritative while leaving the input
attacker-chosen. That is the same defect the finding described, and my reply asserted it was fixed.
Treat that assertion as withdrawn.

The replacement mechanism is not settled, and I overstated it above. An earlier revision of
this comment called a per-session OPENAB_BRIDGE_SECRET — minted by the pool, injected into the
agent's environment, presented by the shim on connect — the "agreed fix". One reviewer does not
agree, and the objection is the sharper one: an environment secret lives in /proc/<pid>/environ,
which is exactly the surface this defect is about, so a same-uid process may be able to read the
credential rather than forge the claim — the same threat model, one step later. (Whether it can in
practice depends on the deployment's ptrace_scope, which is not something the fix should rest on.)
The shim's environment also gets scrubbed in some paths.

The alternatives on the table are server-side: a pidfd, or a pid + start-time generation map, or a
credential passed as an inherited file descriptor that the peer cannot read out of its own process
image. Ancestry verification is rejected either way — environ is self-injectable, a process can
fork() to shape its own ancestor chain, and pid reuse plus pid namespaces make "was this pid
spawned by us" unreliable on its own. The defect is confirmed by everyone; the mechanism is still
being argued, and I am not going to record agreement that does not exist.

Two smaller corrections to my previous comment while I am here:

  • I wrote that deny-all is "currently inexpressible". That is wrong: an entry with an empty tools
    list yields an empty allowlist for that server, and because any explicit entry replaces the
    built-in default wholesale, unlisted servers are denied too. Only the empty section cannot
    express it. The real defect is the missing deny_unknown_fields, which lets a mistyped section be
    ignored and silently fall back to the full default set.
  • I described roughly a dozen stale #[allow(dead_code)] attributes as disabling dead-code
    detection for the module. They are per-item attributes and do not do that; the count is 13, of
    which one is still doing work.

Current list — 11 defects, all independently confirmed

Every item below was verified against the source by at least two reviewers working independently,
and each of the six marked ✅ I also traced myself. Items 1–4 share one root cause: identity
inferred from data the other party controls, or from a key shared between parties.

# Defect Where
1 ✅ Bridge authentication is forgeable (above) — removed by deleting bridge mcp_proxy.rs:787
2 ✅ Connection teardown removes tunnels by channel_id from a registry created once and shared across every WS connection, so an old connection's cleanup evicts a live tunnel — and the reply sink — belonging to a newer one acp_server.rs:1302, main.rs:473
3 ✅ Token mint still evicts by channel (R1 fixed only the revoke side), and reset_session removes the creating gate that purge_session_entries documents must never be removed sources.rs:99, pool.rs:825 vs :155
4 ✅ The tool allowlist exists only in the facade path; proxy and bridge forward tool names ungated — removed by deleting both transports mcp_proxy.rs:148, :740
5 ✅ handle_initialize never advertises agentCapabilities.mcpCapabilities.acp, which the RFD requires acp_server.rs
6 ✅ The tunnel never performs the inner MCP initialize/notifications/initialized lifecycle before tools/list or tools/call acp_server.rs:769
7 The config writer clobbers a file it failed to parse, panics on a JSON array root, is non-atomic, and chmods after writing a file carrying a live bearer mcp_proxy.rs:290, :294, :348
8 start_session_server spawns the listener before fallible steps; any later ? returns without cancelling, leaving an orphan listener with a live bearer — removed with proxy mcp_proxy.rs:266
9 Resume is not reconciliation: it stores the raw uncapped declaration list, never removes tunnels for withdrawn declarations, and lets repeated resumes pile up connect tasks acp_server.rs:1488
10 Tunnel establish tasks are pushed into the prompt task set, so pending mcp/connects consume the in-flight prompt budget and real prompts get a misleading overload error acp_server.rs:854, :1169
11 The channel id is logged at info level; the session id is derivable from it, so an operator's logs hand out the credential needed to resume someone else's session acp_server.rs

The one open disagreement is now closed — toward the stricter reading. Item 6's severity was
contested: one reviewer held it a compliance gap that no current deployment trips, since the
extension in use answers tools/list without a handshake; the other held that this PR's stated
deliverable is generic client-declared MCP servers, so a standards-compliant server being unusable
is an interoperability failure that should not be downgraded because today's single implementation
happens to be lenient. The first reviewer has since withdrawn the downgrade and both now agree:
standards compliance is compatibility for a generic reverse proxy. Item 6 is treated as blocking.

Item 11 was added after the list above was first published, and is worse than first described: the
acp_<uuid> emitted at info level does not merely reveal that a session exists — the sess_<uuid>
needed to resume it is derivable from it. Drop it to debug! or redact it.

Items 2 and 9 are not done when the teardown filter is fixed. A fix is only acceptable if it
also covers three cases a naive owner check misses: a late establish must not overwrite a
successor's handle; an aborted task must not insert a dead handle after cleanup has run; and a
same-key replacement must not silently drop the connection it replaced. These are the cases that
make an ownership generation necessary rather than a "only remove what I inserted" test.

Also being fixed here, though it is a performance defect rather than a correctness one: the
screenshot path deep-clones the payload twice. Both reviewers argued it belongs in this PR — it is
the steady-state hot path, it wastes megabytes per screenshot, and moving the value out is an
idiom rather than a refactor. I had it deferred; they were right.

Fixed before merge, though not blocking on their own — these are drift this PR itself
introduced, so they are not being deferred out of it: the missing #[serde(deny_unknown_fields)],
which lets a mistyped config section be ignored and silently fall back to the full default tool set
(described in the corrections above but not previously listed as work); the smoke test still
asserting the old 1 MiB framing semantics; and the documentation drift — browser vs katashiro
in the ADR, the reversed default-mode note, §6.6 still listing F1′/F3′/F4 as remaining when they
shipped in this PR, the setup guide's jq 'del(…)' file cleanup commands that print to stdout
instead of editing the file, and the tunnel contract having no limits section at all, so an
extension implementer cannot see the per-session server cap, the method-frame ceiling or the tunnel
timeout.

Known and deliberately deferred: the discovery cache having no eviction, consolidation of the
remaining config writers (a pure refactor, not appropriate mid-review), and kiro agent files being
chmod'd 0600 as a side effect of a path that carries no secret. The unbounded line reads that were
listed here previously are gone with the shim.

The PR stays in draft until items 1–11 land. I would rather post a list this long than have you find
these one round at a time.

What we are actually doing, in order

Deletions first, because they remove work rather than add it — three of the defects below stop
existing, and every later fix is then made against the final shape instead of against code that is
about to vanish.

  1. Remove bridge — shim, subcommand, socket server, /proc ancestry resolver, channel injection,
    config writer, mode arm.
  2. Remove proxy and make [mcp] required — per-session server, bearer, per-session config
    write/cleanup, and the silent Facade → Proxy demotion. With no [mcp], one INFO line saying
    browser control is unconfigured; nothing starts.
  3. Add a WebSocket integration test — binds the real /acp route and drives a scripted client
    through initializesession/new declaring a type:acp server → answering a real
    mcp/connect, then puts a genuine mcp/message across the socket and asserts both a result and
    an error, plus the mcp/disconnect on same-name replacement. No browser and no model, so it runs
    in CI. This is our answer to your second reason, and it gives the tunnel adapter its first tests.
  4. Item 2 — teardown ownership, with three acceptance cases: a late establish must not overwrite a
    successor, an aborted task must not insert a dead handle after cleanup, and a same-key
    replacement must not silently drop the connection it replaced.
  5. Item 3(b) — reset_session removing the creating gate that the neighbouring function documents
    must never be removed. 3(a) is already fixed.
  6. Item 9 — resume as reconciliation, including retiring tunnels for withdrawn declarations. The
    test drives the read loop this time; testing the helper in isolation is what let this through.
  7. Item 10 — separate task budgets for tunnel establishment and prompts.
  8. Item 7 — the facade config writer: fail closed on parse failure, guard the non-object root,
    0600 temp file plus rename, serialised per path, with the destructive cases regression-tested.
  9. Item 11 — stop logging a derivable resume credential.
  10. Item 5 — advertise mcpCapabilities.acp.
  11. Item 6 — the inner MCP initialize lifecycle.
  12. The pre-merge cleanups listed above, plus removing every remaining mention of the two deleted
    transports from the docs and the ADR.

Then the PR comes out of draft. We would rather show you the plan than have you infer it from a
series of commits.

brettchien and others added 14 commits July 29, 2026 02:46
…tics

55eab82 removed the "one live token per channel" invariant from `mint` — a
second mint no longer invalidates a credential a running session is still
presenting — but the public API docs on all three methods still described the
old behaviour. Left as-is they teach the exact model the fix removed, and the
`revoke_channel` note justified itself with a replacement that no longer
happens.

`mint` now states that tokens for a channel coexist and points at
`revoke_token` as what keeps the map bounded; `revoke_channel` is marked a
deliberate channel-wide eviction that will also destroy other live sessions'
credentials; `revoke_token` is named as what a session's drop guard should use.

Docs only — no behaviour change. Raised by Falcon reviewing 55eab82.

Co-Authored-By: Claude <noreply@anthropic.com>
BREAKING CHANGE: `OPENAB_BROWSER_MODE=bridge` no longer selects a transport.

The bridge existed because a fixed HTTP endpoint could not do per-session
browser control, so each session needed its own stdio relay that resolved its
channel by walking `/proc` up to the agent process. The facade is a fixed
endpoint that solved exactly that with a per-session bearer, which leaves the
bridge with no problem of its own to solve — and a large surface: a per-pod
unix socket shared by every session, a channel derived from process ancestry,
and its own hand-rolled MCP dispatch and line framing.

Removed: the `openab browser-bridge` subcommand and its shim, the socket
server and connection handler, the `/proc` ancestry resolver, the bounded
frame reader, `write_bridge_mcp_config`, `browser_socket_path`, the
`OPENAB_BROWSER_CHANNEL` injection (and the now-unused `browser_channel`
parameter on `AcpConnection::spawn`), and the `Bridge` mode variant.

Two things are deliberately kept:

`bridge` degrades to `Facade` rather than erroring. A deployment still setting
the old value has to come up with working browser control, not refuse to start
on a string that was valid one release ago.

The `openab-browser` entry matcher stays, now purely as migration cleanup.
An upgraded deployment still has that entry in its `mcp.json`, naming a
subcommand that no longer exists, and its MCP client would retry it every
session; facade setup removes it. It is still matched by exact shape, since
that shape is the only proof the entry is ours.

Note the transport change for existing bridge deployments: they move to
facade, and with no `[mcp]` configured they fall back to proxy. Still
functional — making `[mcp]` required is a separate change.

Also fixes a doc-comment that had drifted: `write_facade_mcp_config` had none,
because its `///` block had come to sit on `is_openab_direct_browser_entry`
along with that function's own.

Facade-path coverage is unchanged (browser_source.rs, 21 tests); what leaves
is the bridge's own MCP dispatch, framing and ancestry tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Accepting the stale value keeps an upgraded deployment running, but accepting
it silently leaves the operator believing they are on a transport that was
deleted. Warn once per process, and warn with the transport actually in use.

That distinction is the substance of the fix, not a detail of wording. Two
demotions compose here: `bridge` is unrecognised so it falls through to
Facade, and Facade falls back to Proxy when no `[mcp]` wired a registrar. An
operator who set `bridge` and has no `[mcp]` is running **proxy** — the
transport furthest from what they asked for. A warning that echoed the parse
result would tell them "using facade" and be wrong.

So the resolution and the warning now live in one place. `resolve_browser_mode`
is pure and owns both demotions, so it is testable without touching process
env; `browser_mode_effective` reads the env, resolves, and warns once with the
resolved value. The pool's inline Facade->Proxy fallback moved into it, and
`browser_mode` is removed rather than left as a second entry point that would
resolve without warning.

`is_unrecognised_mode` deliberately excludes unset and empty: those express no
preference, and warning on them would fire for every default deployment and
train operators to ignore the line.

Also corrects the doc on the old `browser_mode`, which still said
"default: proxy" — stale since facade became the default, and directly below
an enum doc that had already been rewritten to say so.

Raised by Falcon reviewing 6ea4b44.

Co-Authored-By: Claude <noreply@anthropic.com>
BREAKING CHANGE: browser control now requires an `[mcp]` section in
config.toml. Without one there is none, and nothing is auto-started.

With the bridge gone the proxy was the last alternative to the facade, and
keeping it meant keeping a second way to reach the browser that the facade's
policy and audit do not cover. Removed: `ProxyHandler` and its `ServerHandler`
impl, `require_bearer`, `spawn_mcp_server`, `start_session_server`, the
per-session config write and cleanup, and `merge_kiro_agent_configs` /
`cleanup_kiro_agent_configs`.

The pool's three-arm transport match collapses to one facade path, which
deletes the silent `Facade -> Proxy` demotion. That demotion is the reason
`[mcp]` could be absent without anyone noticing: browser tools still appeared,
served by a transport the operator had not chosen. Now an unconfigured
deployment has no browser control and is told so once at startup.

`OPENAB_BROWSER_MODE` no longer selects anything, so the enum, the parser and
the resolver introduced one commit ago all go with it. What replaces them is a
startup notice rather than nothing: `warn_if_browser_mode_set` reports the
value that is being ignored *and* whether browser control survives (`facade`
or `disabled`), because "ignored" alone does not tell an operator whether they
still have the feature. It runs once at configuration time rather than on the
session path, where a warning would repeat per spawn.

`proxy` warns as loudly as `bridge`. It is equally inert now, and it is the
value more deployments actually set, so staying quiet for it would rebuild the
silence this notice exists to remove.

`browser_mode_migration_notice` holds the decision as a pure function so it is
testable without a subscriber or process env — the same split that made
`resolve_browser_mode` reviewable.

Kept: `browser_tools` (the facade source's seed catalog), `write_private`, and
the bridge-entry matcher, which is now migration cleanup.

Docs: the setup guide's two "Legacy: <mode>" sections are replaced by one
migration note. The bridge section had survived the previous commit describing
a socket server and shim that no longer exist, in the present tense.

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

Four things the previous commit left behind, all raised by Falcon.

**The startup report did not exist for the case that needed it most.** The
notice only fired when `OPENAB_BROWSER_MODE` was set, so a clean deployment
with no `[mcp]` and no legacy variable was told nothing at all — exactly the
deployment that just lost browser control. Worse, the same commit's
documentation promised openab "says so once at startup", and the runbook asked
for an INFO line in as many words. The claim was written and the code was not.

`warn_if_browser_mode_set` becomes `report_browser_control`, which always logs
which of the two states the process is in, then adds the migration warning
when the dead variable is set. Silence was the failure this whole change set
exists to remove; shipping a new instance of it while documenting the cure was
the wrong way round.

**`SessionPool::browser_tunnel` and `with_browser_tunnel` are gone.** The
field existed to hand a tunnel to `start_session_server`; with that deleted
nothing reads it, and the root's own `browser_tunnel` (which the facade's
capability source does use) stays. A field that is written and never read does
not fail any lint while a `pub` setter keeps it reachable.

**`axum` and `subtle` leave `openab-core`, and `rmcp` loses its server
features.** `subtle` was the constant-time bearer compare, `axum` the loopback
listener, and `server` / `transport-streamable-http-server` the MCP server
itself — all removed with the proxy. Only `rmcp::model` is still used, for the
browser tool definitions the facade source seeds from.

None of these is gate-detectable: unused Cargo features do not fail clippy, a
written-never-read field behind a public setter does not either, and a false
sentence in a document has no checker at all.

Co-Authored-By: Claude <noreply@anthropic.com>
The code was already correct; ten sentences describing it were not. None of
these is reachable by any gate — comments, doc comments, dependency notes and
an identifier are not checked by anything.

- `Cargo.toml`: an orphaned "core-hosted MCP proxy server" header left above
  the replacement comment, and the `acp-mcp` feature note claiming core hosts
  an MCP server. Core hosts none.
- `AcpConnection`'s guard: the doc said it cancels a per-session MCP proxy
  server. It revokes a facade token. The field was also named
  `mcp_server_guard`, which is the same false claim in a form the compiler
  propagates to every use — renamed to `facade_token_guard`.
- `main.rs`: a comment promising the pool's mode fallback keeps the
  per-session proxy path. There is no such path. The same edit had also left
  two consecutive identical `#[cfg]` attributes on one statement.
- `write_private`: justified `0600` by "the file holds a live bearer token".
  It no longer does — the facade entry references `${OPENAB_SESSION_TOKEN}`
  and the value lives only in the agent's environment. The mode is kept, with
  the actual reason.
- A dangling `// --- Option C: browser-bridge socket dispatch ---` marker left
  labelling tests that outlived the section.
- `mcp-over-acp-tunnel-contract.md`: still described internal routing as the
  core-hosted proxy. That file describes the extension-facing hop, which is
  exactly why removing an internal transport never led anyone to it.

Left in place deliberately: text that names a removed thing in order to
explain why something else exists — why a stale bridge entry is recognised and
removed, and why a stale proxy entry is not.

Found by sweeping the vocabulary of the removed transports across the repo
rather than the files under edit. A second sweep, minutes after the first,
still turned up two of the above; both were outside the files being changed.

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

The ADR had been excluded from every sweep this change set ran, because the
first one filtered `docs/adr/` out and later sweeps copied that scope forward.
Five sites were wrong.

Two were substantive rather than prose:

- §6 lists `ProxyHandler::forward_tool_call` among three pieces that "already
  generalize and are reused as-is", describing it as forwarding any tool name
  with no browser-specific validation. `ProxyHandler` is deleted, and its
  replacement is the opposite: `AcpTunnelSource::call` refuses an unlisted
  server name and an unpinned tool before the tunnel is resolved at all.
- The sequence diagram still had core starting a per-session MCP proxy and
  writing whichever of the two entry shapes the mode selected.

Three were stale claims:

- "Proxy mode remains as the one explicit `OPENAB_BROWSER_MODE` opt-out; bridge
  mode was removed" — written two commits ago, false in both halves now.
- F5 was still listed as a pending follow-up ("retire the superseded
  per-session proxy path once Facade mode has soaked") when this PR is what
  did it. Struck through and dated, since a follow-up list that still contains
  finished work will have someone doing it twice.
- D5's supersession note ended "still the behaviour of `proxy` mode".

Historical text that names a removed transport in order to explain why the
current design looks the way it does is kept — that is the point of the D
sections and the supersession notice.

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

All four described the removed transports as current:

- §4 "The downstream hop has two delivery modes: `proxy` (HTTP MCP, default)
  and `bridge` (stdio relay)". There is one, and neither of those.
- §6.6 "`BrowserMode::Facade` is the new default (falling back to `Proxy` when
  no facade is serving)". That enum no longer exists.
- The supersession notice kept D2/D3/D5 partly "because `proxy`/`bridge`
  remain selectable". They are not.
- The 2026-07-28 bridge-removal update said `bridge` "degrades to `facade`".
  True for exactly one commit: removing the proxy took `BrowserMode` and the
  whole `OPENAB_BROWSER_MODE` mechanism with it, so the variable now selects
  nothing and is only reported at startup. A correction can go stale as
  readily as the claim it corrected, which is worth saying out loud in the
  paragraph rather than silently rewriting it.

Found by review, not by the vocabulary sweeps this change set has been using.
Two of the four did match the sweep's terms and were lost because it excluded
`docs/adr/`; the other two never matched any term in it — one says "two
delivery modes", the other "remain selectable". A term list only finds
phrasings its author thought of, so it cannot be sufficient evidence that a
removal is fully described.

Co-Authored-By: Claude <noreply@anthropic.com>
Neither read was a keyword sweep; both went through the ADR section by section
checking each present-tense claim against the code. They overlapped on four,
and each found things the other missed — one and four respectively. A sweep
would have found fewer than either.

Reviewer-only:

- §5's sequence still ended with the agent seeing `katashiro.*` in its own
  `tools/list` and calling `tools/call katashiro.click`. Under the facade the
  model sees two meta-tools and goes through `search_capabilities` →
  `execute_capability`. Two commits ago I corrected lines 105-106 of this same
  diagram and did not read the twelve lines below them.

Mine only:

- §6.2 said the broker "writes it into that agent's MCP client config". The
  config gets the literal `${OPENAB_SESSION_TOKEN}`; the value goes only into
  the agent's process environment. The sentence described the shared-workdir
  exposure this design exists to prevent, while §6.6 described it correctly.
- The §6.4 allowlist default is `katashiro`, not `browser` — §7.1 records that
  rename and §6.4 was never updated.
- "Facade mode is the default transport" — there are no transports to be
  default among.
- Capabilities publish as `openab-browser`, not `openab`. `openab` is the
  mcp.json entry key.

Both:

- The §4 diagram still drew the per-session proxy as the live core→agent edge.
- F1′, F3′ and F4 were still listed as remaining; all three landed here. F5
  was struck through three commits ago while these three sat directly above it
  untouched, which is how the same list gets worked twice.
- D4 described the discovery cache as keyed by `(channel_id, server_id)`; it
  is keyed by declared name, so a reconnect minting a fresh id keeps the cache.
- §8 called static-advertise superseded by dynamic + `list_changed`, kept as a
  browser-only opt-in. It is the implemented posture, `list_changed` was
  dropped, and there is no opt-in.

F6 is left open deliberately and the section header now says so: no test
covers a host-level `mcp.json` provider coexisting with the tunnel source, or
two concurrent sessions each reaching only their own browser. The test double
ignores `channel_id` and every test shares one context.

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

Both were flagged rather than guessed at, and the reviewer ruled on them.

§6.5 said "Retired once this lands" of work this PR has done. Now stated as
retired in this PR, and extended to name the bridge as well — the sentence
listed only the proxy, so on its own it read as though one transport survived.

D4's "The in-pod MCP server is always-on" could not simply be moved to past
tense: the supersession notice explicitly carries D4 over, so it is a live
claim, not a historical one. Rewritten to the condition that actually holds —
with `[mcp]` configured the facade listener is process-lifetime and decoupled
from extension attach; without `[mcp]` no listener starts and there is no
browser control. Softening it into history would have quietly dropped a
decision the ADR still relies on.

Co-Authored-By: Claude <noreply@anthropic.com>
…wser` name

Three more from a reviewer's full re-read.

The §5 sequence had the agent's `tools/list` triggering an upstream
`tools/list` to the extension and only then returning the meta-tools.
Backwards: `tools()` returns the two meta-tools immediately with no upstream
call, and discovery is spawned from the *pull* — `search_capabilities` — per
`(channel_id, declared_name)`, then cached. Redrawn as three phases, and
PHASE 1's label no longer says "tool discovery", because that phase does none.

This diagram has now needed four passes. Twice before I corrected the lines I
was looking at — 105-106, then the closing two — and treated the rest of the
same diagram as already checked.

The status lines still said the §6 generalization was "implementing" in this
PR. F1′, F3′, F4 and F5 have landed; F6 has not, so the status says that
instead.

`:156` still used `"browser"` as the example of the stable `name`, in the
passage explaining which of `id`/`name` is stable. The same name was corrected
at three other sites two commits ago.

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

A second end-to-end read, aimed at the sections earlier passes had not opened.
Seven are prose corrections; two are not documentation bugs at all.

Prose:

- §2's rationale still said the facade re-exposes the client's tools "so the
  LLM's `tools/list` sees them". It does not — that is the whole point of the
  meta-tool indirection, which §6.3 describes correctly.
- §6 and §6.3 said servers are declared "on `initialize`". They are declared
  on `session/new` and re-declared on `session/resume`; `handle_initialize`
  never reads `mcpServers`. This one misleads anyone implementing the client
  side against the contract.
- §6.3 said `tools(ctx)` expresses which servers the session's client
  declared. It iterates the operator policy map: a pinned server is advertised
  with nothing attached, and a declared server with no policy entry
  contributes nothing. What varies per session is the discovery cache.
- §6.4's example of a name that grants nothing was `browser`, which is not
  allowlisted, so the example was refused by the gate it was illustrating —
  the fifth surviving site of that rename.
- §7.1 said PNG base64 (~5.5 MB) "would exceed the cap". It exceeded the old
  1 MiB cap; it fits the 8 MiB one that replaced it.

Gaps, recorded in §6.4 and filed as F7:

- §6.4 twice promised a "logged warning" — for a declaration outside the
  allowlist, and for a tool dropped by the pin. Neither is logged;
  `src/browser_source.rs` has no logging call at all. An operator who
  mistypes a name in `[[mcp.acp_servers]]` sees missing tools and nothing
  else, which is the failure §6.4 exists to prevent.
- §6 claims the facade gives capability sources "schema validation, timeouts,
  circuit breaking, redaction, audit". Only validation and audit apply on the
  source path; timeout, breaker and redaction live in `meta_tool::dispatch`,
  which only downstream `mcp.json` servers traverse.

Both are code changes and out of scope here. Rewording the ADR down to match
the code would have deleted the record of a real defect by editing the
specification that named it, so the section now states the gap and F7 carries
it.

Co-Authored-By: Claude <noreply@anthropic.com>
Every other test in this file calls the handlers directly with hand-built
structures, so the axum route, the upgrade, the frame codec and the
request/reply correlation had never been exercised over a socket. The tunnel
was asserted by construction rather than observed working — which is what the
reviewer objected to. `browser_tunnel.rs` had no tests at all.

Three tests bind a real listener and drive it with a scripted
`tokio-tungstenite` client: `initialize` → `session/new` declaring a
`type:acp` server → answer the real `mcp/connect`, then call server-side so a
genuine `mcp/message` crosses the socket. Both halves are asserted — a result
comes back as a result, and a JSON-RPC error comes back as `Err` rather than a
null result, which a handler that swallowed errors would otherwise pass.

Two things writing it made clear:

Replacement had to be a **resume**, not a second `session/new`. Eviction is
scoped `c == &channel_id`, and a new session is a new channel, so two
independent sessions declaring the same name do not collide — and must not.
The first version of this test drove it with two `session/new` calls; it
asserted nothing while looking like R7 coverage.

Registration completes *after* the client answers `mcp/connect`, so reading
the registry straight after replying races it. `wait_for_tunnels` polls the
real condition rather than sleeping a fixed interval and hoping.

The R7 assertion was mutation-checked: flipping the expected `connectionId`
from the replaced connection to its replacement fails the test, so it
distinguishes disconnecting the right tunnel from disconnecting the wrong one.

Co-Authored-By: Claude <noreply@anthropic.com>
Both registries are keyed by identifiers that outlive a connection — the reply
registry by `channel_id`, the tunnel registry by `(channel_id, server_id)` —
and `session/resume` hands the same channel to a *new* connection. So a
closing connection's cleanup could delete a successor's live entry: the reply
sink stops delivering, or the tunnel disappears from under a working session,
in both cases with no error anywhere.

"Only remove keys I inserted" does not fix it, because the key is exactly what
gets reused. `ReplySink` and `TunnelHandle` now record the `acp_conn_*` id of
the connection that installed them, and teardown removes an entry only when
the channel matches *and* the owner is the closing connection.

Eviction is deliberately NOT owner-scoped, and I had that wrong first.
Last-attach-wins is cross-connection by design: a reconnecting client arrives
on a new socket with a fresh `server_id`, so its predecessor's handle belongs
to the old connection. Scoping eviction by owner leaves that dead tunnel
registered beside the live one under the same declared name — the ambiguity
§6.1 exists to prevent. The WebSocket test added for R7 caught this by failing;
the test that had been written for the wrong semantics is deleted rather than
kept, since it would have pinned the regression in place while looking like
coverage.

Teardown asks whose entries these are. Eviction asks which tunnel now owns a
name. Only the first needs an owner.

Three acceptance cases, all verified to fail against key-only teardown: a late
cleanup must not remove a successor's tunnel, or its reply sink, and a cleanup
must still remove the entries it does own.

Co-Authored-By: Claude <noreply@anthropic.com>
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