Skip to content

feat(acp): ACP server over WebSocket (revives #1260) - #1418

Merged
thepagent merged 49 commits into
openabdev:mainfrom
brettchien:feat/acp-server-revive
Jul 23, 2026
Merged

feat(acp): ACP server over WebSocket (revives #1260)#1418
thepagent merged 49 commits into
openabdev:mainfrom
brettchien:feat/acp-server-revive

Conversation

@brettchien

@brettchien brettchien commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What problem does this solve?

OpenAB exposes agents only through platform-specific adapters (Discord, Telegram, LINE, …) and an OpenAI-compatible VTuber endpoint. Every new frontend — IDE plugin, desktop app, browser/web client, CLI — needs a bespoke adapter, and none of them get bidirectional streaming, tool-call approval, or session resume for free.

This adds an ACP (Agent Client Protocol) server over WebSocket at GET /acp, so any standard ACP client can drive an OpenAB agent through one wire-conformant protocol.

  • Revives the auto-closed implementation in feat(acp): ACP Server with WebSocket transport #1260 (resolves its outstanding review fix).
  • Absorbs the ADR proposal in docs(adr): ACP Server with WebSocket Transport #1258 (kept verbatim as acp-server-websocket.md), plus an as-built companion ADR (acp-server-websocket-base.md) and a design-only forward ADR (acp-server-websocket-mcp-browser.md, Status: Proposed) that records the post-base roadmap referenced from base ADR §6. No browser/MCP code lands in this PR — the ADR is documentation only.

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1527521703255605338

At a Glance

ACP client (browser / Zed / JetBrains / CLI)
      │  WSS  GET /acp   (JSON-RPC 2.0)
      ▼
openab-gateway :: adapters/acp_server
   initialize · session/new · session/resume · session/prompt · session/cancel
      │  GatewayEvent (platform="acp", channel=acp_<uuid>)
      ▼
OAB core  ──session_key = acp:<channel_id>──▶  agent (claude / codex / kiro)
      ▲  GatewayReply (Phase-1: one terminal snapshot)
      └── session/update { agent_message_chunk } ──▶ client

Prior Art & Industry Research

OpenClawopenclaw acp (docs, acpx) is a Gateway-backed bridge: it speaks ACP over stdio and forwards prompts to the OpenClaw Gateway over WebSocket, mapping each ACP session id to a Gateway session key so IDEs can reconnect to the same transcript. This is essentially the architecture used here (ACP ↔ gateway event bus, sessionIdchannel_id/session_key, default isolated acp:<uuid> session). The difference: OpenClaw's bridge is a locally-spawned stdio process; OpenAB exposes ACP directly at the gateway over WebSocket.

Hermes Agenthermes acp (docs, #569) starts a stdio JSON-RPC ACP server used by VS Code / Zed / JetBrains, exposing session creation, prompt, streaming agent-message chunks, tool-call events, permission requests, cancel, and auth, with sessions that persist across editor restarts. OpenAB's Phase 1 matches the chat subset (create / resume / prompt / stream / cancel); tool-calls + permissions are deferred to Phase 2.

ProtocolAgent Client Protocol (Apache-2.0, by Zed; backed by Zed + JetBrains). This PR is pinned to Schema v1.19.0; the exact method surface and OpenAB's coverage are tracked in docs/acp-official-methods.md.

Both references use stdio (local, IDE-spawned). OpenAB deliberately chooses WebSocket to also serve remote and browser clients, which stdio cannot.

Proposed Solution

A new acp_server gateway adapter behind the acp feature + OPENAB_ACP_ENABLED, wire-conformant with ACP Schema v1.19.0:

  • initialize → integer protocolVersion: 1, official agentCapabilities (sessionCapabilities.resume, loadSession: false, promptCapabilities), authMethods: [].
  • session/new{ sessionId }; session/prompt delivers the reply via a session/update notification (agent_message_chunk) — Phase-1 sends the whole reply as one terminal chunk (backend streaming=false); progressive multi-chunk streaming is Phase-2 — and returns { stopReason } (end_turn / cancelled).
  • session/cancel → one-way notification; ends the in-flight prompt with stopReason:"cancelled".
  • session/resume → re-attach to a persisted session without replaying history.
  • Token auth on the WS upgrade (timing-safe compare).
  • Content blockstext and resource_link (ACP baseline) are accepted; resource_link is passed through as a text reference (not fetched — SSRF-safe), and any other block type (image / audio / embedded resource) is rejected explicitly rather than silently dropped.

sessionId = sess_<uuid> and channel_id = acp_<uuid> share one uuid; prompts become a GatewayEvent and OAB core keys continuity by session_key = acp:<channel_id>.

Why this approach?

  • WebSocket, not stdio — unlike OpenClaw/Hermes' locally-spawned stdio bridges, WSS serves remote and browser clients directly.
  • session/resume, not session/load — OpenAB keeps no replayable upstream transcript (conversation state lives in the downstream agent CLI), so it cannot satisfy session/load's replay contract; it advertises loadSession: false and re-attaches via core's persisted session mapping. Rationale and the core evidence are in docs/adr/acp-server-websocket-base.md §3.
  • Hand-rolled JSON-RPC — the Phase 1 surface is small; avoids adding the agent-client-protocol crate dependency.

Alternatives Considered

  • stdio bridge (OpenClaw/Hermes style) — rejected: cannot serve browser/remote clients.
  • Extend the OpenAI-compatible VTuber endpoint (feat(vtuber): add OpenAI-compatible adapter #1234) — rejected: request/response SSE is one-way, so no tool-approval / cancel / resume.
  • session/load with replay — deferred to Phase 3; requires an upstream transcript store OpenAB does not have today.

Validation

Mirrors ci.yml:

  • cargo check --workspace
  • cargo clippy --workspace -- -D warnings
  • cargo clippy --workspace --features unified -- -D warnings
  • cargo test --workspace

Manual client testing against a live ACP client (Zed) for field-level exactness is noted as follow-up in docs/acp-official-methods.md.

Scope / follow-ups (not in this PR)

  • Phase 2: tool-call session/update variants + session/request_permission.
  • Phase 3: session/load with history replay.
  • Phase 4: multi-agent fan-out. Phase 5: Streamable HTTP.
  • cwd / mcpServers are accepted for wire conformance but not yet propagated to the agent.

Review Contract

Goal

Ship a wire-conformant ACP v1 server over WebSocket at GET /acp for the streaming chat subset (initialize / session.new / session.resume / session.prompt / session.cancel), mounted on both the standalone gateway and the embedded openab run server, with generated (typify) serde-only wire types and conformance/handler/streaming tests. Also lands the review-hardening fixes: JSON-RPC notification semantics, required-param validation, content-block rejection, resource caps, trace redaction, grapheme-safe message splitting, and full (untruncated) ACP reply delivery.

Non-goals

  • Tool calls / session.request_permission, fs/*, terminal/* (later phases).
  • Multi-agent fan-out, Streamable HTTP transport, session.load history replay.
  • Full backpressure (bounded outbound channel), idle eviction, global connection limits.
  • Backend cancellation propagation and process-wide session ownership (see Follow-ups).
  • Enabling transport auth by default (see Accepted Residual Risks).

Accepted Residual Risks

  • Transport auth (addressed)/acp requires OPENAB_ACP_AUTH_KEY on any non-loopback bind (fail-closed off loopback, F1); no-key is permitted only on a loopback bind for local dev. The key is carried via Authorization: Bearer or, for browsers, the Sec-WebSocket-Protocol subprotocol (openab.bearer.<key>) — kept out of the URL (F11); ?token= remains only as a deprecated fallback. Residual: a header-logging middlebox could still capture the key (use wss://); identity admission still relies on the gateway trust registry (GATEWAY_ALLOWED_USERS).
  • session.cancel does not stop the backend — it ends the gateway waiter with stopReason:"cancelled"; downstream model/tool work may continue (F3).
  • Resume has no process-wide ownership — concurrent holders of a resumable session id can race; low risk for the single-user base (F5).
  • Outbound queue unbounded — per-connection session/in-flight/frame caps are applied, but the outbound channel itself is not yet bounded (F6 partial).

Acceptance Criteria

  • Gate green: cargo clippy --workspace --features unified -- -D warnings, cargo test --workspace, cargo build --features unified.
  • Conformance, handler-level, and streaming unit tests pass; a runnable scripts/acp-ws-smoke.py drives a live deployment.
  • Live e2e on a real backend: initialize → session.new → streamed prompt → stopReason:end_turn → resume, plus /model and /reset replies rendering back over ACP.

Follow-ups

  • Backend cancellation propagation (F3) and process-wide session ownership (F5).
  • Full backpressure / bounded outbound channel / idle eviction / global limits (F6).
  • initialize negotiation validation (F8). (F10 resource_link support is resolved — accepted as SSRF-safe passthrough; see Proposed Solution.)
  • Live long-reply e2e re-verify of the truncation fix (needs a redeploy).

brettchien and others added 3 commits July 17, 2026 21:55
Restore the ACP server adapter from the auto-closed openabdev#1260, rebased onto
main. Exposes OAB as an ACP server over WebSocket at `GET /acp`, gated by
the `acp` feature + `OPENAB_ACP_ENABLED`. Prompts are bridged to
`GatewayEvent` and replies streamed back through the existing pipeline.

Also resolves the outstanding review finding on openabdev#1260: `serve()` built
`AcpConfig::from_env()` twice (two independent registries) — now extracted
once, matching `AppState::from_env()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Align the ACP server to the official Agent Client Protocol (Schema v1.19.0)
so standard ACP clients interoperate:

- initialize: integer `protocolVersion: 1`, official `agentCapabilities`
  (`sessionCapabilities.resume`, `loadSession: false`, `promptCapabilities`),
  `authMethods: []`.
- streaming: `session/update` notifications with
  `sessionUpdate: "agent_message_chunk"` and a `content` ContentBlock
  (was a custom `session/notification` + `chunk` wrapper).
- stopReason: official snake_case (`end_turn` / `cancelled`); a backend
  timeout has no ACP stopReason and returns a JSON-RPC error instead.
- session/cancel: one-way notification (no response) that ends the in-flight
  prompt with `stopReason: "cancelled"`.
- session/resume: re-attach to a persisted session without replaying history
  (`session/load` is intentionally unsupported — the gateway keeps no
  replayable transcript; conversation state lives in the downstream agent).

Also make delta streaming panic-safe: slice the reply snapshot with
`str::get` instead of a byte index, so a boundary landing mid-codepoint
(CJK / emoji) skips the frame instead of panicking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Absorbs the pending ADR proposal from openabdev#1258 and adds two companion docs:

- `docs/adr/acp-server-websocket.md` — the original proposal (@pahud), verbatim.
- `docs/adr/acp-server-websocket-phase1.md` — the as-built Phase 1 ADR: the
  wire-conformant primitive surface, the `session/resume` (not `session/load`)
  decision with its rationale, and divergences from the proposal.
- `docs/acp-official-methods.md` — the official ACP method surface pinned to
  Schema v1.19.0, with OpenAB's Phase 1 coverage and intentional non-support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien requested a review from thepagent as a code owner July 17, 2026 14:05
@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jul 17, 2026
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Important

CHANGES REQUESTED ⚠️ — The ACP endpoint is not safely or functionally integrated with the trust, unified-routing, cancellation, and turn-finalization contracts yet.

Consolidated review: #1418 (comment)

}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();
if auth_key.is_none() {
warn!("OPENAB_ACP_AUTH_KEY not set — ACP endpoint is UNAUTHENTICATED");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP with no key leaves /acp unauthenticated; an empty environment value is also accepted as a configured key. On the default 0.0.0.0 listener, a configuration omission exposes agent access to the network.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in.


// Convert to GatewayEvent and dispatch
let event = GatewayEvent::new(
"acp",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F2 — Register ACP with the shared trust gate

Both gateway ingress paths gate this platform: "acp" event, but the trust registry never registers acp; its fallback is L3 deny-all. Authenticated prompts therefore stop before dispatch.

Requested change: Add an explicit ACP trust policy tied to successful transport authentication and test that authenticated prompts are admitted.

Comment thread Cargo.toml

# Opt-in: compile all gateway adapters into a single unified binary
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams"]
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams", "acp"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F3 — Do not advertise incomplete unified support

This enables ACP in unified builds, but the unified Axum router does not mount /acp and UnifiedGatewayAdapter::dispatch_reply has no "acp" arm, so the feature is unreachable and replies would be dropped.

Requested change: Wire both route and reply dispatch in unified mode, or remove acp from this feature until that integration is complete.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F4 — Use an explicit end-of-turn signal

Inferring completion from send_message breaks both modes: streaming normally finalizes by editing the first chunk and never emits Done, while send-once output over 4096 characters becomes multiple sends and this branch ends/removes the route after the first chunk.

Requested change: Carry explicit turn completion across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F5 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled".

Requested change: Send cancellation through a core control path to the underlying session and finalize the ACP request only after cancellation is propagated.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F6 — Add connection ownership to resumed sessions

The reply registry is global and keyed only by channel ID, while each connection owns a separate session map. Concurrent connections can resume the same session and this insertion replaces the earlier waiter; stale disconnect cleanup can then remove the newer route.

Requested change: Track connection/turn ownership, define reject-or-handoff semantics, and use compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F7 — Bound client-controlled queues and session state

The outbound queue is unbounded and the same connection can create unlimited sessions and prompt tasks. A leaked shared token can therefore drive unbounded memory growth.

Requested change: Use a bounded queue and enforce per-connection session/prompt caps plus a global connection limit with explicit overload errors.

@brettchien
brettchien force-pushed the feat/acp-server-revive branch from d28d352 to 01ab50b Compare July 17, 2026 16:10
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Important

CHANGES REQUESTED ⚠️ — The embedded route now exists, but ACP authentication, trust admission, turn completion, cancellation, ownership, resource bounds, and feature-matrix behavior still require changes.

Consolidated review: #1418 (comment)

}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();
if auth_key.is_none() {
warn!("OPENAB_ACP_AUTH_KEY not set — ACP endpoint is UNAUTHENTICATED");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP with no key leaves /acp unauthenticated, and an empty value is accepted as the expected key. The embedded server listens on 0.0.0.0:8080 by default, so a configuration omission exposes agent access.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in.


// Convert to GatewayEvent and dispatch
let event = GatewayEvent::new(
"acp",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F2 — Bind ACP authentication to trust admission

This emits platform: "acp", but the shared trust registry never registers ACP; the unknown-platform fallback is L3 deny-all. Every prompt is rejected before dispatch even after the new embedded route is mounted.

Requested change: Register an ACP policy tied to successful transport authentication and test authenticated admission plus unauthenticated denial.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F3 — Use an explicit end-of-turn signal

Treating send_message as completion breaks both delivery modes: normal streaming finalizes via edit_message and never emits Done, while send-once output over 4096 characters produces multiple sends and this first send closes the route before later chunks.

Requested change: Carry explicit turn completion/correlation across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F4 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled", and late output can race with a subsequent prompt.

Requested change: Cancel the underlying core session and retain a generation/ownership barrier until the old turn cannot affect a new prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F5 — Add process-wide ownership to resumed sessions

The global registry is keyed only by channel ID, while session state is per connection. A second connection can resume the same session and overwrite this sender; stale prompt or disconnect cleanup can then remove the newer route.

Requested change: Track connection/turn ownership, define duplicate-resume semantics, and use generation-bound compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F6 — Bound client-controlled state and queues

This outbound queue is unbounded, as are per-prompt reply queues, session entries, and prompt tasks. Full response snapshots are cloned into queues, so a slow or unauthenticated client can drive unbounded memory growth.

Requested change: Use bounded queues and enforce per-connection session/prompt caps plus a global connection limit with explicit overload errors.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F7 — Compile the embedded server for the ACP-only feature

This branch is nested inside an outer #[cfg(any(...))] that omits feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. Therefore --no-default-features --features acp compiles out the server and /acp route.

Requested change: Add ACP to all three guards and add an ACP-only feature-matrix build/test.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F8 — Require IDs for request-only ACP methods

A missing ID becomes null, after which request-only methods execute and send id: null responses. Notification-shaped session/new and session/prompt can mutate state or start backend work without a correlatable request.

Requested change: Do not execute request-only ACP methods without an ID, and never send a response to a JSON-RPC notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F9 — Keep long-lived bearer credentials out of URLs

?token= can be captured by reverse-proxy/access logs, browser history, and observability systems, leaking the shared ACP key.

Requested change: Use Authorization for non-browser clients and a redacted short-lived ticket or secure cookie flow for browsers; otherwise require short-lived query credentials and documented log redaction.

@brettchien
brettchien force-pushed the feat/acp-server-revive branch 2 times, most recently from 35b59a4 to 19ec888 Compare July 17, 2026 17:53
@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jul 17, 2026
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Important

CHANGES REQUESTED ⚠️ — Trust admission is fixed, but ACP authentication, turn finalization, backend cancellation, ownership, resource bounds, protocol request handling, and tests still require changes.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP without a key skips authentication entirely, and an empty configured key accepts an empty bearer/query token. On the default 0.0.0.0:8080 listener, a configuration omission exposes agent access.

Requested change: Require a non-empty key before mounting /acp, or gate anonymous mode behind an explicit loopback-only opt-in; test absent, empty, invalid, and valid keys.

match reply.command.as_deref() {
Some("edit_message") => {
// Streaming update — send as text snapshot
if tx.send(ReplyChunk::Text(full_text)).is_err() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F2 — Use an explicit end-of-turn signal

Streaming finalization arrives as edit_message, which emits text but never Done, so successful prompts wait for the 180-second timeout. Conversely, a multi-chunk send-once response emits Done and removes the route after its first chunk.

Requested change: Carry explicit correlated turn completion across the core↔gateway contract and test short streaming plus multi-chunk send-once responses.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F3 — Propagate cancellation to the backend

This only wakes the WebSocket-side waiter. The core session keeps running, so tool calls and side effects can continue after the client receives stopReason: "cancelled", and late output can race with the next prompt.

Requested change: Cancel the underlying core session and retain a generation/ownership fence until the old turn can no longer affect a new prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F4 — Add process-wide ownership to resumed sessions

The global registry is keyed only by deterministic channel ID while session state is per connection. A second connection can replace this sender, and stale prompt/disconnect cleanup can remove the newer route.

Requested change: Track connection/turn generations, define duplicate-resume semantics, and use generation-bound compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F5 — Bound client-controlled state and queues

This outbound queue is unbounded, as are per-prompt reply queues, session entries, and prompt tasks. Full response snapshots are cloned into queues, so a slow client or leaked credential can drive unbounded memory growth.

Requested change: Use bounded queues and enforce per-connection session/prompt caps, a global connection limit, input limits, and deterministic overload behavior.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F6 — Compile the embedded server for the ACP-only feature

This runtime branch is nested inside outer #[cfg(any(...))] guards that omit feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. Therefore an ACP-only root feature build compiles out the server and route.

Requested change: Add ACP to all enclosing guards and add an ACP-only startup test for /health plus /acp.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F7 — Require IDs for request-only ACP methods

A missing ID becomes null, after which request-only methods execute and send id: null responses. Notification-shaped session/new and session/prompt can mutate state or start backend work without a correlatable request.

Requested change: Do not execute request-only ACP methods without an ID, and never send a response to a JSON-RPC notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F8 — Keep long-lived bearer credentials out of URLs

?token= can be captured by reverse-proxy/CDN logs, browser history, and observability systems, leaking the shared ACP key.

Requested change: Use Authorization for non-browser clients and a redacted short-lived ticket or secure-cookie flow for browsers; otherwise require short-lived query credentials and documented log redaction.

openabdev#1260 mounted the ACP endpoint only on the standalone `openab-gateway`
binary (`serve()`). Fleet deployments run the unified binary's embedded
gateway (`openab run`, main.rs) instead, which never called `serve()` — so
`/acp` was unreachable there.

Mount `/acp` on the embedded gateway too (gated by `OPENAB_ACP_ENABLED`),
and route ACP replies back through the unified adapter's `dispatch_reply`
(`platform == "acp"`). Also start the embedded HTTP server when ACP is
enabled even if only non-webhook platforms (e.g. Discord, which the core
connects to directly) are configured — otherwise the listener never binds.

Seed the `acp` platform into the gateway trust registry so its identity
gating honours `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` (the
ACP sender id is `acp_client`); without this the acp platform defaulted to
deny-all and every prompt was rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brettchien
brettchien force-pushed the feat/acp-server-revive branch from 19ec888 to 0c099df Compare July 18, 2026 02:40
brettchien and others added 5 commits July 18, 2026 12:11
Proposed design for the agent's LLM to autonomously operate the user's
browser ("computer use" against the real, logged-in Chrome), exposed as
MCP tools tunnelled over the existing `/acp` WebSocket (MCP-over-ACP):

- extension runs the MCP server role over its outbound WS (MV3 can't listen);
- OpenAB core proxies the browser tools to the in-pod agent (MCP client);
- needs the agent→client request direction added, and generated (v1) types.

Design only — not implemented. Linked from the Phase 1 roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe the as-built ACP-over-WS server ADR from "Phase 1" to "base" — it is
the foundation later phases build on, not one numbered phase of many. Renames
`acp-server-websocket-phase1.md` → `acp-server-websocket-base.md`, retitles,
and updates the two referencing docs (method coverage + the Phase 2
browser-control ADR). Roadmap phase numbers (Phase 2–5) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`acp-mcp-browser-control.md` → `acp-server-websocket-mcp-browser.md` so all
three ADRs share the `acp-server-websocket-*` prefix (proposal / base /
mcp-browser) and group together. Updates the base ADR roadmap link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the original proposal's numbered Phase 2–5 roadmap with a re-scoped
plan driven by the north-star goal (LLM operating the user's browser):

- base §6: Critical path (agent→client requests, request_permission,
  MCP-over-ACP + core proxy, generated typed v1) / Optional / Not needed /
  Observability tiers.
- Multi-agent "conversation" clarified: N independent OpenAB instances relayed
  by the client (a room) — client-side, not ACP fan-out. Fan-out removed.
- Note OpenAB command parity is mostly free over ACP (text directives/slash are
  platform-agnostic).
- Recommend an ACP trace mode first (know behavior, de-risk generated types).
- Browser ADR de-phased (proposed north-star, not "Phase 2").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the standalone typing analysis into base ADR §7: hand-rolled now →
generated (typify, serde-only, ~0 dep) for the expanded surface; full
`agent-client-protocol` crate rejected (2nd async runtime), schema-only crate
is +24 schemars-heavy deps; v1 only; round-trip caveat; hand-roll trivial /
generate complex, downstream core client is the bigger ROI. Renumbers refs to §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Important

CHANGES REQUESTED ⚠️ — Documentation was expanded, but fail-open authentication, broken turn finalization, backend cancellation, ownership, resource, protocol, and test gaps remain.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F1 — Fail closed when transport authentication is absent

Enabling ACP without a non-empty key skips upgrade authentication. Because every connection becomes the same synthetic acp_client, allowing that identity in the trust registry makes an omitted key a network-wide bypass.

Requested change: Refuse to enable/mount /acp without a non-empty key, or require an explicit loopback-only insecure mode; test absent, empty, invalid, and valid keys.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F2 — Do not infer turn completion from send_message

Core normally finalizes streaming with edit_message, which never emits Done, while any send_message emits Done and removes the route. Short streamed turns time out; multi-chunk output can end after the first send and drop the rest.

Requested change: Carry an explicit correlated turn-finished signal (or aggregate the complete turn) and test short streaming plus every overflow/send-once path.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F3 — Propagate cancellation to the backend

This only wakes the local WebSocket waiter. The dispatched core/downstream turn keeps running, so tool calls and side effects can continue after the client receives stopReason:"cancelled".

Requested change: Route cancellation through the core session-control path, observe backend termination, and fence late output before allowing the next prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F4 — Bind reply routes to a connection/turn owner

The registry is process-wide and keyed only by deterministic channel ID, while busy is connection-local. Two holders of the same resumed session can overwrite this sender, and either stale prompt/disconnect can remove the newer route.

Requested change: Add connection/turn generations, reject or explicitly hand off duplicate ownership, and perform compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F5 — Bound client-controlled state and queues

The outbound channel is unbounded; the per-prompt reply channel is also unbounded, and session/task counts have no caps. A slow or malicious authenticated client can drive unbounded memory/task growth.

Requested change: Use bounded queues/backpressure, per-connection session/prompt caps, a global connection limit, and deterministic overload behavior.

Comment thread src/main.rs
// The ACP endpoint (mounted below) needs this embedded HTTP server too —
// start it even when only non-webhook platforms (e.g. Discord, which the
// core connects to directly) are configured.
let acp_enabled = cfg!(feature = "acp")

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F6 — Make the ACP-only feature path reachable

This check is nested inside outer #[cfg(any(...))] guards that omit feature = "acp"; the same omission guards mod unified_adapter and unified_platform_enabled. An ACP-only root build therefore compiles this branch out despite the ADR saying it starts the embedded listener.

Requested change: Add ACP to every enclosing/paired guard and cover --no-default-features --features acp with an endpoint smoke test.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F7 — Enforce request-versus-notification shape

Converting a missing ID to null lets request-only methods execute and respond to notification-shaped messages. That can create sessions or start backend work with no correlatable request.

Requested change: Require IDs for ACP request methods, never execute/respond to notification-shaped requests, and validate session/cancel as a notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F8 — Keep long-lived credentials out of URLs

A shared key in ?token= can be retained by reverse-proxy/CDN logs, browser history, observability systems, and traces.

Requested change: Use the Authorization header where possible and a short-lived one-use ticket or secure-cookie flow for browsers; otherwise require expiry and log redaction.

Comment thread docs/acp-official-methods.md Outdated

| Method | Direction | Purpose | OpenAB base |
|---|---|---|---|
| `session/cancel` | Client → Agent | Cancel in-flight work (one-way, no response) | ✅ conformant (notification; prompt ends `stopReason:"cancelled"`) |

@chaodu-agent chaodu-agent Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F10 — Do not mark cancellation conformant before backend cancellation works

The implementation only cancels the local waiter; it does not cancel the backend turn. This table and the new accepted ADR also claim wire conformance/end-to-end verification while turn completion, request-shape handling, and ACP-only startup still diverge from the described contract.

Requested change: Downgrade these claims and list the known divergences until executable tests verify the full behavior.

brettchien and others added 2 commits July 18, 2026 15:07
Flag-gated (OPENAB_ACP_TRACE=1|true) logging of every JSON-RPC frame on the
upstream client<->gateway hop, in both directions (dir="in"/"out"). Off by
default. Traced at the two choke points: the inbound ws_rx read and the single
outbound writer task. Captures real ACP traffic to validate the planned
generated-type round-trip against what clients/agents actually emit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add crates/openab-gateway/src/adapters/acp_schema.rs, generated by cargo-typify
0.7.0 from the vendored ACP v1 JSON schema (schemas/acp-v1.schema.json, pinned
to upstream schema.json @ eb88e992 / ACP Schema v1.19.0). Plain serde — no
schemars/serde_with runtime dep. Feature-gated (acp), allow(dead_code) until
acp_server migrates onto it.

Full v1 surface is generated (one closed dep graph); the base wires only the
chat subset. Round-trip of the chat subset against known-good wire was proven
before committing (Initialize/NewSession/Prompt/StopReason/ContentBlock/
SessionNotification all exact; StopReason snake_case; ContentBlock untagged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Important

CHANGES REQUESTED ⚠️ — Generated ACP types remain unused and critical lifecycle/security gaps remain.

Consolidated review: #1418 (comment)

if !enabled {
return None;
}
let auth_key = std::env::var("OPENAB_ACP_AUTH_KEY").ok();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F1 — Fail closed when ACP authentication is absent

Enabling ACP without a non-empty key skips upgrade authentication; an empty configured key also accepts an empty token. On the default network listener, a configuration omission can expose the admitted synthetic ACP identity.

Requested change: Require a non-empty key before mounting /acp, or gate an explicit insecure mode to loopback; test absent, empty, invalid, and valid keys.

registry.lock().unwrap_or_else(|e| e.into_inner()).remove(key);
}
}
None | Some("send_message") => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F2 — Do not infer turn completion from message commands

Streaming normally finalizes through edit_message, which never emits Done; multi-chunk send paths hit this branch repeatedly, but the first call emits Done and removes the route. Successful turns can therefore time out or lose later chunks.

Requested change: Carry an explicit correlated turn-finished signal (or aggregate the complete turn) and test streaming plus all overflow/send-once paths.

if let Some(k) = sess_key {
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
if let Some(n) = notify {
n.notify_one();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 F3 — Propagate cancellation to the backend

This only wakes the local WebSocket waiter. The dispatched core/downstream turn keeps running, so model requests, tool calls, and side effects can continue after the client receives stopReason:"cancelled". ACP requires the cancelled response only after ongoing operations have been aborted.

Requested change: Route cancellation through the backend session-control path, observe termination, and fence late output before allowing the next prompt.

registry
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(channel_id.clone(), reply_tx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F4 — Bind reply routes to a connection and turn owner

The registry is process-wide and keyed only by deterministic channel ID while busy/session state is connection-local. Another connection can replace this sender, and stale prompt/disconnect cleanup can remove the newer route.

Requested change: Add connection/turn generations, reject or explicitly hand off duplicate ownership, preserve active resume state, and use compare-and-remove cleanup.

let mut prompt_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

// Channel for sending messages back to the client
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F5 — Bound client-controlled state and queues

This outbound channel and the per-prompt reply channels are unbounded; session and prompt-task counts are also uncapped. A slow or malicious authenticated client can drive unbounded memory/task growth.

Requested change: Add bounded queues/backpressure, per-connection caps, a global connection/worker limit, input limits, idle eviction, and deterministic overload behavior.

continue;
}

let id = req.id.clone().unwrap_or(Value::Null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F7 — Enforce request-versus-notification shape

Converting a missing ID to null lets request-only methods execute and emit id:null responses. ACP/JSON-RPC notifications must never receive success or error responses; the generated schema now distinguishes these shapes but is not used here.

Requested change: Require IDs for ACP request methods, never execute/respond to notification-shaped requests, and validate session/cancel as a notification.

.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.or_else(|| query.get("token").map(|s| s.as_str()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F8 — Keep long-lived credentials out of URLs

A shared key in ?token= can be retained by reverse-proxy/CDN logs, browser history, observability systems, and traces.

Requested change: Use Authorization where possible and a short-lived one-use ticket or secure-cookie flow for browsers; otherwise require expiry and mandatory log redaction.

let send_task = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if trace {
info!(connection = %send_conn, dir = "out", frame = %msg, "ACP frame");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F9 — Do not log complete ACP payloads at INFO

These frames contain prompts, responses, resources, session capability IDs, and future tool inputs/outputs. INFO logs commonly have broad access and long retention, so enabling diagnostics can create a durable sensitive-data copy.

Requested change: Log redacted metadata by default; put any raw payload dump behind an explicitly unsafe development-only switch, document the risk, and add redaction tests.

pub mod acp_server;
#[cfg(feature = "acp")]
#[allow(clippy::all, dead_code, unused)]
pub mod acp_schema;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F10 — Wire generated types into behavior or defer them

The latest commit adds 27,873 lines of schema/generated code, exports it under broad dead-code/lint allowances, but no runtime code consumes it. The live parser remains hand-written, so this does not enforce conformance or fix request/notification handling.

Requested change: Use the generated types at the current wire boundary with fixture/round-trip tests and narrow allowances, or defer this module until the phase that consumes it.

Comment thread docs/acp-official-methods.md Outdated

| Method | Direction | Purpose | OpenAB base |
|---|---|---|---|
| `session/cancel` | Client → Agent | Cancel in-flight work (one-way, no response) | ✅ conformant (notification; prompt ends `stopReason:"cancelled"`) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 F12 — Do not claim cancellation conformance before backend cancellation works

The implementation confirms cancellation after stopping only the local waiter; it does not stop model/tool activity. Turn completion and request/notification handling also diverge from the claimed wire-conformant subset.

Requested change: List these divergences and downgrade the verified/conformant claims until executable tests prove the full contract.

Add acp_conformance test module: every payload acp_server hand-rolls (initialize
/ session.new / resume / prompt stopReason / session.update agent_message_chunk)
and accepts (prompt request) is asserted to deserialize into the generated
acp_schema ACP v1 types, with serde proven a stable fixed point. Any casing /
field-name / shape drift — the class of bug fixed during the base build
(agentMessageChunk->agent_message_chunk, integer protocolVersion, snake_case
stopReason) — now fails CI.

Per ADR §7 the trivial chat payloads stay hand-rolled; this guard locks them to
the schema. Typed construction for the complex bidirectional/MCP surface is the
roadmap's job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chaodu-agent

Copy link
Copy Markdown
Collaborator

/review

…e (R16-F2)

The unconditional registry remove / busy reset in the prompt cleanup path is
correct only because no newer turn can exist on the same session_id while one is
in flight — session/prompt and session/resume both reject with -32001 when busy.
Document that coupling so a future relaxation of the busy gate doesn't silently
reintroduce the F2 cleanup-clobber; cross-connection ownership stays a residual (F5).

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

Copy link
Copy Markdown
Contributor Author

Thanks for the round-16 review — the findings were precise and actionable. All five are addressed on the current head (f7c9378). Summary below; each item links its commit.

Round-16 findings

  • F1 🔴 — prompt→cancel startup race (acb…eb3cf07): the per-prompt cancel state (busy + cancel Notify) is now reserved synchronously under the session lock in the read loop, before the prompt task is spawned. Because the read loop is sequential, a session/cancel arriving on the next frame always observes the installed handle. Regression test prompt_cancel_race_before_first_update_cancels sends prompt→cancel with no wait and asserts stopReason:"cancelled".
  • F2 🔴 — resume/cleanup ownership (1388591d): session/resume now rejects with -32001 while the session has a prompt in flight, so the active turn's cancel handle and reply sink are never clobbered. Combined with the existing busy-gate on session/prompt, no newer turn can exist on a session_id while one is running, which also makes the prompt cleanup path safe. Test resume_while_busy_is_rejected_and_preserves_state asserts the rejection and that busy/cancel survive. I took the "reject while busy" option; the generation/owner-aware cleanup variant is documented as a Phase-2 follow-up, and I added an explicit invariant comment at the cleanup site (f7c9378) so the busy-gate coupling can't be relaxed by accident. Cross-connection same-session_id ownership remains the accepted residual (F5) noted in the ADR.
  • F3 🟡 — streaming claim (c314ece6): rather than implement progressive chunking now, I narrowed the contract to match the implementation. The ADR and PR body now state that Phase-1 delivers the whole reply as a single terminal agent_message_chunk (streaming=false); progressive multi-chunk streaming is Phase-2 (ADR §6). Test phase1_emits_single_terminal_agent_message_chunk anchors the documented behavior.
  • F4 🟡 — ACP tests not in CI (70847fe1): ci.yml now runs cargo test -p openab-gateway --features acp and builds --features unified so the embedded /acp endpoint and the conformance/handler/streaming tests are exercised (scoped to -p openab-gateway to avoid the workspace hooks::tests parallel flake). Removed the incorrect "covered by workspace test" comment.
  • F5 🟡 — smoke assertion precedence (acb0bde6): the Unicode check was ("🎉" in text and family in text) or ("👨" in text), which passed on a lone 👨. It now requires every marker via all(m in text for m in ["你好","🎉","👨‍👩‍👧‍👦","❤️"]) and fails closed on timeout / JSON-RPC error before inspecting content.

Local verification at the new head: cargo test -p openab-gateway --features acp294 passed, 0 failed.

Review status

The OpenAB PR Review check is now review-limit-reached / "Circuit breaker: exceeded 30 review cycles", so the automated reviewer will not re-evaluate these fixes. The two 🔴 blockers and the three 🟡 items above are resolved with regression tests; the remaining boundaries (backend cancellation propagation, full outbound backpressure, process-wide session ownership) are the accepted residuals / later-phase items already documented in the PR body and ADR.

Could a maintainer take a look for the human review pass? Happy to adjust anything.

@smallgun01

Copy link
Copy Markdown
Contributor

@brettchien We are evaluating an ACP-over-WS client for a persistent Live2D/Electron companion. Our current frontend uses the VTuber OpenAI-compatible endpoint and keeps continuity through the existing VTuber gateway path.

For a future ACP client, is there an intended/supported way to link or hand off continuity between an ACP session and an existing platform/gateway session, or are ACP sessions intentionally isolated (acp:<uuid>) by design? If isolation is intentional, we can treat ACP as a separate companion session; we just want to avoid relying on unsupported cross-channel session behavior.

We understand Phase 1 deliberately emits a terminal reply rather than progressive chunks, which is fine for an initial canary. Is cross-connection ownership/handoff for session/resume expected in a later phase, or should clients treat a session capability as single-active-client only?

@brettchien

Copy link
Copy Markdown
Contributor Author

@smallgun01 Thanks — good questions, and they land right on the boundaries the Phase-1 ADR calls out explicitly.

1. ACP session isolation vs. handoff to the existing gateway session

Isolation is intentional by design. An ACP session lives entirely in its own namespace: session/new mints sessionId = sess_<uuid>, the gateway derives channel_id = acp_<uuid> from the same uuid, and the core keys continuity by session_key = acp:<channel_id> (ADR §2, "Session ↔ core mapping"). There is deliberately no supported bridge that links or hands off continuity between an acp:<…> session and a different platform's session (e.g. your VTuber OpenAI-compatible gateway path) — those are separate session_key namespaces, and nothing in the base crosses them.

So your instinct is right: treat the ACP client as its own companion session, and don't rely on cross-channel session behavior — it isn't supported and isn't planned as a cross-platform handoff. Continuity within ACP does persist (the core keeps a thread_key → agent sessionId mapping that survives a process restart, within the downstream agent's retention / session_ttl_hours, default 4h), but that's continuity of the same ACP session, not a link into the VTuber path.

If what you actually want is one logical companion identity shared across both frontends, that's an orchestration concern on the client side (your app deciding both surfaces talk to the same logical companion), not something ACP is intended to broker.

2. Phase-1 terminal reply

Correct, and glad it works as a canary. Phase-1 emits the whole reply as one terminal agent_message_chunk because the adapter reports streaming=false. Progressive multi-chunk streaming is a Phase-2 item (ADR §6) — and the gateway's delta path is already char-boundary-safe for multiple chunks, so it's a forward-compatible flip, not a wire-breaking change for your client.

3. session/resume — cross-connection ownership / single-active-client

For now, treat a session capability as single-active-client. The base is 1:1 by construction (ADR §5): the reply registry is channel_id → single reply_tx, and the delta stream assumes one monotonic text — which matches ACP's 1:1 nature (one client ↔ one agent).

The supported resume flow is reconnect: on WS disconnect the per-connection session map is dropped, and the same client reconnects with session/resume + its persisted sessionId to restore context (no history replay — the client keeps its own transcript for display; ADR §3). While a prompt is in flight, session/resume is rejected (-32001) so an active turn's state can't be clobbered.

What is not in the base is generation/owner-aware cleanup for two different connections claiming the same session_id concurrently — cross-connection same-session_id ownership is an accepted residual (review finding F5), documented in the ADR. Proper cross-connection ownership/handoff is a later-phase item; until then, don't design for two live connections sharing one sessionId — one active client per session, reconnect-to-resume.

Happy to go deeper on any of these, or to note your Live2D/Electron use case as a driver for the Phase-2 streaming + ownership work.

@chaodu-obk

chaodu-obk Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ -- The current ACP transport still permits unsafe browser access in anonymous loopback mode and accepts wire shapes outside the declared ACP contract.

What This PR Does

This PR adds a feature-gated ACP v1 WebSocket server at GET /acp, translating ACP sessions and prompts into OpenAB gateway events and routing gateway replies back as ACP notifications. It mounts the endpoint in standalone and embedded gateway paths, adds transport authentication, resource caps, conformance tests, and ADR documentation.

How It Works

initialize, session/new, session/resume, session/prompt, and session/cancel are handled by a connection-local ACP state machine. A sess_<uuid> maps to acp_<uuid>, prompts become GatewayEvent(platform="acp"), and a process-wide reply registry routes matching GatewayReply snapshots back to the active prompt using the originating event id as a stale-reply fence. Phase 1 deliberately delivers one terminal agent_message_chunk because the ACP adapter uses streaming=false.

Findings

# Severity Finding Location
F1 🔴 Critical Keyless loopback mode accepts browser WebSockets without validating Origin, so an arbitrary website can drive the local ACP endpoint. crates/openab-gateway/src/adapters/acp_server.rs:306-343
F2 🟡 Important The legacy ?token= fallback still exposes the long-lived bearer key through URLs, access logs, browser history, and tracing infrastructure. crates/openab-gateway/src/adapters/acp_server.rs:317-322
F3 🟡 Important The live parser is still hand-rolled and accepts shapes outside the generated ACP schema; it also acknowledges request-shaped session/cancel although that method is notification-only. acp_server.rs:618-643, 1040-1090; acp_schema.rs:7475-7487, 8293-8323
F4 🟢 Praise The latest fixes reserve cancellation before spawning, reject resume while busy, fence stale replies, and add explicit ACP CI coverage. acp_server.rs:540-591, 763-790; .github/workflows/ci.yml:57-59
Finding Details

🔴 F1: Validate browser Origin in anonymous loopback mode

When OPENAB_ACP_AUTH_KEY is absent, the endpoint intentionally allows a loopback bind without a bearer token. The upgrade handler checks the token when configured but does not inspect the WebSocket Origin header. WebSocket handshakes are not protected by the browser same-origin policy, so any malicious page opened in the user's browser can connect to ws://127.0.0.1:<port>/acp, initialize a session, and submit prompts to the local agent. This is especially sensitive because ACP prompts may reach model tools and local side effects.

Requested change: Reject non-allowlisted Origin values in keyless loopback mode, or make anonymous mode an explicit non-browser-only development option. Add regression tests for absent, allowed, and disallowed origins while retaining bearer authentication for remote binds.

🟡 F2: Remove or harden the query-token compatibility path

The handler still accepts ?token=<shared-key> after the Authorization and WebSocket subprotocol paths. A long-lived shared key in a URL can be retained by reverse-proxy/access logs, browser history, telemetry, and copied links. The subprotocol path is a better browser mechanism, but retaining the query fallback means deployments can still use the unsafe path.

Requested change: Remove the query fallback by default. If backward compatibility is required, use a short-lived one-use ticket and document mandatory URL redaction at every proxy and observability layer.

🟡 F3: Enforce the generated ACP request shapes at the wire boundary

The generated PromptRequest requires an array of ContentBlock, while extract_prompt_params also accepts a plain string. The generated ResourceLink requires its schema fields, while the live parser accepts a missing name/title fallback. The dispatch path also sends an empty success response when session/cancel arrives with an id, even though ACP defines it as a one-way notification. These extensions make the implementation less wire-conformant than the declared v1.19.0 contract and allow clients to receive behavior not covered by the generated schema.

Requested change: Deserialize request payloads through the generated types (or explicitly document and version extensions), reject non-conformant shapes, and add negative tests for string prompts, incomplete resource links, and request-shaped session/cancel.

🟢 F4: Good lifecycle hardening

The prompt cancellation state is reserved synchronously before spawning, resume rejects an in-flight session without clobbering its cancel handle, and reply routing fences stale event ids. The PR also adds feature-gated ACP tests and a unified build step to CI, and the Phase-1 terminal-only streaming contract is now documented rather than overstated.

Addressing External Reviewer Feedback

External client/session feedback

ACP sessions should be treated as isolated sessions, and the current capability should be treated as single-active-client until process-wide ownership is implemented.

Accepted. The PR documents acp:<uuid> isolation and the single-active-client limitation. Cross-connection ownership remains a documented residual and is not duplicated as a new finding here.

Existing review-round feedback

The previously reported prompt-to-cancel race, busy-session resume clobber, ACP-only CI coverage, terminal-only streaming contract, and smoke assertion issues are acknowledged as addressed by the commits leading to the current head. Backend cancellation propagation, full outbound backpressure/global limits, and cross-connection ownership remain explicitly documented follow-ups/residuals.

Baseline Check

  • PR opened: 2026-07-17.
  • Declared base: main; declared base SHA: 2e3292bc3e3e01f0fb203dc38003ad4c9afc7ca5.
  • Merge-base: 30bc145233a202754d205b21e8529491d97b1a7c.
  • Main already has downstream ACP-over-stdio support and the generic gateway event/reply bridge, but it does not expose this client-facing ACP WebSocket endpoint.
  • Net-new value: the remote/browser ACP transport, deterministic session mapping, embedded/standalone mounting, and protocol documentation.

What's Good (🟢)

  • Constant-time bearer comparison and fail-closed behavior for non-loopback binds.
  • Explicit unsupported content handling avoids silently dropping image/audio/resource blocks.
  • UUID namespace validation prevents forged session ids from escaping the ACP channel namespace.
  • Stale-reply fencing and the latest busy/cancel reservation fixes are careful cross-component hardening.
  • The PR records its Phase-1 boundaries and deferred cancellation/backpressure/ownership work instead of hiding them.

5. Three Reasons We Might Not Need This PR

  1. The gateway contract is not fully turn-oriented yet -- adding explicit correlation, completion, and cancellation to the shared core bridge first may avoid ACP-specific lifecycle workarounds.
  2. A local stdio bridge may cover current IDE demand with less exposure -- it could reuse the mature downstream ACP path while remote/browser demand is validated.
  3. The WebSocket contract may be premature -- shipping only after browser-origin policy and wire-shape enforcement are deterministic would reduce the risk of clients depending on ambiguous behavior.

@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 ⚠️ -- Keyless loopback WebSockets lack Origin validation, the legacy query-token path leaks bearer credentials, and the live parser accepts shapes outside the declared ACP schema.

Consolidated review: #1418 (comment)

WS handshakes bypass the browser same-origin policy, so a keyless
`ws://127.0.0.1/acp` was reachable cross-origin by any web page. In
keyless mode the upgrade handler now rejects a browser-set `Origin`
that is not allowlisted (403); a request with no `Origin` (non-browser
client) is still admitted, and the keyed/bearer path is unchanged. The
allowlist is opt-in via `OPENAB_ACP_ALLOWED_ORIGINS` (comma-separated),
plumbed through `AcpConfig` next to `auth_key`. Documented in the ADR
§2 security note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brettchien and others added 4 commits July 23, 2026 16:11
A bearer key in the URL query leaks into access logs / browser history /
referers. The two header-borne sources — `Authorization: Bearer` and the
`Sec-WebSocket-Protocol: openab.bearer.<token>` subprotocol — already cover
both non-browser and browser clients, so drop the legacy `?token=` fallback.
Token extraction is factored into `ws_bearer_token` (headers only); the
`query`/`Query` extractor is gone from `ws_upgrade`. A request presenting a
credential solely via `?token=` is now rejected 401 in keyed mode. ADR §2
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generated `PromptRequest.prompt` is `[ContentBlock]`. `extract_prompt_params`
previously coerced a plain-string `prompt` (and fell through to a generic error
for other shapes); now any non-array `prompt` is rejected, surfaced as -32602
invalid params at the call site, rather than leniently accepted. Baseline text /
resource_link array handling is unchanged. Test updated: a string prompt now
returns an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generated `ResourceLink` requires both `name` and `uri`. The prompt
parser previously required only `uri`, treating `name` as optional and
falling back to `title`/a bare-uri render. It now rejects a resource_link
missing its required `name` (-32602 invalid params) rather than silently
rendering it, matching the schema. Accepted links still render as
`[name](uri)`. Tests updated: a no-`name` link is now an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ACP defines `session/cancel` as notification-only. A request-shaped cancel
(with an `id`) was being acknowledged with `success({})`, which contradicts
the ADR's "No response" contract and falsely implies cancel is a valid
request method. Extract `handle_session_cancel`: the notification form still
fires the session's cancel signal with no response; a request-shaped cancel
is now rejected -32600 invalid request and does not fire. Tests: cancel with
an id -> -32600 (no empty-success frame); notification -> no frame + fires.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the round-17 review. All three actionable findings (F1, F2, F3a–c) are addressed on the current head; the F4 items you called out are retained unchanged. Per-finding summary:

🔴 F1 — Validate browser Origin in keyless loopback mode

Fixed in 6ee620cf.

In keyless (loopback, no OPENAB_ACP_AUTH_KEY) mode the upgrade handler now inspects Origin: a browser-set Origin that is not allowlisted is rejected with 403, while a request with no Origin (a non-browser client) is admitted. The allowlist is opt-in via a new OPENAB_ACP_ALLOWED_ORIGINS env (comma-separated exact origins), plumbed through AcpConfig next to auth_key; it defaults to empty, so every browser origin is blocked until explicitly allowed. The keyed/bearer path is unchanged. Documented in the ADR §2 security note.

  • Regression test acp_origin_ok_keyless_gating: absent Origin → accept; allowlisted → accept; disallowed → reject (403); empty allowlist blocks all; exact match (no scheme/host/port or trailing-slash leniency).

🟡 F2 — Remove the ?token= query fallback

Fixed in b56ece5b.

The query-token fallback is removed by default (no short-lived-ticket replacement). The bearer is now carried only by the two header-borne sources — Authorization: Bearer and the Sec-WebSocket-Protocol: openab.bearer.<token> subprotocol — which already cover non-browser and browser clients. Token extraction is factored into ws_bearer_token (headers only); the query/Query extractor is gone from ws_upgrade. ADR §2 updated to record the removal.

  • Test ws_bearer_token_ignores_query_only_request: a request whose only credential would have been ?token= yields no extractable bearer → 401 in keyed mode; the Authorization and subprotocol paths still work.

🟡 F3 — Enforce the generated ACP request shapes at the wire boundary

Shapes are validated against the official ACP schema, not a hand-picked contract: agentclientprotocol/agent-client-protocolschema/v1/schema.json @ eb88e99 (ACP Schema v1.19.0), vendored at crates/openab-gateway/schemas/acp-v1.schema.json and code-generated into acp_schema.rs via cargo-typify. "Conformant" below means "matches that generated type."

Split into three commits:

  • F3a — string prompts (077e8e26): extract_prompt_params now rejects any non-array prompt (a plain string or object), surfaced as -32602 at the dispatch site, instead of coercing a bare string. The generated PromptRequest.prompt is [ContentBlock]. Test flipped: a string prompt is now an error (plus a non-array object case).
  • F3b — incomplete resource links (bd726ff8): in the official schema ResourceLink.required = ["name", "uri"] and title is optional — so the review's "name/title" is, per the generated type, name + uri. The parser previously required only uri (name optional, bare-uri fallback); it now rejects a resource_link missing its required name with -32602, matching the generated ResourceLink. Accepted links still render [name](uri). Test: a no-name link is now an error.
  • F3c — request-shaped session/cancel (c0cb2f1f): a request-shaped cancel (with an id) was acknowledged with success({}), contradicting the ADR's "one-way; no response" contract. Extracted handle_session_cancel: the notification form still fires the cancel signal with no response; a request-shaped cancel is now rejected -32600 invalid request and does not fire. Tests: cancel with an id → -32600 (no empty-success frame); notification → no frame + fires.

🟢 F4 — Lifecycle hardening (retained)

No changes — the synchronous cancel-reservation-before-spawn, resume-while-busy rejection, stale-reply fencing, ACP CI coverage, and the documented Phase-1 terminal-only streaming contract are all still in place.

Scope / gates

Five focused commits on top of f7c93783, touching only crates/openab-gateway/src/adapters/acp_server.rs and docs/adr/acp-server-websocket-base.md (no unrelated reformatting). Each commit is green under the gateway CI gates: cargo clippy -p openab-gateway --features acp -- -D warnings and cargo test -p openab-gateway --features acp.

Ready for a re-review when you have a slot.

@chaodu-agent

Copy link
Copy Markdown
Collaborator

Note

LGTM ✅ -- All round-17 findings (F1 origin gating, F2 query-token removal, F3a-c wire-shape enforcement) are verifiably fixed on the current head with regression tests; no new findings in the incremental diff, and all frozen acceptance-criteria gates are green.

What This PR Does

Adds a feature-gated ACP v1 WebSocket server at GET /acp, translating ACP sessions and prompts into OpenAB gateway events and routing gateway replies back as ACP notifications, so any standard ACP client (IDE, CLI, browser) can drive an OpenAB agent over one wire-conformant protocol. Mounted on both the standalone gateway and the embedded openab run server, with transport auth, resource caps, conformance/handler/streaming tests, and ADR documentation.

How It Works

initialize, session/new, session/resume, session/prompt, and session/cancel are handled by a connection-local state machine. sess_<uuid> maps to acp_<uuid>; prompts become GatewayEvent(platform="acp") keyed by session_key = acp:<channel_id>; a process-wide reply registry routes GatewayReply snapshots back to the active prompt with the originating event id as a stale-reply fence. Phase 1 delivers one terminal agent_message_chunk (backend streaming=false), documented as such.

Findings

This round is an incremental re-review of the five R17 fix commits (f7c9378..c0cb2f1, +252/-61 in acp_server.rs + base ADR only). No critical or important findings remain.

# Severity Finding Location
1 🟢 R17-F1 fixed: keyless loopback mode now gates browser Origin against an opt-in exact-match allowlist (OPENAB_ACP_ALLOWED_ORIGINS, default empty blocks all browser origins, including Origin: null); 403 pre-upgrade; fail-closed when config is absent. acp_server.rs (acp_origin_ok, ws_upgrade)
2 🟢 R17-F2 fixed: the ?token= query fallback is fully removed -- the Query extractor is gone from ws_upgrade; the bearer is header-borne only (Authorization: Bearer / Sec-WebSocket-Protocol subprotocol). ADR sect. 2 updated. acp_server.rs (ws_bearer_token)
3 🟢 R17-F3a fixed: a non-array prompt (string or object) is rejected -32602, never coerced; the pre-spawn busy reservation is correctly released on this rejection path. acp_server.rs (extract_prompt_params, handle_session_prompt)
4 🟢 R17-F3b fixed: resource_link now requires both name and uri. Independently verified against the vendored official schema: ResourceLink.required = ["name","uri"], title optional -- the author's correction of the review's "name/title" phrasing is accurate per the generated type. acp_server.rs; schemas/acp-v1.schema.json
5 🟢 R17-F3c fixed: a request-shaped session/cancel is rejected -32600 without firing the cancel signal; the notification form still fires it with no response frame. The smoke suite already uses the notification form, so it remains compatible. acp_server.rs (handle_session_cancel)
6 🟢 R17-F4 lifecycle hardening retained unchanged (pre-spawn cancel reservation, busy-resume rejection, stale-reply fencing, ACP CI coverage, terminal-only streaming contract). --
Finding Details

🟢 1: Browser Origin gating in keyless mode (R17-F1)

acp_origin_ok(origin, allowed) admits a request with no Origin header (non-browser client) and requires an exact allowlist match for any browser-set Origin. The allowlist comes from OPENAB_ACP_ALLOWED_ORIGINS (comma-separated, trimmed, empty entries dropped) and defaults to empty, so every browser origin -- including the literal null origin from sandboxed contexts -- is blocked until explicitly allowed. The check lives in the keyless else branch of the bearer gate and returns 403 before the WS upgrade; when the ACP config is absent the allowlist resolves to empty (fail-closed). The keyed path is unchanged: the bearer key remains the trust boundary there and Origin is deliberately not consulted. Regression test acp_origin_ok_keyless_gating covers absent/allowed/disallowed/empty-allowlist plus exact-match strictness (no trailing-slash or scheme leniency).

🟢 2: Query-token fallback removal (R17-F2)

ws_upgrade no longer takes a Query extractor at all; extraction is factored into ws_bearer_token, which reads only Authorization: Bearer and the openab.bearer.<token> subprotocol. A request whose only credential would have been ?token= now yields no extractable bearer and is rejected 401 in keyed mode. Test ws_bearer_token_ignores_query_only_request verifies all three paths. scripts/acp-ws-smoke.py authenticates via the subprotocol, so the live suite is unaffected by the removal.

🟢 3-5: Wire-shape enforcement (R17-F3a-c)

All three parser gaps are closed against the generated schema types with negative tests flipped or added: bare-string and non-array prompts error, resource_link missing name or uri errors, and a request-shaped cancel is a -32600 protocol violation instead of an empty-success ack. The conformance test module reflects the new contract (the old lenient cases now assert rejection).

Verified

At head c0cb2f1f564aedd6b5f11e001dd6d0001f414437:

  • cargo clippy -p openab-gateway --features acp -- -D warnings -- clean
  • cargo check -p openab-gateway (feature-off) -- clean
  • cargo test -p openab-gateway --features acp -- 298 passed, 0 failed; the new R17 regression tests (acp_origin_ok_keyless_gating, ws_bearer_token_ignores_query_only_request, cancel_as_request_is_rejected_not_empty_success, cancel_as_notification_fires_signal_and_returns_no_response, plus the flipped prompt_content_blocks_baseline_accepted_gated_rejected) all pass when run by name
  • cargo clippy --workspace --features unified -- -D warnings -- clean
  • cargo test --workspace -- 704 passed, 1 failed: secrets::tests::resolve_exec_nonzero_exit, a known pre-existing environment-specific failure unrelated to this PR (also fails on main in the same environment)
  • Vendored schema check: ResourceLink.required = ["name","uri"]; PromptRequest.required = ["sessionId","prompt"]

Non-blocking observations (not findings): OPENAB_ACP_ALLOWED_ORIGINS is documented in the base ADR, consistent with where OPENAB_ACP_AUTH_KEY lives today; a live smoke re-run at the final head remains the follow-up already recorded in the PR body.

Baseline Check
  • PR opened: 2026-07-17. Declared base: main; merge-base 30bc145233a202754d205b21e8529491d97b1a7c (unchanged since round 17).
  • Main already has downstream ACP-over-stdio support and the generic gateway event/reply bridge, but no client-facing ACP WebSocket endpoint.
  • Net-new value: the remote/browser ACP transport, deterministic session mapping, embedded/standalone mounting, transport auth + origin gating, wire-shape enforcement, and protocol documentation.
  • This round reviewed only the incremental diff f7c9378..c0cb2f1 under the frozen review contract.

Addressing External Reviewer Feedback

Automated review round 17

Keyless loopback mode accepts browser WebSockets without validating Origin; the ?token= fallback exposes the bearer; the parser accepts shapes outside the generated ACP schema and acks request-shaped session/cancel.

Addressed in 6ee620c, b56ece5, 077e8e2, bd726ff, c0cb2f1 -- each verified above with code inspection and passing regression tests.

Community client feedback (Live2D/Electron use case)

Session isolation, streaming granularity, and reconnection semantics for a persistent companion client.

ℹ️ Accepted as documented Phase-1 boundaries -- the author's response correctly maps these to the terminal-chunk streaming contract, acp:<uuid> session isolation, and the single-active-client residual (F5), all recorded in the ADR and PR body as Phase-2+ work.

What's Good (🟢)

  • Fail-closed security posture throughout: non-loopback binds require a key, keyless mode now blocks all browser origins by default, and the credential never rides the URL.
  • Wire conformance is enforced against the vendored official schema rather than a hand-picked contract, with negative tests for every rejected shape.
  • The five fix commits are surgical (one file + ADR), each individually green under the gateway gates, with no unrelated reformatting.
  • The author's F3b pushback (schema requires name+uri, not name/title) demonstrates the right spec-authority hierarchy: generated type over reviewer phrasing.

5. Three Reasons We Might Not Need This PR

  1. The gateway contract is not fully turn-oriented yet -- explicit correlation/completion/cancellation in the shared core bridge first might have avoided ACP-specific lifecycle workarounds; the PR mitigates this with the event-id fence and documented residuals.
  2. A local stdio bridge could cover current IDE demand with less exposure -- but it cannot serve the remote/browser clients this PR explicitly targets, and community interest in exactly that use case surfaced in this thread.
  3. Phase-1 terminal-only streaming may underwhelm interactive clients -- accepted and documented; the contract is honest about it and Phase 2 is scoped.

@thepagent
thepagent merged commit 2c5b549 into openabdev:main Jul 23, 2026
43 checks passed
brettchien added a commit to brettchien/openab that referenced this pull request Jul 28, 2026
Raising MAX_FRAME_BYTES to 8 MiB for browser tool results also raised it for
every other inbound frame, so one connection could hold MAX_INFLIGHT_PROMPTS
(32) x 8 MiB of prompt text — the ~256 MiB worst case the review flagged.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
thepagent pushed a commit that referenced this pull request Aug 3, 2026
…1447)

* docs(acp): add MCP-over-ACP browser-control implementation blueprint (§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>

* docs(adr): resolve MCP-over-ACP browser-control design (D1-D4) + flow 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 #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>

* feat(gateway/acp): server-initiated request direction (T1.2/1.3)

Add the agent->client REQUEST direction to the ACP WebSocket server, the
plumbing the MCP-over-ACP tunnel needs. The base only had client->server
requests plus server->client notifications; this adds:

- route_client_response(): the read loop now recognises an inbound client
  *response* (id present, no `method`, carries result/error) and routes it to
  the waiting request via a per-connection pending map, instead of answering it
  with -32600. Gated on !is_notification so notification/request handling is
  untouched.
- pending_requests: Arc<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>

* refactor(gateway/acp): construct trivial responses from generated types (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>

* feat(gateway/acp): MCP-over-ACP tunnel frame API (T4.1)

Add the gateway-side helpers for the official MCP-over-ACP RFD tunnel
(agentclientprotocol.com/rfds/mcp-over-acp), built on the T1 send_request
machinery (its first real callers):

- mcp_connect(acpId) -> connectionId
- mcp_message_request(connectionId, method, params) -> inner MCP result
- mcp_disconnect(connectionId)
- McpConnectParams / McpConnectResult / McpMessageParams / McpDisconnectParams
  (hand-rolled: these RFC methods are not in the generated acp_schema, which
  only has the session/new McpServer* declaration types + McpCapabilities), plus
  frame_result() to unwrap a response frame's result / surface its error.

Per the RFD, mcp/message flattens the inner MCP method/params into the params
object WITHOUT the inner MCP id; correlation is purely by the outer ACP id, and
the response result is the inner MCP result payload. Adds a mock-tunnel
round-trip test (mcp/connect -> connectionId, mcp/message tools/list -> result).

Helpers carry #[allow(dead_code)] until T5 wires them to the core MCP proxy
(their real caller). Remaining T4: session/new "type":"acp" parsing + advertise
mcpCapabilities.acp, gateway<->core routing (with T5), contract doc.

Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified.

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

* feat(core): scaffold MCP proxy server deps + static browser toolset (T5.1a)

Introduce the core-hosted MCP proxy for MCP-over-ACP browser control (D3/D4),
starting with the dependency integration + the static tool set:

- openab-core gains optional rmcp (server + transport-streamable-http-server),
  axum 0.8 (matches the gateway, one axum in the workspace), and tokio-util,
  gated behind a new `acp-mcp` feature so non-acp builds don't pull them. The
  root `acp` feature (included by `unified`) now enables `openab-core/acp-mcp`.
  This is the workspace's first rmcp server-side usage; it resolves + compiles
  cleanly alongside openab-agent's rmcp client features.
- New `mcp_proxy` module (feature-gated) with `browser_tools()`: the fixed
  DOM-semantic tool set (click / read_dom / navigate / type / screenshot) that,
  per D4, core static-advertises regardless of whether an extension is attached.
  Built from rmcp `Tool::new` + typed input schemas; unit-tested.

`browser_tools()` carries #[allow(dead_code)] until the ServerHandler wires it.
Next (T5.1b): ServerHandler impl + spawn_mcp_server (loopback + bearer axum
listener); then T5.2 config injection and T5.3 tunnel wiring.

Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified.

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

* feat(core): MCP proxy ServerHandler + loopback server (T5.1b)

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>

* feat(core): bearer gate on the MCP proxy server (T5.2 part 1)

Even bound to loopback, the core MCP server now requires the token the agent's
MCP config carries (D3), so another local process on the host can't reach the
browser tools. spawn_mcp_server takes a `bearer` and layers an axum middleware
that returns 401 when Authorization: Bearer <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>

* docs(adr): correct runtime diagram — mcp/message flattens, no inner id

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>

* feat(gateway/acp): parse + record client-declared type:acp mcpServers (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>

* docs: MCP-over-ACP tunnel contract for the extension (T4.3)

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>

* feat(gateway/acp): TunnelHandle — per-connection MCP tunnel handle (T5.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>

* feat(gateway/acp): tunnel registry + establish_and_register_tunnel (T5.3)

- AcpTunnelRegistry (channel_id -> TunnelHandle), mirroring AcpReplyRegistry, so
  the core MCP proxy can look up the tunnel for a given browser session.
- establish_and_register_tunnel: mcp/connect to a session's declared "type":"acp"
  server, build a TunnelHandle from the returned connectionId, and register it
  under the session's channel_id. First real caller of mcp_connect/send_request.
  Documented as spawn-only (awaiting mcp_connect inline in the read loop would
  deadlock, since only that loop delivers the response).

Test: a mock extension answers mcp/connect; the handle lands in the registry
keyed by channel_id. #[allow(dead_code)] until the read loop spawns it and it's
threaded through AppState (next). Then the core ProxyHandler consumes the
registry to forward tools/list + tools/call.

Gate green: clippy -D warnings + test --test-threads=1 + build, --features unified.

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

* feat(gateway): thread AcpTunnelRegistry through AppState (T5.3)

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>

* feat(gateway/acp): open MCP tunnels on session/new + cleanup (T5.3)

Wire the tunnel producer into the connection read loop:

- handle_acp_connection mints a per-connection next_req_id (Arc<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>

* feat(core): BrowserTunnel trait + ProxyHandler forwards tool calls (T5.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>

* feat(core): start_session_server — per-session server + .cursor/mcp.json (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>

* feat(core): start per-session MCP proxy on agent spawn (T5.2/D5-a)

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>

* feat(root): wire the browser tunnel bridge end-to-end (T5.3, D6-a')

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>

* docs(adr): record the as-built OpenAB side (D5-a + D6-a', end-to-end)

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>

* test(e2e): MCP-over-ACP tunnel producer section in acp-ws-smoke (T7)

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>

* test(e2e): complete the MCP-over-ACP tunnel suite (fan-out + filtering)

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>

* fix(acp-mcp): address Mira review nits — constant-time bearer + string-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>

* fix(acp-mcp): establish the browser tunnel on session/resume, not just 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>

* chore(acp-mcp): log browser tunnel open/register for live-session observability

establish_and_register_tunnel is reached only when a client declared a "type":"acp"
server, so an info line there answers "did the extension advertise itself?" from
the gateway log alone (the raw upstream session frame isn't otherwise logged).
Logs on open and on successful registry insert.

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

* fix(acp-mcp): raise ACP frame cap 1→8 MiB for browser-tool results

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>

* fix(acp-mcp): one browser tunnel per session — fix fan-out overwrite/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>

* fix(acp-mcp): 0600 mcp.json + strip stale bearer on evict (M-B2)

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>

* chore(acp-mcp): lock subtle dependency

Cargo.lock was missing the openab-core `subtle` entry added for the constant-time
bearer compare, which would fail a `--locked` build. No code change.

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

* feat(mcp-proxy): write openab-browser into kiro-cli config too, not just Cursor

start_session_server now merges the openab-browser entry into BOTH .cursor/mcp.json
and .kiro/settings/mcp.json (each CLI ignores the other's), and cleans both on evict.
kiro-cli parses the {url, headers} shape identically. Deployed as acpmcp-kirofix.
Also add .dockerignore (exclude target/, .git/, data/) for the acp image builds.

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

* docs: browser MCP agent setup — per-variant mcp.json how-to (Phase 2 #8)

How the openab-browser tools reach each agent CLI: the per-session loopback proxy +
where openab writes the {url, headers} entry per variant (Cursor/Kiro auto today;
Claude/Codex/Gemini paths documented, not yet auto). Honest caveat: static manual
config awaits the stable-endpoint redesign (#9).

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

* feat(mcp-proxy): per-pod browser-bridge socket server (Option C, P1)

serve_browser_socket: one unix socket multiplexes all sessions; the openab
browser-bridge shim forwards {channel_id, inner MCP request} frames, routed via
dispatch_browser_mcp -> the shared BrowserTunnel by channel. Reuses browser_tools()
+ tunnel.call (single source of truth vs the HTTP ProxyHandler). +8 tests.

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

* feat(cli): openab browser-bridge subcommand — stdio MCP relay to the browser socket (Option C, P2)

A thin per-session shim: reads OPENAB_BROWSER_CHANNEL, wraps each stdin MCP request
as {channel_id, request}, forwards to the per-pod core socket, relays responses to
stdout verbatim. All browser MCP logic stays in core; the agent's config line is
static. Gated by feature acp. + wrap/relay tests over in-memory pipes.

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

* feat(acp): inject OPENAB_BROWSER_CHANNEL into the agent env (Option C, P3)

AcpConnection::spawn gains a browser_channel param; for an acp: session the pool
passes the channel_id so the agent (and the browser-bridge shim it later spawns)
inherits it and routes browser tool calls to THIS session's tunnel. env_clear-safe
(re-injected explicitly). + set_browser_channel unit tests.

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

* feat(mcp-proxy): static write-once browser-bridge config (Option C, P4)

write_bridge_mcp_config writes the SAME {command:openab, args:[browser-bridge]} entry
to cursor + kiro mcp.json — no port/bearer, so it never goes stale and can't clobber
across sessions (the root cause of multi-window browser flakiness). Merges without
touching the user's servers; idempotent. Additive — P5 wires the proxy/bridge toggle.

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

* feat: OPENAB_BROWSER_MODE proxy|bridge toggle wiring (Option C, P5)

BrowserMode + browser_mode() (default proxy) + shared browser_socket_path(). Pool
branches: proxy = per-session HTTP server (unchanged default); bridge = static
write-once config, no per-session server. Broker starts the per-pod socket server once
in bridge mode. browser-bridge shim uses the shared socket path. + parse tests.

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

* fix(browser-bridge): resolve channel via process-ancestry, not env (Option C, b2 B1)

The MCP client scrubs the child env (cursor gives the bridge only HOME/PATH/USER or
the pod env, never the per-session OPENAB_BROWSER_CHANNEL), so env inheritance can't
carry the channel. resolve_channel() now walks up the PPID chain and reads
OPENAB_BROWSER_CHANNEL from the ancestor agent's /proc/<pid>/environ (openab injected
it via the pool) — generic across all stdio-MCP vendors. Logs the resolved channel to
stderr. + parse_ppid_from_stat / parse_channel_from_environ unit tests.

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

* feat(mcp-proxy): revert bridge config to pure {command,args} (Option C, b2 B2)

Drop the ${OPENAB_BROWSER_CHANNEL} config env — cursor doesn't expand it (spawns from
pod/clean env). The bridge now resolves its channel via process-ancestry (B1), so the
config is a byte-identical static entry again: idempotent, never stale, no clobber.

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

* fix(acp): add acp_mcp_servers to test-only AcpSession initializers

The 4 AcpSession constructors in `mod acp_review_fixes` tests missed the
acp_mcp_servers field added in T4, breaking `cargo test -p openab-gateway
--features acp` (E0063). build/clippy don't compile this crate's test
target under `acp`, so only CI caught it. Also syncs Cargo.lock to the
already-committed openab 0.10.0 version.

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

* docs(acp): split into reverse-MCP mechanism ADR + browser-control ADR, embed diagrams

Two ADRs instead of one:
- acp-server-websocket-reverse-mcp.md — the generic reverse-MCP-over-ACP mechanism
  (roles, call route, protocol gap, §6 multi-server generalization: compound-key
  routing, dynamic tools/list + list_changed, per-server Option B). Embeds the
  architecture + MCP-usage sequence diagrams (mermaid), using browser control as the
  example. Flipped Proposed -> Accepted (as-built in #1447).
- acp-server-websocket-mcp-browser.md — the browser-specific design + the contract the
  browser extension implements (D1-D6, detailed id-paired runtime sequence, tasks,
  as-built). Defers the mechanism to the reverse-MCP ADR.

Update base ADR + tunnel-contract cross-links; mark base §6 browser critical-path done.

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

* docs(adr): correct D4 list_changed overclaim in browser ADR

list_changed is designed but not yet implemented (0 hits in gateway/core
crates); it was described as shipped alongside static-advertise. Reword to
mark it as P2b-tracked (reverse-MCP §6.2), not as-built.

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

* refactor(acp): compound-key (channel_id,server_id) tunnel registry + rename BrowserTunnel->AcpMcpTunnel (P1, Fork A)

Behavior-preserving refactor toward generic multi-server MCP-over-ACP
(reverse-MCP ADR §6). No functional change for the single browser server.

- AcpTunnelRegistry: HashMap<String,_> -> HashMap<(String,String),_>; register
  under the client-declared srv.id; evict all (channel_id,*) on teardown.
- Core trait BrowserTunnel -> AcpMcpTunnel; call() gains a server_id param.
- Read side (Fork A): the single-browser proxy + bridge pass an empty server_id
  sentinel; RootBrowserTunnel resolves the sole tunnel on the channel (errors if
  ambiguous). Real per-server read-side routing (bridge-frame server_id) is
  deferred to P2.

Gate: build --features acp, clippy --workspace -D warnings (+unified),
test -p openab-gateway --features acp — all green (307 passed).

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

* chore: regenerate Cargo.lock for merged deps

* fix(mcp-proxy): register browser server in kiro per-agent configs (--agent mode)

When kiro-cli runs with --agent <name> — as every OAB bot deployment
does — the MCP server list comes from .kiro/agents/<name>.json and tools
are gated by that file's default-deny allowedTools, NOT from
.kiro/settings/mcp.json (verified live on the b2 fleet deployment; see
docs/gmail-native.md 'Kiro CLI gotcha'). Without this, browser tools are
invisible to exactly the deployments this feature targets.

- merge_kiro_agent_configs: merge the openab-browser entry into every
  .kiro/agents/*.json and add @openab-browser to allowedTools; agent
  files carry unrelated config, so unparseable files are skipped, never
  clobbered (unlike the settings writer); macOS ._* droppings ignored;
  idempotent; 0600 (proxy entries carry a live bearer).
- cleanup_kiro_agent_configs on session evict: remove the entry and
  revoke the allowlist grant only when the URL is still ours, preserving
  a concurrent session's live entry (same rule as the settings cleanup).
- Wired into both the per-session proxy writer and the static Option C
  bridge writer; 4 new tests.

* feat(mcp): browser capabilities through the session-aware facade (Facade mode, default)

Routes browser tools through the OAB MCP Facade as a session-aware
in-process capability source (openab-mcp #1454), replacing per-session
proxy servers as the default transport. Proxy and Option C bridge modes
are unchanged and remain explicit opt-outs (OPENAB_BROWSER_MODE).

- src/browser_source.rs: CapabilitySource over the existing AcpMcpTunnel
  (requires_session; D4 static-advertise; tunnel errors surface as MCP
  error results); FacadeRegistrar adapts the facade's SessionTokens to
  core's new SessionTokenRegistrar hook (core stays openab-mcp-free).
- core mcp_proxy: BrowserMode::Facade (new default; runtime fallback to
  Proxy when no facade is serving), write_facade_mcp_config — a static,
  write-once 'openab' entry whose Authorization references
  ${OPENAB_SESSION_TOKEN}; the per-session secret rides the agent
  process env instead of config files, eliminating the shared-workdir
  clobber class entirely (incl. kiro --agent files + @openab allowlist).
- pool: with_facade_sessions wiring; mints/injects the token per spawn,
  revokes via the same DropGuard plumbing proxy mode uses.
- main: facade constructed with the BrowserSource; one listener, one
  discovery surface (search_capabilities/execute_capability).
- rmcp re-exported from openab-mcp for source implementors.
- docs: facade-mode section in the browser setup guide.

* fix: facade_serving is acp-only — derive it, don't flag it (default-features -D warnings)

* docs(adr): §6 builds on the OAB MCP Facade — one AcpTunnelSource, static-advertise, trust gate

Rewrites reverse-MCP ADR §6.2-§6.6 now that the facade seam landed upstream
(#1448/#1453 facade, #1454 session-aware CapabilitySource, #1446 ADR):

- §6.2 expose every client-declared type:acp server through ONE in-process
  CapabilitySource (AcpTunnelSource) registered with the facade, rather than
  the bespoke per-(session,server) loopback proxies + N mcp.json entries.
  Sources are registered once at construction, so the source fans out
  internally and routes on the <server>.<tool> prefix to (channel_id,
  server_id). Session identity moves to the facade's SessionTokens.
- §6.3 drop notifications/tools/list_changed outright — facade discovery is
  pull-based (search_capabilities re-reads per call), so nothing caches a
  tool list to invalidate. Keep the static-advertise posture, implemented as
  fetch-once-per-declared-server + per-(channel,server) cache; unavailability
  is a call error, not a vanishing catalog entry.
- §6.4 new trust requirement: #1454 assumes operator-granted tool sets, but a
  tunnel source's tools are client-declared — require an operator allowlist
  (default: browser only) plus a per-declared-server tool_filter.
- §6.5 records what this retires and leaves stdio bridge-mode removal as an
  explicit operator call; flags the meta-tool-vs-direct hop as open.
- Browser ADR: correct D4 (list_changed dropped, static-advertise kept) and
  add a supersession notice over D2/D3/D5.

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

* docs(adr): align §6 with the merged facade series + record the mcpServers divergence

Follows the merge of main (adapter ADR #1446, gmail doc #1455) into this branch:

- Link the OAB MCP Adapter ADR directly now that it is present, and note the
  whole facade series (#1446/#1448/#1449/#1450/#1453/#1454) is merged with no
  facade PR left open — §6 builds on a settled foundation.
- Cite adapter ADR §6.2 / Alternative C ("no second generic inbound MCP server",
  browser and external capabilities share one delivery mechanism), which makes
  retiring the bespoke per-session proxy (F5) an upstream design requirement
  rather than optional cleanup.
- Record an unresolved divergence: the adapter ADR says the facade is delivered
  via ACP `mcpServers` and explicitly not by editing CLI config files, while the
  as-built `write_facade_mcp_config` does write a static entry — deliberately,
  since browser D2 found Cursor ignores ACP-passed mcpServers. Flagged for the
  facade contract owner instead of unilaterally reconciling.

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

* docs(adr): tighten §6.4 trust gate — deny-all tool_filter + pinned browser tool set

Falcon's review of §6 (F7): the operator allowlist of declared server names is
not a trust boundary on its own. The name is chosen by the same remote client
that declares the tools, so a client may declare a server named `browser` and
publish an arbitrary tool set under it.

Align the §6.6 F4 summary with the requirement §6.4 now states: the
per-declared-server tool_filter is deny-all by default, and the `browser` entry
ships pinned to its five known tools so a same-name declaration cannot inject
others.

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

* docs(adr): §6.1/§6.2 — declared id vs name, and last-attach-wins on same-name tunnels

Found while scoping F1': the routing contract as written could not be
implemented. A declaration is {type:"acp", id, name} and the reference client
mints `id` as a fresh crypto.randomUUID() per connection while `name`
("browser") is stable. The registry is keyed by `id`, but the `<server>`
segment of a tool name (`browser.click`) and the §6.4 allowlist are the `name` —
so routing "on the prefix to the matching (channel_id, server_id) tunnel" can
never match: the key is a UUID the tool name never contains.

Record what review settled (Mira + Falcon, 2026-07-26):
- registry stays keyed by (channel_id, id) — keying by name would let two
  same-name tunnels overwrite each other, the fan-out collapse §6 fixes — but
  must also record the declared name so a source can enumerate (name, id);
- trust gating is keyed by name, since ids are per-connection UUIDs;
- same-name collisions are last-attach-wins: the new tunnel replaces and evicts
  the older entry. Answering "ambiguous" there would wedge the client out of its
  own tools on every reconnect, because each reconnect mints a new id.

Also clarify that the prefix selects the tunnel and is NOT stripped: the full
published name is what goes over the tunnel, since that is what the server's own
tools/call expects.

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

* feat(acp): record declared server name, establish all declared tunnels, LWW on re-attach

Tunnel-layer plumbing for §6 F1' (generalising the capability source to N
client-declared servers). Behaviour-neutral for today's single-browser client:
one declared server still yields exactly one tunnel.

A declaration is {type:"acp", id, name} and the two fields have different
lifetimes — the reference client mints `id` as a fresh crypto.randomUUID() per
connection while `name` ("browser") is stable. The registry is keyed by `id`,
but a tool name carries the `name` (browser.click) and the §6.4 trust gate is
keyed by it too, so the name has to survive registration to be routable.

- TunnelHandle records the declared `server_name` and exposes it.
- establish_and_register_tunnel takes the declared name and resolves a
  re-declared name last-attach-wins: the new tunnel evicts stale same-name
  entries on the channel. Because a reconnect mints a new id, the dead tunnel
  would otherwise linger beside the live one, and answering "ambiguous, pass a
  server_id" there would wedge the client out of its own tools on every
  reconnect. The eviction is also what bounds registry growth.
- spawn_browser_tunnel -> spawn_acp_tunnels now establishes EVERY declared
  server. The old first-only limit existed because the registry was keyed by
  channel_id alone, where a second server overwrote the first and orphaned its
  tunnel; the compound key removed that collision.
- AcpMcpTunnel gains servers(channel_id) -> Vec<(name, id)>, implemented by
  RootBrowserTunnel over the registry. This is what lets a capability source
  resolve a tool prefix back to a tunnel; matching a prefix against the
  registry key alone can never work, since the key is a UUID the tool name
  never contains. Default impl is empty so test doubles are unaffected.

The source-side consumer (AcpTunnelSource routing + the §6.4 trust gate) is the
next step; nothing reads servers() yet.

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

* feat(acp): route client-declared servers by name and gate their tool sets

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

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

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

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

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

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

* docs(adr): §6.3 — policy entries seed the catalog; cache narrows, never grants

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

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

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

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

* feat(mcp): operator config surface for the client-declared server allowlist

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

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

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

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

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

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

* feat(acp): discovery cache — fetch each declared server's real tools/list

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

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

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

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

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

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

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

* test(acp): two client-declared servers in one session

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

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

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

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

* refactor(mcp): rename the client-declared browser surface to katashiro.*

Counterpart to the katashiro-side rename. `browser` / `browser.*` collided
with Playwright MCP's `browser_*` tools; the declared name and tool prefix
become `katashiro` / `katashiro.*`.

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

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

Names only; policy semantics, routing and schemas unchanged.

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

* docs(adr): follow the katashiro.* rename through both ADRs

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

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

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

* docs: lead the browser-MCP setup guide with facade mode, demote proxy/bridge

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

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

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

* docs(adr): fold the browser ADR into the reverse-MCP ADR as its worked example

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

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

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

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

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

* docs(mcp): note that mcp.audit must be named in RUST_LOG or auditing is silently off

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

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

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

* docs: warn that a leftover mode entry silently bypasses the facade

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

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

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

* ci: run the acp-mcp core and acp root tests

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

Add two steps mirroring the existing acp-gateway one.

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

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

* fix(acp): do not open tunnels after a rejected session/resume

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

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

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

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

* fix(acp): do not mint a facade session token when its config write fails

When write_facade_mcp_config failed, the code warned and carried on: it minted
OPENAB_SESSION_TOKEN and spawned the agent anyway. The agent then had no
`openab` entry, so it could not reach the facade at all, while a live credential
stayed registered for that channel until eviction — and the only trace was a
warning.

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

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

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

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

* fix(mcp): retire the direct browser transport when facade mode sets up

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

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

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

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

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

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

* fix(acp): keep the 8 MiB frame allowance to tunnel results only

Raising MAX_FRAME_BYTES to 8 MiB for browser tool results also raised it for
every other inbound frame, so one connection could hold MAX_INFLIGHT_PROMPTS
(32) x 8 MiB of prompt text — the ~256 MiB worst case the review flagged.

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

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

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

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

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

* fix(mcp): authenticate the bridge connection, not the frame

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

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

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

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

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

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

* fix(mcp): revoke facade session tokens by token, not by channel

Session lifetimes overlap. `mint` replaces whatever token a channel holds, so a
replaced session's drop guard runs after its successor has already minted — and
the guard revoked by channel, which removed the live token. The new agent lost
facade access wit…
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.

4 participants