diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..cb222e6ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target/ +.git/ +data/ +*.tgz diff --git a/Cargo.lock b/Cargo.lock index a05e04d2a..c7b795033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,6 +2533,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sigv4", + "axum", "base64", "bytes", "chrono", @@ -2550,17 +2551,20 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest 0.12.28", + "rmcp", "rpassword", "rustls 0.22.4", "serde", "serde_json", "serenity", "sha2 0.10.9", + "subtle", "tar", "tempfile", "tokio", "tokio-rustls 0.25.0", "tokio-tungstenite 0.21.0", + "tokio-util", "toml", "toml_edit", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 294a3532d..fb5a4a8db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"] googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"] wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"] teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"] -acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"] +acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp", "openab-core/acp-mcp"] [dev-dependencies] tempfile = "3.27.0" diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 9d8b7389d..dd0cc49ab 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -49,6 +49,13 @@ aws-credential-types = { version = "1", optional = true } urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } +# MCP proxy server (browser-control, feature `acp-mcp`): core hosts an in-process +# Streamable-HTTP MCP server the colocated agent connects to (D3). rmcp server + a +# self-owned loopback axum listener; kept optional so non-acp builds don't pull them. +rmcp = { version = "1.7", default-features = false, features = ["server", "transport-streamable-http-server"], optional = true } +axum = { version = "0.8", optional = true } +tokio-util = { version = "0.7", optional = true } +subtle = { version = "2", optional = true } # constant-time bearer compare for the loopback MCP server [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -65,3 +72,5 @@ config-s3 = ["dep:aws-sdk-s3", "dep:aws-config"] pre-seed = ["dep:aws-sdk-s3", "dep:aws-config", "dep:zip", "dep:hex", "dep:flate2", "dep:tar"] filestore = ["dep:aws-sdk-s3", "dep:aws-config"] agentcore = ["dep:aws-config", "dep:aws-sigv4", "dep:aws-credential-types", "dep:urlencoding", "dep:hex", "dep:http", "dep:rustls", "dep:tokio-rustls", "dep:webpki-roots"] +# Core-hosted MCP proxy server for MCP-over-ACP browser control (enabled by the root `acp`). +acp-mcp = ["dep:rmcp", "dep:axum", "dep:tokio-util", "dep:subtle"] diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index f40ceb486..4ff4a4255 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -189,6 +189,11 @@ pub struct AcpConnection { pub session_reset: bool, _reader_handle: JoinHandle<()>, _stderr_handle: Option>, + /// Cancels this session's per-session MCP proxy server (D5-a) when the connection is + /// dropped. Held only for its `Drop` side effect (never read). + #[cfg(feature = "acp-mcp")] + #[allow(dead_code)] + mcp_server_guard: Option, } /// Build the final set of env vars for the agent subprocess. @@ -331,6 +336,7 @@ impl AcpConnection { working_dir: &str, env: &std::collections::HashMap, inherit_env: &[String], + browser_channel: Option<&str>, ) -> Result { info!(cmd = command, ?args, cwd = working_dir, "spawning agent"); @@ -397,6 +403,10 @@ impl AcpConnection { cmd.env("SystemDrive", v); } } + // Option C: hand this session's browser channel to the agent so the `openab + // browser-bridge` stdio shim it later spawns inherits it and routes tool calls to THIS + // session's tunnel. env_clear() above dropped it, so (re)inject explicitly. + set_browser_channel(&mut cmd, browser_channel); for (k, v) in env { cmd.env(k, expand_env(v)); } @@ -485,9 +495,17 @@ impl AcpConnection { session_reset: false, _reader_handle: reader_handle, _stderr_handle: stderr_handle, + #[cfg(feature = "acp-mcp")] + mcp_server_guard: None, }) } + /// Attach the guard that stops this session's MCP proxy server when the connection drops. + #[cfg(feature = "acp-mcp")] + pub fn set_mcp_guard(&mut self, guard: Option) { + self.mcp_server_guard = guard; + } + fn next_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::Relaxed) } @@ -834,11 +852,39 @@ impl Drop for AcpConnection { } } +/// Inject the session's browser channel into the agent's env so the `openab browser-bridge` +/// stdio shim the agent later spawns inherits it (Option C) and routes tool calls to THIS +/// session's tunnel. No-op for non-browser sessions (`None`). +fn set_browser_channel(cmd: &mut tokio::process::Command, browser_channel: Option<&str>) { + if let Some(channel) = browser_channel { + cmd.env("OPENAB_BROWSER_CHANNEL", channel); + } +} + #[cfg(test)] mod tests { - use super::{build_agent_env, build_permission_response, pick_best_option}; + use super::{build_agent_env, build_permission_response, pick_best_option, set_browser_channel}; use serde_json::json; + #[test] + fn set_browser_channel_injects_env_when_present() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, Some("acp_win1")); + assert!(cmd.as_std().get_envs().any(|(k, v)| { + k == "OPENAB_BROWSER_CHANNEL" && v == Some(std::ffi::OsStr::new("acp_win1")) + })); + } + + #[test] + fn set_browser_channel_is_noop_when_absent() { + let mut cmd = tokio::process::Command::new("true"); + set_browser_channel(&mut cmd, None); + assert!(!cmd + .as_std() + .get_envs() + .any(|(k, _)| k == "OPENAB_BROWSER_CHANNEL")); + } + #[test] fn picks_allow_always_over_other_options() { let options = vec![ diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 394d9f260..2d8d24e8d 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -53,6 +53,14 @@ pub struct SessionPool { mapping_path: PathBuf, meta_path: PathBuf, default_config_options: HashMap, + /// Bridge from a session's core MCP proxy to its browser tunnel (D5-a/D6-a'); set by the + /// root. `None` = no browser wiring (tool calls report not-connected). + #[cfg(feature = "acp-mcp")] + browser_tunnel: Option>, + #[cfg(feature = "acp-mcp")] + session_registrar: Option>, + #[cfg(feature = "acp-mcp")] + facade_url: Option, } type CancelHandle = (Arc>, String); @@ -197,9 +205,42 @@ impl SessionPool { mapping_path, meta_path, default_config_options, + #[cfg(feature = "acp-mcp")] + browser_tunnel: None, + #[cfg(feature = "acp-mcp")] + session_registrar: None, + #[cfg(feature = "acp-mcp")] + facade_url: None, } } + /// Wire the browser tunnel bridge (D6-a', set by the root) so per-session MCP proxies can + /// reach the browser. Call before sharing the pool. + #[cfg(feature = "acp-mcp")] + pub fn with_browser_tunnel( + mut self, + tunnel: Option>, + ) -> Self { + self.browser_tunnel = tunnel; + self + } + + /// Wire the facade session-token registrar + facade URL (Facade mode, + /// set by the root when `[mcp]` is running). With both present, browser + /// capabilities route through the facade: the pool mints one token per + /// session, injects it as `OPENAB_SESSION_TOKEN` in the agent process + /// env, and writes the static facade MCP entry once per workdir. + #[cfg(feature = "acp-mcp")] + pub fn with_facade_sessions( + mut self, + registrar: Option>, + facade_url: Option, + ) -> Self { + self.session_registrar = registrar; + self.facade_url = facade_url; + self + } + fn load_mapping(path: &Path) -> HashMap { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { @@ -347,14 +388,106 @@ impl SessionPool { self.config.working_dir.clone() }; + // Per-session MCP proxy (D5-a): for a browser (`acp:`) session, start a loopback MCP + // server + write `.cursor/mcp.json` BEFORE the agent boots so it connects to it. The + // returned guard cancels that server when this connection is dropped (any evict path). + // Facade mode: mint the per-session token (returned env var rides the + // agent spawn below) and write the static facade entry once. Falls + // back to Proxy mode when the root wired no registrar (no [mcp]). + #[cfg(feature = "acp-mcp")] + let mut session_token: Option = None; + #[cfg(feature = "acp-mcp")] + let mcp_guard: Option = + if let Some(channel_id) = thread_id.strip_prefix("acp:") { + let mode = match crate::mcp_proxy::browser_mode() { + crate::mcp_proxy::BrowserMode::Facade + if self.session_registrar.is_none() || self.facade_url.is_none() => + { + crate::mcp_proxy::BrowserMode::Proxy + } + m => m, + }; + match mode { + crate::mcp_proxy::BrowserMode::Facade => { + // unwraps guarded by the fallback arm above + let registrar = self.session_registrar.as_ref().unwrap(); + let facade_url = self.facade_url.as_ref().unwrap(); + if let Err(e) = + crate::mcp_proxy::write_facade_mcp_config(&effective_workdir, facade_url) + .await + { + warn!(thread_id, error = %e, "failed to write facade mcp config"); + } + session_token = Some(registrar.mint(channel_id)); + info!(thread_id, "session token minted for facade browser capabilities"); + // Revoke on evict/replace: piggyback the same DropGuard + // plumbing proxy mode uses for its server teardown. + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + let chan = channel_id.to_string(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&chan); + }); + Some(ct.drop_guard()) + } + // Bridge mode (Option C): the agent's static mcp.json points at `openab + // browser-bridge`, which dials the pod-wide socket server (started at boot). + // Just ensure the write-once config exists — no per-session server/guard. + crate::mcp_proxy::BrowserMode::Bridge => { + if let Err(e) = + crate::mcp_proxy::write_bridge_mcp_config(&effective_workdir).await + { + warn!(thread_id, error = %e, "failed to write bridge mcp config"); + } + None + } + // Proxy mode (default): per-session loopback HTTP MCP server + dynamic config. + crate::mcp_proxy::BrowserMode::Proxy => { + match crate::mcp_proxy::start_session_server( + channel_id, + &effective_workdir, + self.browser_tunnel.clone(), + ) + .await + { + Ok((addr, ct)) => { + info!(thread_id, %addr, "started per-session MCP proxy server"); + Some(ct.drop_guard()) + } + Err(e) => { + warn!(thread_id, error = %e, "failed to start MCP proxy; browser tools unavailable"); + None + } + } + } + } + } else { + None + }; + // Build the replacement connection outside the state lock so one stuck // initialization does not block all unrelated sessions. + #[cfg(feature = "acp-mcp")] + let spawn_env: std::collections::HashMap = { + let mut env = self.config.env.clone(); + if let Some(tok) = &session_token { + // The static facade MCP entry references ${OPENAB_SESSION_TOKEN}; + // the value lives only in this agent process's environment. + env.insert("OPENAB_SESSION_TOKEN".to_string(), tok.clone()); + } + env + }; + #[cfg(not(feature = "acp-mcp"))] + let spawn_env = self.config.env.clone(); let mut new_conn = AcpConnection::spawn( &self.config.command, &self.config.args, &effective_workdir, - &self.config.env, + &spawn_env, &self.config.inherit_env, + thread_id.strip_prefix("acp:"), ) .await?; @@ -423,6 +556,8 @@ impl SessionPool { let activity_handle = new_conn.activity_handle(); let child_pgid = new_conn.child_pgid(); let cancel_session_id = new_conn.acp_session_id.clone().unwrap_or_default(); + #[cfg(feature = "acp-mcp")] + new_conn.set_mcp_guard(mcp_guard); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 7e50ce4ce..4e26a641d 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -1,5 +1,7 @@ pub mod acp; pub mod adapter; +#[cfg(feature = "acp-mcp")] +pub mod mcp_proxy; pub mod bot_turns; pub mod config; pub mod cron; diff --git a/crates/openab-core/src/mcp_proxy.rs b/crates/openab-core/src/mcp_proxy.rs new file mode 100644 index 000000000..c38c55513 --- /dev/null +++ b/crates/openab-core/src/mcp_proxy.rs @@ -0,0 +1,1290 @@ +//! Core-hosted MCP proxy server for MCP-over-ACP browser control (feature `acp-mcp`). +//! +//! Per ADR §7 (D3), OpenAB **core** hosts an in-process Streamable-HTTP MCP server on +//! loopback that the colocated agent CLI connects to as a normal MCP client. The server is a +//! proxy: its tool list + tool execution are backed by the remote browser extension over the +//! `/acp` MCP-over-ACP tunnel (wired in T5.3). Per D4 the browser tool set is +//! **static-advertised** regardless of whether an extension is currently attached — a call +//! while disconnected returns a "browser not connected" error rather than hiding the tools. +//! +//! This module currently provides the static tool set; the `ServerHandler` + loopback +//! listener (`spawn_mcp_server`) and the tunnel wiring land in the following T5 sub-ticks. + +use rmcp::model::{ + object, CallToolRequestParams, CallToolResult, ErrorData as McpError, JsonObject, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::ServerHandler; +use axum::response::IntoResponse; +use serde_json::{json, Value}; +use std::sync::Arc; + +/// Core-side interface to the browser MCP-over-ACP tunnel (D6-a'). Implemented by the ROOT +/// (which bridges to the gateway's per-connection tunnel registry) and consumed by the MCP +/// proxy here. Keeping the trait in core with the impl in root preserves the core/gateway +/// sibling independence, matching the existing `ChatAdapter` pattern. +#[async_trait::async_trait] +pub trait AcpMcpTunnel: Send + Sync { + /// Forward an inner MCP request (e.g. `tools/call`) to the client MCP server identified by + /// `(channel_id, server_id)` and return the inner MCP result payload. Err if no matching + /// tunnel is currently attached to that session. + /// + /// `server_id` selects among multiple `type:acp` servers on one session (compound-key + /// registry, P1). During the single-browser transition an empty `server_id` is a sentinel + /// meaning "the sole tunnel on this channel" — the proxy/bridge callers don't yet know the + /// client-declared id at spawn time (real per-server routing lands in P2). + async fn call( + &self, + channel_id: &str, + server_id: &str, + method: &str, + params: Option, + ) -> Result; +} + +/// The fixed set of browser tools OpenAB advertises over MCP (D4 static-advertise). DOM- +/// semantic actions the extension executes in the user's active tab; model-agnostic. +pub fn browser_tools() -> Vec { + vec![ + Tool::new( + "browser.click", + "Click the element matching a CSS selector in the active browser tab.", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "CSS selector" } }, + "required": ["selector"] + })), + ), + Tool::new( + "browser.read_dom", + "Read a snapshot of the active tab's DOM (optionally scoped to a selector).", + object(json!({ + "type": "object", + "properties": { "selector": { "type": "string", "description": "optional CSS selector to scope the snapshot" } } + })), + ), + Tool::new( + "browser.navigate", + "Navigate the active browser tab to a URL.", + object(json!({ + "type": "object", + "properties": { "url": { "type": "string", "description": "absolute URL" } }, + "required": ["url"] + })), + ), + Tool::new( + "browser.type", + "Type text into the element matching a CSS selector in the active tab.", + object(json!({ + "type": "object", + "properties": { + "selector": { "type": "string", "description": "CSS selector" }, + "text": { "type": "string", "description": "text to type" } + }, + "required": ["selector", "text"] + })), + ), + Tool::new( + "browser.screenshot", + "Capture a screenshot of the active browser tab.", + object(json!({ "type": "object", "properties": {} })), + ), + ] +} + +/// The core-hosted MCP server the colocated agent connects to (D3). A proxy: it advertises +/// the browser tools and (once T5.3 wires the tunnel) forwards `tools/call` to the extension +/// over MCP-over-ACP. Until then it static-advertises (D4) and returns "browser not +/// connected" on call. +#[derive(Clone)] +pub struct ProxyHandler { + /// The browser session this server instance serves (D5-a: one MCP server per session). + channel_id: String, + /// Bridge to that session's browser tunnel; `None` when no browser is attached (or the + /// process has no tunnel wiring). A call while `None` reports "browser not connected" (D4). + tunnel: Option>, +} + +impl ProxyHandler { + pub fn new(channel_id: String, tunnel: Option>) -> Self { + Self { channel_id, tunnel } + } + + /// Forward a tool call to the browser over the tunnel (as an MCP `tools/call`), or report + /// not-connected (D4) when no browser is attached. + async fn forward_tool_call( + &self, + name: &str, + arguments: Option, + ) -> Result { + let Some(tunnel) = &self.tunnel else { + return Err(McpError::internal_error( + "browser not connected: open the OpenAB side panel in your browser", + None, + )); + }; + let params = json!({ "name": name, "arguments": arguments }); + // Empty server_id sentinel (Fork A): this single-browser proxy doesn't know the + // client-declared server id; RootBrowserTunnel resolves the sole tunnel on the channel. + let result = tunnel + .call(&self.channel_id, "", "tools/call", Some(params)) + .await + .map_err(|e| McpError::internal_error(e, None))?; + serde_json::from_value(result) + .map_err(|e| McpError::internal_error(format!("malformed tool result: {e}"), None)) + } +} + +impl ServerHandler for ProxyHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "OpenAB browser-control proxy: DOM-semantic tools executed in the user's browser \ + via MCP-over-ACP.", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + // D4 static-advertise: expose the browser tools regardless of extension state. + Ok(ListToolsResult { + tools: browser_tools(), + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + self.forward_tool_call(request.name.as_ref(), request.arguments) + .await + } +} + +/// Loopback bearer gate for the MCP server (D3): even bound to 127.0.0.1, require the token +/// the agent's MCP config carries, so another local process on the host can't reach the +/// browser tools. Returns 401 when the `Authorization: Bearer ` header is absent or +/// wrong. +async fn require_bearer( + axum::extract::State(expected): axum::extract::State>, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let authed = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + // Constant-time compare so a wrong token can't be probed byte-by-byte via response + // timing (mirrors the gateway's feishu/wecom signature checks). + .is_some_and(|t| { + use subtle::ConstantTimeEq; + t.as_bytes().ct_eq(expected.as_bytes()).into() + }); + if authed { + next.run(req).await + } else { + axum::http::StatusCode::UNAUTHORIZED.into_response() + } +} + +/// Start the in-process Streamable-HTTP MCP proxy server on an OS-assigned **loopback** port +/// (D3), gated by `bearer`. Returns the bound address; the caller hands `addr.port()` + the +/// same `bearer` to the colocated agent's native MCP config (T5.2). Shuts down when `ct` is +/// cancelled. +pub async fn spawn_mcp_server( + channel_id: String, + tunnel: Option>, + bearer: String, + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result { + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_cancellation_token(ct.child_token()); + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(ProxyHandler::new(channel_id.clone(), tunnel.clone())), + Default::default(), + config, + ); + let router = axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn_with_state( + Arc::::from(bearer), + require_bearer, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { ct.cancelled_owned().await }) + .await; + }); + Ok(addr) +} + +/// Start a per-session MCP proxy server (D5-a) and register it in the agent's native MCP +/// config so the colocated agent connects to it (D2). Mints a fresh bearer, starts the +/// loopback server, and writes/merges `/.cursor/mcp.json` with the `openab-browser` +/// HTTP entry (Cursor's config; other agents get their own writer later). Returns the bound +/// address + the `CancellationToken` the caller cancels to stop the server on session evict. +pub async fn start_session_server( + channel_id: &str, + workdir: &str, + tunnel: Option>, +) -> std::io::Result<(std::net::SocketAddr, tokio_util::sync::CancellationToken)> { + let bearer = uuid::Uuid::new_v4().to_string(); + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server(channel_id.to_string(), tunnel, bearer.clone(), ct.clone()).await?; + + let our_url = format!("http://{addr}/mcp"); + let entry = json!({ + "url": our_url.clone(), + "headers": { "Authorization": format!("Bearer {bearer}") } + }); + + // Merge the openab-browser entry into each colocated ACP CLI's native MCP config (don't + // clobber servers the user/agent already configured). Cursor reads /.cursor/mcp.json; + // kiro-cli reads /.kiro/settings/mcp.json. We write both — each CLI ignores the + // other's file — so the browser server reaches whichever agent is colocated. + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + cfg["mcpServers"]["openab-browser"] = entry.clone(); + // 0600: the file carries a live bearer token — default umask would leave it world-readable. + write_private(cfg_path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + + // kiro `--agent ` deployments read the agent file, not settings/mcp.json — + // merge (and allowlist) there too, or the tools never reach OAB bot agents. + merge_kiro_agent_configs(workdir, &entry).await?; + + // On session evict/drop the caller cancels `ct`; strip our now-dead `openab-browser` entry + // (with its live bearer) from each config so a stale credential doesn't linger. Only remove it + // if it still points at OUR addr — a concurrent/reconnected session may have already replaced + // it, and we must not clobber that live entry (the mcp.json paths are shared across acp: sessions). + let cleanup_paths = cfg_paths.to_vec(); + let cleanup_url = our_url; + let cleanup_ct = ct.clone(); + let cleanup_workdir = workdir.to_string(); + tokio::spawn(async move { + cleanup_ct.cancelled().await; + cleanup_kiro_agent_configs(&cleanup_workdir, &cleanup_url).await; + for cleanup_path in &cleanup_paths { + let Ok(bytes) = tokio::fs::read(cleanup_path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(cleanup_url.as_str()); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(cleanup_path, &out).await; + } + } + }); + + Ok((addr, ct)) +} + +/// Write `bytes` to `path`, then tighten it to owner-only (0600). The file holds a live bearer +/// token for the loopback MCP server, so it must not be group/world readable. +async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + tokio::fs::write(path, bytes).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + } + Ok(()) +} + +/// Merge the `openab-browser` entry into every kiro **per-agent** config +/// (`/.kiro/agents/*.json`). When kiro-cli runs with `--agent ` +/// — as every OAB bot deployment does — it reads its MCP server list from the +/// agent file, NOT from `.kiro/settings/mcp.json`, and gates tools through the +/// file's `allowedTools` allowlist (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. +/// +/// Unlike the settings-file writer, agent files carry unrelated config +/// (model, description, allowlists), so an unparseable file is SKIPPED — +/// never clobbered with a fresh object. macOS metadata droppings +/// (`._*.json`) are ignored. Missing agents dir = no-op. +async fn merge_kiro_agent_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); // no agents dir → nothing runs with --agent here + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; // agent files carry model/allowlists — never clobber + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab-browser"] != *entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + changed = true; + } + // `allowedTools` is a default-deny allowlist: adding the server + // without allowlisting it leaves every browser tool blocked. + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab-browser")) { + allowed.push(json!("@openab-browser")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Session-evict counterpart of [`merge_kiro_agent_configs`]: strip the +/// now-dead `openab-browser` entry (and its `allowedTools` grant) from every +/// kiro agent file — but only when the entry still points at OUR `url`, so a +/// concurrent/reconnected session's live entry is never clobbered (same rule +/// as the settings-file cleanup). Static (url-less) bridge entries are left +/// alone. +async fn cleanup_kiro_agent_configs(workdir: &str, url: &str) { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return; + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + let still_ours = cfg + .pointer("/mcpServers/openab-browser/url") + .and_then(Value::as_str) + == Some(url); + if !still_ours { + continue; + } + if let Some(servers) = cfg.get_mut("mcpServers").and_then(Value::as_object_mut) { + servers.remove("openab-browser"); + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + allowed.retain(|v| v.as_str() != Some("@openab-browser")); + } + if let Ok(out) = serde_json::to_vec_pretty(&cfg) { + let _ = write_private(&path, &out).await; + } + } +} + +/// Write the STATIC, write-once `openab-browser` bridge entry into each colocated CLI's mcp.json +/// (Option C, bridge mode). Unlike the per-session HTTP proxy config, this carries no port/bearer +/// — it is the same `{command:"openab", args:["browser-bridge"]}` for every session, so it can be +/// written once and never goes stale. That fixes the shared-config clobber the per-session dynamic +/// write suffers when several sessions of one agent share a single mcp.json. Merges without +/// touching the user's other servers; idempotent (a no-op when already present + identical). +pub async fn write_bridge_mcp_config(workdir: &str) -> std::io::Result<()> { + // Pure static entry — byte-identical for every session (idempotent, no cross-session clobber). + // The channel is deliberately NOT carried here: the MCP client scrubs the server's env and its + // config-var expansion is vendor-specific, so the `openab browser-bridge` shim resolves its OWN + // channel by walking up to the agent process (Option C b2). This entry never goes stale. + let entry = json!({ "command": "openab", "args": ["browser-bridge"] }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Idempotent: only rewrite when absent or changed (no needless mtime churn each session). + if cfg["mcpServers"]["openab-browser"] != entry { + cfg["mcpServers"]["openab-browser"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + // kiro `--agent` deployments read agent files, not settings/mcp.json (same + // gap as the proxy-mode writer). The static entry is idempotent there too. + merge_kiro_agent_configs(workdir, &entry).await?; + Ok(()) +} + +/// Write the STATIC, write-once `openab` facade entry into each colocated CLI's MCP config +/// (Facade mode). Like the Option C bridge entry it is byte-identical for every session — +/// the per-session secret is NOT in the file: the entry references the +/// `OPENAB_SESSION_TOKEN` environment variable, which the pool injects into each spawned +/// agent process (config-var expansion is exactly how deployed agents already reference +/// per-bot secrets). No cross-session clobber, nothing to clean up on evict — the token +/// dies with the agent process and its registry entry. +pub async fn write_facade_mcp_config(workdir: &str, facade_url: &str) -> std::io::Result<()> { + let entry = json!({ + "url": facade_url, + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + }); + let cfg_paths = [ + std::path::Path::new(workdir).join(".cursor").join("mcp.json"), + std::path::Path::new(workdir) + .join(".kiro") + .join("settings") + .join("mcp.json"), + ]; + for cfg_path in &cfg_paths { + if let Some(dir) = cfg_path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let mut cfg: Value = match tokio::fs::read(cfg_path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})), + Err(_) => json!({}), + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + // Publish under "openab" (the facade), not "openab-browser": the agent + // reaches ALL facade capabilities through this one entry. + if cfg["mcpServers"]["openab"] != entry { + cfg["mcpServers"]["openab"] = entry.clone(); + tokio::fs::write(cfg_path, serde_json::to_vec_pretty(&cfg)?).await?; + } + } + // kiro `--agent` deployments read agent files, not settings/mcp.json. + merge_kiro_agent_facade_configs(workdir, &entry).await?; + Ok(()) +} + +/// Facade-mode sibling of [`merge_kiro_agent_configs`]: merges the static +/// `openab` facade entry + `@openab` allowlist grant into every +/// `.kiro/agents/*.json`. Same never-clobber rules; nothing to clean up on +/// evict (the entry is static and the token lives in the process env). +async fn merge_kiro_agent_facade_configs(workdir: &str, entry: &Value) -> std::io::Result<()> { + let dir = std::path::Path::new(workdir).join(".kiro").join("agents"); + let Ok(mut rd) = tokio::fs::read_dir(&dir).await else { + return Ok(()); + }; + while let Ok(Some(f)) = rd.next_entry().await { + let path = f.path(); + let name = f.file_name(); + let name = name.to_string_lossy(); + if !name.ends_with(".json") || name.starts_with("._") { + continue; + } + let Ok(bytes) = tokio::fs::read(&path).await else { + continue; + }; + let Ok(mut cfg) = serde_json::from_slice::(&bytes) else { + continue; + }; + if !cfg.get("mcpServers").map(Value::is_object).unwrap_or(false) { + cfg["mcpServers"] = json!({}); + } + let mut changed = false; + if cfg["mcpServers"]["openab"] != *entry { + cfg["mcpServers"]["openab"] = entry.clone(); + changed = true; + } + if let Some(allowed) = cfg.get_mut("allowedTools").and_then(Value::as_array_mut) { + if !allowed.iter().any(|v| v.as_str() == Some("@openab")) { + allowed.push(json!("@openab")); + changed = true; + } + } + if changed { + write_private(&path, &serde_json::to_vec_pretty(&cfg)?).await?; + } + } + Ok(()) +} + +/// Broker-side session credential hook (Facade mode). Implemented by the root +/// (closing over the facade's `SessionTokens` registry — core stays free of +/// the openab-mcp dependency); the pool calls it at session spawn/evict. +pub trait SessionTokenRegistrar: Send + Sync { + /// Mint (or re-mint) the token for `channel_id`; returns the value the + /// pool injects as `OPENAB_SESSION_TOKEN` in the agent's environment. + fn mint(&self, channel_id: &str) -> String; + /// Revoke every token for `channel_id` (session evicted/replaced). + fn revoke(&self, channel_id: &str); +} + +/// Selected browser transport for the Option C rollout. `OPENAB_BROWSER_MODE=bridge` opts into +/// the stdio bridge; anything else (including unset) keeps the per-session HTTP proxy — the safe +/// default during rollout, so existing Cursor/Kiro browser control is unchanged until flipped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserMode { + /// Browser tools served through the OAB MCP Facade as a session-aware + /// in-process capability source (one listener, session identity via + /// broker-minted tokens). The default when the facade is running; + /// falls back to `Proxy` when it is not (no `[mcp]` in config). + Facade, + /// Per-session loopback HTTP MCP server + dynamic config (the original + /// default; explicit opt-out from facade routing). + Proxy, + Bridge, +} + +impl BrowserMode { + pub fn is_bridge(self) -> bool { + matches!(self, BrowserMode::Bridge) + } +} + +fn parse_browser_mode(s: Option<&str>) -> BrowserMode { + match s.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("bridge") => BrowserMode::Bridge, + Some("proxy") => BrowserMode::Proxy, + _ => BrowserMode::Facade, + } +} + +/// Read the browser transport mode from `OPENAB_BROWSER_MODE` (default: proxy). +pub fn browser_mode() -> BrowserMode { + parse_browser_mode(std::env::var("OPENAB_BROWSER_MODE").ok().as_deref()) +} + +/// Per-pod browser-bridge socket path (overridable via `OPENAB_BROWSER_SOCKET`). Single source of +/// truth shared by the core socket server and the `openab browser-bridge` shim so they agree. +pub fn browser_socket_path() -> std::path::PathBuf { + if let Ok(p) = std::env::var("OPENAB_BROWSER_SOCKET") { + return p.into(); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".into()); + std::path::Path::new(&home).join(".openab").join("browser.sock") +} + +// ---- Option C: per-pod stdio-bridge socket server ------------------------------------------- +// A single unix socket per pod multiplexes ALL sessions. The `openab browser-bridge` shim +// (spawned per agent session by the CLI's MCP client) connects and forwards inner MCP requests +// tagged with its own `channel_id` (from the OPENAB_BROWSER_CHANNEL env it inherits); core routes +// `tools/call` to that session's AcpMcpTunnel. This is the stable, variant-agnostic replacement +// for the per-session HTTP proxy (Option C). Wire = newline-delimited JSON, one frame per line: +// bridge → core : {"channel_id": "...", "request": } +// core → bridge : (omitted for notifications) + +const BROWSER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +fn mcp_result(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn mcp_error(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +/// Dispatch one inner MCP request for `channel_id`, backing `tools/call` with the shared browser +/// tunnel. Returns the MCP response, or `None` for a JSON-RPC notification (no reply). Same tool +/// set + not-connected semantics as the HTTP `ProxyHandler` (single source of truth). +pub(crate) async fn dispatch_browser_mcp( + channel_id: &str, + request: &Value, + tunnel: &Option>, +) -> Option { + // A JSON-RPC notification has no `id` → no response. + let id = request.get("id").cloned()?; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let resp = match method { + "initialize" => mcp_result( + id, + json!({ + "protocolVersion": BROWSER_MCP_PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": "openab-browser", "version": env!("CARGO_PKG_VERSION") } + }), + ), + "tools/list" => { + let tools = serde_json::to_value(browser_tools()).unwrap_or_else(|_| json!([])); + mcp_result(id, json!({ "tools": tools })) + } + "tools/call" => match tunnel { + // Empty server_id sentinel (Fork A): the bridge frame carries no server id yet; + // RootBrowserTunnel resolves the sole tunnel on the channel. Real per-server routing + // (server_id in the frame) lands in P2. + Some(t) => match t + .call(channel_id, "", "tools/call", request.get("params").cloned()) + .await + { + Ok(v) => mcp_result(id, v), + Err(e) => mcp_error(id, -32603, &e), + }, + None => mcp_error( + id, + -32603, + "browser not connected: open the OpenAB side panel in your browser", + ), + }, + other => mcp_error(id, -32601, &format!("method not found: {other}")), + }; + Some(resp) +} + +/// Serve the per-pod browser-bridge socket at `path`, routing each connection's framed requests +/// via [`dispatch_browser_mcp`]. Binds a fresh 0600 unix socket (same-uid only), spawns the accept +/// loop, and runs until `ct` is cancelled. Idempotent on a stale socket file from a prior run. +pub async fn serve_browser_socket( + path: std::path::PathBuf, + tunnel: Option>, + ct: tokio_util::sync::CancellationToken, +) -> std::io::Result<()> { + let _ = tokio::fs::remove_file(&path).await; // clear a stale socket from a prior run + let listener = tokio::net::UnixListener::bind(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + tokio::spawn(async move { + loop { + tokio::select! { + _ = ct.cancelled() => break, + accepted = listener.accept() => { + match accepted { + Ok((stream, _)) => { + tokio::spawn(handle_browser_conn(stream, tunnel.clone())); + } + Err(_) => continue, + } + } + } + } + let _ = tokio::fs::remove_file(&path).await; + }); + Ok(()) +} + +/// Start the browser socket for the process lifetime (no external cancellation handle) — used by +/// the broker in bridge mode. The pod-wide server lives as long as the process, so no caller-side +/// tokio-util dependency is needed. +pub async fn serve_browser_socket_forever( + path: std::path::PathBuf, + tunnel: Option>, +) -> std::io::Result<()> { + serve_browser_socket(path, tunnel, tokio_util::sync::CancellationToken::new()).await +} + +async fn handle_browser_conn( + stream: tokio::net::UnixStream, + tunnel: Option>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let Ok(frame) = serde_json::from_str::(&line) else { + continue; // skip a malformed frame rather than drop the connection + }; + let channel_id = frame.get("channel_id").and_then(Value::as_str).unwrap_or(""); + let Some(request) = frame.get("request") else { + continue; + }; + if let Some(resp) = dispatch_browser_mcp(channel_id, request, &tunnel).await { + let Ok(mut buf) = serde_json::to_vec(&resp) else { + continue; + }; + buf.push(b'\n'); + if write_half.write_all(&buf).await.is_err() { + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + browser_tools, cleanup_kiro_agent_configs, dispatch_browser_mcp, + merge_kiro_agent_configs, parse_browser_mode, serve_browser_socket, spawn_mcp_server, + start_session_server, write_bridge_mcp_config, AcpMcpTunnel, BrowserMode, ProxyHandler, + }; + + /// Unique throwaway workdir with a `.kiro/agents/` tree. + async fn tmp_workdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-{tag}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(dir.join(".kiro").join("agents")) + .await + .unwrap(); + dir + } + + #[tokio::test] + async fn kiro_agent_merge_adds_server_and_allowlist_preserving_the_rest() { + let wd = tmp_workdir("merge").await; + let agent = wd.join(".kiro/agents/terra.json"); + tokio::fs::write( + &agent, + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "terra", + "model": "gpt-5.6-terra", + "mcpServers": { "github": { "url": "http://ghpool:8080/mcp" } }, + "allowedTools": ["@builtin", "@github"] + })) + .unwrap(), + ) + .await + .unwrap(); + let entry = serde_json::json!({ + "url": "http://127.0.0.1:45678/mcp", + "headers": { "Authorization": "Bearer tok" } + }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["openab-browser"], entry); + assert_eq!( + cfg["mcpServers"]["github"]["url"], "http://ghpool:8080/mcp", + "pre-existing servers must be preserved" + ); + assert_eq!(cfg["model"], "gpt-5.6-terra", "unrelated fields preserved"); + let allowed = cfg["allowedTools"].as_array().unwrap(); + assert!( + allowed.iter().any(|v| v == "@openab-browser"), + "allowedTools is default-deny — the server must be allowlisted: {allowed:?}" + ); + // Idempotent: second merge changes nothing (byte-stable allowlist). + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + let cfg2: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&agent).await.unwrap()).unwrap(); + assert_eq!(cfg, cfg2); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_skips_unparseable_and_metadata_files() { + let wd = tmp_workdir("skip").await; + let junk = wd.join(".kiro/agents/broken.json"); + tokio::fs::write(&junk, b"{not json").await.unwrap(); + let meta = wd.join(".kiro/agents/._terra.json"); + tokio::fs::write(&meta, b"\x00\x05\x16\x07").await.unwrap(); + let entry = serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }); + merge_kiro_agent_configs(wd.to_str().unwrap(), &entry) + .await + .unwrap(); + assert_eq!( + tokio::fs::read(&junk).await.unwrap(), + b"{not json", + "unparseable agent files must be skipped, never clobbered" + ); + assert_eq!(tokio::fs::read(&meta).await.unwrap(), b"\x00\x05\x16\x07"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_merge_without_agents_dir_is_noop() { + let wd = std::env::temp_dir().join(format!( + "oab-mcp-proxy-test-noop-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + tokio::fs::create_dir_all(&wd).await.unwrap(); + merge_kiro_agent_configs( + wd.to_str().unwrap(), + &serde_json::json!({ "url": "http://127.0.0.1:1/mcp" }), + ) + .await + .unwrap(); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[tokio::test] + async fn kiro_agent_cleanup_removes_only_our_url_and_its_grant() { + let wd = tmp_workdir("cleanup").await; + let ours = wd.join(".kiro/agents/ours.json"); + tokio::fs::write( + &ours, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:1111/mcp" } }, + "allowedTools": ["@builtin", "@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + let foreign = wd.join(".kiro/agents/foreign.json"); + tokio::fs::write( + &foreign, + serde_json::to_vec_pretty(&serde_json::json!({ + "mcpServers": { "openab-browser": { "url": "http://127.0.0.1:2222/mcp" } }, + "allowedTools": ["@openab-browser"] + })) + .unwrap(), + ) + .await + .unwrap(); + cleanup_kiro_agent_configs(wd.to_str().unwrap(), "http://127.0.0.1:1111/mcp").await; + let ours_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&ours).await.unwrap()).unwrap(); + assert!(ours_cfg["mcpServers"]["openab-browser"].is_null()); + assert!( + !ours_cfg["allowedTools"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "@openab-browser"), + "the stale allowlist grant must be revoked with the entry" + ); + let foreign_cfg: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&foreign).await.unwrap()).unwrap(); + assert_eq!( + foreign_cfg["mcpServers"]["openab-browser"]["url"], "http://127.0.0.1:2222/mcp", + "a concurrent session's live entry must never be clobbered" + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + #[test] + fn browser_mode_defaults_to_facade_with_proxy_and_bridge_opt_outs() { + // Facade is the default: browser tools ride the OAB MCP Facade as a + // session-aware source (falls back to Proxy at runtime when no + // facade is serving — see the pool's mode fallback). + assert_eq!(parse_browser_mode(None), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("")), BrowserMode::Facade); + assert_eq!(parse_browser_mode(Some("junk")), BrowserMode::Facade); + // Explicit opt-outs keep their exact prior semantics. + assert_eq!(parse_browser_mode(Some("proxy")), BrowserMode::Proxy); + assert_eq!(parse_browser_mode(Some("bridge")), BrowserMode::Bridge); + assert_eq!(parse_browser_mode(Some(" Bridge ")), BrowserMode::Bridge); + assert!(BrowserMode::Bridge.is_bridge()); + assert!(!BrowserMode::Proxy.is_bridge()); + assert!(!BrowserMode::Facade.is_bridge()); + } + + struct MockTunnel; + #[async_trait::async_trait] + impl AcpMcpTunnel for MockTunnel { + async fn call( + &self, + channel_id: &str, + server_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(channel_id, "acp_x"); + assert_eq!(server_id, ""); // proxy passes the empty sentinel (Fork A) + assert_eq!(method, "tools/call"); + Ok(serde_json::json!({"content": [{"type": "text", "text": "clicked"}]})) + } + } + + #[tokio::test] + async fn call_tool_forwards_to_the_tunnel() { + let h = ProxyHandler::new("acp_x".into(), Some(std::sync::Arc::new(MockTunnel))); + let result = h.forward_tool_call("browser.click", None).await.unwrap(); + let v = serde_json::to_value(&result).unwrap(); + assert_eq!(v["content"][0]["text"], serde_json::json!("clicked")); + } + + #[tokio::test] + async fn call_tool_reports_not_connected_without_a_tunnel() { + let h = ProxyHandler::new("acp_x".into(), None); + assert!( + h.forward_tool_call("browser.click", None).await.is_err(), + "a call with no attached browser must error (D4)" + ); + } + + // --- Option C: browser-bridge socket dispatch --- + struct RecordTunnel { + result: serde_json::Value, + } + #[async_trait::async_trait] + impl AcpMcpTunnel for RecordTunnel { + async fn call( + &self, + channel_id: &str, + _server_id: &str, + method: &str, + _params: Option, + ) -> Result { + assert_eq!(method, "tools/call"); + assert_eq!(channel_id, "acp_win1"); + Ok(self.result.clone()) + } + } + struct ErrTunnel; + #[async_trait::async_trait] + impl AcpMcpTunnel for ErrTunnel { + async fn call( + &self, + _c: &str, + _s: &str, + _m: &str, + _p: Option, + ) -> Result { + Err("no browser attached".into()) + } + } + fn req(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { + serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) + } + fn arc_tunnel(t: T) -> Option> { + Some(std::sync::Arc::new(t)) + } + + #[tokio::test] + async fn dispatch_initialize_advertises_tools() { + let r = dispatch_browser_mcp("acp_x", &req(1, "initialize", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["id"], 1); + assert_eq!(r["result"]["capabilities"]["tools"], serde_json::json!({})); + assert_eq!(r["result"]["serverInfo"]["name"], "openab-browser"); + } + + #[tokio::test] + async fn dispatch_tools_list_returns_five_tools() { + let r = dispatch_browser_mcp("acp_x", &req(2, "tools/list", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 5); + } + + #[tokio::test] + async fn dispatch_tools_call_routes_to_the_channel_tunnel() { + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let r = dispatch_browser_mcp( + "acp_win1", + &req(3, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["result"]["content"][0]["text"], "ok"); + } + + #[tokio::test] + async fn dispatch_tools_call_without_tunnel_is_not_connected() { + let r = dispatch_browser_mcp( + "acp_x", + &req(4, "tools/call", serde_json::json!({ "name": "browser.click" })), + &None, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert!(r["error"]["message"].as_str().unwrap().contains("not connected")); + } + + #[tokio::test] + async fn dispatch_tools_call_surfaces_tunnel_error() { + let tunnel = arc_tunnel(ErrTunnel); + let r = dispatch_browser_mcp( + "acp_x", + &req(5, "tools/call", serde_json::json!({ "name": "browser.click" })), + &tunnel, + ) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32603); + assert_eq!(r["error"]["message"], "no browser attached"); + } + + #[tokio::test] + async fn dispatch_notification_gets_no_response() { + let notif = serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert!(dispatch_browser_mcp("acp_x", ¬if, &None).await.is_none()); + } + + #[tokio::test] + async fn dispatch_unknown_method_is_method_not_found() { + let r = dispatch_browser_mcp("acp_x", &req(6, "bogus/thing", serde_json::json!({})), &None) + .await + .unwrap(); + assert_eq!(r["error"]["code"], -32601); + } + + #[tokio::test] + async fn write_bridge_config_writes_static_entry_to_both_variants() { + let dir = tempfile::tempdir().unwrap(); + write_bridge_mcp_config(dir.path().to_str().unwrap()) + .await + .unwrap(); + for rel in [".cursor/mcp.json", ".kiro/settings/mcp.json"] { + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(rel)).unwrap()).unwrap(); + let e = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(e["command"], "openab"); + assert_eq!(e["args"], serde_json::json!(["browser-bridge"])); + assert!( + e.get("env").is_none(), + "channel is resolved by the shim (b2), not carried in config" + ); + assert!(e.get("url").is_none(), "bridge entry carries no url/port"); + assert!(e.get("headers").is_none(), "bridge entry carries no bearer"); + } + } + + #[tokio::test] + async fn write_bridge_config_merges_without_clobber_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let wd = dir.path().to_str().unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); + write_bridge_mcp_config(wd).await.unwrap(); // idempotent second call + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert_eq!(cfg["mcpServers"]["other"]["url"], "http://x"); // user's server preserved + assert_eq!(cfg["mcpServers"]["openab-browser"]["command"], "openab"); + } + + #[tokio::test] + async fn browser_socket_round_trip() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("browser.sock"); + let tunnel = arc_tunnel(RecordTunnel { + result: serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }), + }); + let ct = tokio_util::sync::CancellationToken::new(); + serve_browser_socket(sock.clone(), tunnel, ct.clone()) + .await + .unwrap(); + let stream = loop { + match tokio::net::UnixStream::connect(&sock).await { + Ok(s) => break s, + Err(_) => tokio::task::yield_now().await, + } + }; + let (rd, mut wr) = stream.into_split(); + let frame = serde_json::json!({ + "channel_id": "acp_win1", + "request": req(9, "tools/call", serde_json::json!({ "name": "browser.read_dom", "arguments": {} })) + }); + let mut line = serde_json::to_vec(&frame).unwrap(); + line.push(b'\n'); + wr.write_all(&line).await.unwrap(); + let mut resp = String::new(); + BufReader::new(rd).read_line(&mut resp).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(v["id"], 9); + assert_eq!(v["result"]["content"][0]["text"], "ok"); + ct.cancel(); + } + + #[tokio::test] + async fn start_session_server_writes_cursor_config() { + let dir = tempfile::tempdir().unwrap(); + let (addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + assert!(addr.ip().is_loopback()); + + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join(".cursor/mcp.json")).unwrap()) + .unwrap(); + let entry = &cfg["mcpServers"]["openab-browser"]; + assert_eq!(entry["url"], serde_json::json!(format!("http://{addr}/mcp"))); + assert!(entry["headers"]["Authorization"] + .as_str() + .unwrap() + .starts_with("Bearer ")); + // The file holds a live bearer — it must be owner-only (0600), not umask-default 0644. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(".cursor/mcp.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mcp.json (live bearer) must be 0600"); + } + ct.cancel(); + } + + #[tokio::test] + async fn start_session_server_merges_existing_config() { + let dir = tempfile::tempdir().unwrap(); + let cursor = dir.path().join(".cursor"); + std::fs::create_dir_all(&cursor).unwrap(); + std::fs::write( + cursor.join("mcp.json"), + r#"{"mcpServers":{"other":{"url":"http://x"}}}"#, + ) + .unwrap(); + let (_addr, ct) = start_session_server("acp_x", dir.path().to_str().unwrap(), None) + .await + .unwrap(); + let cfg: serde_json::Value = + serde_json::from_slice(&std::fs::read(cursor.join("mcp.json")).unwrap()).unwrap(); + assert!( + cfg["mcpServers"]["other"].is_object(), + "existing server must be preserved" + ); + assert!( + cfg["mcpServers"]["openab-browser"].is_object(), + "openab-browser must be added" + ); + ct.cancel(); + } + + #[test] + fn browser_tools_advertises_the_fixed_set() { + let tools = browser_tools(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + [ + "browser.click", + "browser.read_dom", + "browser.navigate", + "browser.type", + "browser.screenshot" + ] + ); + } + + #[test] + fn every_browser_tool_has_an_object_input_schema() { + for t in browser_tools() { + assert_eq!( + t.input_schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "tool {} must have an object input schema", + t.name + ); + assert!(t.description.is_some(), "tool {} needs a description", t.name); + } + } + + const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; + + #[tokio::test] + async fn mcp_server_binds_loopback_and_initializes_with_bearer() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) + .await + .unwrap(); + assert!(addr.ip().is_loopback(), "MCP server must bind loopback only"); + + let url = format!("http://{addr}/mcp"); + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", "Bearer secret-token") + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["jsonrpc"], "2.0"); + assert!(body["result"].is_object(), "initialize must return a result"); + assert!( + body["result"]["capabilities"]["tools"].is_object(), + "server must advertise the tools capability" + ); + ct.cancel(); + } + + #[tokio::test] + async fn mcp_server_rejects_missing_or_wrong_bearer() { + let ct = tokio_util::sync::CancellationToken::new(); + let addr = spawn_mcp_server("acp_test".into(), None, "secret-token".to_string(), ct.clone()) + .await + .unwrap(); + let url = format!("http://{addr}/mcp"); + let client = reqwest::Client::new(); + + // no Authorization header -> 401 + let no_auth = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(no_auth.status(), 401); + + // wrong token -> 401 + let wrong = client + .post(&url) + .header("Authorization", "Bearer nope") + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(INIT_BODY) + .send() + .await + .unwrap(); + assert_eq!(wrong.status(), 401); + ct.cancel(); + } +} diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 18c2b2cca..581bf0df3 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -21,8 +21,9 @@ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -35,7 +36,7 @@ const ACP_PROTOCOL_VERSION: u32 = 1; /// eviction, and global connection/worker limits are a follow-up (review F6, roadmap). const MAX_SESSIONS_PER_CONNECTION: usize = 128; const MAX_INFLIGHT_PROMPTS: usize = 32; -const MAX_FRAME_BYTES: usize = 1 << 20; // 1 MiB per inbound JSON-RPC frame +const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB /// JSON-RPC implementation-defined server error for a hit resource cap. const ACP_OVERLOADED: i32 = -32000; @@ -258,6 +259,41 @@ struct AcpSession { /// "cancelled"` to the prompt's own request id (rather than hard-aborting /// the task and orphaning that id). cancel: Option>, + /// Client-declared MCP-over-ACP servers (the RFD `{"type":"acp"}` mcpServers entries): + /// the browser extension serves its MCP tools over this same /acp WS. Recorded so the + /// gateway can later `mcp/connect` to them (T5.3). Unused until that wiring lands. + #[allow(dead_code)] + acp_mcp_servers: Vec, +} + +/// A client-declared MCP-over-ACP server (the RFD `"type":"acp"` `mcpServers` entry). Not in +/// the generated schema (the RFD is a proposal), so parsed from raw params. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +struct AcpMcpServer { + id: String, + name: String, +} + +/// Extract the `"type":"acp"` entries from a `session/new` / `session/resume` params +/// `mcpServers` array. http/sse/stdio servers are ignored here — the agent connects to those +/// itself; only the `acp`-transport ones are tunnelled over this WS. +fn parse_acp_mcp_servers(params: Option<&Value>) -> Vec { + params + .and_then(|p| p.get("mcpServers")) + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("acp")) + .filter_map(|e| { + Some(AcpMcpServer { + id: e.get("id").and_then(Value::as_str)?.to_string(), + name: e.get("name").and_then(Value::as_str)?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() } pub enum ReplyChunk { @@ -287,6 +323,18 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } +/// Registry of open MCP-over-ACP tunnels: `(channel_id, server_id)` → `TunnelHandle`. The +/// gateway inserts a handle once it has `mcp/connect`ed to a session's declared `type:acp` +/// server; the core MCP proxy looks one up to route a tool call to the right server (T5.3). +/// Keyed by the compound `(channel_id, server_id)` (P1) so one session can carry several +/// `type:acp` servers without collision; eviction drops all `(channel_id, *)` on teardown. Same +/// std::sync::Mutex rationale as `AcpReplyRegistry`. +pub type AcpTunnelRegistry = Arc>>; + +pub fn new_tunnel_registry() -> AcpTunnelRegistry { + Arc::new(std::sync::Mutex::new(HashMap::new())) +} + // --------------------------------------------------------------------------- // JSON-RPC types (minimal subset for ACP) // --------------------------------------------------------------------------- @@ -324,6 +372,65 @@ struct JsonRpcNotification { params: Value, } +/// A SERVER-INITIATED JSON-RPC request (the agent→client REQUEST direction, T1). The base +/// only ever *received* requests, so `JsonRpcRequest` is deserialize-only; this is the +/// outbound counterpart used by `send_request`. Wired to a caller by T1.4 (core↔gateway +/// bridge) / the MCP-over-ACP tunnel; landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct JsonRpcRequestOut { + jsonrpc: &'static str, + id: u64, + method: String, + params: Value, +} + +// --- MCP-over-ACP tunnel frames (T4) ------------------------------------------ +// The official MCP-over-ACP RFD (agentclientprotocol.com/rfds/mcp-over-acp) tunnels MCP +// over the /acp WS with three methods: mcp/connect → connectionId, then mcp/message (the +// inner MCP method/params FLATTENED into the params, correlated by the OUTER ACP id — the +// inner MCP id is not carried), and mcp/disconnect. These types are NOT in the generated +// `acp_schema` (the RFD is a proposal, not the stable v1 schema), so they are hand-rolled +// here. The extension is the MCP server and assigns `connectionId`; the gateway (agent side +// of the upstream hop) is the connector and issues connect/message/disconnect. + +/// `mcp/connect` params — `acpId` matches the `id` of the client's `session/new` +/// `mcpServers` entry with `"type":"acp"`. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpConnectParams { + #[serde(rename = "acpId")] + acp_id: String, +} + +/// `mcp/connect` result — the client-assigned connection handle. +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct McpConnectResult { + #[serde(rename = "connectionId")] + connection_id: String, +} + +/// `mcp/message` params — the inner MCP `method`/`params` are flattened in (no inner id); +/// `connectionId` selects the tunnelled MCP connection. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpMessageParams { + #[serde(rename = "connectionId")] + connection_id: String, + method: String, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +/// `mcp/disconnect` params. +#[allow(dead_code)] +#[derive(Debug, Serialize)] +struct McpDisconnectParams { + #[serde(rename = "connectionId")] + connection_id: String, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -406,6 +513,266 @@ pub async fn ws_upgrade( // ACP Connection handler // --------------------------------------------------------------------------- +/// Route an inbound client *response* (an id-bearing frame with `result`/`error` and NO +/// `method`) to the `send_request` awaiter registered under its id. Returns `true` when the +/// frame was a response we consumed (so the caller must stop dispatching it as a request). +/// Mirrors the client-side correlation in `openab-core/src/acp/connection.rs`. +async fn route_client_response( + pending: &Arc>>>, + raw: &Value, +) -> bool { + let has_method = raw.get("method").is_some(); + let looks_like_response = raw.get("result").is_some() || raw.get("error").is_some(); + if has_method || !looks_like_response { + return false; + } + // Accept a numeric id (what we mint) or a stringified number ("1") from a spec-loose + // client, so its responses still correlate to the pending request instead of being dropped. + let Some(id) = raw + .get("id") + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + else { + return false; + }; + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(raw.clone()); + } else { + warn!(id, "acp: client response with no matching pending request"); + } + true +} + +/// Send a server-initiated JSON-RPC request to the connected ACP client and await its +/// response (the agent→client REQUEST direction, T1). Mints an id, registers a oneshot in +/// `pending`, writes the frame via the outbound channel, then awaits the correlated response +/// (resolved by `route_client_response`) with a timeout. Wired to a caller by T1.4 (the +/// core↔gateway MCP-over-ACP bridge); landed ahead of its caller as ready infrastructure. +#[allow(dead_code)] +async fn send_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + method: impl Into, + params: Value, + timeout_secs: u64, +) -> Result { + let id = next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(id, tx); + let frame = JsonRpcRequestOut { + jsonrpc: "2.0", + id, + method: method.into(), + params, + }; + if out_tx.send(serde_json::to_string(&frame).unwrap()).is_err() { + pending.lock().await.remove(&id); + return Err("connection closed".into()); + } + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await { + Ok(Ok(v)) => Ok(v), + Ok(Err(_)) => Err("connection closed before response".into()), + Err(_) => { + pending.lock().await.remove(&id); + Err("request timed out".into()) + } + } +} + +/// Extract the `result` from a JSON-RPC response frame, mapping an `error` member to `Err`. +/// `send_request` yields the whole response frame; the tunnel helpers want just the payload. +#[allow(dead_code)] +fn frame_result(frame: Value) -> Result { + if let Some(err) = frame.get("error") { + return Err(format!("remote error: {err}")); + } + Ok(frame.get("result").cloned().unwrap_or(Value::Null)) +} + +/// `mcp/connect` (T4): open a tunnelled MCP connection to the client-provided (`"type":"acp"`) +/// MCP server identified by `acp_id`; returns the client-assigned `connectionId`. +#[allow(dead_code)] +async fn mcp_connect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + acp_id: &str, + timeout_secs: u64, +) -> Result { + let params = serde_json::to_value(McpConnectParams { + acp_id: acp_id.to_string(), + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/connect", params, timeout_secs).await?; + let result: McpConnectResult = serde_json::from_value(frame_result(frame)?) + .map_err(|e| format!("mcp/connect: malformed result: {e}"))?; + Ok(result.connection_id) +} + +/// `mcp/message` REQUEST (T4): tunnel an inner MCP request over `connection_id`; returns the +/// inner MCP result payload (the outer ACP id does the correlation; the inner MCP id is not +/// carried on the wire). +#[allow(dead_code)] +async fn mcp_message_request( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + method: &str, + params: Option, + timeout_secs: u64, +) -> Result { + let msg = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: method.to_string(), + params, + }) + .unwrap(); + let frame = send_request(out_tx, pending, next_id, "mcp/message", msg, timeout_secs).await?; + frame_result(frame) +} + +/// `mcp/disconnect` (T4): close a tunnelled MCP connection. +#[allow(dead_code)] +async fn mcp_disconnect( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + timeout_secs: u64, +) -> Result<(), String> { + let params = serde_json::to_value(McpDisconnectParams { + connection_id: connection_id.to_string(), + }) + .unwrap(); + send_request(out_tx, pending, next_id, "mcp/disconnect", params, timeout_secs) + .await + .map(|_| ()) +} + +/// A cloneable handle to one `/acp` connection's MCP-over-ACP tunnel (T5.3). Bundles the +/// per-connection outbound channel + pending-request map + id counter + the `connectionId` +/// from `mcp/connect`, so a holder can issue `mcp/message` requests to that browser and await +/// the result. Built by the gateway once the tunnel is open and (next) registered under the +/// session's `channel_id` in a shared registry, so the core MCP proxy can route a tool call +/// to the right browser. D5-agnostic: both the per-session and shared core-server designs use +/// this same handle. +#[derive(Clone)] +pub struct TunnelHandle { + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + connection_id: String, +} + +impl TunnelHandle { + /// Tunnel an inner MCP request (`tools/list`, `tools/call`, …) to the extension over this + /// connection and return the inner MCP result payload. + pub async fn mcp_message( + &self, + method: &str, + params: Option, + timeout_secs: u64, + ) -> Result { + mcp_message_request( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + method, + params, + timeout_secs, + ) + .await + } + + /// Close this tunnel (`mcp/disconnect`). + pub async fn disconnect(&self, timeout_secs: u64) -> Result<(), String> { + mcp_disconnect( + &self.out_tx, + &self.pending, + &self.next_id, + &self.connection_id, + timeout_secs, + ) + .await + } +} + +/// Open the MCP-over-ACP tunnel to a session's declared `"type":"acp"` server and register a +/// `TunnelHandle` under the session's `channel_id` so the core MCP proxy can reach it (T5.3). +/// MUST run in a spawned task, never inline in the connection read loop: `mcp_connect` awaits +/// the client's response, which only that same read loop can deliver — awaiting it inline +/// would deadlock. +#[allow(dead_code)] +async fn establish_and_register_tunnel( + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + acp_id: String, + channel_id: String, + registry: AcpTunnelRegistry, + timeout_secs: u64, +) -> Result<(), String> { + // Observability: reaching here means the client DID declare a "type":"acp" server, so this + // line in the log answers "did the browser extension advertise itself?" for a live session. + info!(acp_id = %acp_id, channel_id = %channel_id, "ACP: opening MCP-over-ACP browser tunnel"); + let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; + let handle = TunnelHandle { + out_tx, + pending, + next_id, + connection_id, + }; + registry + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert((channel_id.clone(), acp_id.clone()), handle); + info!(channel_id = %channel_id, server_id = %acp_id, "ACP: browser tunnel registered — extension attached"); + Ok(()) +} + +/// Open + register the browser tunnel for a session's declared `type:acp` servers. +/// +/// Exactly ONE tunnel per session is supported: the core proxy resolves a browser by +/// `channel_id`, so registering a second server under the same `channel_id` would overwrite +/// the first in the registry and orphan its already-opened tunnel. We therefore establish only +/// the first declared server and warn if the client sent more. Spawned (not awaited inline) +/// because `establish_and_register_tunnel` awaits the client's `mcp/connect` response, which +/// only the read loop delivers — awaiting inline would deadlock. +#[allow(clippy::too_many_arguments)] +fn spawn_browser_tunnel( + servers: Vec, + channel_id: String, + registry: AcpTunnelRegistry, + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &Arc, + prompt_tasks: &mut Vec>, +) { + if servers.len() > 1 { + warn!( + channel_id = %channel_id, + count = servers.len(), + "ACP: multiple type:acp servers declared; only one browser tunnel per session is supported — using the first" + ); + } + let Some(srv) = servers.into_iter().next() else { + return; + }; + let out_tx = out_tx.clone(); + let pending = pending.clone(); + let next_id = next_id.clone(); + prompt_tasks.push(tokio::spawn(async move { + if let Err(e) = + establish_and_register_tunnel(out_tx, pending, next_id, srv.id, channel_id, registry, 30) + .await + { + warn!(error = %e, "ACP: failed to open MCP-over-ACP tunnel"); + } + })); +} + async fn handle_acp_connection(state: Arc, socket: WebSocket) { let (mut ws_tx, mut ws_rx) = socket.split(); let connection_id = format!("acp_conn_{}", Uuid::new_v4()); @@ -418,6 +785,14 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { // Session state for this connection let sessions: Arc>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Pending server-initiated requests (T1): id → oneshot for the client's response. The + // base had only client→server requests + server→client notifications; the MCP-over-ACP + // tunnel adds the server→client REQUEST direction, correlated through this map by + // `route_client_response` (inbound) and `send_request` (outbound, wired in T1.4). + let pending_requests: Arc>>> = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); + // Monotonic id source for server-initiated requests (mcp/connect, mcp/message). + let next_req_id = Arc::new(AtomicU64::new(1)); let mut initialized = false; // Track spawned prompt tasks so we can abort on disconnect @@ -494,6 +869,15 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } + // A client *response* to a server-initiated request (T1): id present, no `method`, + // carries `result`/`error`. Route it to the waiting `send_request` and stop — it is + // neither a client request nor a notification. Gated on `!is_notification` (a + // notification never carries an id, so it can never be a response), keeping the + // existing notification/request handling below untouched. + if !is_notification && route_client_response(&pending_requests, &raw).await { + continue; + } + let req: JsonRpcRequest = match serde_json::from_value(raw) { Ok(r) => r, Err(e) => { @@ -567,8 +951,26 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let resp = handle_session_new(&sessions, id.clone()).await; + let acp_mcp_servers = parse_acp_mcp_servers(req.params.as_ref()); + let (resp, channel_id) = + handle_session_new(&sessions, id.clone(), acp_mcp_servers.clone()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // If the client declared "type":"acp" MCP servers, open + register a tunnel to + // each so the core MCP proxy can reach this browser. SPAWNED, not awaited + // inline: `establish_and_register_tunnel` awaits `mcp/connect`, whose response + // only THIS read loop delivers — awaiting inline would deadlock. + if let Some(registry) = state.acp_tunnel_registry.clone() { + spawn_browser_tunnel( + acp_mcp_servers, + channel_id.clone(), + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); + } } "session/resume" => { if !initialized { @@ -587,6 +989,31 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // Re-open + register the browser tunnel(s) on resume too. katashiro persists its + // ACP session and RECONNECTS via session/resume (not session/new), re-declaring its + // "type":"acp" browser server each time. Without this, a resumed session records the + // server but never opens a tunnel, so the core proxy reports "no browser attached". + // channel_id is derived deterministically from the sessionId, matching the handler. + if let Some(registry) = state.acp_tunnel_registry.clone() { + if let Some(channel_id) = req + .params + .as_ref() + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()) + .and_then(derive_channel_id) + { + spawn_browser_tunnel( + parse_acp_mcp_servers(req.params.as_ref()), + channel_id, + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut prompt_tasks, + ); + } + } } "session/prompt" => { if !initialized { @@ -699,31 +1126,43 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { prompt_tasks.retain(|h| !h.is_finished()); } + // Drain any in-flight server-initiated requests: dropping each oneshot sender makes the + // corresponding `send_request` awaiter resolve to "connection closed before response" + // rather than hang until timeout. Mirrors the close-drain in openab-core connection.rs. + for (_id, tx) in pending_requests.lock().await.drain() { + drop(tx); + } + // --- Disconnect cleanup --- // Abort any in-flight prompt tasks to prevent registry leaks for handle in prompt_tasks { handle.abort(); } - // Remove all sessions for this connection from the reply registry - if let Some(ref registry) = state.acp_reply_registry { + // Remove all of this connection's sessions from the reply + tunnel registries. + let channel_ids: Vec = { let sessions_guard = sessions.lock().await; - let channel_ids: Vec = sessions_guard + sessions_guard .values() .map(|s| s.channel_id.clone()) - .collect(); - drop(sessions_guard); - + .collect() + }; + if let Some(ref registry) = state.acp_reply_registry { let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); for cid in &channel_ids { reg.remove(cid); } - debug!( - connection = %connection_id, - sessions_cleaned = channel_ids.len(), - "ACP connection cleanup complete" - ); } + if let Some(ref registry) = state.acp_tunnel_registry { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + // Compound-key registry (P1): drop every `(channel_id, *)` tunnel for the closed session. + reg.retain(|(cid, _), _| !channel_ids.contains(cid)); + } + debug!( + connection = %connection_id, + sessions_cleaned = channel_ids.len(), + "ACP connection cleanup complete" + ); send_task.abort(); info!(connection = %connection_id, "ACP client disconnected"); @@ -783,10 +1222,13 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { ) } +/// Returns the response plus the minted `channel_id`, so the caller can open the +/// MCP-over-ACP tunnel(s) for any declared `"type":"acp"` servers under that key. async fn handle_session_new( sessions: &Arc>>, id: Value, -) -> JsonRpcResponse { + acp_mcp_servers: Vec, +) -> (JsonRpcResponse, String) { // sessionId and channel_id share one uuid so channel_id is always // re-derivable from a persisted sessionId (see session/resume). let uuid = Uuid::new_v4(); @@ -796,17 +1238,29 @@ async fn handle_session_new( sessions.lock().await.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, + acp_mcp_servers, }, ); // Downgraded from info! — sessionId is a resume capability; keep it out of normal logs (F12). debug!(session = %session_id, "ACP session created"); - // ACP session/new response is just { sessionId }. - JsonRpcResponse::success(id, json!({ "sessionId": session_id })) + // ACP session/new response is just { sessionId }. Constructed from the generated + // NewSessionResponse (T2.1) so the wire shape is type-checked against acp_schema; the + // optional fields skip-serialize, giving the same { "sessionId": ... } wire. + let resp = crate::adapters::acp_schema::NewSessionResponse { + session_id: crate::adapters::acp_schema::SessionId(session_id), + config_options: None, + meta: None, + modes: None, + }; + ( + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()), + channel_id, + ) } /// `session/resume` — re-attach to a session the client persisted, WITHOUT @@ -874,14 +1328,18 @@ async fn handle_session_resume( channel_id, busy: false, cancel: None, + // The client re-presents its mcpServers on resume; re-record the acp ones. + acp_mcp_servers: parse_acp_mcp_servers(params), }, ); drop(guard); debug!(session = %session_id, "ACP session resumed"); - // ACP session/resume response is an empty object (no history replay). - JsonRpcResponse::success(id, json!({})) + // ACP session/resume response is an empty object (no history replay) — the generated + // ResumeSessionResponse default serializes to {} (T2.1, type-checked against acp_schema). + let resp = crate::adapters::acp_schema::ResumeSessionResponse::default(); + JsonRpcResponse::success(id, serde_json::to_value(&resp).unwrap()) } /// Handle `session/cancel`. Per ACP it is a one-way NOTIFICATION: the notification form @@ -1034,14 +1492,15 @@ async fn handle_session_prompt( // Stream replies back as ACP `session/update` notifications. let mut sent_len = 0usize; let timeout = tokio::time::Duration::from_secs(180); - let mut stop_reason = "end_turn"; + // Typed StopReason (T2.1) so the final PromptResponse is constructed from acp_schema. + let mut stop_reason = crate::adapters::acp_schema::StopReason::EndTurn; let mut timed_out = false; loop { tokio::select! { // session/cancel fired — stop gracefully. _ = cancel.notified() => { - stop_reason = "cancelled"; + stop_reason = crate::adapters::acp_schema::StopReason::Cancelled; break; } recv = tokio::time::timeout(timeout, reply_rx.recv()) => { @@ -1099,7 +1558,12 @@ async fn handle_session_prompt( let resp = if timed_out { JsonRpcResponse::error(id, -32603, "Timed out waiting for agent backend") } else { - JsonRpcResponse::success(id, json!({ "stopReason": stop_reason })) + // T2.1: construct the typed PromptResponse; serializes to { "stopReason": ... }. + let pr = crate::adapters::acp_schema::PromptResponse { + stop_reason, + meta: None, + }; + JsonRpcResponse::success(id, serde_json::to_value(&pr).unwrap()) }; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); } @@ -1585,10 +2049,211 @@ mod acp_streaming { // Handler-level tests — call the real handlers (not just literal round-trips) and // assert their actual output + side effects. // --------------------------------------------------------------------------- +#[cfg(test)] +mod acp_requests { + //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an + //! id, awaits the correlated response) and inbound `route_client_response`. + use super::{mcp_connect, mcp_message_request, route_client_response, send_request}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + use tokio::sync::{mpsc, oneshot}; + + fn new_pending( + ) -> Arc>>> { + Arc::new(tokio::sync::Mutex::new(HashMap::new())) + } + + #[tokio::test] + async fn route_client_response_resolves_pending() { + let pending = new_pending(); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(5, tx); + let consumed = + route_client_response(&pending, &json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}})) + .await; + assert!(consumed, "an id+result frame is a response we consume"); + assert_eq!(rx.await.unwrap()["result"]["ok"], json!(true)); + assert!( + pending.lock().await.is_empty(), + "the pending entry must be removed" + ); + } + + #[tokio::test] + async fn route_client_response_ignores_requests_and_notifications() { + let pending = new_pending(); + // has `method` → a request, not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":1,"method":"foo"})).await); + // notification-shaped, no result/error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","method":"bar"})).await); + // id present but neither result nor error → not a response + assert!(!route_client_response(&pending, &json!({"jsonrpc":"2.0","id":2})).await); + } + + #[tokio::test] + async fn route_client_response_unknown_id_consumes_without_panic() { + let pending = new_pending(); + let consumed = route_client_response( + &pending, + &json!({"jsonrpc":"2.0","id":99,"error":{"code":-1,"message":"x"}}), + ) + .await; + assert!(consumed, "an unmatched response is still consumed (logged, no panic)"); + } + + #[tokio::test] + async fn send_request_mints_incrementing_ids_and_returns_response() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Driver: read the emitted request frame, assert its shape, feed a matching response. + let pending2 = pending.clone(); + let driver = tokio::spawn(async move { + let frame = out_rx.recv().await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["jsonrpc"], json!("2.0")); + assert_eq!(v["id"], json!(1)); + assert_eq!(v["method"], json!("mcp/message")); + assert_eq!(v["params"]["connectionId"], json!("conn-1")); + let id = v["id"].as_u64().unwrap(); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":id,"result":{"pong":true}})) + .await; + }); + + let resp = send_request( + &out_tx, + &pending, + &next_id, + "mcp/message", + json!({"connectionId":"conn-1"}), + 5, + ) + .await + .unwrap(); + assert_eq!(resp["result"]["pong"], json!(true)); + driver.await.unwrap(); + assert_eq!(next_id.load(Ordering::Relaxed), 2, "the id counter advanced"); + } + + #[tokio::test] + async fn mcp_tunnel_connect_and_message_roundtrip() { + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + // Mock extension: answer mcp/connect with a connectionId, then turn an mcp/message + // tools/list into an inner result, routing each reply by the frame's own outer id. + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f1: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f1["method"], json!("mcp/connect")); + assert_eq!(f1["params"]["acpId"], json!("srv-1")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f1["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; + + let f2: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f2["method"], json!("mcp/message")); + assert_eq!(f2["params"]["connectionId"], json!("conn-9")); + assert_eq!(f2["params"]["method"], json!("tools/list")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f2["id"],"result":{"tools":[{"name":"browser.click"}]}}), + ) + .await; + }); + + let conn = mcp_connect(&out_tx, &pending, &next_id, "srv-1", 5) + .await + .unwrap(); + assert_eq!(conn, "conn-9"); + let result = mcp_message_request(&out_tx, &pending, &next_id, &conn, "tools/list", None, 5) + .await + .unwrap(); + assert_eq!(result["tools"][0]["name"], json!("browser.click")); + ext.await.unwrap(); + } + + #[tokio::test] + async fn tunnel_handle_mcp_message_roundtrips() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let handle = super::TunnelHandle { + out_tx, + pending: pending.clone(), + next_id, + connection_id: "conn-9".into(), + }; + + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/message")); + assert_eq!(f["params"]["connectionId"], json!("conn-9")); + assert_eq!(f["params"]["method"], json!("tools/call")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"ok":true}})) + .await; + }); + + let result = handle + .mcp_message("tools/call", Some(json!({"name": "browser.click"})), 5) + .await + .unwrap(); + assert_eq!(result["ok"], json!(true)); + ext.await.unwrap(); + } + + #[tokio::test] + async fn establish_tunnel_registers_handle_under_channel_and_server_id() { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let registry = super::new_tunnel_registry(); + + // mock extension: answer mcp/connect with a connectionId + let pending2 = pending.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + assert_eq!(f["params"]["acpId"], json!("srv-1")); + route_client_response(&pending2, &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}})) + .await; + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "acp_abc".into(), + registry.clone(), + 5, + ) + .await + .unwrap(); + ext.await.unwrap(); + + assert!( + registry + .lock() + .unwrap() + .contains_key(&("acp_abc".to_string(), "srv-1".to_string())), + "a TunnelHandle must be registered under (channel_id, server_id)" + ); + } +} + #[cfg(test)] mod acp_handlers { use super::{ - handle_initialize, handle_session_new, handle_session_resume, AcpSession, JsonRpcRequest, + handle_initialize, handle_session_new, handle_session_resume, parse_acp_mcp_servers, + AcpMcpServer, AcpSession, JsonRpcRequest, }; use serde_json::json; use std::collections::HashMap; @@ -1638,12 +2303,48 @@ mod acp_handlers { #[tokio::test] async fn session_new_mints_and_stores_a_session() { let sessions = new_sessions(); - let v = serde_json::to_value(handle_session_new(&sessions, json!(2)).await).unwrap(); + let v = + serde_json::to_value(handle_session_new(&sessions, json!(2), vec![]).await.0).unwrap(); let sid = v["result"]["sessionId"].as_str().unwrap(); assert!(sid.starts_with("sess_"), "sessionId must be sess_: {sid}"); assert!(sessions.lock().await.contains_key(sid), "session must be stored"); } + #[test] + fn parse_acp_mcp_servers_keeps_only_acp_entries() { + let params = json!({ + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "srv-1", "name": "browser"}, + {"type": "http", "url": "http://x"}, + {"type": "acp", "id": "srv-2", "name": "other"} + ] + }); + assert_eq!( + parse_acp_mcp_servers(Some(¶ms)), + vec![ + AcpMcpServer { id: "srv-1".into(), name: "browser".into() }, + AcpMcpServer { id: "srv-2".into(), name: "other".into() }, + ] + ); + // no mcpServers -> empty + assert!(parse_acp_mcp_servers(Some(&json!({"cwd": "/w"}))).is_empty()); + assert!(parse_acp_mcp_servers(None).is_empty()); + } + + #[tokio::test] + async fn session_new_records_declared_acp_servers() { + let sessions = new_sessions(); + let servers = vec![AcpMcpServer { id: "srv-1".into(), name: "browser".into() }]; + let v = serde_json::to_value( + handle_session_new(&sessions, json!(2), servers.clone()).await.0, + ) + .unwrap(); + let sid = v["result"]["sessionId"].as_str().unwrap().to_string(); + let guard = sessions.lock().await; + assert_eq!(guard.get(&sid).unwrap().acp_mcp_servers, servers); + } + #[tokio::test] async fn session_resume_valid_stores_and_invalid_errors() { let sessions = new_sessions(); @@ -1831,6 +2532,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); // Cancel arrives before the handler's stream loop (reserved-then-immediate-cancel). @@ -1874,6 +2576,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); @@ -1907,7 +2610,7 @@ mod acp_review_fixes { let cancel = Arc::new(tokio::sync::Notify::new()); sessions.lock().await.insert( sid.clone(), - AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()) }, + AcpSession { channel_id: channel_id.clone(), busy: true, cancel: Some(cancel.clone()), acp_mcp_servers: Vec::new() }, ); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -1983,6 +2686,7 @@ mod acp_review_fixes { channel_id: format!("acp_{}", Uuid::new_v4()), busy: true, cancel: Some(cancel.clone()), + acp_mcp_servers: Vec::new(), }, ); let params = json!({"sessionId": sid}); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index b64a4e5e6..b730c90f0 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -62,6 +62,8 @@ pub struct AppState { pub acp: Option, #[cfg(feature = "acp")] pub acp_reply_registry: Option, + #[cfg(feature = "acp")] + pub acp_tunnel_registry: Option, pub ws_token: Option, pub event_tx: broadcast::Sender, pub reply_token_cache: ReplyTokenCache, @@ -105,6 +107,8 @@ impl AppState { acp: None, #[cfg(feature = "acp")] acp_reply_registry: None, + #[cfg(feature = "acp")] + acp_tunnel_registry: None, ws_token: None, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -182,6 +186,8 @@ impl AppState { let acp = adapters::acp_server::AcpConfig::from_env(); #[cfg(feature = "acp")] let acp_reply_registry = acp.as_ref().map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp.as_ref().map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -212,6 +218,8 @@ impl AppState { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -695,6 +703,10 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { let acp_reply_registry = acp .as_ref() .map(|_| adapters::acp_server::new_reply_registry()); + #[cfg(feature = "acp")] + let acp_tunnel_registry = acp + .as_ref() + .map(|_| adapters::acp_server::new_tunnel_registry()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -729,6 +741,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, ws_token, event_tx, reply_token_cache, diff --git a/crates/openab-mcp/src/lib.rs b/crates/openab-mcp/src/lib.rs index 53cd3fca2..b2ce17814 100644 --- a/crates/openab-mcp/src/lib.rs +++ b/crates/openab-mcp/src/lib.rs @@ -20,6 +20,10 @@ //! original `openab-agent` layout so the moved code's `crate::` paths and //! the agent's `crate::…` re-export shims stay stable. +/// Re-exported for `CapabilitySource` implementors in dependent crates +/// (the trait surface names `rmcp::model::Tool`). +pub use rmcp; + pub mod acp; pub mod auth; pub mod llm; diff --git a/docs/adr/acp-server-websocket-base.md b/docs/adr/acp-server-websocket-base.md index c6f6a2de7..783cf4de5 100644 --- a/docs/adr/acp-server-websocket-base.md +++ b/docs/adr/acp-server-websocket-base.md @@ -217,9 +217,12 @@ and rejecting forged ids. ## 6. Roadmap (re-scoped; not the original proposal's numbered phases) North star: the agent's LLM autonomously operating the user's real browser (generalized -"computer use") — see [MCP-over-ACP browser control](./acp-server-websocket-mcp-browser.md). +"computer use") — see [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md). ### Critical path (next) — everything the browser goal requires +> **Done in #1447** — all four items below shipped (agent→client request direction, the +> MCP-over-ACP tunnel + core MCP proxy, and the generated v1 wire types). See the +> [Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md). - **agent→client REQUEST direction** — the base does only client→agent + agent→client *notifications*; browser/tool use needs the agent to send *requests* to the client and await a result. The WS is already bidirectional; the dispatch loop must add this path. @@ -329,4 +332,4 @@ Notes: - Original proposal: [acp-server-websocket.md](./acp-server-websocket.md) - Official method surface + coverage: [acp-official-methods.md](../acp-official-methods.md) -- MCP-over-ACP browser control: [acp-server-websocket-mcp-browser.md](./acp-server-websocket-mcp-browser.md) +- Reverse MCP-over-ACP over WebSocket: [acp-server-websocket-reverse-mcp.md](./acp-server-websocket-reverse-mcp.md) diff --git a/docs/adr/acp-server-websocket-mcp-browser.md b/docs/adr/acp-server-websocket-mcp-browser.md index 7757cde7d..efe3eceba 100644 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ b/docs/adr/acp-server-websocket-mcp-browser.md @@ -1,104 +1,200 @@ -# ADR: Browser control via MCP-over-ACP (proposed) +# ADR: Browser control via MCP-over-ACP -- **Status:** Proposed (design only — not implemented). North-star capability the base - builds toward; see the base ADR §6 roadmap "Critical path". -- **Date:** 2026-07-18 +- **Status:** Accepted — OpenAB side **as-built in #1447** (compiles + unit-tested; live-validated + 2026-07-20). The browser extension implements the [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **Date:** 2026-07-18 (updated 2026-07-24) - **Author:** @brettchien -- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), - [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), - [openab-agent MCP](./openab-agent-mcp.md) +- **Related:** **Mechanism — roles, call route, generalization, and the architecture + usage-sequence + diagrams: [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md).** + [Base (as-built)](./acp-server-websocket-base.md), [tunnel contract](../mcp-over-acp-tunnel-contract.md), + [browser MCP agent setup](../browser-mcp-agent-setup.md). --- -## 1. Context - -The base ships a 1:1 streaming chat ACP server at `GET /acp`; a browser side-panel -extension connects as an ACP client and drives an OpenAB agent. The next goal is for the -agent's **LLM to autonomously operate the user's browser** (click, read the DOM, navigate) -— i.e. browser "computer use", but targeting the user's real, logged-in Chrome session -rather than a sandbox VM. - -## 2. Decision - -Expose the browser as **MCP tools** and route them to the agent via **MCP-over-ACP**, -tunnelled over the **existing `/acp` WebSocket** the extension already holds. - -Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use browser -actions, they must appear in the agent's tool list (`tools/list`) so the model discovers -and calls them. A custom `ExtRequest` is a transport-level ACP extension the LLM never -sees as a tool — it only fits OpenAB-driven (non-LLM) operations. MCP is the standard way -agents receive tools, so browser actions must be MCP tools. - -### Roles -- **Extension = MCP server (role/logic).** It handles `tools/list` / `tools/call` and - executes DOM actions. An MV3 extension cannot open a *listening* socket, but MCP - server/client is about *who provides tools*, not who opens the connection — so the - extension serves MCP over the **outbound `/acp` WS it already opened**. This is the only - way a can't-listen extension can be a full MCP server. -- **OpenAB core = MCP proxy/aggregator.** OpenAB is a middlebox between two ACP - connections. It consumes the extension's tools from the upstream tunnel and re-exposes - them to the agent downstream (via `mcpServers`) so the LLM's `tools/list` sees them. -- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Gemini …) is a subprocess - colocated in the OpenAB pod; it calls the tools over its in-pod ACP/MCP link. - -### Call route — the agent is in-pod; only the extension is remote +## 1. Context & scope + +The [base](./acp-server-websocket-base.md) ships a 1:1 streaming chat ACP server at `GET /acp`; a +browser side-panel extension connects as an ACP client and drives an OpenAB agent. The goal here is +for the agent's **LLM to autonomously operate the user's real, logged-in Chrome** (click, read the +DOM, navigate) — browser "computer use" against the user's own session, not a sandbox VM. + +This ADR is the **browser-specific design** and the design the **browser extension** implements. The +underlying transport — how a can't-listen WS client serves MCP over its own `/acp` WS, the roles, +the call route, and the generalization to multiple servers — is the +[Reverse MCP-over-ACP ADR](./acp-server-websocket-reverse-mcp.md); its **§4 architecture diagram** +and **§5 usage-sequence diagram** illustrate this exact browser flow. + +## 2. Browser toolset + +Five **DOM-semantic** MCP tools, served by the extension: `browser.read_dom` (snapshot), +`browser.screenshot`, `browser.navigate`, `browser.click(selector)`, `browser.type(selector, text)`. + +- **DOM-semantic, not a model-specific `computer` (pixel) tool** — `click(selector)` / `read_dom` + are cheaper, more reliable, and model-agnostic; screenshot + coordinates remain expressible if + wanted, but are not the primary surface. +- **Screenshots are JPEG** (`captureVisibleTab {format:"jpeg", quality:70}`, ~300–500 KB); the ACP + frame cap is raised 1→8 MiB to carry tool results. PNG base64 (~5.5 MB) would exceed the cap. + +## 3. Design decisions (D1–D6) + +- **D1 — permission model.** Auto-approve **all** browser tool permissions for now: core keeps + auto-replying `session/request_permission` with OK. Fine-grained consent is deferred. Consequence: + a dedicated `request_permission`-relay task is **dropped**, but the server→client request machinery + is still required for the upstream MCP tunnel. +- **D2 — how the agent receives the tools (injection).** The ACP `session/new` `mcpServers` parameter + is **not** reliable: Cursor's CLI ignores ACP-passed MCP servers and only loads MCP from its **own + config** (`.cursor/mcp.json`) — see [zed#50924](https://github.com/zed-industries/zed/issues/50924). + So the proxy is registered **per-agent, in that agent's native MCP config** (Cursor → `.cursor/mcp.json`; + Kiro → `.kiro/settings/mcp.json`; others via their own file/format). The **content** (an HTTP MCP + entry: `url` + `headers`) is portable across vendors. +- **D3 — where MCP is tunnelled.** Downstream (agent ↔ core) is a **normal** in-process + Streamable-HTTP MCP server on `127.0.0.1:` (loopback + bearer, via `rmcp`); the agent connects + to it like any other MCP server. Only the **upstream** (core/gateway ↔ extension) is tunnelled — an + MV3 extension cannot listen — adopting the official + [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) framing (`mcp/connect` → + `connectionId`, then `mcp/message`), not a hand-rolled envelope. +- **D4 — lifecycle: the WS may connect *after* session start.** Core's HTTP MCP server is always-on + and decoupled from the extension WS. As shipped, browser tools are **static-advertised** regardless + of WS state (a `tools/call` with no extension attached returns an MCP error "browser not connected"). + `notifications/tools/list_changed` on attach/detach is **designed but not yet implemented** — no code + emits it today (grep `list_changed` → 0 hits in the gateway/core crates); it is tracked as P2b in + [reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md). **Superseded as the default:** the + generic design ([reverse-MCP ADR §6.2](./acp-server-websocket-reverse-mcp.md)) drops static-advertise + as the default in favour of dynamic `tools/list` forwarding + `list_changed`, keeping static-advertise + as an opt-in for the browser case. +- **D5 — per-session MCP server.** The pool starts one loopback Streamable-HTTP MCP proxy per `acp:` + session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so + correlation is implicit. Server lifetime is tied to the `AcpConnection` via a `CancellationToken` + `DropGuard`, so it stops on any evict path. +- **D6 — tunnel trait in core, impl in root.** `openab-core` defines `mcp_proxy::BrowserTunnel` + (generically `AcpMcpTunnel` under [reverse-MCP ADR §6.1](./acp-server-websocket-reverse-mcp.md)); the + **root** binary implements it (`src/browser_tunnel.rs`) by looking up the gateway's + `AcpTunnelRegistry` and calling `TunnelHandle::mcp_message`. This keeps `openab-core` and + `openab-gateway` sibling-independent (no cross-crate dep), mirroring the `ChatAdapter` root-glue pattern. + +## 4. Runtime sequence (detailed) — one `browser.click` round-trip + +The high-level phase diagram is in [reverse-MCP ADR §5](./acp-server-websocket-reverse-mcp.md); this is +the message-level detail, including the **two id spaces**. + +``` +Participants A = agent/LLM (Cursor, MCP client) C = core (HTTP MCP srv + proxy) + G = gateway (/acp WS srv) E = extension (MCP server, browser) + +Transports --ACP--> downstream ACP over stdio (chat / permission) + --HTTP--> downstream HTTP MCP, 127.0.0.1 loopback (tools) + ==WS===> upstream /acp WebSocket (official mcp/message tunnel; only hop off-pod) + +Precondition: session open, extension WS attached, tools/list already discovered +-------------------------------------------------------------------------------- + 1 A --ACP--> C session/request_permission {toolCall:"click #submit"} id=acp#1 + 2 A <--ACP-- C result: allow <- core auto-approves (D1) id=acp#1 + .............................................................................. + 3 A --HTTP--> C tools/call name=browser.click args={selector:"#submit"} id=mcp#7 + 4 C --(in-pod handoff)--> G wrap upstream: mcp/message connId=conn-1 + params={method:"tools/call", ...} FLATTENED, no inner id id=acp#55 + 5 G ==WS===> E server->client request = MCP-over-ACP outer id=acp#55 <-off-pod + 6 E chrome.scripting.executeScript -> clicks #submit, page -> /thanks + 7 G <==WS== E response result={ok,url:"/thanks"} (the inner MCP result) outer id=acp#55 <-on-pod + 8 C <--(in-pod)-- G gateway pending-map matches acp#55 -> core maps the result back to mcp#7 + 9 A <--HTTP- C tools/call result {content:[{text:"clicked; now /thanks"}]} id=mcp#7 + .............................................................................. +10 A LLM consumes the tool result, keeps reasoning +11 A --ACP--> C session/update agent_message_chunk {"I clicked Submit..."} (notif) +12 C ==WS===> E chat stream forwarded on /acp -> user sees narration <-off-pod +-------------------------------------------------------------------------------- +Two id spaces (never mixed) + - mcp#7 = MCP-layer id, lives ONLY on the agent<->core HTTP hop (steps 3/9). Per the RFD, + mcp/message FLATTENS the inner method/params and does NOT carry an inner MCP id, so + mcp#7 never travels on the tunnel. + - acp#55 = outer ACP-envelope id correlating the whole upstream round-trip (steps 4<->8); the + response result IS the inner MCP result payload. The core proxy maps mcp#7 <-> acp#55. + - acp#1 = downstream ACP permission id; unrelated to the two above + +Only steps 5/7/12 leave the pod (all on the /acp WS). If the extension is not attached at step 5, +core returns an MCP error "browser not connected" (D4 static-advertise: fails gracefully, no crash). +``` + +## 5. Execution flow (bootstrap → discovery → runtime) + ``` - REMOTE (user's browser) OPENAB POD (`openab run` — one process tree) - ┌──────────────────┐ ┌────────────────────────────────────────────────────┐ - │ browser extension│ │ ┌─────────┐ ┌───────────┐ ┌──────────────────┐ │ - │ = MCP SERVER │◀─/acp─▶│ │ gateway │──▶│ core │──▶│ agent CLI │ │ - │ browser tools │ WS │ │ /acp srv│ │ MCP proxy │ │ (subprocess) │ │ - └──────────────────┘ (only │ └─────────┘ └───────────┘ │ LLM (MCP client)│ │ - remote │ ▲ in-pod └──────────────────┘ │ - hop) │ └── stdio: ACP + MCP(mcpServers) ──┘ - └────────────────────────────────────────────────────┘ - - one tool call (LLM clicks a button); only ❸/❺ leave the pod: - ❶ LLM ─tools/call "browser.click"─▶ core (MCP proxy) [in-pod] - ❷ core ─▶ gateway ─❸ MCP-over-ACP──▶ extension [out of pod → remote] - ❹ extension runs it in the browser - ❺ result ──▶ gateway ─❻▶ core ─❼▶ LLM continues [remote → back in-pod] +Legend = ACP (JSON-RPC over stdio) - HTTP MCP (loopback) <=> /acp WS (only hop off-pod) + [C] = MCP client [S] = MCP server + + OPENAB POD (`openab run`) REMOTE (user browser) + ┌───────────┐ ┌──────────────────────────────────┐ ┌────────────────┐ + │ agent CLI │===│ gateway core │ │ browser ext. │ + │ (Cursor) │ │ /acp srv MCP proxy + │ │ (katashiro) │ + │ LLM [C] │-┐ │ HTTP MCP srv :PORT │<==WS==> │ [S] browser │ + └───────────┘ │ └──────────────────────────────────┘ └────────────────┘ + └── http 127.0.0.1:PORT ──┘ + +Bootstrap + B1 core starts in-process HTTP MCP server @127.0.0.1:PORT (loopback + bearer) + B2 core [per-agent adapter] writes the HTTP MCP entry into the agent's native config + (Cursor → .cursor/mcp.json) BEFORE the agent boots + B3 extension opens /acp WS <=> gateway; ACP initialize; session/new declares its browser + MCP server ("type":"acp") + B4 gateway --mcp/connect--> extension → connectionId (upstream tunnel established) + B5 agent boots, reads config → HTTP-connects to core's MCP server → MCP initialize + +Discovery (tools/list) + D1 agent --http tools/list--> core proxy + D2 core --mcp/message: tools/list--> gateway <=> extension (or served from static set, D4) + D3 extension returns [click, read_dom, navigate, type, screenshot] + D4 core returns the list --http--> agent → the LLM now sees browser tools + +Runtime → see §4 for the detailed id-paired round-trip. ``` -### One WebSocket, multiplexed -The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / -session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), -distinguished by ACP method namespace. No second connection. - -## 3. Protocol gap to close first - -The base does only client→agent (prompt) and agent→client **notifications** (streaming -text). Browser control needs the **agent→client REQUEST** direction (request/response: -the agent asks the client to do X and awaits a result). The WS is already bidirectional; -`acp_server`'s dispatch loop must add the agent-initiated-request path. This is also the -point to move the wire types from hand-rolled to **generated** (see §5). - -## 4. Alternatives considered - -- **Custom `ExtRequest` per browser action** — rejected: not surfaced to the LLM as a - tool, so the model can't autonomously call it. Fits OpenAB-driven ops only. -- **Extension hosts a standalone MCP server (HTTP/SSE)** — rejected: MV3 extensions - cannot open a listening socket. -- **Anthropic-style `computer` tool (screenshot + pixel coords)** — subsumed: you can - expose `screenshot` + `click(x,y)` as MCP tools if desired, but DOM-semantic tools - (`click(selector)`, `read_dom`) are cheaper/more reliable and model-agnostic. - -## 5. Typing / dependencies - -- Bidirectional tool-call / client-method messages are exactly where hand-rolling breaks; - adopt **generated types** for the expanded surface. Use **v1** schema (stable; `v2` is - experimental and currently wire-identical). Prefer offline codegen (e.g. `typify`) to - emit plain-serde types — this avoids the `schemars`-heavy dependency tree the official - `agent-client-protocol-schema` crate pulls in for `JsonSchema` derives OpenAB doesn't - use at runtime. -- The MCP protocol machinery itself (handshake, tool lifecycle, tunnel framing) is NOT - just types — it needs an MCP implementation (e.g. `rmcp`, already used by - `openab-agent`), plus the ACP-tunnel transport glue. - -## 6. Relationship to Computer Use - -Same category as browser "computer use" (LLM autonomously drives a browser via a -perceive→act tool loop), but generalized: (a) targets the **user's real Chrome** (live, -logged-in), not a sandbox; (b) action surface is **extension-defined MCP tools** -(DOM-semantic or screenshot), not a model-specific tool; (c) **model-agnostic** — any -MCP-capable agent can use it. +## 6. Findings & ownership + +- The **agent→client REQUEST direction already existed downstream**: `openab-core`'s ACP connection + receives `session/request_permission` from the agent and auto-replies it. So the server→client + request work is *relaying* those upstream to the `/acp` client, not green-field. +- `session/new` / `session/resume` send `mcpServers: []`, but that path is **not** how the agent gets + the tools (D2 — Cursor ignores ACP-passed MCP servers). Tools reach the agent via core's proxy HTTP + MCP server registered in the agent's **native** config. +- **Ownership** — OpenAB side (`feat/acp-mcp-browser`): server→client request direction, generated + typed wire, tunnel framing + core proxy. Extension side (katashiro): MCP server role over the + outbound `/acp` WS + the DOM tools + executing in the active tab. Both: integration/e2e. + +## 7. Tasks (as executed) + +- **T0 spike** — confirm a CLI loads an HTTP MCP server from its native config, honours + `tools/list_changed`, and handles a `tools/call` error gracefully. +- **T1** agent→client REQUEST direction (relay; gateway outbound request + pending-response map; + read loop distinguishes client response vs request). +- **T2** migrate `acp_server` to generated typed wire (bidirectional surface). +- **T3** `session/request_permission` — **dropped** (D1 auto-approve). +- **T4** MCP-over-ACP tunnel framing (upstream only) — adopt the RFD (`mcp/connect` + `mcp/message` + + `mcp/disconnect`); contract doc: [tunnel contract](../mcp-over-acp-tunnel-contract.md). +- **T5** OpenAB core = MCP proxy/aggregator (in-process HTTP MCP server; per-agent config injection; + proxy as MCP client to the extension over the tunnel; tool-call routing; `rmcp` wiring). +- **T6** extension (katashiro) = MCP server + the five DOM tools, executing via `chrome.scripting`. +- **T7** integration + e2e (`scripts/acp-ws-smoke.py`) + deploy. + +## 8. As-built (2026-07-20, OpenAB side wired end-to-end) + +Realised call path (all in one `openab run` process): + +``` +agent tools/call ─http▶ core per-session ProxyHandler (mcp_proxy.rs) + ─▶ BrowserTunnel (core trait) ─▶ RootBrowserTunnel (root, src/browser_tunnel.rs) + ─▶ gateway AcpTunnelRegistry[channel_id] ─▶ TunnelHandle::mcp_message + ═mcp/message═▶ extension (only this hop leaves the pod) +``` + +Config injection is per-agent (`.cursor/mcp.json` / `.kiro/settings/mcp.json` merged at the session +workdir, loopback + bearer). The full loop (read_dom / screenshot / navigate / click / type + status +pill + reconnect on `session/resume`) was live-validated on a real deployment on 2026-07-20. A second +downstream delivery mode — `bridge` (stdio relay, `OPENAB_BROWSER_MODE`) — is also shipped; see the +[reverse-MCP ADR §6.3](./acp-server-websocket-reverse-mcp.md). + +## 9. References + +- **Mechanism:** [Reverse MCP-over-ACP over WebSocket](./acp-server-websocket-reverse-mcp.md) + (roles, call route, architecture + sequence diagrams, multi-server generalization) +- [Base ADR](./acp-server-websocket-base.md) · [tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) diff --git a/docs/adr/acp-server-websocket-reverse-mcp.md b/docs/adr/acp-server-websocket-reverse-mcp.md new file mode 100644 index 000000000..e85cd5211 --- /dev/null +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -0,0 +1,228 @@ +# ADR: Reverse MCP-over-ACP over WebSocket + +- **Status:** Accepted — the mechanism is **as-built in #1447**; the generic multi-server + generalization (§6) is accepted and implementing in the same PR. +- **Date:** 2026-07-18 (updated 2026-07-24) +- **Author:** @brettchien +- **Related:** [ACP Server over WebSocket — Base (as-built)](./acp-server-websocket-base.md), + [ACP Server with WebSocket Transport](./acp-server-websocket.md) (original proposal), + [openab-agent MCP](./openab-agent-mcp.md). + **Browser-specific design + the contract the extension implements:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md). + +--- + +## 1. Context + +This ADR records **reverse MCP-over-ACP**: a mechanism that lets an ACP **WebSocket client** — +one that cannot open a listening socket — nevertheless act as an **MCP server**, serving its +tools to a colocated agent over the outbound `/acp` WS it already holds. OpenAB core is the MCP +proxy/aggregator in the middle; the agent is a normal in-pod MCP client. + +The first, driving consumer is **browser control**: a browser side-panel extension serves DOM +tools so the agent's LLM can autonomously operate the user's real, logged-in Chrome (see the +[browser ADR](./acp-server-websocket-mcp-browser.md) for that concrete design and the extension +contract). This ADR describes the general mechanism and its generalization to **multiple, +arbitrary** client-side MCP servers (§6), using browser control as the running example. + +## 2. Decision + +Expose a client-side capability as **MCP tools** and route them to the agent via **MCP-over-ACP**, +tunnelled over the **existing `/acp` WebSocket** the client already holds. + +Why MCP (not a custom ACP `ExtRequest`): for the LLM to *autonomously* use a capability, its +actions must appear in the agent's tool list (`tools/list`) so the model discovers and calls them. +A custom `ExtRequest` is a transport-level ACP extension the LLM never sees as a tool — it only +fits OpenAB-driven (non-LLM) operations. MCP is the standard way agents receive tools. + +### Roles +- **ACP WS client = MCP server (role/logic).** It handles `tools/list` / `tools/call` and executes + the actions. A client that cannot open a *listening* socket (e.g. an MV3 browser extension) can + still be an MCP server — MCP server/client is about *who provides tools*, not who opens the + connection — so it serves MCP over the **outbound `/acp` WS it already opened**. This is the only + way a can't-listen client can be a full MCP server. +- **OpenAB core = MCP proxy/aggregator.** A middlebox between two connections: it consumes the + client's tools from the upstream tunnel and re-exposes them to the agent downstream so the LLM's + `tools/list` sees them. +- **Agent = MCP client.** The agent (Claude / Codex / Cursor / Kiro …) is a subprocess colocated in + the OpenAB pod; it calls the tools over its in-pod MCP link. + +### One WebSocket, multiplexed +The single `/acp` WS carries BOTH the ACP chat session (initialize / session.prompt / +session.update) AND the tunnelled MCP traffic (tools/list / tools/call / results), distinguished by +ACP method namespace. No second connection. This multiplexing applies to the **upstream** hop +(client ↔ gateway), using the official MCP-over-ACP `mcp/message` framing. The **downstream** hop +(core ↔ agent) is *not* tunnelled over ACP — core hosts a normal in-process MCP server the agent +connects to; only the client, which cannot listen, needs MCP tunnelled over its `/acp` WS. + +## 3. Protocol gap to close first + +The base does only client→agent (prompt) and agent→client **notifications** (streaming text). +Reverse MCP needs the **agent→client REQUEST** direction (request/response: the agent asks the +client to do X and awaits a result). The WS is already bidirectional; `acp_server`'s dispatch loop +adds the agent-initiated-request path. This is also where the wire types move from hand-rolled to +**generated** (see §8). + +## 4. Architecture (browser control as the example) + +```mermaid +flowchart LR + EXT["Side-panel MV3 extension = MCP SERVER
(cannot open a listening socket → serves MCP
over the outbound /acp WS it already holds)
tools: read_dom · screenshot · navigate · click · type"] + subgraph POD["OPENAB POD — 'openab run', one process tree"] + direction LR + GW["openab-gateway
/acp WS server
AcpTunnelRegistry"] + CORE["openab-core
MCP proxy /
aggregator"] + AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] + GW <--> CORE + CORE ==>|"proxy mode (default)
per-session loopback HTTP MCP
{url,headers} → .cursor / .kiro mcp.json
bearer-gated · 0600 · stripped on evict"| AGENT + CORE -.->|"bridge mode (Option C)
per-pod unix socket + stdio relay
'openab browser-bridge' · static {command,args}
channel via process-ancestry (multi-window)"| AGENT + end + EXT <==>|"UPSTREAM — only remote hop
MCP-over-ACP · mcp/message framing
multiplexed with ACP chat on ONE /acp WSS
8 MiB frame cap · JPEG screenshots"| GW + classDef remote fill:#fde68a,stroke:#b45309,color:#111; + classDef pod fill:#bfdbfe,stroke:#1e40af,color:#111; + class EXT remote; + class GW,CORE,AGENT pod; +``` + +Only the client (extension) is remote; core, gateway and agent are one in-pod `openab run` process +tree. The downstream hop has two delivery modes (§ browser ADR / §6.3): `proxy` (HTTP MCP, default) +and `bridge` (stdio relay). + +## 5. MCP usage sequence (browser.click as the example) + +```mermaid +sequenceDiagram + autonumber + participant Tab as Chrome tab
(user's real, logged-in) + participant Ext as browser ext.
MCP SERVER + participant GW as openab-gateway
/acp WS + participant Core as openab-core
MCP proxy + participant LLM as agent LLM
MCP client + + Note over Ext,LLM: PHASE 1 — connect & tool discovery + Ext->>GW: WS GET /acp — initialize
mcpServers = [ type:acp, "openab-browser" ] + GW-->>Ext: initialize result (agentCapabilities) + Ext->>GW: session/new (or session/resume on reconnect) + GW->>GW: register per-session TunnelHandle
(AcpTunnelRegistry) + GW->>Core: spawn agent + start per-session MCP proxy + Core->>Core: write "openab-browser" into agent's mcp.json
proxy: {url,headers} · bridge: static {command,args} + LLM->>Core: MCP initialize + tools/list + Core->>GW: tools/list (MCP-over-ACP: mcp/message frame) + GW->>Ext: mcp/message → tools/list + Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type + GW-->>Core: tools result + Core-->>LLM: tools/list — browser tools now in the model's tool list + + Note over Tab,LLM: PHASE 2 — one autonomous action (e.g. click) + LLM->>Core: tools/call browser.click(selector) + Core->>GW: tools/call (mcp/message over the SAME /acp WS) + GW->>Ext: mcp/message → tools/call + Ext->>Tab: chrome.scripting / tabs API
click · type · read_dom · captureVisibleTab · navigate + Tab-->>Ext: DOM mutated / navigated / pixels + Ext-->>GW: tool result
(screenshot = JPEG q70, frame <= 8 MiB) + GW-->>Core: result + Core-->>LLM: tool result + LLM->>GW: session/update agent_message_chunk (narration) + GW->>Ext: streamed to the side panel + + Note over GW,Ext: only the gateway-to-extension hop leaves the pod. LLM, core and gateway stay in-pod. +``` + +The exact two-id-space bookkeeping (outer ACP-envelope id ↔ inner MCP id, flattened per the RFD) is +detailed in the [browser ADR](./acp-server-websocket-mcp-browser.md) §4. + +## 6. Generalization — multiple client-side MCP servers + +The browser path wires **one** MCP server. This section is the accepted direction (implementing in +#1447) to make reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** +`type:acp` MCP servers on `initialize`, and the agent's LLM discovers and calls each server's real +tools. The browser extension becomes *one instance* of the mechanism, not a special case. + +Three pieces already generalize and are reused as-is: +- `parse_acp_mcp_servers` already parses **N** `type:acp` entries with arbitrary `{id, name}`. +- `establish_and_register_tunnel(…, srv.id, …)` already threads the declared `srv.id` into + `mcp/connect` — the wire already carries a per-server discriminator. +- `ProxyHandler::forward_tool_call` forwards **any** tool name+args down the tunnel — no + browser-specific validation. + +### 6.1 Address every hop by `(channel_id, serverId)` +- `AcpTunnelRegistry` becomes keyed by `(channel_id, serverId)` instead of `channel_id` alone — the + "one tunnel per session" collapse was a fan-out fix; the correct fix is a **compound key**. +- Rename the core trait `BrowserTunnel` → **`AcpMcpTunnel`**; `call(channel_id, server_id, method, params)`. +- Evict all `(channel_id, *)` entries on session teardown. + +### 6.2 Dynamic tool discovery (supersedes static-advertise as the default) +- Each per-server proxy's `tools/list` forwards the client server's **real** tool list over its tunnel. +- **Unattached / reconnecting:** return an empty list for that server — never fabricate tools. +- **Attach → refresh:** on tunnel attach (or client re-declare on `session/resume`), push + `notifications/tools/list_changed` downstream so the agent re-lists. This preserves the + "usable before/without attach" UX that the browser's static-advertise (D4) gave, honestly. +- Keep a per-server **cache** of the last good `tools/list` to survive brief reconnects; **debounce** + `list_changed` against reconnect storms. +- The browser's static-advertise stays only as an **opt-in** fallback for the browser case; it is no + longer the default. + +### 6.3 Per-server downstream exposure (Option B — decided) +Each declared client server is surfaced to the agent as its **own** MCP server entry (`openab-`), +not merged into one namespaced blob: +- **proxy mode (HTTP):** one loopback MCP server per `(session, server)`, its own port + bearer; + openab writes **N entries** into the agent's native MCP config, one per server. +- **bridge mode (stdio):** the static entry gains a selector — + `{"command":"openab","args":["mcp-bridge","--server",""]}` — one relay per server (rename the + `browser-bridge` subcommand to `mcp-bridge`, keeping `browser-bridge` as a compat alias). + +Rejected — **Option A** (one aggregating proxy, `__` namespacing): the prefix leaks into +the tool names the model sees and needs reversible de-namespacing on every call. Option B is cleaner +for the LLM and maps to MCP's native "one server = one connection" model. (A stays a possible future mode.) + +### 6.4 Backward compatibility +The browser extension is unchanged: it declares `{type:acp, id, name:"openab-browser"}` and serves its +five DOM tools via its own `tools/list` — now discovered dynamically instead of static-advertised. Both +downstream modes are retained; single-server (browser-only) sessions behave identically. + +### 6.5 Generic implementation plan (folded into #1447) +- **P1** compound-key registry + `serverId` on the tunnel trait (no behaviour change; browser stays single). +- **P2** dynamic `tools/list` forwarding + per-server cache + `list_changed` attach/detach lifecycle. +- **P3** per-server downstream exposure (Option B) in both proxy + bridge modes; loop config-writing. +- **P4** generalize naming (`AcpMcpTunnel`, `openab-`, `openab mcp-bridge`) + error strings. +- **P5** e2e: a second, non-browser `type:acp` MCP server declared alongside the browser — both + discovered and callable in one session. + +## 7. Alternatives considered + +- **Custom `ExtRequest` per action** — rejected: not surfaced to the LLM as a tool, so the model + can't call it autonomously. Fits OpenAB-driven ops only. +- **Client hosts a standalone MCP server (HTTP/SSE)** — rejected for can't-listen clients: an MV3 + extension cannot open a listening socket. +- **On-stream MCP-over-ACP for the downstream hop** — rejected: agents already connect to normal MCP + servers well; a special on-stream MCP type is invasive + ([ACP discussion #58](https://github.com/orgs/agentclientprotocol/discussions/58)). Only the + can't-listen *client* leg is tunnelled; downstream stays a normal in-process MCP server. +- **Static-advertise as the default** — superseded by §6.2 (dynamic + `list_changed`); kept as an + opt-in for browser only. + +## 8. Typing / dependencies + +- Bidirectional tool-call / client-method messages are where hand-rolling breaks; the expanded + surface uses **generated** serde-only **v1** wire types (offline `typify` codegen, avoiding the + `schemars`-heavy `agent-client-protocol-schema` crate). Landed in the base. +- The MCP machinery (handshake, tool lifecycle, tunnel framing) needs an MCP implementation + (`rmcp`, already used by `openab-agent`) plus the ACP-tunnel transport glue. + +## 9. Relationship to Computer Use + +Same category as "computer use" (LLM autonomously drives an app via a perceive→act tool loop), but +generalized: (a) targets the **user's real** app/session (e.g. logged-in Chrome), not a sandbox; (b) +the action surface is **client-defined MCP tools** (DOM-semantic or screenshot), not a model-specific +tool; (c) **model-agnostic** — any MCP-capable agent can use it. + +## 10. References + +- [Base ADR](./acp-server-websocket-base.md) · [Original proposal](./acp-server-websocket.md) · + [openab-agent MCP](./openab-agent-mcp.md) +- **Browser-specific design + extension contract:** + [Browser control via MCP-over-ACP](./acp-server-websocket-mcp-browser.md) +- [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.md) · + [Browser MCP agent setup](../browser-mcp-agent-setup.md) +- [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp) · MCP + `notifications/tools/list_changed` diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md new file mode 100644 index 000000000..fce45231e --- /dev/null +++ b/docs/browser-mcp-agent-setup.md @@ -0,0 +1,97 @@ +# Browser MCP — how the agent gets the `openab-browser` tools + +The browser MCP server exposes five DOM-semantic tools — +`browser.read_dom`, `browser.screenshot`, `browser.navigate`, `browser.click`, `browser.type` — +served by the **browser extension** over the MCP-over-ACP tunnel (see +[tunnel contract](./mcp-over-acp-tunnel-contract.md)). This doc covers the *other* hop: how the +colocated agent CLI actually **sees** those tools. + +## How it reaches the agent + +The gateway↔extension tunnel terminates in the pod at a **per-session loopback MCP proxy** +(`openab-core` `mcp_proxy::start_session_server`). To expose it to the agent, openab writes an +`openab-browser` entry into the agent CLI's **native MCP config file** — the agent connects to +`http://127.0.0.1:/mcp` and re-lists the tools. The entry looks like: + +```json +{ + "mcpServers": { + "openab-browser": { + "url": "http://127.0.0.1:/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Both `` and `` are **minted fresh per session** and the entry is stripped on session +evict — so this is written by openab, not hand-editable to a fixed value (see *Caveat* below). + +## Per-variant MCP config location + +openab writes the same entry into whichever file the colocated CLI reads. Current state: + +| Variant | MCP config file (under `$workdir`, = `$HOME`) | HTTP MCP + `headers` | Auto-written by openab today | +|---|---|---|---| +| **Cursor** (`cursor-agent`) | `.cursor/mcp.json` | yes | ✅ yes | +| **Kiro** (`kiro-cli`) | `.kiro/settings/mcp.json` | yes | ✅ yes | +| **Claude Code** | `.mcp.json` / `~/.claude.json` `mcpServers` | yes | ⛔ not yet | +| **Codex** | `~/.codex/config.toml` `[mcp_servers.*]` (TOML) | check version | ⛔ not yet | +| **Gemini CLI** | `~/.gemini/settings.json` `mcpServers` | yes | ⛔ not yet | + +`start_session_server` currently writes **`.cursor/mcp.json` + `.kiro/settings/mcp.json`**. Adding +a variant = teach that function the CLI's config path + format (same `{url, headers}` shape for +JSON configs; Codex uses TOML and needs a small serializer). + +## Manual / unsupported variants + +If a variant isn't auto-written, a user *could* add the `openab-browser` entry to that CLI's +mcp.json by hand — **but** the current proxy endpoint is per-session ephemeral (fresh port + +bearer each session), so a static hand-written entry goes stale on the next session and cannot +be used as-is. Manual configuration for arbitrary variants is therefore gated on a **stable +browser-MCP endpoint** (a fixed URL + stable auth the user configures once). That redesign is +tracked separately (see `drafts/` browser-MCP stable-endpoint design). Until then: + +- **Cursor / Kiro:** work out of the box (auto-injected). +- **Other variants:** either add the variant's writer to `start_session_server`, or wait for the + stable endpoint. + +## Verify + +Inside the agent pod, after the extension attaches a browser session: + +```sh +# the entry openab wrote for this session +cat "$HOME/.cursor/mcp.json" # Cursor +cat "$HOME/.kiro/settings/mcp.json" # Kiro + +# does the CLI see the server / tools? (CLI-specific; e.g. Kiro:) +kiro-cli mcp list +``` + +Gateway log confirms the extension side: +`ACP: browser tunnel registered — extension attached`. + +## Facade mode (default when `[mcp]` is enabled) + +With the OAB MCP Facade running (`[mcp]` in `config.toml`), browser tools are +served as a **session-aware in-process capability source** of the facade +instead of per-session proxy servers: + +- **One listener** (the facade's, e.g. `127.0.0.1:8848/mcp`) — no per-session + ports, no per-session config rewrites. +- **Identity**: the pool mints one token per chat session and injects it as + `OPENAB_SESSION_TOKEN` into the agent process environment; the (static, + write-once) MCP config entry references it as + `"Authorization": "Bearer ${OPENAB_SESSION_TOKEN}"`. Tokens are revoked on + session evict; calls route to that session's browser via the same + `channel_id` tunnel contract as proxy mode. +- **Discovery**: agents find browser tools through `search_capabilities` + alongside every other facade capability, and execute them via + `execute_capability`. + +Mode selection (`OPENAB_BROWSER_MODE`): unset/`facade` → facade routing when +the facade is serving, with automatic fallback to `proxy` when it is not +(no `[mcp]` section); `proxy` → force the original per-session loopback +servers; `bridge` → Option C stdio bridge. Proxy and bridge behavior is +unchanged. diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md new file mode 100644 index 000000000..af82b4f43 --- /dev/null +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -0,0 +1,121 @@ +# MCP-over-ACP tunnel — extension implementation contract + +This is the wire contract the **browser extension** (the ACP client / MCP server end) +implements so the OpenAB gateway can tunnel MCP to it over the existing `/acp` WebSocket, per +[ADR: Reverse MCP-over-ACP over WebSocket](./adr/acp-server-websocket-reverse-mcp.md). It adopts +the official [MCP-over-ACP RFD](https://agentclientprotocol.com/rfds/mcp-over-acp). + +Scope: only the **gateway ↔ extension** hop (the sole hop that leaves the pod). How OpenAB +routes tool calls internally (core-hosted MCP proxy, agent subprocess) is out of scope for +the extension and may change without affecting this contract. + +## Roles + +- **Extension = ACP client + MCP server.** It opens the `/acp` WS, drives the chat session, + and *serves* the browser MCP tools over that same socket. +- **Gateway = ACP server + MCP client (connector).** It initiates `mcp/connect` / + `mcp/message` / `mcp/disconnect` toward the extension. + +An MV3 extension cannot open a listening socket, so MCP is tunnelled over the outbound WS the +extension already holds — that is the whole point of this contract. + +## 1. Transport + auth (unchanged from the base) + +`GET /acp` WebSocket. Bearer auth via the `Sec-WebSocket-Protocol` offer +`openab.bearer., acp.v1`; the server echoes `acp.v1`. All frames are JSON-RPC 2.0. + +## 2. Declaring the MCP server (in `session/new`) + +When the extension creates a session it declares its browser MCP server in the `mcpServers` +array with the `acp` transport type: + +```json +{ "method": "session/new", + "params": { + "cwd": "...", + "mcpServers": [ + { "type": "acp", "id": "", "name": "browser" } + ] } } +``` + +- `id` is extension-generated and stable for the session; the gateway uses it as the `acpId` + in `mcp/connect`. +- The gateway records this declaration per session (it does not yet act on it until it + connects — see §3). + +## 3. Opening the tunnel — `mcp/connect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/connect", "params": { "acpId":"" } } +``` + +Extension replies with a fresh, extension-assigned connection handle: + +```json +{ "jsonrpc":"2.0", "id":, "result": { "connectionId":"" } } +``` + +`connectionId` scopes all subsequent `mcp/message` traffic for this MCP connection. + +## 4. Carrying MCP — `mcp/message` (bidirectional) + +The inner MCP method + params are **flattened** into the `mcp/message` params (there is **no** +inner MCP `id`; correlation is by the outer ACP JSON-RPC id): + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/message", + "params": { "connectionId":"", "method":"", "params": { ... } } } +``` + +- **Request** (outer frame has `id`): the extension executes the inner MCP method and replies + with the **inner MCP result as the ACP response `result`**: + ```json + { "jsonrpc":"2.0", "id":, "result": { ...inner MCP result... } } + ``` + An inner MCP-level error is returned as the outer JSON-RPC `error`. +- **Notification** (outer frame has no `id`): fire-and-forget inner MCP notification; no + reply. The **extension** sends these upward for server-originated MCP notifications (e.g. + `notifications/tools/list_changed` when its tool set changes). + +Inner MCP methods the extension must handle as a server: +- `initialize` → advertise `capabilities.tools`. +- `tools/list` → return the browser tools (§6). +- `tools/call` → execute the named tool in the active tab; return an MCP `CallToolResult`. + +## 5. Closing — `mcp/disconnect` (gateway → extension, request) + +```json +{ "jsonrpc":"2.0", "id":, "method":"mcp/disconnect", "params": { "connectionId":"" } } +``` +Extension releases the connection and replies `{ "result": {} }`. + +## 6. Browser tools (the MCP tools the extension serves) + +Baseline DOM-semantic set (model-agnostic; OpenAB also static-advertises these so they appear +even before the extension attaches). `tools/call` executes in the **active tab**. + +| name | arguments | behaviour | +|---|---|---| +| `browser.click` | `{ "selector": string }` | click the element matching the CSS selector | +| `browser.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | +| `browser.navigate` | `{ "url": string }` | navigate the active tab to the URL | +| `browser.type` | `{ "selector": string, "text": string }` | type text into the matched element | +| `browser.screenshot` | `{}` | capture a screenshot of the active tab | + +`tools/call` returns an MCP `CallToolResult` (`{ "content": [ { "type":"text", "text":... } ] }`, +or an image content block for `screenshot`). On failure return an MCP tool error result. The +extension MAY expose additional tools beyond this baseline; they surface to the agent via +`tools/list` + a `tools/list_changed` notification. + +## 7. Permissions + +OpenAB core auto-approves tool permissions today (ADR D1); the extension does **not** need a +per-call consent UX yet. Fine-grained consent is a later addition to this contract. + +## Notes for implementers + +- One `connectionId` per `mcp/connect`; the gateway may reconnect (the MCP server / HTTP + proxy inside OpenAB is decoupled from the WS lifecycle, so the extension may attach after a + session has already started — ADR D4). +- Never assume an inner MCP `id`; always correlate by the outer ACP frame `id`. +- Keep tool execution idempotent where possible; the agent may retry. diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index cfc313edd..d55f3cc01 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -314,6 +314,67 @@ async def section_lifecycle(): record("life", stop == "cancelled", "session/cancel → prompt ends stopReason:cancelled", f"got {stop!r}") +async def collect_mcp_connects(c: Conn, ws, mcp_servers, window=6.0): + """session/new with the given mcpServers, then collect the server-initiated mcp/connect + requests the gateway issues within `window` seconds, answering each with a connectionId. + Returns (sessionId, [mcp/connect frames]).""" + r = await c.call("session/new", {"cwd": "/home/agent", "mcpServers": mcp_servers}) + sid = r.get("result", {}).get("sessionId", "") + connects = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + window + while loop.time() < deadline: + try: + m = json.loads(await asyncio.wait_for(ws.recv(), timeout=max(0.1, deadline - loop.time()))) + except asyncio.TimeoutError: + break + if m.get("method") == "mcp/connect": + connects.append(m) + await ws.send(json.dumps({"jsonrpc": "2.0", "id": m["id"], "result": {"connectionId": f"conn-{len(connects)}"}})) + return sid, connects + + +async def section_tunnel(): + """MCP-over-ACP tunnel producer (T5.3): a `type:acp` mcpServers entry makes the gateway + open a tunnel to us (a server-initiated mcp/connect). Covers the single case, fan-out over + multiple servers, and mixed-transport filtering. Exercises the live read-loop spawn path + the unit tests can't reach. (The agent→tool→browser leg needs a real extension — T6.)""" + # 1) single type:acp → exactly one mcp/connect carrying the declared id + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + sid, connects = await collect_mcp_connects(c, ws, [{"type": "acp", "id": "srv-solo", "name": "browser"}]) + record("tunnel", sid.startswith("sess_"), "session/new with a type:acp mcpServers entry is accepted") + record("tunnel", len(connects) == 1, "single type:acp server → exactly one server-initiated mcp/connect", f"got {len(connects)}") + if connects: + p = connects[0].get("params", {}) + record("tunnel", p.get("acpId") == "srv-solo", "mcp/connect carries the declared acpId", str(p)) + record("tunnel", connects[0].get("id") is not None, "mcp/connect is a request (has an id)") + + # 2) fan-out: two type:acp servers → one distinct mcp/connect each + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-a", "name": "a"}, {"type": "acp", "id": "srv-b", "name": "b"}] + ) + ids = sorted(x.get("params", {}).get("acpId") for x in connects) + record("tunnel", ids == ["srv-a", "srv-b"], "two type:acp servers → one mcp/connect each (fan-out)", str(ids)) + outer = [x.get("id") for x in connects] + record("tunnel", len(set(outer)) == len(outer) and all(i is not None for i in outer), + "each mcp/connect uses a distinct request id", str(outer)) + + # 3) mixed transports: only the acp server is tunnelled (http is the agent's own concern) + async with await try_connect(TOKEN) as ws: + c = Conn(ws) + await c.initialize() + _, connects = await collect_mcp_connects( + c, ws, [{"type": "acp", "id": "srv-x", "name": "browser"}, {"type": "http", "url": "http://example/mcp"}] + ) + ids = [x.get("params", {}).get("acpId") for x in connects] + record("tunnel", ids == ["srv-x"], "mixed acp+http mcpServers → only the acp one gets mcp/connect", str(ids)) + + async def main() -> int: if not TOKEN: print("ERROR: OPENAB_ACP_TOKEN is required (the /acp endpoint mandates a transport token off loopback).", file=sys.stderr) @@ -323,6 +384,7 @@ async def main() -> int: await section_compliance() await section_edges() await section_lifecycle() + await section_tunnel() total = len(results) passed = sum(1 for _, ok, _ in results if ok) @@ -333,7 +395,7 @@ async def main() -> int: if ok: s[0] += 1 print("\n" + "-" * 60, flush=True) - labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport"} + labels = {"auth": "Transport / Auth", "comp": "Protocol compliance", "edge": "Protocol edge cases", "life": "Lifecycle / transport", "tunnel": "MCP-over-ACP tunnel"} for sec, (p, t) in by_section.items(): print(f" {labels.get(sec, sec):22} {p}/{t}", flush=True) print(f"\nRESULT: {passed}/{total} checks passed", flush=True) diff --git a/src/browser_bridge.rs b/src/browser_bridge.rs new file mode 100644 index 000000000..84756fc02 --- /dev/null +++ b/src/browser_bridge.rs @@ -0,0 +1,224 @@ +//! `openab browser-bridge` — a stdio MCP server that is a thin relay to the per-pod browser +//! socket (Option C). The agent's MCP client spawns it per session; it reads +//! `OPENAB_BROWSER_CHANNEL` from its inherited env, wraps each stdin MCP request as +//! `{channel_id, request}`, forwards it to the core socket, and relays responses to stdout +//! verbatim. ALL browser MCP logic lives in core (`dispatch_browser_mcp`) — this is a pure pipe +//! + channel tag, so the config line agents carry is static (`{"command":"openab","args": +//! ["browser-bridge"]}`) and disambiguation is by the inherited env, never by config. +//! +//! stdout carries the MCP wire, so this path emits nothing to stdout except MCP responses +//! (diagnostics, if any, go to stderr). + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Wrap one stdin MCP request line into a socket frame `{channel_id, request}`. Returns `None` +/// for a blank/unparseable line (skip it) so a stray line can't break the relay. +fn wrap_frame(channel: &str, line: &str) -> Option> { + let line = line.trim(); + if line.is_empty() { + return None; + } + let request: Value = serde_json::from_str(line).ok()?; + let frame = json!({ "channel_id": channel, "request": request }); + let mut buf = serde_json::to_vec(&frame).ok()?; + buf.push(b'\n'); + Some(buf) +} + +/// Resolve this bridge's browser channel. The MCP client scrubs the child env (verified: cursor +/// launches us with only HOME/PATH/USER, or the pod env — never the per-session +/// OPENAB_BROWSER_CHANNEL openab injects into the AGENT), so we can't read it from our own env. +/// Instead walk UP the process tree and read OPENAB_BROWSER_CHANNEL from the first ancestor that +/// has it: the agent process openab spawned (channel injected via the pool) is always an ancestor +/// of this stdio MCP server, for EVERY vendor. Prefer our own env first (clients that DO pass it); +/// return "" if not found (core then reports "no browser attached"). +fn resolve_channel() -> String { + if let Ok(c) = std::env::var("OPENAB_BROWSER_CHANNEL") { + if !c.is_empty() { + return c; + } + } + let mut pid = std::process::id(); + for _ in 0..16 { + if let Ok(bytes) = std::fs::read(format!("/proc/{pid}/environ")) { + if let Some(c) = parse_channel_from_environ(&bytes) { + return c; + } + } + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + break; + }; + match parse_ppid_from_stat(&stat) { + Some(ppid) if ppid > 1 => pid = ppid, // step up (stop at init/tini = 1) + _ => break, + } + } + String::new() +} + +/// Extract OPENAB_BROWSER_CHANNEL from a null-separated `/proc//environ` blob. +fn parse_channel_from_environ(bytes: &[u8]) -> Option { + for kv in bytes.split(|&b| b == 0) { + if let Some(rest) = kv.strip_prefix(b"OPENAB_BROWSER_CHANNEL=") { + if !rest.is_empty() { + return Some(String::from_utf8_lossy(rest).into_owned()); + } + } + } + None +} + +/// Parse the parent PID from a `/proc//stat` line. Field 2 (`comm`) is parenthesized and may +/// contain spaces or `)`, so split after the LAST `)`: the remainder is "state ppid pgrp ...". +fn parse_ppid_from_stat(stat: &str) -> Option { + let after = stat.rsplit_once(')')?.1; + after.split_whitespace().nth(1)?.parse().ok() +} + +/// Run the bridge: connect the core socket, then pump stdin→socket (channel-tagged) and +/// socket→stdout (verbatim MCP responses) until either side closes. +pub async fn run() -> std::io::Result<()> { + let channel = resolve_channel(); + eprintln!("[openab browser-bridge] resolved channel={channel:?}"); + let sock = + tokio::net::UnixStream::connect(openab_core::mcp_proxy::browser_socket_path()).await?; + let (sock_rd, sock_wr) = sock.into_split(); + pump( + channel, + BufReader::new(tokio::io::stdin()), + tokio::io::stdout(), + BufReader::new(sock_rd), + sock_wr, + ) + .await +} + +/// The relay, generic over the four streams so it can be tested with in-memory pipes. Ends when +/// either stdin (agent gone) or the socket (core gone) closes. +async fn pump( + channel: String, + mut stdin: In, + mut stdout: Out, + mut sock_rd: SockR, + mut sock_wr: SockW, +) -> std::io::Result<()> +where + In: AsyncBufReadExt + Unpin, + Out: AsyncWriteExt + Unpin, + SockR: AsyncBufReadExt + Unpin, + SockW: AsyncWriteExt + Unpin, +{ + let to_sock = async { + let mut line = String::new(); + loop { + line.clear(); + if stdin.read_line(&mut line).await? == 0 { + break; // stdin closed → agent gone + } + if let Some(frame) = wrap_frame(&channel, &line) { + sock_wr.write_all(&frame).await?; + sock_wr.flush().await?; + } + } + Ok::<(), std::io::Error>(()) + }; + let to_stdout = async { + let mut line = String::new(); + loop { + line.clear(); + if sock_rd.read_line(&mut line).await? == 0 { + break; // socket closed → core gone + } + stdout.write_all(line.as_bytes()).await?; + stdout.flush().await?; + } + Ok::<(), std::io::Error>(()) + }; + // Whichever side closes first ends the relay; the other pump is dropped (we're shutting down). + tokio::select! { + r = to_sock => r, + r = to_stdout => r, + } +} + +#[cfg(test)] +mod tests { + use super::{parse_channel_from_environ, parse_ppid_from_stat, pump, wrap_frame}; + + #[test] + fn parse_ppid_handles_comm_with_spaces_and_parens() { + assert_eq!(parse_ppid_from_stat("834 (sh) S 25 834 25 0 -1 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("658 (cursor agent) R 25 658 ..."), Some(25)); + assert_eq!(parse_ppid_from_stat("5 (weird )proc) S 3 5 ..."), Some(3)); // ')' inside comm + assert_eq!(parse_ppid_from_stat("nonsense"), None); + } + + #[test] + fn parse_channel_from_environ_finds_the_var() { + let env = b"HOME=/home/agent\0OPENAB_BROWSER_CHANNEL=acp_xyz\0PATH=/bin\0"; + assert_eq!(parse_channel_from_environ(env).as_deref(), Some("acp_xyz")); + assert_eq!(parse_channel_from_environ(b"HOME=/x\0PATH=/y\0"), None); + assert_eq!(parse_channel_from_environ(b"OPENAB_BROWSER_CHANNEL=\0"), None); // empty ignored + } + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + #[test] + fn wrap_frame_tags_the_channel_and_appends_newline() { + let out = wrap_frame("acp_win1", r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).unwrap(); + assert_eq!(*out.last().unwrap(), b'\n'); + let v: serde_json::Value = serde_json::from_slice(&out[..out.len() - 1]).unwrap(); + assert_eq!(v["channel_id"], "acp_win1"); + assert_eq!(v["request"]["method"], "tools/list"); + assert_eq!(v["request"]["id"], 1); + } + + #[test] + fn wrap_frame_skips_blank_and_malformed_lines() { + assert!(wrap_frame("c", " ").is_none()); + assert!(wrap_frame("c", "").is_none()); + assert!(wrap_frame("c", "not json").is_none()); + } + + #[tokio::test] + async fn pump_relays_request_to_socket_and_response_to_stdout() { + // Four in-memory pipes standing in for stdin, stdout, and the two socket halves. + let (mut stdin_w, stdin_r) = tokio::io::duplex(1024); + let (stdout_w, mut stdout_r) = tokio::io::duplex(1024); + let (mut sock_peer_w, sock_rd) = tokio::io::duplex(1024); // core → bridge (responses) + let (sock_wr, mut sock_peer_r) = tokio::io::duplex(1024); // bridge → core (frames) + + let handle = tokio::spawn(pump( + "acp_win1".to_string(), + BufReader::new(stdin_r), + stdout_w, + BufReader::new(sock_rd), + sock_wr, + )); + + // Agent writes an MCP request on stdin → bridge should emit a channel-tagged frame to core. + stdin_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{}}\n") + .await + .unwrap(); + let mut frame = String::new(); + BufReader::new(&mut sock_peer_r).read_line(&mut frame).await.unwrap(); + let fv: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(fv["channel_id"], "acp_win1"); + assert_eq!(fv["request"]["id"], 9); + + // Core writes an MCP response on the socket → bridge should relay it verbatim to stdout. + sock_peer_w + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"ok\":true}}\n") + .await + .unwrap(); + let mut out = String::new(); + BufReader::new(&mut stdout_r).read_line(&mut out).await.unwrap(); + let ov: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(ov["id"], 9); + assert_eq!(ov["result"]["ok"], true); + + drop(stdin_w); // agent gone → relay ends + let _ = handle.await; + } +} diff --git a/src/browser_source.rs b/src/browser_source.rs new file mode 100644 index 000000000..959a95fc8 --- /dev/null +++ b/src/browser_source.rs @@ -0,0 +1,96 @@ +//! Browser capability source (Facade mode): serves the browser tool set as a +//! **session-aware in-process capability source** of the OAB MCP Facade +//! (`openab_mcp::mcp::sources`), replacing the per-session loopback proxy as +//! the default transport. Identity comes from the broker-minted session +//! token (`OPENAB_SESSION_TOKEN` in the agent's env → `Authorization` header +//! → `SessionCtx`), and calls route into the same MCP-over-ACP tunnel the +//! proxy used — `channel_id` semantics unchanged. +//! +//! Root-hosted because it needs both worlds: `openab_mcp`'s source trait and +//! `openab_core`'s tunnel bridge (core and the mcp crate stay independent). + +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use openab_core::mcp_proxy::AcpMcpTunnel; +use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use serde_json::{json, Map, Value}; + +/// Facade capability source backed by the browser MCP-over-ACP tunnel. +pub struct BrowserSource { + tunnel: Arc, +} + +impl BrowserSource { + pub fn new(tunnel: Arc) -> Self { + Self { tunnel } + } +} + +#[async_trait::async_trait] +impl CapabilitySource for BrowserSource { + fn provider(&self) -> &str { + "openab-browser" + } + + /// D4 static-advertise (unchanged from proxy mode): the tool set is + /// constant regardless of extension attachment — a call while + /// disconnected returns a "browser not connected" error result rather + /// than catalog flapping. + fn tools(&self, _ctx: Option<&SessionCtx>) -> Vec { + openab_core::mcp_proxy::browser_tools() + } + + async fn call( + &self, + ctx: Option<&SessionCtx>, + tool: &str, + args: &Map, + ) -> Result<(Value, bool)> { + // requires_session() guarantees ctx in practice; defend anyway. + let ctx = ctx.ok_or_else(|| anyhow!("browser capabilities require a session token"))?; + let params = json!({ "name": tool, "arguments": args }); + // Empty server_id sentinel (Fork A) — same routing contract as the + // per-session proxy: RootBrowserTunnel resolves the sole tunnel on + // the channel. + match self + .tunnel + .call(&ctx.channel_id, "", "tools/call", Some(params)) + .await + { + // The tunnel returns the inner MCP CallToolResult payload; pass + // it through and mirror its own isError flag. + Ok(result) => { + let is_error = result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok((result, is_error)) + } + // Tunnel-level failure (no extension attached, session gone): + // an error *result* — the agent gets an actionable message, the + // facade dispatch itself did not fault. + Err(msg) => Ok(( + json!({ "content": [{ "type": "text", "text": msg }], "isError": true }), + true, + )), + } + } + + fn requires_session(&self) -> bool { + true + } +} + +/// Root-side adapter: exposes the facade's `SessionTokens` registry through +/// core's `SessionTokenRegistrar` hook (core cannot depend on openab-mcp). +pub struct FacadeRegistrar(pub openab_mcp::mcp::sources::SessionTokens); + +impl openab_core::mcp_proxy::SessionTokenRegistrar for FacadeRegistrar { + fn mint(&self, channel_id: &str) -> String { + self.0.mint(channel_id) + } + fn revoke(&self, channel_id: &str) { + self.0.revoke_channel(channel_id) + } +} diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs new file mode 100644 index 000000000..1c46488ad --- /dev/null +++ b/src/browser_tunnel.rs @@ -0,0 +1,58 @@ +//! Root-side bridge implementing the core `AcpMcpTunnel` trait (D6-a'). Reads the gateway's +//! per-session MCP-over-ACP tunnel registry and forwards a tool call to the client MCP server +//! attached to a given `(channel_id, server_id)`. This lives in the root binary — the only place +//! that depends on BOTH openab-core (the trait) and openab-gateway (the `TunnelHandle`), +//! preserving the two crates' sibling independence (mirroring the existing `ChatAdapter` glue at +//! the root). + +use openab_core::mcp_proxy::AcpMcpTunnel; +use openab_gateway::adapters::acp_server::AcpTunnelRegistry; +use serde_json::Value; + +pub struct RootBrowserTunnel { + registry: AcpTunnelRegistry, +} + +impl RootBrowserTunnel { + pub fn new(registry: AcpTunnelRegistry) -> Self { + Self { registry } + } +} + +#[async_trait::async_trait] +impl AcpMcpTunnel for RootBrowserTunnel { + async fn call( + &self, + channel_id: &str, + server_id: &str, + method: &str, + params: Option, + ) -> Result { + // Clone the handle out under the lock; never hold the std mutex across `.await`. + let handle = { + let reg = self.registry.lock().unwrap_or_else(|e| e.into_inner()); + if server_id.is_empty() { + // Fork A sentinel: the single-browser proxy/bridge doesn't know the client- + // declared server id, so resolve the SOLE tunnel registered for this channel. + // Ambiguous (0 or >1) is an error — real per-server routing arrives in P2. + let mut matches = reg.iter().filter(|((c, _), _)| c == channel_id); + match (matches.next(), matches.next()) { + (Some((_, h)), None) => Some(h.clone()), + (None, _) => None, + (Some(_), Some(_)) => { + return Err(format!( + "multiple MCP servers attached to session {channel_id}; a server_id is required to disambiguate" + )); + } + } + } else { + reg.get(&(channel_id.to_string(), server_id.to_string())) + .cloned() + } + }; + match handle { + Some(h) => h.mcp_message(method, params, 30).await, + None => Err(format!("no browser attached to session {channel_id}")), + } + } +} diff --git a/src/main.rs b/src/main.rs index b922e92ac..622eac800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,12 @@ mod ctl; feature = "acp", ))] mod unified_adapter; +#[cfg(feature = "acp")] +mod browser_tunnel; +#[cfg(feature = "acp")] +mod browser_source; +#[cfg(feature = "acp")] +mod browser_bridge; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -99,6 +105,11 @@ enum Commands { #[arg(long, default_value = "kiro-cli acp --trust-all-tools")] command: String, }, + /// Internal: stdio MCP bridge to the per-pod browser socket (Option C). Spawned per session + /// by the agent's MCP client; relays MCP over stdio to core's browser tunnel by inherited + /// OPENAB_BROWSER_CHANNEL. + #[cfg(feature = "acp")] + BrowserBridge, /// Set a runtime value (e.g. thread.name) Set { /// Key to set (e.g. thread.name) @@ -309,6 +320,10 @@ async fn main() -> anyhow::Result<()> { } => { return acp::agentcore::run_bridge(&runtime_arn, ®ion, &command).await; } + #[cfg(feature = "acp")] + Commands::BrowserBridge => { + return browser_bridge::run().await.map_err(Into::into); + } Commands::Set { key, value, thread } => { let resp = ctl::send_request(&ctl::Request { action: ctl::Action::Set, @@ -452,30 +467,91 @@ async fn main() -> anyhow::Result<()> { let shutdown_hook = cfg.hooks.pre_shutdown.clone(); + // Shared MCP-over-ACP tunnel registry (D6-a'): the gateway populates it per browser + // session; the core MCP proxy reads it via the RootBrowserTunnel bridge below. + #[cfg(feature = "acp")] + let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + #[cfg(feature = "acp")] + let browser_tunnel: Arc = Arc::new( + browser_tunnel::RootBrowserTunnel::new(acp_tunnel_registry.clone()), + ); + // OAB MCP Facade (`[mcp]` in config.toml — OAB MCP Adapter ADR §6.2): // serve the loopback Streamable HTTP MCP server in-process so any coding // CLI on this host can reach authorized external capabilities via // http:///mcp. Absent section = no listener (backward compat). // A bind failure is fatal at startup (fail fast, like a bad platform // token) rather than a silently missing capability surface. + // + // Browser capabilities (Facade mode, default): registered as a + // session-aware in-process source — one listener, per-session identity + // via broker-minted tokens; no per-session proxy servers. + let facade_sessions = openab_mcp::mcp::sources::SessionTokens::new(); + // Only read under the acp feature (pool facade wiring below). + #[cfg(feature = "acp")] + let facade_serving = cfg.mcp.is_some(); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); + let tokens = facade_sessions.clone(); + #[allow(unused_mut)] + let mut sources: Vec> = Vec::new(); + #[cfg(feature = "acp")] + if !openab_core::mcp_proxy::browser_mode().is_bridge() { + sources.push(Arc::new(browser_source::BrowserSource::new( + browser_tunnel.clone(), + ))); + } tokio::spawn(async move { - if let Err(e) = openab_mcp::mcp::facade::serve_http(&listen).await { + if let Err(e) = + openab_mcp::mcp::facade::serve_http_with(&listen, sources, tokens).await + { tracing::error!(error = %format!("{e:#}"), listen, "OAB MCP facade exited"); std::process::exit(1); } }); } - let pool = Arc::new(acp::SessionPool::new( + let pool_inner = acp::SessionPool::new( cfg.agent, cfg.pool.max_sessions, cfg.pool .prompt_hard_timeout_secs .saturating_add(cfg.pool.hung_grace_secs), cfg.pool.default_config_options, - )); + ); + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_browser_tunnel(Some(browser_tunnel.clone())); + // Facade mode session wiring: only when the facade is actually serving — + // otherwise the pool's mode fallback keeps the per-session proxy path. + #[cfg(feature = "acp")] + let pool_inner = pool_inner.with_facade_sessions( + facade_serving.then(|| { + Arc::new(browser_source::FacadeRegistrar(facade_sessions.clone())) + as Arc + }), + facade_serving.then(|| { + format!( + "http://{}/mcp", + cfg.mcp.as_ref().map(|m| m.listen.as_str()).unwrap_or("127.0.0.1:8848") + ) + }), + ); + // Option C bridge mode: start the per-pod browser socket server once; the `openab + // browser-bridge` shims each agent spawns dial it. Proxy mode (default) skips this. + #[cfg(feature = "acp")] + if openab_core::mcp_proxy::browser_mode().is_bridge() { + let sock = openab_core::mcp_proxy::browser_socket_path(); + match openab_core::mcp_proxy::serve_browser_socket_forever( + sock.clone(), + Some(browser_tunnel.clone()), + ) + .await + { + Ok(()) => info!(?sock, "browser bridge socket serving (Option C)"), + Err(e) => warn!(?sock, error = %e, "failed to start browser bridge socket"), + } + } + let pool = Arc::new(pool_inner); let ttl_secs = cfg.pool.session_ttl_hours * 3600; // Resolve STT config (auto-detect GROQ_API_KEY from env) @@ -1047,6 +1123,12 @@ async fn main() -> anyhow::Result<()> { // Build gateway AppState from env vars (shared factory with standalone gateway) let mut gw_state_inner = openab_gateway::AppState::from_env(event_tx.clone(), None); + // Share the tunnel registry the core MCP proxy reads (D6-a'), so the gateway + // populates the same map the RootBrowserTunnel bridge looks up. + #[cfg(feature = "acp")] + { + gw_state_inner.acp_tunnel_registry = Some(acp_tunnel_registry.clone()); + } // First-class `[telegram]` config overrides env-derived values