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/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7ec296af..122ec6014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,41 @@ jobs: # `-p openab-gateway` to avoid the workspace hooks::tests parallel flake. - name: cargo test (acp gateway) run: cargo test -p openab-gateway --features acp + # Same default-features gap on the core side: `mcp_proxy` is the only `acp-mcp`-gated module, + # so its tunnel-trait, tools-cache and bridge-dispatch tests never compile above either. + # Filtered to `mcp_proxy::` for the same reason the gateway step is scoped to one package — + # it keeps the flaky hooks::tests out of this job. + - name: cargo test (acp-mcp core) + # `cargo test` exits 0 when a filter selects NOTHING, so "selected nothing" and "everything + # passed" are indistinguishable — that is precisely how a compiled-but-never-selected test + # shipped here before. Count the selection first and fail on zero. + # + # The filter lives in a variable inside a block scalar: written inline after `run:`, its + # trailing `::` reads as a YAML mapping indicator and makes the whole file unparseable. + run: | + set -euo pipefail + FILTER='mcp_proxy::' + n=$(cargo test -p openab-core --features acp-mcp "$FILTER" -- --list | grep -c ': test$' || true) + echo "filter '$FILTER' selected $n test(s)" + [ "$n" -gt 0 ] || { echo "::error::filter '$FILTER' selected ZERO tests — stale or mistyped"; exit 1; } + cargo test -p openab-core --features acp-mcp "$FILTER" + # The pool's facade-session tests are `acp-mcp`-gated too but live outside `mcp_proxy::`, so + # the filter above compiled them and then selected them away. Naming the module runs them + # while still leaving the flaky hooks::tests out. + - name: cargo test (acp-mcp pool) + # Same zero-match guard as above, and the same reason for the block scalar. + run: | + set -euo pipefail + FILTER='acp::pool::' + n=$(cargo test -p openab-core --features acp-mcp "$FILTER" -- --list | grep -c ': test$' || true) + echo "filter '$FILTER' selected $n test(s)" + [ "$n" -gt 0 ] || { echo "::error::filter '$FILTER' selected ZERO tests — stale or mistyped"; exit 1; } + cargo test -p openab-core --features acp-mcp "$FILTER" + # And the root package's own tests — the ACP-tunnel capability source (browser_source.rs) and + # are `acp`-gated, so `--workspace` above skips + # them too. This is where the source's routing and trust-gate coverage lives. + - name: cargo test (acp root) + run: cargo test --features acp - name: cargo build (unified) run: cargo build --features unified diff --git a/Cargo.lock b/Cargo.lock index f03ae71d4..89f00d9c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2550,6 +2550,7 @@ dependencies = [ "rand 0.8.6", "regex", "reqwest 0.12.28", + "rmcp", "rpassword", "rustls 0.22.4", "serde", @@ -2561,6 +2562,7 @@ dependencies = [ "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 605aadbef..5a834a13f 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"] lineworks = ["dep:openab-gateway", "dep:axum", "openab-gateway/lineworks"] [dev-dependencies] diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index 9d8b7389d..1ede398f1 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -49,6 +49,11 @@ aws-credential-types = { version = "1", optional = true } urlencoding = { version = "2", optional = true } hex = { version = "0.4", optional = true } http = { version = "1", optional = true } +# Browser tool definitions for the facade's capability source. Only `rmcp::model` is used now — +# the loopback MCP server that needed the server + streamable-http transport features (and its own +# axum listener) was removed with the per-session proxy. +rmcp = { version = "1.7", default-features = false, optional = true } +tokio-util = { version = "0.7", optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -65,3 +70,6 @@ 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-side wiring for MCP-over-ACP browser control (enabled by the root `acp`): the browser tool +# definitions and the agent's facade MCP config. Core hosts no MCP server of its own any more. +acp-mcp = ["dep:rmcp", "dep:tokio-util"] diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index f40ceb486..5f5d83747 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -189,6 +189,14 @@ pub struct AcpConnection { pub session_reset: bool, _reader_handle: JoinHandle<()>, _stderr_handle: Option>, + /// Revokes this session's facade token when the connection is dropped, on any evict path. + /// Held only for its `Drop` side effect (never read). + /// + /// It used to cancel a per-session MCP proxy server; that server is gone and the guard now + /// carries the minted token instead. + #[cfg(feature = "acp-mcp")] + #[allow(dead_code)] + facade_token_guard: Option, } /// Build the final set of env vars for the agent subprocess. @@ -485,9 +493,17 @@ impl AcpConnection { session_reset: false, _reader_handle: reader_handle, _stderr_handle: stderr_handle, + #[cfg(feature = "acp-mcp")] + facade_token_guard: None, }) } + /// Attach the guard that revokes this session's facade token when the connection drops. + #[cfg(feature = "acp-mcp")] + pub fn set_facade_token_guard(&mut self, guard: Option) { + self.facade_token_guard = guard; + } + fn next_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::Relaxed) } diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 394d9f260..29c4d158c 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -53,6 +53,10 @@ pub struct SessionPool { mapping_path: PathBuf, meta_path: PathBuf, default_config_options: HashMap, + #[cfg(feature = "acp-mcp")] + session_registrar: Option>, + #[cfg(feature = "acp-mcp")] + facade_url: Option, } type CancelHandle = (Arc>, String); @@ -102,13 +106,46 @@ fn better_candidate(current_oldest: Option, candidate_last_active: Inst } } -/// Remove every non-`active` pool entry for `key`, reset-style. +/// Prepare facade browser capabilities for one session: write the agent's facade MCP entry, and +/// mint its session token **only if that write succeeded**. /// -/// Hung eviction must NOT leave the session resumable: the old streaming task -/// still holds an Arc clone of the connection, so the agent process may be -/// alive and mid-turn. If the session id stayed in `suspended`/`persisted`, -/// the next message would `session/load` the same session while the old -/// process still owns an in-flight turn. Mirror `reset_session` instead. +/// The token is useless without the config. The entry is what points the agent at the facade and +/// carries `Authorization: Bearer ${OPENAB_SESSION_TOKEN}`; with no entry the agent never reaches +/// the facade and never presents the token. Minting regardless would register a live credential +/// for a session that cannot use it and leave it valid until eviction, while the failure showed up +/// only as a warning. Returning `None` keeps the session running without browser capabilities, +/// which is the honest description of what actually happened. +#[cfg(feature = "acp-mcp")] +async fn setup_facade_session( + workdir: &str, + facade_url: &str, + channel_id: &str, + registrar: &Arc, +) -> Option { + match crate::mcp_proxy::write_facade_mcp_config(workdir, facade_url).await { + Ok(()) => Some(registrar.mint(channel_id)), + Err(e) => { + tracing::error!( + workdir, error = %e, + "facade mcp config write failed — starting this session WITHOUT browser \ + capabilities and not minting a session token that could never be presented" + ); + None + } + } +} + +/// Remove every non-`active` pool entry for `key`. +/// +/// The single implementation for both hung eviction and [`SessionPool::reset_session`]; the latter +/// removes `active` itself and then calls this. It used to be a second copy of the same list, which +/// is how the two could drift — and the line most likely to be lost from a copy is the one below +/// about the creating gate, because it says *not* to remove something. +/// +/// Hung eviction must NOT leave the session resumable: the old streaming task still holds an Arc +/// clone of the connection, so the agent process may be alive and mid-turn. If the session id +/// stayed in `suspended`/`persisted`, the next message would `session/load` the same session while +/// the old process still owns an in-flight turn. fn purge_session_entries(state: &mut PoolState, key: &str) { state.cancel_handles.remove(key); state.activity.remove(key); @@ -197,9 +234,29 @@ impl SessionPool { mapping_path, meta_path, default_config_options, + #[cfg(feature = "acp-mcp")] + session_registrar: None, + #[cfg(feature = "acp-mcp")] + facade_url: None, } } + /// 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,13 +404,70 @@ impl SessionPool { self.config.working_dir.clone() }; + // Browser capabilities for an `acp:` session come from the OAB MCP Facade and nowhere + // else: mint a per-session token (it rides the agent spawn below as OPENAB_SESSION_TOKEN) + // and write the static facade entry before the agent boots. The returned guard revokes + // that token when this connection is dropped, on any evict path. + // + // There is no transport fallback. Without `[mcp]` the root wires no registrar, and the + // session simply starts without browser capabilities — which is the honest outcome and is + // reported once at startup rather than being silently substituted per session. + #[cfg(feature = "acp-mcp")] + let mut session_token: Option = None; + #[cfg(feature = "acp-mcp")] + let facade_token_guard: Option = match ( + thread_id.strip_prefix("acp:"), + self.session_registrar.as_ref(), + self.facade_url.as_ref(), + ) { + (Some(channel_id), Some(registrar), Some(facade_url)) => { + match setup_facade_session(&effective_workdir, facade_url, channel_id, registrar) + .await + { + Some(token) => { + session_token = Some(token.clone()); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session token minted for facade browser capabilities"); + // The guard carries the TOKEN it minted, not the channel. A replaced + // session's teardown runs after its successor has already re-minted for + // the same channel, so revoking by channel would strip the live token and + // silently cut the new agent off from the facade; revoking this exact + // token is a no-op by then (R1). + let ct = tokio_util::sync::CancellationToken::new(); + let child = ct.child_token(); + let registrar = registrar.clone(); + tokio::spawn(async move { + child.cancelled().await; + registrar.revoke(&token); + }); + Some(ct.drop_guard()) + } + // No config, so no token and no revoke guard to arm. The session still + // starts — it simply has no browser capabilities. + None => None, + } + } + _ => 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, ) .await?; @@ -366,7 +480,7 @@ impl SessionPool { if new_conn.supports_load_session { match new_conn.session_load(sid, &effective_workdir).await { Ok(()) => { - info!(thread_id, session_id = %sid, "session resumed via session/load"); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), "session resumed via session/load"); resumed = true; } Err(e) => { @@ -374,7 +488,7 @@ impl SessionPool { let is_transient = TRANSIENT_LOAD_ERRORS.iter().any(|s| err_str.contains(s)); if is_transient { - warn!(thread_id, session_id = %sid, error = %e, + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), error = %e, "session/load failed transiently, preserving session ID for retry"); load_failed = Some(if err_str.contains("timeout waiting for") { "timeout" @@ -382,7 +496,7 @@ impl SessionPool { "connection lost" }); } else { - warn!(thread_id, session_id = %sid, error = %e, + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), session_id = %crate::redact::redact_session_ids(sid), error = %e, "session/load failed, creating new session"); } } @@ -423,6 +537,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_facade_token_guard(facade_token_guard); let new_conn = Arc::new(Mutex::new(new_conn)); let mut state = self.state.write().await; @@ -436,7 +552,7 @@ impl SessionPool { if existing.alive() { return Ok(false); } - warn!(thread_id, "stale connection, rebuilding"); + warn!(thread_id = %crate::redact::redact_session_ids(thread_id), "stale connection, rebuilding"); drop(existing); state.active.remove(thread_id); state.cancel_handles.remove(thread_id); @@ -450,7 +566,7 @@ impl SessionPool { state.cancel_handles.remove(&key); state.activity.remove(&key); state.pgids.remove(&key); - info!(evicted = %key, "pool full, suspending oldest idle session"); + info!(evicted = %crate::redact::redact_session_ids(&key), "pool full, suspending oldest idle session"); if let Some(sid) = sid { state.persisted.insert(key.clone(), sid.clone()); state.suspended.insert(key, sid); @@ -458,7 +574,7 @@ impl SessionPool { state.persisted.remove(&key); } } else { - warn!(evicted = %key, "pool full but eviction candidate changed before removal"); + warn!(evicted = %crate::redact::redact_session_ids(&key), "pool full but eviction candidate changed before removal"); } } else if skipped_locked_candidates > 0 { warn!( @@ -530,7 +646,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; @@ -562,7 +678,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; conn.set_config_option(config_id, value).await @@ -578,7 +694,7 @@ impl SessionPool { .active .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no connection for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let mut conn = conn.lock().await; conn.get_usage().await @@ -593,14 +709,14 @@ impl SessionPool { .cancel_handles .get(thread_id) .cloned() - .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? + .ok_or_else(|| anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id)))? }; let data = serde_json::to_string(&serde_json::json!({ "jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id, "sending session/cancel"); + tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "sending session/cancel"); use tokio::io::AsyncWriteExt; let mut w = stdin.lock().await; w.write_all(data.as_bytes()).await?; @@ -626,7 +742,7 @@ impl SessionPool { "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id, "reset: sending session/cancel"); + tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "reset: sending session/cancel"); use tokio::io::AsyncWriteExt; let mut w = stdin.lock().await; let _ = w.write_all(data.as_bytes()).await; @@ -636,20 +752,18 @@ impl SessionPool { let mut state = self.state.write().await; let had_active = state.active.remove(thread_id).is_some(); - state.cancel_handles.remove(thread_id); - state.activity.remove(thread_id); - state.pgids.remove(thread_id); - state.suspended.remove(thread_id); - state.persisted.remove(thread_id); - state.creating.remove(thread_id); - state.session_workdirs.remove(thread_id); + // Everything else a reset clears is exactly what hung eviction clears, including the rule + // that the creating gate survives. Call the one implementation rather than keeping a second + // copy of the list: the copies are what let the two drift, and the gate rule is precisely + // the kind of line that gets dropped from a duplicate without anyone noticing. + purge_session_entries(&mut state, thread_id); self.save_mapping(&state.persisted); self.save_meta(&state.session_workdirs); if had_active { - info!(thread_id, "session reset"); + info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset"); Ok(()) } else { - Err(anyhow!("no session for thread {thread_id}")) + Err(anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id))) } } @@ -748,7 +862,7 @@ impl SessionPool { let mut state = self.state.write().await; for (key, expected_conn, sid) in stale { if remove_if_same_handle(&mut state.active, &key, &expected_conn).is_some() { - info!(thread_id = %key, "cleaning up idle session"); + info!(thread_id = %crate::redact::redact_session_ids(&key), "cleaning up idle session"); state.cancel_handles.remove(&key); state.activity.remove(&key); state.pgids.remove(&key); @@ -763,7 +877,7 @@ impl SessionPool { } for (key, expected_conn) in hung { if !apply_hung_eviction(&mut state, &key, &expected_conn) { - warn!(thread_id = %key, "hung session was replaced before eviction; maps untouched"); + warn!(thread_id = %crate::redact::redact_session_ids(&key), "hung session was replaced before eviction; maps untouched"); } } self.save_mapping(&state.persisted); @@ -818,6 +932,74 @@ mod tests { use tokio::sync::Mutex; use tokio::time::Instant; + /// Registrar double that records every mint, so a test can assert one never happened. + #[cfg(feature = "acp-mcp")] + #[derive(Default)] + struct CountingRegistrar { + minted: std::sync::Mutex>, + } + + #[cfg(feature = "acp-mcp")] + impl crate::mcp_proxy::SessionTokenRegistrar for CountingRegistrar { + fn mint(&self, channel_id: &str) -> String { + self.minted.lock().unwrap().push(channel_id.to_string()); + "token-xyz".to_string() + } + fn revoke(&self, _token: &str) {} + } + + /// A failed facade config write must not mint a token. The agent has no `openab` entry, so it + /// can never present one; minting anyway would leave a live credential registered for a + /// session that cannot use it until eviction. + #[cfg(feature = "acp-mcp")] + #[tokio::test] + async fn no_token_is_minted_when_the_facade_config_write_fails() { + let dir = tempfile::tempdir().unwrap(); + // Make `/.openab` a FILE, so `create_dir_all` inside the writer fails. + // + // This used to block on `.cursor`, which openab no longer creates: since D-15 it authors + // only `.openab/mcp-facade.json` and never touches a vendor directory. Left pointing at + // `.cursor` the write would SUCCEED, the test would fail, and — worse if it had been + // written the other way round — a test asserting "no mint on failure" would have been + // passing against a call that never failed. + std::fs::write(dir.path().join(".openab"), b"not a directory").unwrap(); + + let counting = Arc::new(CountingRegistrar::default()); + let registrar: Arc = counting.clone(); + let token = super::setup_facade_session( + dir.path().to_str().unwrap(), + "http://127.0.0.1:8848/mcp", + "acp_x", + ®istrar, + ) + .await; + + assert!(token.is_none(), "a failed config write must yield no token"); + assert!( + counting.minted.lock().unwrap().is_empty(), + "the registrar must never be asked to mint when the config could not be written" + ); + } + + /// The happy path still mints exactly once, for the right channel. + #[cfg(feature = "acp-mcp")] + #[tokio::test] + async fn a_successful_facade_config_write_mints_one_token() { + let dir = tempfile::tempdir().unwrap(); + let counting = Arc::new(CountingRegistrar::default()); + let registrar: Arc = counting.clone(); + let token = super::setup_facade_session( + dir.path().to_str().unwrap(), + "http://127.0.0.1:8848/mcp", + "acp_x", + ®istrar, + ) + .await; + + assert_eq!(token.as_deref(), Some("token-xyz")); + assert_eq!(counting.minted.lock().unwrap().as_slice(), ["acp_x"]); + } + #[test] fn remove_if_same_handle_removes_matching_entry() { let expected = Arc::new(Mutex::new(1_u8)); diff --git a/crates/openab-core/src/ambient.rs b/crates/openab-core/src/ambient.rs index f0ef5d2ae..57f9e3213 100644 --- a/crates/openab-core/src/ambient.rs +++ b/crates/openab-core/src/ambient.rs @@ -482,14 +482,14 @@ async fn ambient_consumer_loop( target: Arc, instructions: String, ) { - info!(channel_id = %channel_id, "ambient consumer started"); + info!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient consumer started"); loop { // Wait for first message (blocks until one arrives or channel closes). let first = match rx.recv().await { Some(msg) => msg, None => { - info!(channel_id = %channel_id, "ambient consumer channel closed, exiting"); + info!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient consumer channel closed, exiting"); return; } }; @@ -535,7 +535,7 @@ async fn ambient_consumer_loop( let batch_size = batch.len(); debug!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), batch_size, "ambient flush triggered" ); @@ -544,7 +544,7 @@ async fn ambient_consumer_loop( let _permit = match flush_semaphore.acquire().await { Ok(permit) => permit, Err(_) => { - warn!(channel_id = %channel_id, "flush semaphore closed, exiting"); + warn!(channel_id = %crate::redact::redact_session_ids(&channel_id), "flush semaphore closed, exiting"); return; } }; @@ -558,7 +558,7 @@ async fn ambient_consumer_loop( // Don't drain — messages buffered after the mention are still valid // for the next batch cycle. The current batch is discarded but future // messages will be picked up when the loop restarts and reset() clears. - debug!(channel_id = %channel_id, "ambient flush cancelled by mention during accumulation"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush cancelled by mention during accumulation"); continue; } @@ -590,14 +590,14 @@ async fn ambient_consumer_loop( debug_msg.push_str("\n…(truncated)"); } if let Err(e) = adapter.send_message(&channel_ref, &debug_msg).await { - warn!(channel_id = %channel_id, error = %e, "ambient debug message send failed"); + warn!(channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "ambient debug message send failed"); } } // Ensure session exists. if let Err(e) = target.ensure_session(&session_key, None).await { warn!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "failed to create ambient session, discarding batch" ); @@ -630,7 +630,7 @@ async fn ambient_consumer_loop( // Check post_guard before dispatching (mention may have cancelled). if !post_guard.can_post() { - debug!(channel_id = %channel_id, "ambient flush cancelled by mention before dispatch"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush cancelled by mention before dispatch"); continue; } @@ -647,11 +647,11 @@ async fn ambient_consumer_loop( .await { Ok(()) => { - debug!(channel_id = %channel_id, "ambient flush dispatched"); + debug!(channel_id = %crate::redact::redact_session_ids(&channel_id), "ambient flush dispatched"); } Err(e) => { warn!( - channel_id = %channel_id, + channel_id = %crate::redact::redact_session_ids(&channel_id), error = %e, "ambient flush failed, discarding batch" ); diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 69eb16632..4ee702fcf 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -71,13 +71,92 @@ impl<'de> Deserialize<'de> for AllowBots { /// same host connects to `http:///mcp`. Provider connections stay in /// `~/.openab/agent/mcp.json` (the facade has no provider config here — /// single source of truth, ADR §6.3 / Alternative E). +/// **Strict.** An unknown key here is a hard startup failure, not a warning. +/// +/// This section is a permissions control, and the failure mode of a silently-ignored key runs the +/// wrong way: `[[mcp.acp_server]]` (singular) leaves `acp_servers` empty, and an empty list keeps +/// the built-in default of five browser tools. So an operator writing a one-tool allowlist and +/// mistyping the section gets five tools — tightening the config actually widens it. Every other +/// misconfiguration in this section fails closed; only the typo fails open, which is why the parser +/// has to catch it rather than the policy layer. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct McpFacadeConfig { /// Loopback listen address. Non-loopback addresses are refused at /// startup — the endpoint has no authentication layer, so the host /// boundary is the trust boundary. #[serde(default = "default_mcp_listen")] pub listen: String, + /// Operator gate for **client-declared** `type:acp` MCP servers (ADR §6.4). + /// Absent or empty keeps the built-in default: `katashiro` only, pinned to + /// its five known tools. Listing anything here replaces that default + /// wholesale, so an operator can narrow the browser or admit another + /// client-side service without a code change. + #[serde(default)] + pub acp_servers: Vec, + /// How long a single request tunnelled to a client-declared `type:acp` server may run + /// before openab gives up on it, in seconds. + /// + /// Enforced server-side because on this tunnel OPENAB is the requester and the peer is a + /// browser extension we neither ship nor control, so there is nobody else to bound it. + /// The default sits STRICTLY beneath the ACP per-chunk idle timeout in `handle_session_prompt` + /// (`ACP_PROMPT_IDLE_TIMEOUT_SECS`, 180s), and the margin is the point. Referenced by name, not + /// by line: the same claim was written as a line number twice and was wrong both times, because + /// every edit above it moves the target. Setting the two equal makes which one fires + /// first undecidable at the boundary, and they do different things: only when this one wins + /// does the peer receive `mcp/cancel` and the caller see a timeout error. If the idle timeout + /// wins the turn simply ends, which leaves exactly the stranded work on the extension that + /// cancellation exists to prevent. + /// + /// **180s is therefore the effective ceiling.** A larger value here is not an error and is not + /// clamped, but it cannot take effect: the idle timeout is not operator-configurable, so the turn + /// ends there first and this setting stops mattering. Startup warns when it is set that high + /// rather than letting the number look effective. + /// + /// The check lives in the gateway, beside the constant, as + /// `warn_if_tunnel_timeout_is_ineffective`; the binary only hands it this value. That keeps the + /// ceiling and the comparison in one place, so changing it — or making it configurable — is a + /// single edit. It does not remove coupling: this crate cannot see the constant, since + /// `openab-gateway` does not depend on `openab-core`, and the gateway never sees this value. The + /// binary is the only place both are visible, and it already depends on the gateway. Moving the + /// constant into this crate would ADD a dependency edge to save nothing. Earlier wording said to "raise both, in that + /// order" — there is no second knob to raise, so that instruction could not be followed. + #[serde(default = "default_tunnel_timeout_seconds")] + pub tunnel_timeout_seconds: u64, +} + +/// Public so the binary can use the same value instead of repeating the literal. A private +/// default plus an `unwrap_or(180)` at the call site is two records of one fact, and the one in +/// the binary would silently keep the old number the day this changes. +pub fn default_tunnel_timeout_seconds() -> u64 { + 170 +} + +/// One entry of the §6.4 operator allowlist. +/// +/// Keyed by the declared **name**, never the id: the reference client mints its +/// `id` as a fresh UUID per connection, so an allowlist of ids could not match +/// twice. The name is chosen by the same remote client that declares the tools, +/// so admitting a name grants nothing by itself — `tools` is the second, +/// independent gate that stops a client publishing extra tools under a name the +/// operator trusts. +/// **Strict**, for the same reason as [`McpFacadeConfig`]: a mistyped `tools` key would leave the +/// list empty, and an entry with no tools is accepted-but-publishes-nothing. That direction fails +/// closed, so it is the milder case — but an operator who cannot tell a typo from an intentional +/// deny-all has no way to debug a server that went quiet, and the same keystroke in the parent +/// section fails open. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AcpServerPolicy { + /// Declared server name to accept — matches `name` in the client's + /// `{type:"acp", id, name}` entry and the `` prefix of its tools. + pub name: String, + /// Exactly the tool names this server may publish. **Deny-all**: omitted or + /// empty means the server is accepted but may publish nothing. Names alone + /// are enough — schemas come from the built-in catalog for known servers, + /// or from the server's own `tools/list` once discovery caching lands. + #[serde(default)] + pub tools: Vec, } fn default_mcp_listen() -> String { @@ -2449,6 +2528,53 @@ mod tests { assert_eq!(mcp.listen, "127.0.0.1:8848"); } + /// The typo that motivated `deny_unknown_fields`, asserted as a REJECTION. + /// + /// `[[mcp.acp_server]]` is one character from `[[mcp.acp_servers]]`. Before this, it parsed + /// clean, left `acp_servers` empty, and `browser_source.rs` read an empty list as "keep the + /// built-in default" — five tools, from a config asking for one. + #[test] + fn a_mistyped_acp_servers_section_is_refused_not_ignored() { + let err = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_server]]\nname = \"katashiro\"\n\ + tools = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect_err("a mistyped section must fail the parse, not widen the tool set"); + let msg = err.to_string(); + assert!( + msg.contains("acp_server"), + "the error must name the offending key so the operator can find it, got: {msg}" + ); + } + + /// The correctly spelled section still parses — otherwise the test above would pass for a + /// parser that rejects everything. + #[test] + fn the_correctly_spelled_acp_servers_section_still_parses() { + let cfg = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_servers]]\nname = \"katashiro\"\n\ + tools = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect("the documented spelling must keep working"); + let mcp = cfg.mcp.expect("[mcp] present"); + assert_eq!(mcp.acp_servers.len(), 1); + assert_eq!(mcp.acp_servers[0].name, "katashiro"); + assert_eq!(mcp.acp_servers[0].tools, vec!["katashiro.read_dom".to_string()]); + } + + #[test] + fn a_mistyped_key_inside_an_acp_server_entry_is_refused() { + let err = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_servers]]\nname = \"katashiro\"\n\ + tool = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect_err("`tool` is not `tools`; silently dropping it makes the entry deny-all"); + assert!(err.to_string().contains("tool")); + } + #[test] fn mcp_facade_listen_override() { let cfg = parse_config_str( diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 667a14803..954638044 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -86,7 +86,7 @@ fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { } // Channel allowlist if !filter.allow_all_channels && !filter.allowed_channels.contains(&event.channel.id) { - tracing::info!(channel = %event.channel.id, "gateway: channel not in allowed_channels, skipping"); + tracing::info!(channel = %redact_channel(&event.channel.id), "gateway: channel not in allowed_channels, skipping"); return true; } // User allowlist @@ -913,7 +913,7 @@ pub async fn run_gateway_adapter( info!( platform = %event.platform, sender = %event.sender.name, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event received" ); @@ -1315,7 +1315,7 @@ fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEve tracing::info!( platform = %event.platform, sender = %event.sender.id, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event denied (identity); echoing request-access" ); let throttle_key = format!("{}:{}", event.platform, event.sender.id); @@ -1343,7 +1343,7 @@ fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEve tracing::info!( platform = %event.platform, sender = %event.sender.id, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), ?decision, "gateway event denied (scope); silent" ); @@ -1393,7 +1393,7 @@ pub async fn process_gateway_event( tracing::info!( platform = %event.platform, sender = %event.sender.name, - channel = %event.channel.id, + channel = %redact_channel(&event.channel.id), "gateway event received (unified)" ); @@ -1876,3 +1876,59 @@ mod tests { assert!(!should_skip_event(&event, &filter)); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: the channel id printed here IS a resume credential. Anyone reading operator +/// logs could resume the session, and logs travel further than the sessions they describe. +/// +/// Only ACP ids are hashed. A Discord or Slack channel id is a public identifier that operators +/// legitimately grep for, and redacting it would cost real debuggability to protect nothing. +/// +/// Hashed rather than dropped so the same session still tags identically on every line — that +/// correlation is the whole reason the id is in the log. **The tag must match the one produced in +/// the other crates that log channel ids**, or a session cannot be followed across them; each copy +/// is pinned to the same vector by its own test. The copies exist because these crates deliberately +/// do not depend on one another, and adding an edge to share five lines would trade a documented +/// architectural boundary for a duplicate. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 7e50ce4ce..8c4791e5e 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -1,5 +1,8 @@ pub mod acp; pub mod adapter; +#[cfg(feature = "acp-mcp")] +pub mod mcp_proxy; +pub mod redact; 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..49870746e --- /dev/null +++ b/crates/openab-core/src/mcp_proxy.rs @@ -0,0 +1,583 @@ +//! Agent-facing wiring for MCP-over-ACP browser control (feature `acp-mcp`). +//! +//! The module name is historical. It no longer hosts a proxy: the per-session loopback MCP +//! server, its bearer and its per-session config rewrite were removed along with the stdio +//! bridge, leaving the OAB MCP Facade as the only way an agent reaches browser tools. +//! +//! What remains is the seam between core and the colocated agent CLI: +//! +//! - [`browser_tools`] — the static tool set (D4 static-advertise), now consumed by the facade's +//! capability source rather than served here. It is advertised whether or not an extension is +//! attached; a call while disconnected reports "browser not connected" instead of the tools +//! silently disappearing. +//! - [`AcpMcpTunnel`] — the trait core calls to reach a session's tunnel, implemented in the root. +//! - [`write_facade_mcp_config`] — authors `.openab/mcp-facade.json`, the ONE file openab owns. +//! It does not write, merge into, or read any vendor's MCP config, and it does not invoke a +//! vendor CLI (D-2026-07-30-15). Putting the entry in place is the operator's step for EVERY +//! vendor today. Pointing Claude Code at it with `--mcp-config` at spawn is decided but NOT +//! implemented — it needs a way to identify the vendor at spawn time, which this codebase has +//! deliberately never had. +//! - [`report_browser_control`] — startup report of whether browser control is on, plus the +//! migration notice for the removed `OPENAB_BROWSER_MODE`. + +use rmcp::model::{object, Tool}; +use serde_json::{json, Value}; + +/// 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; + + + /// Resolve a declared server NAME to the `server_id` the registry keys that tunnel by, for one + /// channel. + /// + /// The only way to reach a tunnel by name. There was also an enumerating `servers()`, and both + /// its callers collapsed `name -> id` themselves: routing took the first match, discovery took + /// whichever a `HashMap` kept last. Two collapse rules for one fact, neither beside the eviction + /// that makes the fact true. Both now call this, and the enumerator is gone rather than left as a + /// second route someone would reasonably mistake for the supported one. + /// + /// Required, with no default. A default returning `None` compiles for every existing implementor + /// and then silently answers "not connected" for all routing — the failure surfaces at run time, + /// in tests belonging to whoever did NOT add the method. A missing implementation should be a + /// compile error, not a behaviour change. This was not hypothetical: adding it with a `None` + /// default broke five routing tests that had nothing to do with the change. + fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option; +} + +/// 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( + "katashiro.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( + "katashiro.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( + "katashiro.navigate", + "Navigate the active browser tab to a URL.", + object(json!({ + "type": "object", + "properties": { "url": { "type": "string", "description": "absolute URL" } }, + "required": ["url"] + })), + ), + Tool::new( + "katashiro.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( + "katashiro.screenshot", + "Capture a screenshot of the active browser tab.", + object(json!({ "type": "object", "properties": {} })), + ), + ] +} + + + + +/// Write `value` to `path` atomically, owner-only. +/// +/// Same-directory temp file created `0600` *before* any bytes reach it, then `rename`. Writing in +/// place leaves a window where a reader sees a half-written config, and chmod-after-write leaves +/// one where the file is world-readable. `rename` within a directory is atomic, so a concurrent +/// reader sees either the old file or the new one. +async fn write_json_atomic(path: &std::path::Path, value: &Value) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(value)?; + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + // Unique per WRITE, not per process. A fixed name made concurrent writers share one temp file: + // both opened it with `truncate`, both wrote, and whichever renamed first left the other + // renaming a path that no longer existed. A pid alone does not fix that — the racing writers + // are usually two tasks in the SAME process — so the counter is what makes each attempt + // distinct, and the pid keeps a respawn that overlaps its predecessor off the same paths. + // + // This separates two concerns that were tangled: the lock orders read-modify-write so a writer + // cannot publish over a state it never read, and the unique temp name stops writers colliding + // on the intermediate file. Previously the lock was doing both, which is why removing it + // failed with ENOENT — a filename collision reported as if it were a lost update. + static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let tmp = dir.join(format!( + ".{}.openab-tmp.{}.{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("mcp.json"), + std::process::id(), + TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + { + let mut opts = tokio::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + // tokio's OpenOptions carries `mode` itself; no std extension trait needed. + opts.mode(0o600); + } + let mut f = opts.open(&tmp).await?; + use tokio::io::AsyncWriteExt; + f.write_all(&bytes).await?; + f.flush().await?; + // Durability before the rename: a crash must not leave the new name pointing at a file + // whose contents never reached disk. + f.sync_all().await?; + } + match tokio::fs::rename(&tmp, path).await { + Ok(()) => { + // `sync_all` above made the CONTENTS durable; the rename that publishes them is a + // directory operation and is not covered by it. Without this a crash can leave the + // directory entry unwritten while the data it points at is safely on disk — "fsynced, + // then renamed" is not the same as "the rename survived". + // Report rather than swallow: this function's whole purpose is durability, so a + // silent failure of the step that provides it is the worst shape available. Not fatal + // — the data is written and visible, only the rename's survival across a crash is + // unproven. On Windows a directory cannot be opened as a file at all, so this is + // expected to fail there and is logged at debug rather than warn for that reason. + match tokio::fs::File::open(dir).await { + Ok(d) => { + if let Err(e) = d.sync_all().await { + // `warn!`, not `debug!`: opening a directory can legitimately fail (Windows), + // but an fsync that runs and FAILS is an unexpected durability failure, and + // reporting it more quietly than the thing it protects would hide the worse + // of the two — the same reasoning as the failed-handshake disconnect. + tracing::warn!(dir = %dir.display(), error = %e, + "MCP config: directory fsync failed — the rename may not survive a crash"); + } + } + Err(e) => tracing::debug!(dir = %dir.display(), error = %e, + "MCP config: could not open the directory to fsync it (expected on Windows)"), + } + Ok(()) + } + Err(e) => { + let _ = tokio::fs::remove_file(&tmp).await; + Err(e) + } + } +} + + + + +/// Author the static `openab` facade entry into the one file openab owns +/// (Facade mode). The entry is byte-identical for every session — the bridge it used to be +/// compared against here was removed with the rest of Option C, so the comparison is gone +/// rather than left pointing at code that no longer exists. +/// +/// 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 cfg = json!({ + "mcpServers": { + // Published as "openab" (the facade), not "openab-browser": the agent reaches ALL + // facade capabilities through this one entry. + "openab": { + "url": facade_url, + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + } + } + }); + let path = facade_config_path(workdir); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + // No read-modify-write and no per-path lock. The content is a pure function of `facade_url`, + // so concurrent sessions write identical bytes and the rename in `write_json_atomic` makes + // last-one-wins harmless: there is no prior state to lose, because we own the file. + write_json_atomic(&path, &cfg).await +} + +/// The one file openab authors: `/.openab/mcp-facade.json`. +/// +/// Source for the operator's `kiro-cli mcp import --file … workspace`, and the intended source for +/// Claude Code's `--mcp-config` once spawn-time vendor identification is decided. openab never puts +/// it in place itself (D-15). +pub fn facade_config_path(workdir: &str) -> std::path::PathBuf { + std::path::Path::new(workdir).join(".openab").join("mcp-facade.json") +} + + +/// 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 a fresh token for `channel_id`; returns the value the pool injects as + /// `OPENAB_SESSION_TOKEN` in the agent's environment. + /// + /// Does **not** replace a token the channel already has — tokens for a channel coexist, so a + /// respawned or racing session gets its own credential without invalidating one a running + /// agent is still presenting. + fn mint(&self, channel_id: &str) -> String; + /// Revoke one specific token (the session that held it was evicted). + /// + /// Deliberately keyed by token, not by channel. Because tokens coexist and session lifetimes + /// overlap, a replaced session's teardown runs *after* its successor has already minted its + /// own token for the same channel. Revoking by channel would take the successor's live + /// credential with it and silently cut the new agent off from the facade; revoking this exact + /// token is a no-op by then instead (review R1). + fn revoke(&self, token: &str); +} + +/// Report, once at startup, whether browser control is enabled — and that +/// `OPENAB_BROWSER_MODE` no longer selects anything. +/// +/// Call this from configuration/startup, **not** from a session path: nothing per-session is +/// decided by it any more, and a warning on that path would repeat for every spawn. +/// +/// The variable used to choose between three transports. Two are gone and the third is no longer +/// optional, so any value it holds is inert. Ignoring it silently would leave an operator +/// believing they had configured something — the same failure the removed-bridge warning existed +/// to prevent, one level up. So the message says the value is ignored *and* reports what is +/// actually in force, since "ignored" alone does not tell them whether they still have browser +/// control at all. +pub fn report_browser_control(mcp_configured: bool, workdir: &str) { + if mcp_configured { + // "enabled" alone became false when openab stopped wiring vendor configs (D-15). The + // facade IS running, but no agent can reach it until the entry is placed, and an operator + // reading "enabled" would go looking for a bug instead of doing the remaining step. So the + // line reports the facade AND names the step, with the exact commands. + // + // `workdir` here is the CONFIGURED default. A session may resolve a different one + // (`effective_workdir`: a stored per-session value, or an explicit override), and the file + // is written under whichever that session used. Startup cannot know those, so the path + // below is the default rather than a promise about every session — which is also why the + // deployed default matters: with `working_dir == $HOME` the two coincide. + let path = facade_config_path(workdir); + tracing::info!( + facade_config = %path.display(), + "browser control: the OAB MCP Facade is running ([mcp] configured), and openab has \ + written its entry to the file above. openab does NOT modify your agent's MCP config, \ + so browser tools stay unavailable until that entry is in place." + ); + tracing::info!( + "browser control — to finish wiring, run ONE of these for your agent: \ + kiro: kiro-cli mcp import --file {path} workspace (do not pass --force) | \ + cursor: no import mechanism exists — paste the contents of {path} into the \ + \"mcpServers\" object of .cursor/mcp.json yourself", + path = path.display() + ); + } else { + // Unconditional, and the whole point of the change: with the proxy fallback gone, an + // unconfigured deployment has NO browser control. Saying nothing would leave that to be + // inferred from tools that never appear — which is the failure this replaced, not a + // quieter version of it. + tracing::info!( + "browser control: unconfigured — no [mcp] section in config.toml, so browser tools \ + are unavailable and nothing was started. Add [mcp] to enable them." + ); + } + let raw = std::env::var("OPENAB_BROWSER_MODE").ok(); + if let Some((requested, browser_control)) = + browser_mode_migration_notice(raw.as_deref(), mcp_configured) + { + tracing::warn!( + requested, + browser_control, + "OPENAB_BROWSER_MODE is ignored — it no longer selects a transport and can be unset. \ + Browser control is configured by the [mcp] section of config.toml; `browser_control` \ + reports what is actually in force for this process." + ); + } +} + +/// Decide whether to warn and what to say: `(requested, browser_control)`, or `None` to stay quiet. +/// +/// Split out from the logging so the decision is testable without a subscriber or process env. +/// +/// **Every** non-empty value warns, `proxy` and `facade` included. `proxy` no longer selects +/// anything either, so staying quiet for it would be the same silence this notice exists to +/// remove; `facade` is merely redundant, but reporting it costs one line at startup and saying +/// "this variable is read" of some values and not others would be false. +fn browser_mode_migration_notice( + raw: Option<&str>, + mcp_configured: bool, +) -> Option<(&str, &'static str)> { + let requested = raw?.trim(); + if requested.is_empty() { + return None; + } + Some(( + requested, + if mcp_configured { "facade" } else { "disabled" }, + )) +} + + +/// The destructive cases for the only code that touches a user's `mcp.json`. +/// +/// Each of these previously either destroyed a file or panicked the process, and none of them is +/// exotic: a comment in a JSON config is common, `[]` is what an empty array-shaped config looks +/// like, and two sessions starting together is the normal case on a busy pod. +#[cfg(test)] +mod facade_config_writer { + use super::*; + + async fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("openab-cfg-{tag}-{}", std::process::id())); + let _ = tokio::fs::remove_dir_all(&d).await; + tokio::fs::create_dir_all(&d).await.unwrap(); + d + } + + /// We author exactly one file, in our own directory, with the facade entry. + #[tokio::test] + async fn the_facade_entry_is_written_to_the_file_we_own() { + let wd = tmp_dir("authored").await; + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + let path = facade_config_path(wd.to_str().unwrap()); + assert_eq!(path, wd.join(".openab").join("mcp-facade.json")); + let v: Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); + // The token is never in the file — the literal is, and the agent's env supplies the value. + assert_eq!( + v["mcpServers"]["openab"]["headers"]["Authorization"], + json!("Bearer ${OPENAB_SESSION_TOKEN}") + ); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// THE BOUNDARY (D-2026-07-30-03, D-2026-07-30-15): openab never modifies a file it did not + /// create, and never creates one in someone else's directory. + /// + /// Every defect in canonical item 9 — EACCES read as "file absent" then overwritten, no + /// directory fsync, rename dropping mode/owner and replacing symlinks, a concurrency test that + /// passed for the wrong reason — was a consequence of editing the operator's `mcp.json`. + /// Deleting that path deleted all four, and this test is what stops it coming back: a + /// reintroduced merge would have to modify one of these files to be useful, and that fails + /// here rather than in someone's deployment. + #[tokio::test] + async fn an_operators_own_mcp_config_is_never_touched() { + let wd = tmp_dir("boundary").await; + let cursor = wd.join(".cursor").join("mcp.json"); + let kiro = wd.join(".kiro").join("settings").join("mcp.json"); + let agent = wd.join(".kiro").join("agents").join("terra.json"); + for f in [&cursor, &kiro, &agent] { + tokio::fs::create_dir_all(f.parent().unwrap()).await.unwrap(); + } + // Deliberately including a `//` comment: the shape that the old merge path destroyed. + let cursor_body = "{\n // mine\n \"mcpServers\": {\"mine\": {\"command\": \"x\"}}\n}"; + let kiro_body = "{\"mcpServers\":{\"openab-browser\":{\"command\":\"openab\",\"args\":[\"browser-bridge\"]}}}"; + let agent_body = "{\"allowedTools\":[\"@openab-browser\",\"@github\"]}"; + tokio::fs::write(&cursor, cursor_body).await.unwrap(); + tokio::fs::write(&kiro, kiro_body).await.unwrap(); + tokio::fs::write(&agent, agent_body).await.unwrap(); + + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + + // Byte-for-byte, including the stale `openab-browser` entry and its grant. openab no + // longer retires those — see the PR body: that cleanup became operator-performed, and it + // is a policy bypass rather than a convenience, so it is stated rather than silently + // dropped. + assert_eq!(tokio::fs::read_to_string(&cursor).await.unwrap(), cursor_body); + assert_eq!(tokio::fs::read_to_string(&kiro).await.unwrap(), kiro_body); + assert_eq!(tokio::fs::read_to_string(&agent).await.unwrap(), agent_body); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// A vendor directory that does not exist must not be created either — absence of a file is + /// not permission to author one. + #[tokio::test] + async fn no_vendor_directory_is_created() { + let wd = tmp_dir("novendor").await; + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + for d in [".cursor", ".kiro"] { + assert!( + !wd.join(d).exists(), + "{d} was created — openab may only author inside .openab/" + ); + } + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// Concurrent sessions must still publish a readable file. + /// + /// Weaker than the merge-path version by design: with no read-modify-write there is no lost + /// update to prevent, because both writers produce identical bytes from the same `facade_url`. + /// What remains worth pinning is that the rename never exposes a partial file. + #[tokio::test] + async fn concurrent_writers_publish_a_readable_config() { + let wd = tmp_dir("concurrent").await; + let w = wd.to_str().unwrap().to_string(); + let (a, b) = tokio::join!( + write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), + write_facade_mcp_config(&w, "http://127.0.0.1:8848/mcp"), + ); + a.unwrap(); + b.unwrap(); + let v: Value = + serde_json::from_slice(&tokio::fs::read(facade_config_path(&w)).await.unwrap()) + .unwrap(); + assert_eq!(v["mcpServers"]["openab"]["url"], json!("http://127.0.0.1:8848/mcp")); + let _ = tokio::fs::remove_dir_all(&wd).await; + } + + /// The file we write is owner-only. + #[cfg(unix)] + #[tokio::test] + async fn the_written_config_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let wd = tmp_dir("perms").await; + write_facade_mcp_config(wd.to_str().unwrap(), "http://127.0.0.1:8848/mcp") + .await + .unwrap(); + let path = facade_config_path(wd.to_str().unwrap()); + let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "0600 must be set at creation, not chmod'd after"); + let _ = tokio::fs::remove_dir_all(&wd).await; + } +} + +#[cfg(test)] +mod tests { + use super::{ + browser_mode_migration_notice, browser_tools, + }; + + /// The variable is inert now, so the notice must fire for every value an operator could have + /// set — including `proxy`, which used to be a real selection. Staying quiet for it would + /// reproduce the silence this notice exists to remove. + #[test] + fn every_set_browser_mode_value_is_reported_as_ignored() { + for v in ["bridge", "proxy", "facade", " Bridge ", "typo"] { + assert!( + browser_mode_migration_notice(Some(v), true).is_some(), + "{v:?} is a value someone deliberately set; it must not be ignored silently" + ); + } + // Unset and blank express no preference — warning on them would fire for every default + // deployment and train operators to skip the line. + assert_eq!(browser_mode_migration_notice(None, true), None); + assert_eq!(browser_mode_migration_notice(Some(""), true), None); + assert_eq!(browser_mode_migration_notice(Some(" "), true), None); + } + + /// The second field is the one an operator actually needs: not which mode they are in (there + /// are none left) but whether they still have browser control at all. Without `[mcp]` they do + /// not, and the removed Facade->Proxy fallback no longer hides that. + #[test] + fn the_notice_reports_whether_browser_control_survives_not_which_mode() { + assert_eq!( + browser_mode_migration_notice(Some("bridge"), true), + Some(("bridge", "facade")) + ); + assert_eq!( + browser_mode_migration_notice(Some("bridge"), false), + Some(("bridge", "disabled")) + ); + // Trimmed, so the log shows what was set rather than the surrounding whitespace. + assert_eq!( + browser_mode_migration_notice(Some(" proxy "), false), + Some(("proxy", "disabled")) + ); + } + + // --- F4: facade setup retires the direct transport it replaces --- + + + + + + + /// 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 + } + + + + + + + + + + + + #[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, + [ + "katashiro.click", + "katashiro.read_dom", + "katashiro.navigate", + "katashiro.type", + "katashiro.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"}}}"#; + + +} diff --git a/crates/openab-core/src/redact.rs b/crates/openab-core/src/redact.rs new file mode 100644 index 000000000..85954da12 --- /dev/null +++ b/crates/openab-core/src/redact.rs @@ -0,0 +1,78 @@ +//! Rendering session-bearing identifiers in logs without handing out the session. +//! +//! An ACP session is addressed two ways and both carry the same uuid: the session id is +//! `sess_` and the channel id is `acp_`. Either one is enough to resume — `sess_` is +//! taken directly by resume, and `acp_` differs from it only by prefix — so both are credentials, +//! and a redaction that covers one of them covers nothing. +//! +//! Ids also travel embedded: a pool key is `:`, so scanning for a field +//! named `channel` misses it entirely. Redaction here matches on the VALUE's shape, which is why +//! it can be applied to a composite without the caller taking it apart. +//! +//! Applying this to a non-ACP identifier is a no-op, deliberately. A Discord or Slack channel id +//! is public and operators grep for it, and because the function leaves it untouched, a caller +//! that cannot tell whether a given id will ever be ACP does not have to find out — applying it +//! is free and removes the question. That is cheaper than an investigation and safer than a guess. + +/// Hash any `acp_`/`sess_` prefixed segment of `s`, leaving everything else exactly as it was. +/// +/// Segments are split on `:` so a `:` pool key redacts its id half and keeps +/// the platform readable. +/// +/// The same uuid tags identically whichever prefix carried it, so one session reads as one tag +/// across every log line that mentions it — that correlation is the only reason to keep an +/// identifier in a log at all. +pub fn redact_session_ids(s: &str) -> String { + s.split(':') + .map(|seg| match seg.strip_prefix("acp_").or_else(|| seg.strip_prefix("sess_")) { + Some(uuid) if !uuid.is_empty() => hash_tag(uuid), + _ => seg.to_string(), + }) + .collect::>() + .join(":") +} + +fn hash_tag(uuid: &str) -> String { + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(uuid.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod tests { + use super::redact_session_ids; + + /// A table, not a single vector — the branch structure is what needs pinning. + /// + /// One example only proves the hash. The predicate is the part most likely to move: this + /// function exists because a redaction covering `acp_` but not `sess_` was shipped, and that + /// edit changes which inputs hash without changing the output for any `acp_` input. A single + /// `acp_...` vector cannot see it. + #[test] + fn both_encodings_hash_alike_and_everything_else_passes_through() { + let u = "00000000-0000-0000-0000-000000000000"; + let tag = redact_session_ids(&format!("acp_{u}")); + assert!(tag.starts_with('#') && tag.len() == 9, "expected #<8hex>, got {tag}"); + assert_eq!( + redact_session_ids(&format!("sess_{u}")), + tag, + "both encodings carry the SAME uuid, so they must produce the same tag or one session \ + reads as two" + ); + assert_eq!( + redact_session_ids(&format!("acp:acp_{u}")), + format!("acp:{tag}"), + "a : pool key must redact the id half and keep the platform greppable" + ); + assert_eq!(redact_session_ids("1234567890"), "1234567890", "public ids stay greppable"); + assert_eq!(redact_session_ids("-"), "-", "the no-session sentinel is not a session"); + assert_eq!(redact_session_ids(""), "", "empty in, empty out"); + assert_eq!(redact_session_ids("acp_"), "acp_", "a bare prefix carries no uuid to hide"); + assert_eq!( + redact_session_ids("discord:1234567890"), + "discord:1234567890", + "a non-ACP composite is untouched, which is why applying this blindly is safe" + ); + } +} diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 18c2b2cca..f0c1476d3 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,79 @@ 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 +/// Cap on concurrently-establishing tunnels per connection. +/// +/// Separate from [`MAX_INFLIGHT_PROMPTS`] on purpose. These tasks used to share one budget, so a +/// client with several slow `mcp/connect`s outstanding could exhaust it and make ordinary +/// `session/prompt` calls fail with "Too many in-flight prompts" — an error naming a limit the +/// client had not reached, for work it had not asked for. `MAX_ACP_SERVERS_PER_SESSION` bounds one +/// session's declarations; this bounds every session's establishes on a connection at once. +const MAX_INFLIGHT_ESTABLISHES: usize = 64; +/// Per-chunk idle timeout for a prompt turn, in `handle_session_prompt`. +/// +/// Named rather than left inline because it is the effective ceiling on anything a turn waits for: +/// the tunnel's own timeout has to stay strictly beneath it, and `[mcp] tunnel_timeout_seconds` +/// documents itself against this value. As a bare literal in the middle of a loop it was invisible +/// to exactly the person who needed it — the operator raising the tunnel timeout into it. +/// +/// Not operator-configurable today. Anything set above it is silently capped here, which is why the +/// config path warns rather than letting a larger value look effective. +pub const ACP_PROMPT_IDLE_TIMEOUT_SECS: u64 = 180; + +/// Whether a configured tunnel timeout is overtaken by the idle timeout, and so cannot decide the +/// outcome. +/// +/// Split out from the warning so the boundary is testable without capturing log output: the +/// interesting part is one comparison, and an inverted `>=` would be silent in exactly the case it +/// exists to report. +pub fn tunnel_timeout_is_ineffective(configured_secs: u64) -> bool { + configured_secs >= ACP_PROMPT_IDLE_TIMEOUT_SECS +} + +/// Warn when a configured tunnel timeout cannot take effect because the idle timeout above overtakes +/// it. +/// +/// Lives here, next to the number it is about, rather than in the binary that reads the config. The +/// edge is unchanged — the binary already depends on this crate — but the caller no longer has to +/// know what the ceiling is or which direction to compare, so when that constant changes or becomes +/// configurable there is one place to edit instead of two. This is a relocation of the invariant, not +/// a removal of coupling: the value still has to be handed in, because this crate never sees the +/// config. +pub fn warn_if_tunnel_timeout_is_ineffective(configured_secs: u64) { + if tunnel_timeout_is_ineffective(configured_secs) { + warn!( + configured = configured_secs, + effective_ceiling = ACP_PROMPT_IDLE_TIMEOUT_SECS, + "[mcp] tunnel_timeout_seconds is at or above the ACP prompt idle timeout, which is not \ + configurable — the turn ends there first, so this value cannot take effect" + ); + } +} +/// Cap on `type:acp` servers a single session may declare (review R3-F1). +/// +/// Every declaration costs a spawned task, a pending `mcp/connect` holding a 30s timeout, and an +/// outbound frame. Declarations are tiny, so thousands fit inside the 1 MiB limit that applies to +/// a `session/new` frame — without a cap, one accepted request bursts all of that at once. Eight +/// is far above any real client (the reference client declares one) and far below what hurts. +const MAX_ACP_SERVERS_PER_SESSION: usize = 8; +const MAX_FRAME_BYTES: usize = 8 << 20; // 8 MiB — browser-tool results (e.g. screenshots) exceed 1 MiB +/// The method of a parsed inbound frame when it exceeds the limit for its kind, else `None`. +/// +/// Only client **responses** — `id` present, no `method` — carry tunnel results and may use the +/// full [`MAX_FRAME_BYTES`]. Everything method-bearing is a client request or notification and is +/// held to [`MAX_NON_TUNNEL_FRAME_BYTES`]. +fn oversized_for_its_kind(len: usize, raw: &Value) -> Option<&str> { + let method = raw.get("method").and_then(Value::as_str)?; + (len > MAX_NON_TUNNEL_FRAME_BYTES).then_some(method) +} + +/// Ceiling for every inbound frame that is **not** a tunnel result (review F2). +/// +/// The 8 MiB allowance above exists for browser tool results, and those arrive as client +/// *responses* to our server-initiated `mcp/message` requests — `id` present, no `method`. +/// Nothing else needs it: capping method-bearing frames back at the pre-existing 1 MiB stops the +/// raise from being usable to hold `MAX_INFLIGHT_PROMPTS` × 8 MiB of prompt text per connection. +const MAX_NON_TUNNEL_FRAME_BYTES: usize = 1 << 20; // 1 MiB /// JSON-RPC implementation-defined server error for a hit resource cap. const ACP_OVERLOADED: i32 = -32000; @@ -260,6 +333,60 @@ struct AcpSession { cancel: Option>, } +/// 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. +#[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() +} + +/// Validate a session's declared `type:acp` servers before any of them costs anything. +/// +/// Returns the list to act on, or an error message to reject the whole `session/new` / +/// `session/resume` with. Callers must run this **before** inserting the session or spawning +/// tunnels: the point is that an over-declaring request never reaches the work. +/// +/// Duplicate ids collapse to the first occurrence. A repeated id would otherwise spawn two tunnels +/// racing for one registry key, where the loser is a task and a pending `mcp/connect` that exist +/// only to be overwritten. Entries missing `id` or `name` were already dropped by +/// `parse_acp_mcp_servers`, so after this the list is unique and complete. +fn accept_acp_servers(servers: Vec) -> Result, String> { + let mut seen = std::collections::HashSet::new(); + let deduped: Vec = servers + .into_iter() + .filter(|s| seen.insert(s.id.clone())) + .collect(); + if deduped.len() > MAX_ACP_SERVERS_PER_SESSION { + return Err(format!( + "Too many type:acp servers declared ({}, max {MAX_ACP_SERVERS_PER_SESSION})", + deduped.len() + )); + } + Ok(deduped) +} + pub enum ReplyChunk { /// Incremental text snapshot (full text so far) Text(String), @@ -276,6 +403,60 @@ pub struct ReplySink { /// Originating `GatewayEvent.event_id` (`evt_`), round-tripped as `reply_to`. pub turn_id: String, pub tx: mpsc::UnboundedSender, + /// The `acp_conn_*` id of the WebSocket connection that installed this sink. + /// + /// Teardown removes only what it owns. "Remove the keys for my sessions" is not enough: a + /// client that reconnects and resumes takes over the same `channel_id`, so a slow cleanup from + /// the old connection would delete the *successor's* live sink and silently stop its replies. + pub owner: String, +} + +/// Resolve a client-declared server NAME to the registry key of its tunnel, for one channel. +/// +/// Lives here, beside the code that maintains the uniqueness it relies on, and that is the whole +/// point. The caller used to enumerate the channel's tunnels and take the first name match, which +/// was correct only because same-name entries cannot coexist — an invariant established by the +/// single insert site below, in another crate's line of sight, with nothing binding the two. Weaken +/// that eviction and a "take the first" caller does not fail, it silently picks an arbitrary tunnel. +/// +/// Moving the resolution next to the invariant removes the distance rather than documenting it. +/// +/// If two ever do coexist, this picks the newest by the SAME ordering that produced the invariant +/// and warns. It deliberately does not error: answering "ambiguous, pass a server_id" is the +/// behaviour ADR §6.1 exists to avoid, because it locks a client out of its own tools on every +/// reconnect. A hard stop is the wrong response to a soft inconsistency. +pub fn resolve_by_name( + registry: &AcpTunnelRegistry, + channel_id: &str, + server_name: &str, +) -> Option { + // One pass, no allocation, and `rank > best` says which direction wins out loud. The first + // version collected into a `Vec`, sorted it, and took the last element — the cost of that was + // never the problem at one or two entries, but it asked the reader to re-derive whether the + // direction was right, and direction is what this file has got wrong repeatedly: the rank + // polarity, `<=` against `<`, which of the two numbers is compared first. A shape that already + // holds a `Vec` under the lock also invites the next person to do more work in here. + let reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + let mut count = 0usize; + let mut best: Option<(&String, (u64, u64))> = None; + for ((c, id), h) in reg.iter() { + if c != channel_id || h.server_name != server_name { + continue; + } + count += 1; + let rank = (h.connection_generation, h.generation); + if best.is_none_or(|(_, current)| rank > current) { + best = Some((id, rank)); + } + } + if count > 1 { + warn!( + channel = %redact_id(channel_id), server_name, count, + "ACP: more than one tunnel is registered under one declared name — registry uniqueness \ + was broken upstream; routing to the newest attach" + ); + } + best.map(|(id, _)| id.clone()) } /// Registry of active ACP sessions: channel_id → reply sink. @@ -287,6 +468,28 @@ 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. Same `std::sync::Mutex` rationale as `AcpReplyRegistry`. +/// +/// Teardown removes only the entries THIS connection owns — it matches on `owner`, not on the +/// channel. Removing every `(channel_id, *)` would delete a successor's live entry, because a +/// client that reconnects and resumes takes over the same `channel_id`. +/// +/// **To reach a tunnel by its declared NAME, call [`resolve_by_name`].** Holding this map and +/// matching `server_name()` yourself is possible and is the wrong thing: which entry wins when a +/// name appears twice is decided by rank, that rank is private, and the eviction keeping names +/// unique lives beside `resolve_by_name` rather than beside you. Two callers previously did their +/// own matching, by two different rules, and neither noticed. +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 +527,60 @@ 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`, which the MCP-over-ACP tunnel drives for +/// `mcp/connect`, `mcp/message` and `mcp/disconnect`. +#[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"`. +#[derive(Debug, Serialize)] +struct McpConnectParams { + #[serde(rename = "acpId")] + acp_id: String, +} + +/// `mcp/connect` result — the client-assigned connection handle. +#[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. +#[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. +#[derive(Debug, Serialize)] +struct McpDisconnectParams { + #[serde(rename = "connectionId")] + connection_id: String, +} + impl JsonRpcResponse { fn success(id: Value, result: Value) -> Self { Self { @@ -406,9 +663,628 @@ 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 { + // `debug!`, not `warn!`: since the tunnel cancels on expiry, a reply arriving after we + // gave up is EXPECTED traffic from a well-behaved peer, not a client defect. Logging it at + // warn made a correct peer look broken. + debug!(id, "acp: client response arrived after its request was abandoned; discarding"); + } + 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. +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); + // Tell the peer we gave up. Dropping the pending entry only ends OUR wait: the + // extension is still running the request and still holding whatever it holds — a tab, + // a navigation, a script — with no way to learn that nobody is listening any more. + // Best-effort by construction: this is a notification, so there is no reply to await, + // and a peer that has already gone away simply never reads it. + let cancel = JsonRpcNotification { + jsonrpc: "2.0", + method: "mcp/cancel".to_string(), + params: json!({ "requestId": id }), + }; + let _ = out_tx.send(serde_json::to_string(&cancel).unwrap()); + 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. +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`. +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). +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. +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(|_| ()) +} + +/// Monotonic attach ordering for last-attach-wins (ADR §6.1). +/// +/// Stamped at the START of an establish, before the first round trip, because "which declaration +/// is newer" is decided by when the client asked — not by which handshake happened to finish +/// first. Those differ whenever handshake durations differ, which over a real socket is the +/// normal case: a slow older establish would otherwise complete last, evict the successor that +/// beat it, and leave the STALE tunnel installed. Registration order is not attach order. +/// +/// Process-wide rather than per-connection on purpose: the racing establishes belong to +/// *different* connections (a reconnect mints a fresh `server_id` on a new socket), so a +/// per-connection sequence cannot order them, and cross-connection is precisely the failing case. +static TUNNEL_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// 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, + /// The `name` the client declared for this server (e.g. `"browser"`). Stable across + /// reconnects, unlike the `id` the registry keys by — the reference client mints that as a + /// fresh UUID per connection. Tool prefixes (`browser.click`) and the §6.4 trust allowlist are + /// both keyed by this name, so it must survive registration to be routable (ADR §6.1). + server_name: String, + /// The `acp_conn_*` id of the WebSocket connection this tunnel belongs to. + /// + /// The registry key is `(channel_id, server_id)` and a reconnecting client mints a fresh + /// `server_id`, so a key is not a stable identity — and even the channel is reused across + /// connections by `session/resume`. Teardown therefore matches on this owner rather than on + /// the key, so a late cleanup cannot remove a handle installed by a different connection. + owner: String, + /// Age of the CONNECTION that installed this tunnel, from [`TUNNEL_GENERATION`], stamped + /// when that connection was accepted. + /// + /// A resume sweep is authorised by connection age, not by when the resume happened to be + /// processed: an older connection's late resume must not retire a tunnel installed by a newer + /// connection, and "which resume ran first" cannot express that. Stamping the resume itself + /// measures the wrong thing — the late resume takes the HIGHER number precisely because it ran + /// last, so it would still outrank the newer connection it must not touch. + connection_generation: u64, + /// Attach ordering from [`TUNNEL_GENERATION`], stamped when this establish STARTED. + /// + /// Eviction compares generations instead of trusting arrival order, so a slow establish that + /// finishes after a newer one cannot evict its own successor. + generation: u64, +} + +impl TunnelHandle { + /// The client-declared server name for this tunnel (see the field docs for why the declared + /// name and the registry key are deliberately different things). + /// + /// Exposed for reporting, not for routing. Selecting a tunnel by comparing this against a + /// wanted name is what [`resolve_by_name`] exists to do — it also knows which entry wins if a + /// name ever appears twice, which this accessor cannot tell you. + pub fn server_name(&self) -> &str { + &self.server_name + } + + /// 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 + } +} + +/// MCP protocol version the gateway speaks to a tunnelled client MCP server. +const INNER_MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Revisions this gateway ACCEPTS in an inner `initialize` result (R5, D-2026-07-30-10). +/// +/// Negotiation, not equality. MCP says a server that does not support the requested revision +/// answers with one it does support, so strict equality against +/// [`INNER_MCP_PROTOCOL_VERSION`] rejected a peer for behaving exactly as specified. That matters +/// here because the point of client-declared `type:acp` servers is to work with peers we did not +/// write. +/// +/// WHY THIS SET IS SAFE — re-check this before adding a fifth inner method. +/// +/// The whole inner surface this gateway drives is four methods, and their shapes are compatible +/// across all three revisions: +/// +/// - `initialize` and `notifications/initialized` — `inner_mcp_handshake`, below; +/// - `tools/list` — `src/browser_source.rs:200`; +/// - `tools/call` — `src/browser_source.rs:351`. +/// +/// Verified against the tree: those are the only methods reaching an inner server. +/// +/// RE-CHECK THIS SET when any of three things changes, not just the first: +/// +/// - a fifth inner method is added; +/// - the transport changes; +/// - the framing changes. +/// +/// Compatibility is a property of what we actually send and how we send it, not of the revisions +/// in the abstract, so a transport or framing change can invalidate the set while the method list +/// stays identical. A bare list of version strings with no stated reason is how this becomes +/// silently wrong later. +const SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS: [&str; 3] = + ["2025-06-18", "2025-03-26", "2024-11-05"]; + +/// Perform the inner MCP handshake on a freshly connected tunnel. +/// +/// MCP requires `initialize` → response → `notifications/initialized` before any other request. We +/// were sending `tools/list` and `tools/call` straight after `mcp/connect`, which happens to work +/// against today's single extension because it is lenient. That is not a defence: the deliverable +/// is *generic* client-declared MCP servers, and a standards-compliant server is entitled to +/// reject — or simply not answer — anything that arrives before it has been initialized. +/// +/// Failure here fails the establish, so a server that cannot complete the handshake never reaches +/// the registry: better no tunnel than one whose first real call is rejected for a reason the +/// operator cannot see. +async fn inner_mcp_handshake( + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &AtomicU64, + connection_id: &str, + timeout_secs: u64, +) -> Result<(), String> { + // Reuse `mcp_message_request` rather than re-assembling the frame: sending an inner MCP + // request and unwrapping its result has exactly one implementation, so the `protocolVersion` + // check below only has to exist in one place. + let init = mcp_message_request( + out_tx, + pending, + next_id, + connection_id, + "initialize", + Some(json!({ + "protocolVersion": INNER_MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "openab-gateway", "version": env!("CARGO_PKG_VERSION") } + })), + timeout_secs, + ) + .await?; + + // A reply proves the server answered, not that it agreed. A server answering a version we do + // not speak would be registered here and then fail on its first real `tools/call`, for a + // reason nothing in the log explains — the exact opaque failure this handshake exists to + // prevent. Refuse the establish instead, and name both versions so the mismatch is readable. + match init.get("protocolVersion").and_then(Value::as_str) { + Some(v) if SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS.contains(&v) => {} + Some(other) => { + return Err(format!( + "inner MCP server answered protocolVersion {other}, which this gateway does not \ + speak (requested {INNER_MCP_PROTOCOL_VERSION}, accepts {})", + SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS.join(", ") + )); + } + None => { + return Err( + "inner MCP `initialize` result carried no `protocolVersion` string".to_string(), + ); + } + } + + // `notifications/initialized` is a notification in both directions: an inner MCP notification, + // carried by an outer frame with no `id`, so nothing is awaited and no reply is owed. + let initialized = serde_json::to_value(McpMessageParams { + connection_id: connection_id.to_string(), + method: "notifications/initialized".to_string(), + params: None, + }) + .map_err(|e| e.to_string())?; + let notification = json!({ + "jsonrpc": "2.0", + "method": "mcp/message", + "params": initialized, + }); + out_tx + .send(notification.to_string()) + .map_err(|_| "connection closed before notifications/initialized".to_string())?; + Ok(()) +} + +/// 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(clippy::too_many_arguments)] +async fn establish_and_register_tunnel( + out_tx: mpsc::UnboundedSender, + pending: Arc>>>, + next_id: Arc, + acp_id: String, + acp_name: String, + channel_id: String, + registry: AcpTunnelRegistry, + timeout_secs: u64, + owner: String, + connection_generation: u64, + connection_closed: Arc, +) -> 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, acp_name = %acp_name, channel = %redact_id(&channel_id), "ACP: opening MCP-over-ACP tunnel"); + // Stamp BEFORE the first round trip: this establish's place in attach order is fixed by when + // the client declared the server, not by how long its handshake takes. See `TUNNEL_GENERATION`. + let generation = TUNNEL_GENERATION.fetch_add(1, Ordering::Relaxed); + let connection_id = mcp_connect(&out_tx, &pending, &next_id, &acp_id, timeout_secs).await?; + // MCP lifecycle before the tunnel is usable — see `inner_mcp_handshake`. + // + // On failure the client is still holding an open connection it opened for us, and we are about + // to return without registering a handle for it. Every other cleanup path in this file goes + // through the registry (`reg.remove(..)` then `handle.disconnect(..)`), so a connection that + // never got registered has none at all — it would leak for the life of the process, once per + // attach, and a handshake timeout against a slow server is enough to trigger it. Same + // obligation the eviction path documents: the client believes the connection is open, so it is + // owed an `mcp/disconnect`. + // + // Best-effort and off the failure path, matching the eviction disconnect: a client that just + // failed a handshake may be in no state to answer, and waiting on it would delay the error the + // caller needs to log. + if let Err(e) = + inner_mcp_handshake(&out_tx, &pending, &next_id, &connection_id, timeout_secs).await + { + let (tx, pend, nid, cid) = ( + out_tx.clone(), + pending.clone(), + next_id.clone(), + connection_id.clone(), + ); + tokio::spawn(async move { + if let Err(e) = mcp_disconnect(&tx, &pend, &nid, &cid, 5).await { + // `warn!`, not `debug!`: reaching here means the connection we opened is now + // leaked for the life of the process — it never entered the registry, so nothing + // else will ever close it. Logging the cleanup failure more quietly than the + // handshake failure it cleans up after would hide the worse of the two. + warn!( + error = %e, connection = %redact_id(&cid), + "ACP: mcp/disconnect after a failed handshake did not complete — that \ + connection is now unreclaimable" + ); + } + }); + return Err(e); + } + let handle = TunnelHandle { + out_tx, + pending, + next_id, + connection_id, + server_name: acp_name.clone(), + owner: owner.clone(), + generation, + connection_generation, + }; + // `Superseded` carries the handle back out of the lock: a losing establish still owes its + // client an `mcp/disconnect`, and `disconnect` is async while this is a std mutex. + enum Registered { + Done(Vec), + Superseded(TunnelHandle), + } + let outcome = { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + // Last-attach-wins (ADR §6.1). The client mints a fresh `id` on every connection, so a + // reconnect would otherwise leave the dead tunnel registered beside the live one under + // the same declared name. Answering "ambiguous — pass a server_id" there would wedge the + // client out of its own tools on every reconnect, so the newest attach evicts its stale + // same-name predecessors instead — which is also what bounds registry growth. + // + // Take the stale handles OUT rather than dropping them: the client still believes those + // connections are open, so each one is owed an `mcp/disconnect` (review R7). They are + // collected here and disconnected after the lock is released — `disconnect` is async and + // this is a std mutex, so awaiting under it is not an option. + // + // Deliberately NOT scoped to the attaching connection. A reconnecting client arrives on a + // NEW socket with a fresh `server_id`, so its predecessor's handle is owned by the old + // connection — restricting eviction to one owner would leave that dead tunnel registered + // beside the live one under the same declared name, which is the ambiguity + // last-attach-wins exists to prevent (ADR §6.1). Teardown is owner-scoped; eviction is + // not, and they are different questions. + let own_key = (channel_id.clone(), acp_id.clone()); + let same_name = |c: &String, id: &String, h: &TunnelHandle| { + c == &channel_id && h.server_name == acp_name && id != &acp_id + }; + // Ordering is by generation, not by who reached this lock first. A slow establish that + // started EARLIER but finished later must not evict the newer tunnel that beat it here: + // it lost the race the client actually cares about, so it stands down and closes its own + // connection instead of installing a stale handle over a live one. + // + // OUR OWN KEY is checked separately and must be, because `same_name` excludes it: a client + // is free to reuse the same `server_id` across a reconnect — nothing in the protocol + // requires a fresh one, and a stable id is the more natural implementation for a generic + // peer. When it does, both establishes land on this one key, `same_name` never sees the + // newer entry, and the older arrival would `insert` straight over a live handle. Ordering + // has to hold per key, not just per declared name. + // The closed flag is read HERE, under the registry lock, because that is the only place it + // can be decisive. `abort()` cannot close this race: it takes effect at an await point and + // there is none between the handshake completing and the insert below, so on a + // multi-thread runtime this section runs concurrently with teardown's `retain`. The flag is + // set BEFORE that retain, so whichever takes the lock first the outcome is right — insert + // first and the retain removes it; retain first and we never insert at all. Without it a + // late establish drops a handle owned by a dead connection into an empty slot, where + // nothing will ever remove it. + // Ordering is LEXICOGRAPHIC on (connection age, attach order), and it has to be both. + // + // Attach order alone repeats the mistake the sweep already had to fix: it is stamped when an + // establish starts, so an older connection's LATE resume spawns an establish with a HIGHER + // number — precisely because it ran later — and then outranks the newer connection whose + // tunnel it must not take. Connection age is what says which declaration set is current. + // + // Attach order is still needed as the tiebreak: within ONE connection the ages are equal, and + // there last-attach-wins is exactly right. + let rank = (connection_generation, generation); + let superseded = connection_closed.load(std::sync::atomic::Ordering::Acquire) + || reg + .get(&own_key) + .is_some_and(|h| (h.connection_generation, h.generation) > rank) + || reg.iter().any(|((c, id), h)| { + same_name(c, id, h) && (h.connection_generation, h.generation) > rank + }); + if superseded { + Registered::Superseded(handle) + } else { + // No rank comparison here, deliberately. Everything ranking ABOVE this establish has + // already returned through `superseded`, and ranks are unique, so every surviving + // same-name entry necessarily ranks below — the comparison would be true every time. + // + // It was written out rather than left in place because a condition that looks like a + // check, is in fact always true, and becomes WRONG if it ever stops matching the + // comparison above is worse than no condition at all. Held as lexicographic it was + // redundant; changed on its own to attach-order it silently stopped evicting an incumbent + // that was older by connection but later by attach, leaving two tunnels under one + // declared name. Both readings of it misled me inside two hours. + // + // INVARIANT: the eviction set is "same declared name, not me". Anything that should have + // won is filtered out earlier by `superseded`; if that check is ever weakened, this is + // where the damage shows up, so change the two together. + let stale: Vec<(String, String)> = reg + .iter() + .filter(|((c, id), h)| same_name(c, id, h)) + .map(|(k, _)| k.clone()) + .collect(); + let mut replaced: Vec = + stale.iter().filter_map(|k| reg.remove(k)).collect(); + // Whatever this insert displaces is owed a disconnect too. Discarding the return value + // leaked the previous holder of this exact key: it left the registry, so no cleanup + // path could reach it, while its client still believed the connection was open. + replaced.extend(reg.insert(own_key, handle)); + Registered::Done(replaced) + } + }; + let replaced = match outcome { + Registered::Done(replaced) => replaced, + Registered::Superseded(handle) => { + info!( + channel = %redact_id(&channel_id), server_name = %acp_name, generation, + "ACP: a newer attach already holds this name — standing down without registering" + ); + // Same obligation as eviction: the client opened this connection for us and we are not + // going to use it, so it is owed an `mcp/disconnect`. Best-effort, off the attach path. + tokio::spawn(async move { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a superseded establish did not complete"); + } + }); + return Ok(()); + } + }; + if !replaced.is_empty() { + info!( + channel = %redact_id(&channel_id), server_name = %acp_name, replaced = replaced.len(), + "ACP: last-attach-wins — replaced stale tunnel(s)" + ); + // Best-effort and off the attach path: a replaced connection may already be dead, and + // waiting on its response would stall the tunnel that just came up for no benefit. + tokio::spawn(async move { + for handle in replaced { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a replaced tunnel did not complete"); + } + } + }); + } + info!(channel = %redact_id(&channel_id), server_id = %acp_id, server_name = %acp_name, "ACP: tunnel registered — client MCP server attached"); + Ok(()) +} + +/// Open + register a tunnel for **every** `type:acp` server the session's client declared. +/// +/// The old "first declared server only" limit came from the registry being keyed by `channel_id` +/// alone, where a second server would overwrite the first and orphan its open tunnel. The compound +/// `(channel_id, server_id)` key removed that collision, so all declared servers are established +/// now; a re-declared *name* is resolved last-attach-wins inside `establish_and_register_tunnel` +/// (ADR §6.1). Spawned (not awaited inline) because that function awaits the client's +/// `mcp/connect` response, which only the read loop delivers — awaiting inline would deadlock. +#[allow(clippy::too_many_arguments)] +fn spawn_acp_tunnels( + servers: Vec, + channel_id: String, + registry: AcpTunnelRegistry, + out_tx: &mpsc::UnboundedSender, + pending: &Arc>>>, + next_id: &Arc, + establish_tasks: &mut Vec>, + owner: &str, + connection_generation: u64, + connection_closed: &Arc, +) { + // Drop finished handles first so a long-lived connection does not accumulate them, then bound + // what is still running. Over the cap the declaration is dropped with a warning rather than + // refusing the whole request: the session itself is valid and its other servers still attach. + establish_tasks.retain(|h| !h.is_finished()); + for srv in servers { + let out_tx = out_tx.clone(); + let pending = pending.clone(); + let next_id = next_id.clone(); + let registry = registry.clone(); + let channel_id = channel_id.clone(); + let owner = owner.to_string(); + let connection_closed = connection_closed.clone(); + if establish_tasks.len() >= MAX_INFLIGHT_ESTABLISHES { + warn!( + max = MAX_INFLIGHT_ESTABLISHES, + "ACP: too many tunnels establishing on this connection — dropping a declaration" + ); + break; + } + establish_tasks.push(tokio::spawn(async move { + if let Err(e) = establish_and_register_tunnel( + out_tx, pending, next_id, srv.id, srv.name, channel_id, registry, 30, owner, + connection_generation, connection_closed, + ) + .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()); + // Age of this connection, from the same counter that orders attaches. A resume's authority to + // retire someone else's tunnel is decided by which CONNECTION is newer, so it has to be stamped + // here — once, at accept — rather than per request. + let connection_generation = TUNNEL_GENERATION.fetch_add(1, Ordering::Relaxed); + // Set once, at teardown, and read by in-flight establishes under the registry lock. See the + // check in `establish_and_register_tunnel` for why `abort()` cannot do this job. + let connection_closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); info!(connection = %connection_id, "ACP client connected"); @@ -418,10 +1294,21 @@ 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 let mut prompt_tasks: Vec> = Vec::new(); + // Tunnel establishes get their own set. Sharing one with prompts meant a pending `mcp/connect` + // consumed prompt budget, and the client saw an overload error for work it never requested. + let mut establish_tasks: Vec> = Vec::new(); // Channel for sending messages back to the client let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); @@ -494,6 +1381,46 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } + // Per-kind size cap (review F2). The 8 MiB check above is the transport ceiling, and only + // tunnel results legitimately reach it — those are client RESPONSES (no `method`), handled + // just below. Anything carrying a `method` is a client request or notification, so hold it + // to the pre-existing 1 MiB: otherwise the browser-result allowance doubles as a way to + // park MAX_INFLIGHT_PROMPTS × 8 MiB of prompt text on one connection. + if let Some(method) = oversized_for_its_kind(text.len(), &raw) { + { + warn!( + connection = %connection_id, + method, + bytes = text.len(), + max = MAX_NON_TUNNEL_FRAME_BYTES, + "ACP frame too large for its method; rejecting" + ); + // A notification MUST NOT be answered; drop it and keep the connection. + if !is_notification { + let id = raw.get("id").cloned().unwrap_or(Value::Null); + let err_resp = JsonRpcResponse::error( + id, + ACP_OVERLOADED, + format!( + "Frame too large: {} exceeds the {MAX_NON_TUNNEL_FRAME_BYTES}-byte limit for `{method}`", + text.len() + ), + ); + let _ = out_tx.send(serde_json::to_string(&err_resp).unwrap()); + } + continue; + } + } + + // 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 +1494,40 @@ 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; + // Bound the declaration fan-out BEFORE the session exists or any task is + // spawned (R3-F1): an over-declaring request must cost nothing. + let acp_mcp_servers = match accept_acp_servers(parse_acp_mcp_servers( + req.params.as_ref(), + )) { + Ok(list) => list, + Err(msg) => { + let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); + let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + continue; + } + }; + let (resp, channel_id) = + handle_session_new(&sessions, id.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_acp_tunnels( + acp_mcp_servers, + channel_id.clone(), + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut establish_tasks, + &connection_id, + connection_generation, + &connection_closed, + ); + } } "session/resume" => { if !initialized { @@ -585,8 +1544,134 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; } - let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; + // Same bound on resume: the client re-presents its declarations each time, so + // resume is an equally good burst vector (R3-F1). + let resumed_servers = + match accept_acp_servers(parse_acp_mcp_servers(req.params.as_ref())) { + Ok(list) => list, + Err(msg) => { + let resp = JsonRpcResponse::error(id, ACP_OVERLOADED, msg); + let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + continue; + } + }; + let (resp, resumed_channel) = + handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await; let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); + + // Retire tunnels for declarations this resume withdrew. + // + // Derived from the REGISTRY, not from a remembered declaration set. The previous + // version compared against `sessions`, which is built per connection — so on the + // real reconnect path that map is empty, the lookup returns None, and the withdrawn + // set was ALWAYS empty. The feature was dead in exactly the case it was written + // for, and its test passed because it drove a same-connection resume. + // + // The registry is process-global and keyed `(channel_id, server_id)`, so + // "registered under this channel but absent from what the client just declared" IS + // the withdrawn set — no second record to keep in sync, and correct across a + // reconnect. A resume re-presents the client's WHOLE declaration set, which is what + // makes absence meaningful. + // + // Handles come out under the lock and are disconnected after it is released + // (`disconnect` is async, this is a std mutex). Each is owed that disconnect + // because the client still believes the connection is open — same obligation as a + // same-name replacement (R7). + // + // ABSENT and EMPTY are not the same thing. `mcpServers` omitted entirely means the + // client said nothing about its servers, so nothing is withdrawn; an explicit `[]` + // is a client saying it now offers none, which withdraws everything. Treating the + // two alike was harmless while the withdrawn set was always empty — deriving it + // from the registry made it destructive, so a compliant client that simply left + // the optional field out would have every tunnel on its channel torn down. + // `session/new` has the same reading of absence, and D-06 treats a missing + // `protocolVersion` as fail-closed; absence should not be the most damaging + // interpretation here while it is the safest one there. + // Only a real ARRAY withdraws. `is_some()` was wrong in the case this guard exists + // for: `null` is a third state, semantically next to absent, and it is the shape a + // serde `Option>` without `skip_serializing_if` produces for a field the + // client left unset — so the most likely real wire form of "omitted" landed on the + // destructive side. `{}` and `"x"` were swept too, silently, because + // `parse_acp_mcp_servers` reads through `as_array()` and yields an empty list for + // anything that is not one. A malformed declaration must not be the most damaging + // reading available. + // One reachable way to arrive with an empty `keep` and a present key: the client + // declared only NON-`type:acp` servers. `parse_acp_mcp_servers` filters by type, so + // the list empties while the field itself is a perfectly good array. That withdraws + // every acp tunnel on the channel and is the CORRECT reading — the client + // re-presented its whole set and there is no acp server in it. Noted because it + // looks identical to a defect that was reported here and turned out to be + // unreachable. + let declared_servers = matches!( + req.params.as_ref().and_then(|p| p.get("mcpServers")), + Some(Value::Array(_)) + ); + if let (Some(registry), Some(channel_id)) = + (state.acp_tunnel_registry.clone(), resumed_channel.as_ref()) + { + if declared_servers { + let keep: std::collections::HashSet<&str> = + resumed_servers.iter().map(|s| s.id.as_str()).collect(); + let dropped: Vec = { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + let stale: Vec<(String, String)> = reg + .iter() + .filter(|((c, id), h)| { + c == channel_id + && !keep.contains(id.as_str()) + // Never retire a tunnel a NEWER connection installed. An + // older connection's late resume carries an out-of-date + // declaration set, so its silence about a newer + // connection's server is not a withdrawal — it simply + // never knew about it. + && h.connection_generation <= connection_generation + }) + .map(|(k, _)| k.clone()) + .collect(); + stale.iter().filter_map(|k| reg.remove(k)).collect() + }; + if !dropped.is_empty() { + info!( + channel = %redact_id(channel_id), retired = dropped.len(), + "ACP: resume withdrew declarations — retiring their tunnels" + ); + tokio::spawn(async move { + for handle in dropped { + if let Err(e) = handle.disconnect(5).await { + debug!(error = %e, "ACP: mcp/disconnect for a withdrawn declaration did not complete"); + } + } + }); + } + } + } + + // 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". + // ONLY on a resume the handler accepted — `resumed_channel` is that signal. A + // rejected resume must not touch tunnels: same-name re-attach is last-write-wins, + // so spawning here would let a refused request (busy, over-cap, unknown session) + // evict the very tunnel it was refused in favour of. Deriving the channel from the + // requested sessionId is NOT a sufficient guard — a well-formed id derives fine on + // every one of those rejection paths. + if let (Some(registry), Some(channel_id)) = + (state.acp_tunnel_registry.clone(), resumed_channel) + { + spawn_acp_tunnels( + resumed_servers, + channel_id, + registry, + &out_tx, + &pending_requests, + &next_req_id, + &mut establish_tasks, + &connection_id, + connection_generation, + &connection_closed, + ); + } } "session/prompt" => { if !initialized { @@ -633,7 +1718,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let resp = JsonRpcResponse::error( id, -32602, - format!("Unknown session: {session_id}"), + format!("Unknown session: {}", redact_id(&session_id)), ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); continue; @@ -659,6 +1744,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { let state_clone = state.clone(); let sessions_clone = sessions.clone(); let out_tx_clone = out_tx.clone(); + let conn_for_prompt = connection_id.clone(); let handle = tokio::spawn(async move { handle_session_prompt( &state_clone, @@ -668,6 +1754,7 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &out_tx_clone, session_id, cancel, + &conn_for_prompt, ) .await; }); @@ -695,35 +1782,56 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { } } - // Clean up finished tasks + // Clean up finished tasks (both sets) prompt_tasks.retain(|h| !h.is_finished()); + establish_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 { + // Announce the close BEFORE anything is torn down. An establish that is past its last await + // point cannot be aborted, so the flag — not `abort()` — is what stops it installing a handle + // for a connection that no longer exists. + connection_closed.store(true, std::sync::atomic::Ordering::Release); + // Abort any in-flight tasks to prevent registry leaks. Establishes are aborted too: a task + // still waiting on `mcp/connect` would otherwise insert a handle into the registry AFTER the + // teardown below has run, leaving a dead tunnel registered for a closed connection. + for handle in prompt_tasks.into_iter().chain(establish_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() + }; + // Teardown removes only what THIS connection owns. Matching on the key alone is wrong for + // both registries: a client that reconnects and resumes takes over the same `channel_id`, so a + // late cleanup from the closing connection would delete the successor's live entry — the + // reply sink stops delivering, or the tunnel disappears from under a working session. + 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" - ); + reg.retain(|cid, sink| !(channel_ids.contains(cid) && sink.owner == connection_id)); + } + 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 this connection's `(channel_id, *)` tunnels only. + reg.retain(|(cid, _), h| !(channel_ids.contains(cid) && h.owner == connection_id)); } + 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 +1891,12 @@ 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 { +) -> (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 +1906,28 @@ async fn handle_session_new( sessions.lock().await.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, }, ); // 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 })) + debug!(session = %redact_id(&session_id), "ACP session created"); + + // 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 @@ -824,23 +1945,41 @@ async fn handle_session_new( /// Security: `sessionId` is a server-minted, high-entropy capability; /// `derive_channel_id` requires a well-formed `sess_`, keeping the channel /// inside the `acp_` namespace and rejecting forged ids. +/// Returns the response and, **only when the resume actually succeeded**, the channel it resumed. +/// The caller uses that `Some` as its permission to open tunnels: deriving a channel id from the +/// requested `sessionId` is not sufficient, because a well-formed id derives fine even on the +/// paths that reject the resume (unknown session, per-connection cap, busy). +/// Returns the response, the channel on success, and the declarations this resume RETIRES — +/// servers the session had that the client no longer offers. +/// +/// `accepted` is the deduplicated, capped list, threaded in exactly as `session/new` receives it. +/// Re-parsing the raw params here instead stored an unbounded list in the session record while the +/// tunnels were opened from the accepted one, so the two disagreed about what the client declared. async fn handle_session_resume( sessions: &Arc>>, id: Value, params: Option<&Value>, -) -> JsonRpcResponse { +) -> (JsonRpcResponse, Option) { let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) { Some(s) => s.to_string(), - None => return JsonRpcResponse::error(id, -32602, "Missing sessionId"), + None => { + return ( + JsonRpcResponse::error(id, -32602, "Missing sessionId"), + None, + ) + } }; let channel_id = match derive_channel_id(&session_id) { Some(cid) => cid, None => { - return JsonRpcResponse::error( - id, - -32602, - "Invalid sessionId: expected the form sess_", + return ( + JsonRpcResponse::error( + id, + -32602, + "Invalid sessionId: expected the form sess_", + ), + None, ); } }; @@ -850,10 +1989,13 @@ async fn handle_session_resume( // path (a client can mint unlimited valid `sess_`). An already-present key is // exempt so re-resuming an existing session stays idempotent. if !guard.contains_key(&session_id) && guard.len() >= MAX_SESSIONS_PER_CONNECTION { - return JsonRpcResponse::error( - id, - ACP_OVERLOADED, - format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), + return ( + JsonRpcResponse::error( + id, + ACP_OVERLOADED, + format!("Too many sessions on this connection (max {MAX_SESSIONS_PER_CONNECTION})"), + ), + None, ); } // R16-F2: refuse to resume a session that currently has a prompt in flight. The insert @@ -862,26 +2004,34 @@ async fn handle_session_resume( // state / registry entry, losing its replies. A busy session is already live on this // connection, so reject deterministically instead of stomping it. if guard.get(&session_id).is_some_and(|s| s.busy) { - return JsonRpcResponse::error( - id, - -32001, - "Session busy: a prompt is in progress; cannot resume", + return ( + JsonRpcResponse::error( + id, + -32001, + "Session busy: a prompt is in progress; cannot resume", + ), + None, ); } guard.insert( session_id.clone(), AcpSession { - channel_id, + channel_id: channel_id.clone(), busy: false, cancel: None, }, ); drop(guard); - debug!(session = %session_id, "ACP session resumed"); + debug!(session = %redact_id(&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()), + Some(channel_id), + ) } /// Handle `session/cancel`. Per ACP it is a one-way NOTIFICATION: the notification form @@ -917,6 +2067,61 @@ async fn handle_session_cancel( /// `sessionId` (`sess_`). Returns `None` if malformed — the uuid must /// parse, which keeps a resumed channel inside the `acp_` namespace and rejects /// forged ids. +/// A stable, non-reversible tag for an ACP channel or session id, for logs. +/// +/// `channel_id` is `acp_` and `session_id` is `sess_` — see +/// [`derive_channel_id`] — so either one in a log line is a working `session/resume` credential. +/// Anyone who can read operator logs could take over a live session, and logs travel further than +/// the sessions they describe. +/// +/// Dropping the lines to `debug!` would hide them from the operators who need them ("did the +/// extension attach?"), so the id is hashed instead: the same session tags identically on every +/// line, which is what makes a log readable, but the tag cannot be turned back into the id. +fn redact_id(id: &str) -> String { + // Hash the UUID, not the prefixed string. One session is addressed as `sess_` and as + // `acp_`; hashing the whole string gives those two a different tag each, and a third + // different from `openab-core`, which strips the prefix. That is three tags for one session — + // and `prompt dispatched` prints two of them on a single line. Correlating a session across + // logs is the entire reason the tag exists, so producing several defeats the purpose more + // completely than not redacting would. + let uuid = id.strip_prefix("acp_").or_else(|| id.strip_prefix("sess_")).unwrap_or(id); + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(uuid.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_id_cross_encoding { + /// Both encodings of one session must tag identically, and identically to `openab-core`. + /// + /// This assertion existed in `openab-core` and not here, so the gateway diverged silently while + /// core's own test asserted the property core alone upheld. An invariant that spans two crates + /// has to be asserted in both — checking it where it happens to hold proves nothing about the + /// side that breaks it. + #[test] + fn both_encodings_of_one_session_produce_one_tag() { + let u = "00000000-0000-0000-0000-000000000000"; + let a = super::redact_id(&format!("acp_{u}")); + let s = super::redact_id(&format!("sess_{u}")); + assert_eq!(a, s, "one session must not read as two"); + assert_eq!( + a, + openab_core_tag(u), + "the gateway's tag must equal openab-core's for the same session, or an operator \ + following one log to the other finds nothing" + ); + } + + /// Recomputed rather than imported: the crates do not depend on one another, so the shared + /// value has to be pinned on both sides independently. + fn openab_core_tag(uuid: &str) -> String { + use sha2::{Digest as _, Sha256}; + let d = Sha256::digest(uuid.as_bytes()); + format!("#{}", d.iter().take(4).map(|b| format!("{b:02x}")).collect::()) + } +} + fn derive_channel_id(session_id: &str) -> Option { let uuid = session_id.strip_prefix("sess_")?; Uuid::parse_str(uuid).ok()?; @@ -935,6 +2140,9 @@ async fn release_prompt( } } +// 8 args: the connection id is threaded in so the reply sink records which connection installed +// it. Bundling these into a struct would hide that relationship at the call site. +#[allow(clippy::too_many_arguments)] async fn handle_session_prompt( state: &Arc, sessions: &Arc>>, @@ -945,6 +2153,9 @@ async fn handle_session_prompt( // `cancel` installed under the session lock (R16-F1). This task owns releasing it on return. session_id: String, cancel: Arc, + // `acp_conn_*` id of the connection running this prompt, stamped on the reply sink so + // teardown removes only the sinks this connection installed. + connection_id: &str, ) { // sessionId was validated + reserved by the caller; only the prompt body can still be bad. let prompt_text = match extract_prompt_params(params) { @@ -962,7 +2173,11 @@ async fn handle_session_prompt( Some(s) => s.channel_id.clone(), None => { let resp = - JsonRpcResponse::error(id, -32602, format!("Unknown session: {session_id}")); + JsonRpcResponse::error( + id, + -32602, + format!("Unknown session: {}", redact_id(&session_id)), + ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); release_prompt(sessions, &session_id).await; return; @@ -997,7 +2212,10 @@ async fn handle_session_prompt( registry .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id.clone(), ReplySink { turn_id, tx: reply_tx }); + .insert( + channel_id.clone(), + ReplySink { turn_id, tx: reply_tx, owner: connection_id.to_string() }, + ); } // Send event through the broadcast channel @@ -1029,19 +2247,20 @@ async fn handle_session_prompt( } } - debug!(session = %session_id, channel = %channel_id, "ACP: prompt dispatched"); + debug!(session = %redact_id(&session_id), channel = %redact_id(&channel_id), "ACP: prompt dispatched"); // 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"; + let timeout = tokio::time::Duration::from_secs(ACP_PROMPT_IDLE_TIMEOUT_SECS); + // 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()) => { @@ -1070,7 +2289,7 @@ async fn handle_session_prompt( } Ok(Some(ReplyChunk::Done)) | Ok(None) => break, Err(_) => { - warn!(session = %session_id, "ACP: prompt timed out waiting for reply"); + warn!(session = %redact_id(&session_id), "ACP: prompt timed out waiting for reply"); timed_out = true; break; } @@ -1099,7 +2318,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()); } @@ -1586,9 +2810,633 @@ mod acp_streaming { // assert their actual output + side effects. // --------------------------------------------------------------------------- #[cfg(test)] -mod acp_handlers { +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}; + + /// Answer the inner MCP `initialize` that `establish_and_register_tunnel` now sends after + /// `mcp/connect`, and swallow the `notifications/initialized` that follows. + /// + /// A mock extension that answers only `mcp/connect` leaves the establish waiting on the + /// handshake, so it never registers — surfacing as "no handle in the registry", which says + /// nothing about the handshake. Every mock driving a real establish needs this. + async fn answer_inner_handshake( + out_rx: &mut mpsc::UnboundedReceiver, + pending: &Arc>>>, + ) { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["params"]["method"], json!("initialize"), "expected the inner MCP initialize"); + route_client_response( + pending, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{ + "protocolVersion":"2025-06-18","capabilities":{"tools":{}}, + "serverInfo":{"name":"test-ext","version":"0"} + }}), + ) + .await; + // The notification owes no reply, but it must come off the channel so a later + // `out_rx.recv()` does not mistake it for the frame the test is waiting for. + let n: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(n["params"]["method"], json!("notifications/initialized")); + } + + fn new_pending( + ) -> Arc>>> { + Arc::new(tokio::sync::Mutex::new(HashMap::new())) + } + + /// Drive `inner_mcp_handshake` against a peer answering `version`, and report the outcome. + /// + /// The handshake awaits a reply, so the answer has to be routed from a second task; doing it + /// inline deadlocks on the `pending` entry that has not been created yet. + async fn handshake_answering(version: Option<&str>) -> Result<(), String> { + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let pending = new_pending(); + let next_id = AtomicU64::new(1); + let p2 = pending.clone(); + let version = version.map(str::to_string); + let responder = tokio::spawn(async move { + let f: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + let result = match version { + Some(v) => json!({ + "protocolVersion": v, "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-ext", "version": "0"} + }), + // A result with no `protocolVersion` at all — the branch that was never in + // dispute and must keep rejecting. + None => json!({ + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-ext", "version": "0"} + }), + }; + route_client_response(&p2, &json!({"jsonrpc":"2.0","id":f["id"],"result":result})) + .await; + // Drain `notifications/initialized` if the handshake got far enough to send it. + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), out_rx.recv()).await; + }); + let r = super::inner_mcp_handshake(&out_tx, &pending, &next_id, "conn-1", 5).await; + let _ = responder.await; + r + } + + /// Every revision in the accepted set must be accepted (R5, D-2026-07-30-10). + /// + /// Asserted per-revision rather than "the set is non-empty": a membership test that only ever + /// exercises the requested version would pass for the strict-equality code this replaced. + #[tokio::test] + async fn every_supported_inner_revision_is_accepted() { + for v in super::SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS { + assert!( + handshake_answering(Some(v)).await.is_ok(), + "{v} is in the supported set, so a peer answering it must be accepted" + ); + } + } + + /// A peer answering with a revision outside the set is still refused, and the error names both + /// what we asked for and what we accept — otherwise the operator cannot tell a version + /// mismatch from an unreachable peer. + #[tokio::test] + async fn a_revision_outside_the_set_is_still_refused() { + let err = handshake_answering(Some("1999-01-01")) + .await + .expect_err("an unknown revision must not be negotiated into"); + assert!(err.contains("1999-01-01"), "the error must name what the peer answered: {err}"); + assert!( + err.contains(super::INNER_MCP_PROTOCOL_VERSION), + "the error must name what we requested: {err}" + ); + assert!( + err.contains("2024-11-05"), + "the error must name what we accept, or the operator cannot tell what to change: {err}" + ); + } + + /// The `None` branch was never in dispute: a result carrying no `protocolVersion` string is + /// not a compliant answer, and widening acceptance must not have widened this. + #[tokio::test] + async fn a_result_with_no_protocol_version_is_refused() { + let err = handshake_answering(None) + .await + .expect_err("no protocolVersion is not a compliant initialize result"); + assert!(err.contains("no `protocolVersion`"), "unexpected error: {err}"); + } + + #[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 { + owner: "conn-test".into(), + out_tx, + pending: pending.clone(), + next_id, + connection_id: "conn-9".into(), + server_name: "browser".into(), + generation: 0, + connection_generation: 0, + }; + + 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(); + } + + /// A handle with chosen ordering numbers, for asserting on resolution directly. + /// + /// The establish path cannot produce two tunnels under one name, so a state that only + /// `resolve_by_name` has to cope with has to be built by hand. + /// + /// **Give every handle a distinct rank.** `resolve_by_name` compares with a strict `>`, so on a + /// tie the first one the registry iterator happens to yield wins — and that order is not stable. + /// Real handles cannot tie, because `fetch_add` makes the attach number unique; only handmade + /// ones can. A test built on two equal ranks would be intermittently wrong for a reason with no + /// visible connection to what it was testing. + fn tunnel_ranked(owner: &str, conn_gen: u64, gen: u64) -> super::TunnelHandle { + let (out_tx, _rx) = mpsc::unbounded_channel::(); + super::TunnelHandle { + out_tx, + pending: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + next_id: Arc::new(AtomicU64::new(1)), + connection_id: format!("{owner}-conn"), + server_name: "katashiro".into(), + owner: owner.into(), + connection_generation: conn_gen, + generation: gen, + } + } + + /// `resolve_by_name` picks the newest attach when a name somehow has two tunnels. + /// + /// The registry keeps declared names unique, so this state should not arise — which is exactly + /// why the behaviour needs pinning. The caller this replaced enumerated and took the FIRST match, + /// and a first match is whatever the map iterator happened to yield: correct only while the + /// invariant holds, and silently arbitrary the moment it does not. Constructed directly here + /// because the establish path will not produce it. + /// + /// Newest-wins rather than an error, deliberately: refusing with "ambiguous, pass a server_id" is + /// the behaviour ADR §6.1 exists to avoid, since it locks a client out of its own tools on every + /// reconnect. A soft inconsistency should not become a hard stop. + #[test] + fn resolve_by_name_routes_to_the_newest_attach_if_a_name_is_ever_duplicated() { + let registry = super::new_tunnel_registry(); + { + let mut reg = registry.lock().unwrap(); + // Deliberately out of insertion order relative to rank, so a "first match" answer and a + // "newest" answer differ. + reg.insert(("acp_1".into(), "srv-new".into()), tunnel_ranked("conn-b", 7, 2)); + reg.insert(("acp_1".into(), "srv-old".into()), tunnel_ranked("conn-a", 3, 9)); + reg.insert(("acp_1".into(), "other".into()), { + let mut h = tunnel_ranked("conn-c", 9, 9); + h.server_name = "notes".into(); + h + }); + reg.insert(("acp_2".into(), "elsewhere".into()), tunnel_ranked("conn-d", 99, 99)); + } + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "katashiro").as_deref(), + Some("srv-new"), + "must pick the higher (connection_generation, generation), not whichever the map yields \ + first — note srv-old has the larger attach number, so attach order alone answers wrong" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "notes").as_deref(), + Some("other"), + "a different declared name on the same channel resolves independently" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_1", "absent"), + None, + "an unknown name resolves to nothing rather than to an arbitrary tunnel" + ); + assert_eq!( + super::resolve_by_name(®istry, "acp_other", "katashiro"), + None, + "resolution is scoped to the channel — another channel's tunnel must not be reachable" + ); + } + + /// The ineffective-timeout boundary is inclusive on the ceiling. + /// + /// Equal is the case that matters and the one an inverted comparison would drop: at exactly the + /// idle timeout the two clocks start together and which fires first is undecided, so the value + /// cannot be relied on to decide anything — that is the whole reason the margin exists. + #[test] + fn a_tunnel_timeout_at_or_above_the_idle_timeout_is_ineffective() { + let ceiling = super::ACP_PROMPT_IDLE_TIMEOUT_SECS; + assert!( + super::tunnel_timeout_is_ineffective(ceiling), + "equal to the ceiling must count as ineffective: the two clocks start together, so \ + neither reliably wins" + ); + assert!(super::tunnel_timeout_is_ineffective(ceiling + 1)); + assert!( + !super::tunnel_timeout_is_ineffective(ceiling - 1), + "one second beneath the ceiling is the intended configuration, not a warning" + ); + // The shipped default cannot be checked here: this crate does not depend on the one that + // owns it. That pairing is asserted in the binary, which is the only place both are visible. + } + + /// An establish that finishes after its connection closed must not register. + /// + /// The connection's tasks are aborted at teardown, but `abort()` only takes effect at an await + /// point and there is none between the inner handshake completing and the registry insert. On + /// a multi-thread runtime that section runs concurrently with teardown's `retain`, so a late + /// establish could drop a handle owned by a dead connection into a slot the retain had already + /// emptied — where nothing would ever remove it, because every cleanup path is scoped to a + /// connection that no longer exists. + /// + /// The generation stamp does NOT cover this: it can only lose to a handle that is still there, + /// and after teardown the slot is empty. Both Mira and I claimed otherwise; Orca showed the + /// empty-slot case, and this is the guard that closes it. + #[tokio::test] + async fn an_establish_that_finishes_after_teardown_does_not_register() { + 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(); + let closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let pending2 = pending.clone(); + let closed2 = closed.clone(); + let ext = tokio::spawn(async move { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-9"}}), + ) + .await; + // Close BEFORE answering the handshake, not after. The establish cannot get past its + // handshake until this reply lands, so the flag is ordered by the reply channel itself + // rather than by timing. Setting it afterwards is a real race that the establish + // usually WINS — it goes straight from the handshake to the lock without waiting for + // this task, registers, sends no disconnect, and the `recv` below then blocks forever. + // The first version of this test did that and hung the whole suite. + closed2.store(true, std::sync::atomic::Ordering::Release); + answer_inner_handshake(&mut out_rx, &pending2).await; + // Bounded: a regression must fail, not hang. An unbounded `recv` turns "no disconnect + // was sent" into a stuck suite, which is strictly worse than a red test. + tokio::time::timeout(std::time::Duration::from_secs(10), out_rx.recv()) + .await + .expect("timed out waiting for the mcp/disconnect a stood-down establish owes") + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "browser".into(), + "acp_abc".into(), + registry.clone(), + 5, + "conn-test".into(), + 0, + closed, + ) + .await + .unwrap(); + + // Registry first. With the guard removed the establish does NOT stand down — it registers — + // so awaiting the mock here would fail on "no disconnect from a stood-down establish", + // naming a thing that did not happen and costing a 10s timeout to say it. This ordering + // rule has now been needed three times in this file: run the assertion whose truth differs + // between fixed and broken FIRST, and it is not the same assertion in every test. + assert!( + registry.lock().unwrap().is_empty(), + "an establish registered a tunnel for a connection that had already closed — nothing \ + else can remove it, because every cleanup path is scoped to that dead connection" + ); + let disconnect = ext.await.unwrap(); + let frame: serde_json::Value = serde_json::from_str(&disconnect.expect( + "the stood-down establish still owes its client an mcp/disconnect for the connection \ + it opened", + )) + .unwrap(); + assert_eq!(frame["method"], json!("mcp/disconnect")); + } + + #[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; + answer_inner_handshake(&mut out_rx, &pending2).await; + }); + + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "srv-1".into(), + "browser".into(), + "acp_abc".into(), + registry.clone(), + 5, + "conn-test".into(), + 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await + .unwrap(); + ext.await.unwrap(); + + let reg = registry.lock().unwrap(); + let handle = reg.get(&("acp_abc".to_string(), "srv-1".to_string())); + assert!( + handle.is_some(), + "a TunnelHandle must be registered under (channel_id, server_id)" + ); + assert_eq!( + handle.unwrap().server_name(), + "browser", + "the declared name must survive registration — tool prefixes and the trust allowlist \ + match on it, not on the per-connection id" + ); + } + + /// Drive one `establish_and_register_tunnel` against a mock client that answers `mcp/connect`. + async fn attach(registry: &super::AcpTunnelRegistry, server_id: &str, name: &str) { + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + 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(); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-1"}}), + ) + .await; + answer_inner_handshake(&mut out_rx, &pending2).await; + }); + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + server_id.into(), + name.into(), + "acp_abc".into(), + registry.clone(), + 5, + "conn-test".into(), + 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await + .unwrap(); + ext.await.unwrap(); + } + + /// Last-attach-wins (ADR §6.1): the client mints a fresh `id` per connection, so a reconnect + /// re-declares the same `name` under a new id. The new tunnel must replace the stale one + /// rather than coexist with it — coexistence is what would make routing ambiguous and wedge + /// the client out of its own tools on every reconnect. + #[tokio::test] + async fn reattaching_same_name_evicts_the_stale_tunnel() { + let registry = super::new_tunnel_registry(); + attach(®istry, "uuid-old", "browser").await; + attach(®istry, "uuid-new", "browser").await; + + let reg = registry.lock().unwrap(); + assert_eq!( + reg.len(), + 1, + "the reconnect must evict the stale same-name tunnel, not accumulate beside it" + ); + assert!( + reg.contains_key(&("acp_abc".to_string(), "uuid-new".to_string())), + "the most recently attached tunnel is the one that survives" + ); + } + + /// A replaced tunnel is owed an `mcp/disconnect` (review R7). + /// + /// Last-attach-wins used to simply drop the stale handle, so the only `mcp_disconnect` impl + /// was never called and the client kept believing that connection was open — stale state that + /// accumulates across every reconnect. + #[tokio::test] + async fn a_replaced_tunnel_is_told_to_disconnect() { + let registry = super::new_tunnel_registry(); + + // First attach. Keep this connection's out_rx alive so the disconnect can be observed on + // it once the handle has been replaced. + let pending = new_pending(); + let next_id = Arc::new(AtomicU64::new(1)); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + 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")); + route_client_response( + &pending2, + &json!({"jsonrpc":"2.0","id":f["id"],"result":{"connectionId":"conn-old"}}), + ) + .await; + answer_inner_handshake(&mut out_rx, &pending2).await; + out_rx // hand the receiver back so the test can keep reading it + }); + super::establish_and_register_tunnel( + out_tx, + pending, + next_id, + "uuid-old".into(), + "browser".into(), + "acp_abc".into(), + registry.clone(), + 5, + "conn-test".into(), + 0, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await + .unwrap(); + let mut out_rx = ext.await.unwrap(); + + // Same declared name, fresh id — this replaces the handle above. + attach(®istry, "uuid-new", "browser").await; + + // The replaced connection must be told to close, naming ITS connectionId. + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("a replaced tunnel must be sent mcp/disconnect") + .expect("channel closed before the disconnect arrived"); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["method"], json!("mcp/disconnect")); + assert_eq!( + v["params"]["connectionId"], + json!("conn-old"), + "the disconnect must name the replaced connection, not the live one" + ); + } + + /// A different declared name on the same channel is a genuinely different server and must + /// coexist — that is the whole point of the compound key (§6.1/§6.2 fan-out). + #[tokio::test] + async fn different_names_on_one_channel_coexist() { + let registry = super::new_tunnel_registry(); + attach(®istry, "uuid-b", "browser").await; + attach(®istry, "uuid-o", "other").await; + + assert_eq!( + registry.lock().unwrap().len(), + 2, + "distinct declared names are distinct servers and must both stay registered" + ); + } +} + +#[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,30 +3486,54 @@ 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)).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_resume_valid_stores_and_invalid_errors() { let sessions = new_sessions(); // valid sess_ → {} and the session is (re)stored let sid = format!("sess_{}", Uuid::new_v4()); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(¶ms)).await.0) .unwrap(); assert_eq!(v["result"], json!({})); assert!(sessions.lock().await.contains_key(&sid)); // malformed sessionId shape → -32602 let bad = json!({"sessionId": "not-a-session", "cwd": "/w", "mcpServers": []}); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(4), Some(&bad)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); // missing sessionId → -32602 let v = serde_json::to_value( - handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await, + handle_session_resume(&sessions, json!(5), Some(&json!({"cwd": "/w"}))).await.0, ) .unwrap(); assert_eq!(v["error"]["code"], json!(-32602)); @@ -1692,7 +3564,7 @@ mod acp_review_fixes { for _ in 0..MAX_SESSIONS_PER_CONNECTION { let sid = format!("sess_{}", Uuid::new_v4()); let p = json!({ "sessionId": sid }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(1), Some(&p)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "resume under cap should succeed"); ids.push(sid); @@ -1700,17 +3572,208 @@ mod acp_review_fixes { assert_eq!(sessions.lock().await.len(), MAX_SESSIONS_PER_CONNECTION); // A new distinct session over the cap is refused with ACP_OVERLOADED. let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); - let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await) + let v = serde_json::to_value(handle_session_resume(&sessions, json!(2), Some(&over)).await.0) .unwrap(); assert_eq!(v["error"]["code"], json!(ACP_OVERLOADED), "over-cap resume must be refused"); // Re-resuming an already-present session is exempt (idempotent). let existing = json!({ "sessionId": ids[0] }); let v = - serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await) + serde_json::to_value(handle_session_resume(&sessions, json!(3), Some(&existing)).await.0) .unwrap(); assert_eq!(v["result"], json!({}), "re-resume of existing session must bypass the cap"); } + // --- R3-F1: declaration fan-out is bounded before it costs anything --- + + fn decl(id: &str, name: &str) -> super::AcpMcpServer { + super::AcpMcpServer { + id: id.into(), + name: name.into(), + } + } + + /// Each declaration buys a task, a pending `mcp/connect` holding a 30s timeout, and an + /// outbound frame. Declarations are small enough that thousands fit inside one accepted frame, + /// so the count is capped before the session exists or any task is spawned. + #[test] + fn declaration_fan_out_is_capped_and_deduplicated() { + let cap = super::MAX_ACP_SERVERS_PER_SESSION; + + // At the cap is fine; one past it refuses the whole request. + let at_cap: Vec<_> = (0..cap).map(|i| decl(&format!("id{i}"), "s")).collect(); + assert_eq!(super::accept_acp_servers(at_cap).unwrap().len(), cap); + + let over: Vec<_> = (0..cap + 1).map(|i| decl(&format!("id{i}"), "s")).collect(); + let err = super::accept_acp_servers(over) + .expect_err("declaring past the cap must be refused outright"); + assert!(err.contains("Too many type:acp servers"), "{err}"); + + // A burst of thousands — the shape the finding describes — is refused rather than + // truncated: truncating would silently honour part of a request the client cannot know + // was clipped. + let flood: Vec<_> = (0..5000).map(|i| decl(&format!("id{i}"), "s")).collect(); + assert!(super::accept_acp_servers(flood).is_err()); + + // Duplicate ids collapse to the first, so a repeated id cannot spawn two tunnels racing + // for one registry key. Dedup runs before the cap, so repeats are not a way to trip it. + let dupes = vec![ + decl("same", "browser"), + decl("same", "browser"), + decl("other", "notes"), + ]; + let kept = super::accept_acp_servers(dupes).unwrap(); + assert_eq!(kept.len(), 2); + assert_eq!(kept[0].id, "same"); + assert_eq!(kept[1].id, "other"); + + let many_dupes: Vec<_> = (0..5000).map(|_| decl("same", "browser")).collect(); + assert_eq!( + super::accept_acp_servers(many_dupes).unwrap().len(), + 1, + "repeats of one id are one server, not a cap violation" + ); + } + + /// The accepted list is exactly what gets spawned: one task per unique declaration, no more. + #[tokio::test] + async fn only_accepted_declarations_spawn_tunnels() { + let registry = super::new_tunnel_registry(); + // Built inline: this module has its own helpers and does not share acp_requests'. + let pending: Arc< + tokio::sync::Mutex>>, + > = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let next_id = Arc::new(std::sync::atomic::AtomicU64::new(1)); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut tasks: Vec> = Vec::new(); + + let accepted = super::accept_acp_servers(vec![ + decl("a", "browser"), + decl("a", "browser"), // duplicate — must not spawn twice + decl("b", "notes"), + ]) + .unwrap(); + super::spawn_acp_tunnels( + accepted, + "acp_abc".into(), + registry, + &out_tx, + &pending, + &next_id, + &mut tasks, + "conn-test", + 0, + &Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + + assert_eq!(tasks.len(), 2, "one task per unique declaration"); + // Exactly two mcp/connect frames go out, for the two distinct ids. + let mut ids = Vec::new(); + for _ in 0..2 { + let f: serde_json::Value = serde_json::from_str(&out_rx.recv().await.unwrap()).unwrap(); + assert_eq!(f["method"], json!("mcp/connect")); + ids.push(f["params"]["acpId"].as_str().unwrap().to_string()); + } + ids.sort(); + assert_eq!(ids, ["a", "b"]); + assert!(out_rx.try_recv().is_err(), "no excess mcp/connect was sent"); + for t in tasks { + t.abort(); + } + } + + // --- F2: the 8 MiB allowance is for tunnel results only --- + + /// The raise to 8 MiB was for browser tool results, which arrive as client RESPONSES + /// (`id`, no `method`). Frames carrying a method — `session/prompt` above all — stay at the + /// pre-existing 1 MiB, so the allowance cannot be used to park + /// MAX_INFLIGHT_PROMPTS × 8 MiB of prompt text on one connection. + #[test] + fn only_tunnel_results_may_use_the_larger_frame_allowance() { + let over_1mib = super::MAX_NON_TUNNEL_FRAME_BYTES + 1; + let big_result = 8 * 1024 * 1024; // within MAX_FRAME_BYTES + + // A client response (no `method`) may be large — this is the screenshot path. + let response = json!({ "jsonrpc": "2.0", "id": 7, "result": { "content": [] } }); + assert!( + super::oversized_for_its_kind(big_result, &response).is_none(), + "an 8 MiB tunnel result must still be accepted — that is what the raise is for" + ); + + // A prompt of the same size must not be. + let prompt = json!({ "jsonrpc": "2.0", "id": 1, "method": "session/prompt" }); + assert_eq!( + super::oversized_for_its_kind(over_1mib, &prompt), + Some("session/prompt"), + "a >1 MiB prompt must be rejected" + ); + assert!( + super::oversized_for_its_kind(super::MAX_NON_TUNNEL_FRAME_BYTES, &prompt).is_none(), + "exactly 1 MiB is still allowed — the bound is inclusive" + ); + + // Notifications are method-bearing too, so they are bounded as well (the caller must + // drop them silently rather than answer). + let notification = json!({ "jsonrpc": "2.0", "method": "session/cancel" }); + assert_eq!( + super::oversized_for_its_kind(over_1mib, ¬ification), + Some("session/cancel") + ); + } + + /// A rejected `session/resume` must not become permission to open tunnels. + /// + /// The read loop spawns tunnels only when the handler hands back a channel. Deriving one from + /// the requested `sessionId` — which is what the loop used to do — is not a sufficient guard, + /// because a perfectly well-formed `sess_` derives fine on every rejection path. Combined + /// with last-write-wins same-name re-attach, that let a refused resume evict the live tunnel it + /// was refused in favour of, so each rejection is checked for a `None` channel here. + #[tokio::test] + async fn a_rejected_resume_yields_no_channel_to_open_tunnels_with() { + let sessions = sessions_map(); + + // 1. missing sessionId + let (resp, chan) = + handle_session_resume(&sessions, json!(1), Some(&json!({"cwd": "/w"}))).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); + assert!(chan.is_none(), "missing sessionId must not yield a channel"); + + // 2. malformed sessionId + let bad = json!({"sessionId": "not-a-session", "cwd": "/w"}); + let (resp, chan) = handle_session_resume(&sessions, json!(2), Some(&bad)).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32602)); + assert!(chan.is_none(), "malformed sessionId must not yield a channel"); + + // 3. over the per-connection cap — note the id IS well formed, so the old + // derive-from-params guard would have happily produced a channel here. + for _ in 0..MAX_SESSIONS_PER_CONNECTION { + let p = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); + let (_r, chan) = handle_session_resume(&sessions, json!(3), Some(&p)).await; + assert!(chan.is_some(), "resume under the cap should succeed"); + } + let over = json!({ "sessionId": format!("sess_{}", Uuid::new_v4()) }); + let (resp, chan) = handle_session_resume(&sessions, json!(4), Some(&over)).await; + assert_eq!( + serde_json::to_value(resp).unwrap()["error"]["code"], + json!(ACP_OVERLOADED) + ); + assert!(chan.is_none(), "an over-cap resume must not yield a channel"); + + // 4. busy — likewise a well-formed id on a session that really exists. + let busy_sid = format!("sess_{}", Uuid::new_v4()); + sessions.lock().await.insert( + busy_sid.clone(), + AcpSession { + channel_id: derive_channel_id(&busy_sid).unwrap(), + busy: true, + cancel: Some(Arc::new(tokio::sync::Notify::new())), + }, + ); + let (resp, chan) = + handle_session_resume(&sessions, json!(5), Some(&json!({"sessionId": busy_sid}))).await; + assert_eq!(serde_json::to_value(resp).unwrap()["error"]["code"], json!(-32001)); + assert!(chan.is_none(), "a busy-rejected resume must not yield a channel"); + } + fn reply(channel_id: &str, reply_to: &str, text: &str, command: Option<&str>) -> GatewayReply { GatewayReply { schema: "openab.gateway.reply.v1".into(), @@ -1737,7 +3800,10 @@ mod acp_review_fixes { registry .lock() .unwrap() - .insert("acp_chan".into(), ReplySink { turn_id: "evt_current".into(), tx }); + .insert( + "acp_chan".into(), + ReplySink { turn_id: "evt_current".into(), tx, owner: "conn-test".into() }, + ); // Stale reply (previous turn's event id) → dropped. handle_reply(&reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), ®istry) @@ -1838,7 +3904,7 @@ mod acp_review_fixes { let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); let params = json!({"sessionId": sid, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&state, &sessions, json!(7), Some(¶ms), &out_tx, sid.clone(), cancel) + handle_session_prompt(&state, &sessions, json!(7), Some(¶ms), &out_tx, sid.clone(), cancel, "conn-test") .await; // The final response (matching our request id) must carry stopReason "cancelled". @@ -1878,10 +3944,14 @@ mod acp_review_fixes { ); let params = json!({"sessionId": sid, "cwd": "/w", "mcpServers": []}); - let v = - serde_json::to_value(handle_session_resume(&sessions, json!(9), Some(¶ms)).await) - .unwrap(); + let (resp, resumed) = handle_session_resume(&sessions, json!(9), Some(¶ms)).await; + let v = serde_json::to_value(resp).unwrap(); assert_eq!(v["error"]["code"], json!(-32001), "resume while busy must be rejected"); + assert!( + resumed.is_none(), + "a rejected resume must not hand back a channel — that value is the caller's \ + permission to open tunnels, and same-name re-attach would evict the live one" + ); // The in-flight turn's state survives untouched. let g = sessions.lock().await; @@ -1916,7 +3986,7 @@ mod acp_review_fixes { let sid2 = sid.clone(); let handle = tokio::spawn(async move { let params = json!({"sessionId": sid2, "prompt": [{"type": "text", "text": "hi"}]}); - handle_session_prompt(&st2, &sessions2, json!(11), Some(¶ms), &out_tx, sid2.clone(), cancel) + handle_session_prompt(&st2, &sessions2, json!(11), Some(¶ms), &out_tx, sid2.clone(), cancel, "conn-test") .await; }); @@ -1997,3 +4067,2208 @@ mod acp_review_fixes { ); } } + +/// End-to-end tests over the **real** `/acp` WebSocket route. +/// +/// Every other test in this file calls the handlers directly with hand-built structures, so +/// nothing had ever exercised the axum route, the upgrade, the frame codec, or the request/reply +/// correlation across an actual socket. A reviewer raised exactly that: the tunnel was asserted +/// by construction rather than observed working. These bind a real listener and drive it with a +/// scripted `tokio-tungstenite` client — no browser, no model. +#[cfg(test)] +mod acp_ws_integration { + use super::*; + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message as WsMessage; + + /// Serve `/acp` on an ephemeral loopback port. Returns the URL and the tunnel registry, so a + /// test can drive the server side the way core does. + async fn serve() -> (String, AcpTunnelRegistry) { + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut state = crate::AppState::test_default(tx); + state.acp = Some(AcpConfig { + // Keyless loopback: no bearer. A client sending no `Origin` is accepted, which is + // what a non-browser client (this test, and the real extension's native host) is. + auth_key: None, + allowed_origins: vec![], + }); + state.acp_reply_registry = Some(new_reply_registry()); + let registry = new_tunnel_registry(); + state.acp_tunnel_registry = Some(registry.clone()); + + let app = axum::Router::new() + .route("/acp", axum::routing::get(ws_upgrade)) + .with_state(std::sync::Arc::new(state)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("ws://{addr}/acp"), registry) + } + + type Ws = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + async fn send(ws: &mut Ws, v: Value) { + ws.send(WsMessage::Text(v.to_string())).await.unwrap(); + } + + /// Next JSON frame from the server, skipping anything that is not text (pings etc). + /// + /// The timeout guards against a hang, so it should be generous rather than tight: it is not + /// measuring anything. At 5s it produced two transient failures when these WebSocket tests ran + /// alongside the rest of the suite — a budget that depends on machine load is a flaky test, and + /// a flaky test in a security-adjacent area is worse than none, because the next person learns + /// to re-run it. + async fn recv(ws: &mut Ws) -> Value { + loop { + match tokio::time::timeout(std::time::Duration::from_secs(30), ws.next()) + .await + .expect("timed out waiting for a server frame") + .expect("socket closed") + .unwrap() + { + WsMessage::Text(t) => return serde_json::from_str(&t).unwrap(), + WsMessage::Close(_) => panic!("server closed the socket"), + _ => continue, + } + } + } + + /// Handle a frame belonging to the inner MCP lifecycle, if it is one. + /// + /// Returns `Some("initialize")` after answering the request, `Some("initialized")` after + /// consuming the notification that follows it, `None` for anything else. + /// + /// Deliberately a classifier, not a loop. The first version looped on `recv` until it saw the + /// initialize and dropped everything else on the way — including the `session/new` response + /// its caller was still waiting for, which hung. A helper that eats frames its caller needs is + /// worse than no helper: every call site is already a dispatch loop, so this handles one frame + /// and hands the rest back. + async fn handled_inner_lifecycle(ws: &mut Ws, frame: &Value) -> Option<&'static str> { + if frame.get("method").and_then(Value::as_str) != Some("mcp/message") { + return None; + } + match frame["params"]["method"].as_str() { + Some("initialize") => { + send(ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + })).await; + Some("initialize") + } + // A notification: no reply is owed, but it must still be taken off the socket or the + // next `recv` in a test mistakes it for the frame it was waiting for. + Some("notifications/initialized") => Some("initialized"), + _ => None, + } + } + + /// Drive `initialize` + `session/new` declaring one `type:acp` server, then answer the + /// `mcp/connect` the gateway sends back. Returns the session id. + async fn handshake(ws: &mut Ws, acp_id: &str, name: &str, connection_id: &str) -> String { + send(ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let init = recv(ws).await; + assert!(init.get("result").is_some(), "initialize failed: {init}"); + + send(ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": acp_id, "name": name}] + } + })).await; + + // The gateway now does two things concurrently: answer session/new, and open the tunnel + // by sending mcp/connect. Order is not guaranteed, so accept either first. + // Three things must land before this returns, and the third is easy to forget: the + // gateway registers the tunnel only after the inner MCP handshake completes, so returning + // once `mcp/connect` is answered leaves the `initialize` unread in the socket. Nobody is + // polling the socket after that, the gateway times out, and the establish fails — which + // shows up as "no tunnel registered" rather than anything about initialize. + let mut session_id = None; + let mut connected = false; + let mut lifecycle = 0; + while session_id.is_none() || !connected || lifecycle < 2 { + let frame = recv(ws).await; + if handled_inner_lifecycle(ws, &frame).await.is_some() { + lifecycle += 1; + continue; + } + if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!( + frame["params"]["acpId"], json!(acp_id), + "mcp/connect must name the declared id" + ); + send(ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": connection_id} + })).await; + connected = true; + } else if frame.get("id") == Some(&json!(2)) { + session_id = Some( + frame["result"]["sessionId"].as_str().expect("sessionId").to_string(), + ); + } + } + session_id.unwrap() + } + + /// Wait until `n` tunnels are registered. + /// + /// Registration happens *after* the client answers `mcp/connect` — the establishing task still + /// has to build the handle and take the registry lock — so reading the registry straight after + /// replying is a race. Polling the real condition is honest; sleeping a fixed interval and + /// hoping would make this test flaky on a loaded machine. + async fn wait_for_tunnels(registry: &AcpTunnelRegistry, n: usize) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let len = registry.lock().unwrap().len(); + if len == n { + return; + } + assert!( + std::time::Instant::now() < deadline, + "expected {n} tunnel(s) registered, still {len} after 5s" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + /// The tunnel is not usable until the inner MCP lifecycle has completed. + /// + /// MCP requires `initialize` → response → `notifications/initialized` before any other + /// request. Driven over the socket because the ordering is the whole assertion: a unit test of + /// the handshake function could confirm it sends the right frames while saying nothing about + /// whether `tools/list` can still arrive first. + #[tokio::test] + async fn the_inner_mcp_lifecycle_completes_before_the_tunnel_is_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Bounded explicitly, with its own message. Letting the generic `recv` timeout catch a + // missing lifecycle works, but it costs 30s and reports "timed out waiting for a server + // frame" — which names the harness rather than the regression. A test that catches a bug + // should also say which bug. + let collect = async { + let mut order: Vec = Vec::new(); + let mut connected = false; + let mut lifecycle = 0; + while !connected || lifecycle < 2 { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + order.push("mcp/connect".into()); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + connected = true; + } else if f.get("method").and_then(Value::as_str) == Some("mcp/message") { + let inner = f["params"]["method"].as_str().unwrap_or("").to_string(); + order.push(inner.clone()); + if inner == "initialize" { + // The registry must still be empty: a server that has not answered + // `initialize` has not agreed to serve anything yet. + assert!( + registry.lock().unwrap().is_empty(), + "the tunnel was registered before the MCP handshake completed" + ); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "test-ext", "version": "0" } + } + })).await; + } + lifecycle += 1; + } + } + order + }; + let order = tokio::time::timeout(std::time::Duration::from_secs(10), collect) + .await + .expect( + "no inner MCP lifecycle on the tunnel: the gateway connected but never sent \ + `initialize`, so a standards-compliant client MCP server would be asked for tools \ + before it had been initialized", + ); + + assert_eq!( + order, + vec![ + "mcp/connect".to_string(), + "initialize".to_string(), + "notifications/initialized".to_string() + ], + "the gateway must connect, then initialize, then notify — in that order" + ); + wait_for_tunnels(®istry, 1).await; + } + + /// A server that refuses the handshake must not be registered. + /// + /// `inner_mcp_handshake`'s doc promises "failure here fails the establish, so a server that + /// cannot complete the handshake never reaches the registry" — and nothing tested it. The + /// ordering test cannot: change the call to `let _ = inner_mcp_handshake(...)` and the frame + /// order is unchanged, the registry is still empty when `initialize` arrives, and it stays + /// green while refusing servers get registered anyway. + #[tokio::test] + async fn a_server_that_refuses_initialize_is_not_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Answer `mcp/connect`, then REFUSE `initialize` the way a server that will not serve us + // does: a JSON-RPC error, per the contract's "an inner MCP-level error is returned as the + // outer JSON-RPC `error`". + let mut refused = false; + while !refused { + let f = recv(&mut ws).await; + match f.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + } + Some("mcp/message") if f["params"]["method"] == json!("initialize") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "error": {"code": -32603, "message": "not accepting connections"} + })).await; + refused = true; + } + _ => {} + } + } + + // The client opened a connection for us and we are not going to use it, so it is owed an + // `mcp/disconnect` naming that connection. Asserting only "not registered" is what let the + // leak through: the connection is leaked *inside* the not-registered state, so a test that + // checks the registry alone asserts the broken state as correct. + // Bounded with its own message: without it, a regression here waits out the generic + // `recv` timeout and reports "timed out waiting for a server frame" — which names the + // harness, not the leak, and reads like a flake. + let wait_disconnect = async { + loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = tokio::time::timeout( + std::time::Duration::from_secs(10), + wait_disconnect, + ) + .await + .map(Some) + .expect( + "no `mcp/disconnect` after a refused handshake: the connection the client opened for \ + us is leaked — it never entered the registry, and every cleanup path goes through the \ + registry", + ); + assert_eq!( + disconnected.as_deref(), + Some("conn-1"), + "the connection opened for a server that then refused the handshake must be closed, \ + naming that connection — nothing else can close it, since every other cleanup path \ + goes through the registry it never entered" + ); + + // And it must still not be registered. Poll rather than check once: we are proving + // something stays absent, so give it real time to appear wrongly. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert!( + registry.lock().unwrap().is_empty(), + "a server that refused `initialize` was registered anyway — the handshake failure \ + did not fail the establish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// When connection age and attach order DISAGREE, connection age decides who keeps the name. + /// + /// The construction is what matters: an incumbent that is older by connection but LATER by + /// attach, which happens when the newer connection's establish starts first and then stalls + /// while the older connection's starts later and completes. Every other same-name test has the + /// two dimensions agreeing — same-connection ones have equal ages, and the cross-connection one + /// has the older side also attaching earlier — so none of them can tell the orderings apart. + /// + /// Ordering on attach alone gets this backwards: it reads the arriving establish as the older + /// one, lets it stand down, and leaves the wrong connection holding the name. + /// + /// History worth keeping, because it cost two wrong turns. I first mutated the eviction filter, + /// saw the suite stay green, and wrote into the source that the comparison was "provably inert" + /// — reading green as "nothing can distinguish this" when it only meant "my tests do not". The + /// filter has since been simplified away entirely, since everything that should win is already + /// filtered by the supersede check above, so what this test now pins is that comparison. + #[tokio::test] + async fn eviction_follows_connection_age_when_it_disagrees_with_attach_order() { + let (url, registry) = serve().await; + + // B opens FIRST — older connection — and declares nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // C opens SECOND — newer connection — and starts its establish FIRST, then stalls: its + // `mcp/connect` is captured and deliberately left unanswered, so it takes the LOWER attach + // number while making no progress. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-c", "name": "katashiro"}]} + })).await; + let c_connect_id = loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + break f["id"].clone(); + } + }; + + // Now the OLDER connection declares the same name under a different id and completes, so it + // registers with the HIGHER attach number. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-b", "name": "katashiro"}]} + })).await; + let mut done = false; + while !done { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + done = true; + } + } + wait_for_tunnels(®istry, 1).await; + + // Finally let the stalled, newer-connection establish finish. + send(&mut c, json!({ + "jsonrpc": "2.0", "id": c_connect_id, + "result": {"connectionId": "conn-c"} + })).await; + loop { + let f = recv(&mut c).await; + if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + + // Exactly one tunnel, and it must be the newer connection's. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + if ids == vec!["srv-c".to_string()] { + break; + } + assert!( + std::time::Instant::now() < deadline, + "expected only the newer connection's tunnel, got {ids:?} — the incumbent was older \ + by connection but later by attach, and attach order alone reads that as 'not older' \ + and refuses to evict it" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// Within ONE connection, the later establish still wins — the attach tiebreak is load-bearing. + /// + /// Same-connection ordering tests DO exist — `mod acp_requests` builds handles with a + /// `connection_generation` of 0 throughout — but every one of them exercises the same direction: + /// the later establish also finishes later, and there dropping the tiebreak still answers + /// correctly (equal ages mean nothing supersedes, the same-name eviction is unconditional, and + /// the later arrival wins anyway). What nothing reaches is the REVERSE direction: started + /// earlier, finished later. Compare age alone there and the stale establish is neither superseded + /// nor blocked, so it evicts the successor that already registered and installs itself over it. + /// + /// Stated this way on purpose. An earlier draft claimed "every other ordering test is + /// cross-connection", which is a universal that one same-connection test disproves — and the case + /// for this test would have appeared to fall with it, though the gap is real either way. + /// + /// Deterministic without racing two spawns: the first establish is parked by withholding its + /// `mcp/connect` answer, the second is driven to completion, and only then is the first released. + /// Which one started earlier is fixed by the order the resumes were sent. + #[tokio::test] + async fn within_one_connection_a_late_finishing_older_establish_loses_to_its_successor() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut ws).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // First resume: declare srv-1 and PARK it — its `mcp/connect` is captured, not answered, so + // it holds the lower attach number while making no progress. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let parked_connect_id = loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!(f["params"]["acpId"], json!("srv-1")); + break f["id"].clone(); + } + }; + + // Second resume on the SAME connection: same name, new id, driven to completion. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 4, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + let mut registered = false; + while !registered { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") + && f["params"]["acpId"] == json!("srv-2") + { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-2"} + })).await; + } else if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { + registered = true; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now release the parked, EARLIER establish. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": parked_connect_id, + "result": {"connectionId": "conn-1"} + })).await; + loop { + let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await == Some("initialize") { + break; + } + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + ids, + vec!["srv-2".to_string()], + "the earlier establish finished last and took the name back ({ids:?}) — within one \ + connection the ages are equal, so attach order is the only thing that can decide, \ + and dropping it lets a stale tunnel replace its own successor" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// Same declared name, DIFFERENT id, incumbent on the newer connection: the arriving establish + /// must stand down rather than sit beside it. + /// + /// This is the same-name comparison, which the take-over test cannot reach: there the late + /// resume re-declares the SAME id, so it goes through the own-key check and `same_name` never + /// sees the incumbent (`id != &acp_id` excludes it). Here the ids differ, so this is the only + /// site that can refuse it. + /// + /// The failure is not a take-over — it is TWO tunnels under one declared name, which is exactly + /// the ambiguity last-attach-wins exists to remove (ADR §6.1). Ordering on attach alone permits + /// it: the older connection's late resume carries the higher attach number, so it is neither + /// superseded nor able to evict, and simply lands alongside. + #[tokio::test] + async fn a_same_name_establish_from_an_older_connection_stands_down() { + let (url, registry) = serve().await; + + // B opens first, so its connection is the OLDER one. It declares nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // C opens second (NEWER connection) and establishes srv-3 under the shared name. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-3", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // The OLDER connection now declares the same NAME under a different id, and completes. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-4", "name": "katashiro"}]} + })).await; + let mut done = false; + while !done { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + done = true; + } + } + + // Poll: the wrong outcome is an EXTRA entry appearing, so give it time to appear. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let names: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.server_name().to_string()).collect() + }; + assert_eq!( + names.len(), + 1, + "two tunnels are registered under one declared name ({names:?}) — an establish from \ + an OLDER connection neither stood down nor evicted, because attach order alone \ + ranks it above the incumbent it arrived after" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + // And it must be the newer connection's tunnel that survived. + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!(ids, vec!["srv-3".to_string()], "the newer connection's tunnel must hold the name"); + } + + /// An older connection's late resume must not TAKE OVER a newer connection's tunnel either. + /// + /// The sibling test covers the sweep path, where the older resume withdraws what it never knew + /// about. This covers the establish path, which needed the same fix and did not get it: resume + /// re-spawns an establish for every declared server without checking whether that + /// `(channel, id)` is already live, and that establish is stamped when it STARTS — so the older + /// connection's late one carries the HIGHER attach number, for the same reason its resume ran + /// later. Ordering on attach alone therefore lets it replace the incumbent and disconnect a + /// connection that is newer than itself. + /// + /// The difference from the sibling is only which id the late resume names: there it declared + /// something the newer connection did not hold, so nothing collided. Here it declares exactly + /// what the newer connection holds, which is the case a stable-id client produces on every + /// reconnect. + #[tokio::test] + async fn an_older_connections_late_resume_does_not_take_over_a_newer_connections_tunnel() { + let (url, registry) = serve().await; + + // Connection B (older) opens the session but establishes nothing yet. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": []} + })).await; + let session_id = loop { + let f = recv(&mut b).await; + if f.get("id") == Some(&json!(2)) { + break f["result"]["sessionId"].as_str().unwrap().to_string(); + } + }; + + // Connection C (newer) resumes and establishes srv-1. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // The OLDER connection now resumes declaring THE SAME id, and answers its handshake fully. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let mut answered = false; + while !answered { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-b"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + answered = true; + } + } + + // C must keep the slot: it is the newer connection. Before the fix B took it over and C was + // disconnected, so the discriminating check is that C — not B — is never told to disconnect. + let stolen = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + } + } + }) + .await; + assert!( + stolen.is_err(), + "an older connection's late resume replaced a NEWER connection's live tunnel and \ + disconnected it ({:?}) — the establish path orders by attach number alone, which the \ + late resume wins precisely because it ran late", + stolen.ok() + ); + assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must hold the slot"); + } + + /// An older connection's late resume must not retire a NEWER connection's tunnel. + /// + /// The withdrawn set is "registered under this channel, minus what the client just declared". + /// That is only sound if the declaration is current. An older connection whose resume is + /// processed late carries an out-of-date view: its silence about a server a newer connection + /// established is not a withdrawal, it simply never knew about it. + /// + /// Authorising the sweep by connection age is what expresses that. Stamping the RESUME instead + /// measures the wrong thing and cannot fix this case — the late resume takes the higher number + /// precisely because it ran last, so it would still outrank the newer connection it must not + /// touch. Both reviewers and I initially proposed exactly that, and it fails here. + #[tokio::test] + async fn an_older_connections_late_resume_does_not_retire_a_newer_connections_tunnel() { + let (url, registry) = serve().await; + + // Connection B (older) establishes srv-2 and stays open. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut b, "srv-2", "katashiro", "conn-b").await; + wait_for_tunnels(®istry, 1).await; + + // Connection C (newer) resumes the same session and adds srv-3 under a different name. + let (mut c, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut c).await; + send(&mut c, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.clone(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}, + {"type": "acp", "id": "srv-3", "name": "notes"}]} + })).await; + loop { + let f = recv(&mut c).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") + && f["params"]["acpId"] == json!("srv-3") + { + send(&mut c, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-c"} + })).await; + } else if handled_inner_lifecycle(&mut c, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 2).await; + + // Now the OLDER connection resumes, still declaring only what it knew about. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 9, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + + // srv-3 belongs to a newer connection and must survive. Poll: a wrongful retire is async. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: std::collections::HashSet = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert!( + ids.contains("srv-3"), + "an older connection's late resume retired a tunnel a NEWER connection had \ + established — the sweep was authorised by resume order instead of connection age, \ + leaving: {ids:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// A resume that OMITS `mcpServers` withdraws nothing. + /// + /// Absent and empty are different statements. Omitting the optional field says nothing about + /// the client's servers; an explicit `[]` says it now offers none. Conflating them was + /// harmless while the withdrawn set was always empty, but deriving that set from the registry + /// made absence the most destructive reading available — a compliant client that simply left + /// the field out would have every tunnel on its channel torn down. Elsewhere absence is read + /// fail-closed (a missing `protocolVersion` refuses the establish); it should not be the most + /// damaging reading here. + #[tokio::test] + async fn a_resume_that_omits_mcp_servers_withdraws_nothing() { + let (url, registry) = serve().await; + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + // No `mcpServers` key at all. + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w"} + })).await; + let resumed = recv(&mut b).await; + assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); + + // The other shapes an "omitted" field takes on the wire, pinned as REJECTIONS rather than + // as sweeps. A serde `Option>` without `skip_serializing_if` emits `null`, and a + // guard written as `is_some()` would accept that as a declaration with an empty list — but + // schema validation runs first and refuses anything that is not a sequence, so the + // destructive path is unreachable from the wire. This records that, because the guard below + // reads as if it were the only thing standing between `null` and a full sweep, and the next + // person to relax the schema needs the two facts in one place. + for shape in [json!(null), json!({}), json!("nonsense")] { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": shape} + })).await; + let r = recv(&mut b).await; + assert!(r.get("result").is_some() || r.get("error").is_some(), "no reply: {r}"); + } + + // The tunnel must stay. Poll, because a wrongful teardown is asynchronous. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert_eq!( + registry.lock().unwrap().len(), + 1, + "a resume that never mentioned mcpServers tore down the session's tunnels — \ + absence was read as 'the client withdrew everything'" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// A resume that RE-DECLARES an already-registered id must not have that tunnel SWEPT. + /// + /// Scope deliberately narrow, because the system does not leave the tunnel alone: resume then + /// spawns an establish for every declared server without checking whether that + /// `(channel, id)` is already registered, so the re-declared one is replaced through the + /// own-key path and its predecessor is disconnected. The churn is real and is tracked + /// separately; what this test pins is only that the SWEEP does not take it. + /// + /// It is green for that reason and not because the tunnel survives end to end — the second + /// `mcp/connect` is never answered here, so the replacement cannot complete inside the window. + /// Worth stating plainly: withholding a trigger is exactly why the two tests this one was + /// written to supplement looked like they had coverage. + /// + /// This is what pins the withdrawn set to "registered MINUS declared". Both neighbouring + /// withdrawal tests survive a mutation that simply sweeps the whole channel on every resume: + /// one declares `[]` so its `keep` is empty either way, and the other re-declares under a NEW + /// id, so the tunnel a sweep would wrongly remove was going to be evicted by last-attach-wins + /// anyway and the end state matches. Only re-declaring the SAME id makes the difference + /// observable — the tunnel must not be disconnected and rebuilt, which for a client with + /// stable ids would mean a spurious `mcp/disconnect` and a gap on every single resume. + #[tokio::test] + async fn a_resume_redeclaring_the_same_id_leaves_its_tunnel_untouched() { + let (url, registry) = serve().await; + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Same connection, same id, re-declared. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Nothing may be disconnected. A sweep-everything implementation retires conn-1 here and + // then re-establishes it, which this catches; the correct implementation sends no + // `mcp/disconnect` at all, so a short quiet window is the assertion. + let quiet = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap_or("?").to_string(); + } + } + }) + .await; + assert!( + quiet.is_err(), + "a resume that re-declared the SAME id disconnected its tunnel ({:?}) — the withdrawn \ + set is not 'registered minus declared', it is 'everything'", + quiet.ok() + ); + assert_eq!(registry.lock().unwrap().len(), 1, "the tunnel must still be registered"); + } + + /// A resume on a NEW connection that stops declaring a server retires its tunnel. + /// + /// This is the path the withdrawal feature was written for and the one it could never take. + /// The old implementation compared the new declarations against `sessions`, which is built + /// inside `handle_acp_connection` — per connection. A reconnect arrives on a fresh socket with + /// an empty map, so the lookup returned None and the withdrawn set was always empty. The + /// existing coverage drove a same-connection resume, where the map IS populated, so it passed + /// while the feature did nothing on the only path that matters. + /// + /// Deriving the set from the registry — process-global, keyed `(channel_id, server_id)` — is + /// what makes this case work, and it is why the fix removes state rather than adding it. + #[tokio::test] + async fn a_reconnect_that_withdraws_a_declaration_retires_its_tunnel() { + let (url, registry) = serve().await; + + // Connection 1: declare and fully establish one server. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + // `handshake` already drives the whole inner lifecycle, including `notifications/ + // initialized`, so nothing further is owed on this socket before the tunnel is registered. + let session_id = handshake(&mut a, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Connection 2 — a genuine reconnect — resumes the same session declaring NOTHING. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", "mcpServers": []} + })).await; + let resumed = recv(&mut b).await; + assert!(resumed.get("result").is_some(), "resume failed: {resumed}"); + + // The tunnel must go. Bounded with its own message: before the fix the registry simply + // kept the entry, and a bare `wait_for_tunnels(0)` would report only a count. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if registry.lock().unwrap().is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "a reconnect withdrew every declaration and the tunnel stayed registered — the \ + withdrawn set was derived from per-connection session state, which a reconnect \ + never populates" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // And the original connection is owed its `mcp/disconnect`, on ITS socket. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect("a withdrawn tunnel was dropped without disconnecting its connection"); + assert_eq!(disconnected, "conn-1"); + } + + /// A slow establish that STARTED first must not evict the newer tunnel that beat it to the + /// registry. + /// + /// The failing case is cross-connection by construction, which is why a per-connection + /// sequence number could not have fixed it: a reconnecting client arrives on a NEW socket and + /// mints a fresh `server_id`, so the two establishes racing for one declared name belong to + /// different connections. Here socket A declares `katashiro` and then stalls — its + /// `mcp/connect` is deliberately left unanswered — while socket B resumes the same session, + /// declares the same name, and completes. When A finally finishes it is the OLDER attach, and + /// before the generation stamp it would have evicted B and installed the stale tunnel over the + /// live one. + /// + /// Ordering is stamped at establish start rather than at registration for exactly this reason: + /// registration order is finish order, and finish order inverts whenever handshake durations + /// differ. + #[tokio::test] + async fn a_late_finishing_older_establish_does_not_evict_its_successor() { + let (url, registry) = serve().await; + + // Socket A: declare `katashiro` as srv-1, then STALL — do not answer its `mcp/connect`. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut a).await; + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + let mut session_id = None; + let mut a_connect_id = None; + while session_id.is_none() || a_connect_id.is_none() { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + assert_eq!(f["params"]["acpId"], json!("srv-1")); + a_connect_id = Some(f["id"].clone()); + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + let session_id = session_id.unwrap(); + + // Socket B: resume the SAME session — same channel — declaring the same name as srv-2, and + // carry it all the way to registered. This is the newer attach. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id, "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-2", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now let the OLDER establish finish. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + })).await; + loop { + let f = recv(&mut a).await; + if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { + break; + } + } + + // The newer tunnel must still be the registered one. Poll: we are proving the late arrival + // never displaces it, and it would do so asynchronously. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let ids: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + ids, + vec!["srv-2".to_string()], + "the older establish finished last and displaced its successor — attach order was \ + taken from registration order instead of the generation stamp" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // And the establish that stood down owes ITS client a disconnect. Asserting only "the + // right tunnel is registered" is what let the equivalent leak through on the refusal path: + // the losing connection never enters the registry, so no cleanup path can ever reach it. + // Without this, deleting the whole `tokio::spawn(disconnect)` in the `Superseded` arm + // leaves this test green. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "the superseded establish never disconnected the connection it opened — it is \ + not in the registry, so nothing else can close it", + ); + assert_eq!(disconnected, "conn-old"); + } + + /// A client that REUSES its `server_id` across a reconnect must still be ordered. + /// + /// The same-name predicate deliberately excludes the attaching key (`id != &acp_id`), so when + /// both establishes carry the same `server_id` they land on one registry key and that + /// predicate never sees the newer entry. The older arrival would then `insert` straight over a + /// live handle — and because the old `insert` return value was discarded, the displaced + /// connection left the registry with no cleanup path while its client still believed it was + /// open. Ordering has to hold per key, not just per declared name. + /// + /// Reusing a stable id is not a client bug. Nothing in the protocol requires a fresh one; the + /// "mints a new id per connection" assumption holds for our own extension, and the deliverable + /// here is a GENERIC compliant peer. + #[tokio::test] + async fn a_reconnect_reusing_the_same_server_id_is_still_ordered() { + let (url, registry) = serve().await; + + // Socket A declares srv-1 and stalls with its `mcp/connect` unanswered. + let (mut a, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut a).await; + send(&mut a, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + let mut session_id = None; + let mut a_connect_id = None; + while session_id.is_none() || a_connect_id.is_none() { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + a_connect_id = Some(f["id"].clone()); + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + + // Socket B reconnects and re-declares THE SAME id, completing fully. + let (mut b, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut b).await; + send(&mut b, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": {"sessionId": session_id.unwrap(), "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + loop { + let f = recv(&mut b).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut b, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + } else if handled_inner_lifecycle(&mut b, &f).await == Some("initialize") { + break; + } + } + wait_for_tunnels(®istry, 1).await; + + // Now let the OLDER establish finish, on the same key. + send(&mut a, json!({ + "jsonrpc": "2.0", "id": a_connect_id.unwrap(), + "result": {"connectionId": "conn-old"} + })).await; + loop { + let f = recv(&mut a).await; + if handled_inner_lifecycle(&mut a, &f).await == Some("initialize") { + break; + } + } + + // It must stand down and close its own connection. Before the fix it silently overwrote + // the live handle instead, and `conn-old` was never disconnected because it believed it + // had won — so this wait is what distinguishes the two. + let wait_disconnect = async { + loop { + let f = recv(&mut a).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "an older establish reusing the same server_id overwrote the newer live handle \ + instead of standing down — same-key ordering is not enforced", + ); + assert_eq!(disconnected, "conn-old"); + assert_eq!(registry.lock().unwrap().len(), 1, "exactly one tunnel must remain"); + } + + /// A server that *succeeds* at `initialize` but answers a protocol version we do not speak + /// must not be registered either. + /// + /// Distinct from the refusal test in the one way that matters: there is no JSON-RPC `error` + /// here. The handshake completes, so every check that only asks "did a reply arrive" passes — + /// which is exactly why the version went unchecked. The gateway used to discard this result + /// entirely, register the tunnel, and fail on the first real `tools/call` with nothing in the + /// log to explain it. + /// + /// The mock answered the supported version everywhere, so the happy path was the only path the + /// suite could reach and an absent check passed. + /// + /// "Unsupported" means outside `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS`, which since R5 holds + /// three revisions rather than one — being older than what we request is no longer sufficient. + #[tokio::test] + async fn a_server_answering_an_unsupported_protocol_version_is_not_registered() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [{"type": "acp", "id": "srv-1", "name": "katashiro"}]} + })).await; + + // Answer `mcp/connect`, then answer `initialize` SUCCESSFULLY with a revision outside the + // accepted set. + // + // This used to be `2024-11-05`, chosen because a real MCP revision makes a more plausible + // peer than a nonsense string. R5 then ADDED that revision to + // `SUPPORTED_INNER_MCP_PROTOCOL_VERSIONS`, so the literal quietly became a SUPPORTED + // version and this test began asserting the opposite of the decided behaviour. Picking a + // realistic example coupled the test to the policy it was meant to be independent of. + // + // `2019-01-01` is well-formed and deliberately not a real revision, so extending the set + // cannot silently invert this test again. If it is ever added, that is a decision someone + // has to make explicitly. + let mut answered = false; + while !answered { + let f = recv(&mut ws).await; + match f.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-1"} + })).await; + } + Some("mcp/message") if f["params"]["method"] == json!("initialize") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": { + "protocolVersion": "2019-01-01", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "old-ext", "version": "0" } + } + })).await; + answered = true; + } + _ => {} + } + } + + // Registry first, deliberately. Both obligations below fail when the version check is + // missing, but only this one NAMES that: delete the check and the tunnel is registered and + // genuinely in use, so leading with the disconnect assertion reports a leaked connection — + // a different defect, which is not actually present. A failing test that names the wrong + // cause costs more than one that names none. + // Poll rather than check once: we are proving something stays absent. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert!( + registry.lock().unwrap().is_empty(), + "a server answering an unsupported protocolVersion was registered anyway — the \ + version check did not fail the establish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // The connection the client opened for us is still owed an `mcp/disconnect`: it never + // entered the registry, and every cleanup path goes through the registry. Bounded with its + // own message so a regression names the defect rather than the harness. + let wait_disconnect = async { + loop { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/disconnect") { + return f["params"]["connectionId"].as_str().unwrap().to_string(); + } + } + }; + let disconnected = + tokio::time::timeout(std::time::Duration::from_secs(10), wait_disconnect) + .await + .expect( + "no `mcp/disconnect` after a version mismatch: the connection opened for a \ + server we then rejected is leaked", + ); + assert_eq!( + disconnected, "conn-1", + "the connection opened for a server whose protocol version we rejected must be closed, \ + naming that connection" + ); + } + + /// A real `mcp/message` crosses the socket and its result comes back to the caller. + #[tokio::test] + async fn a_tool_call_crosses_the_real_socket_and_returns_its_result() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "uuid-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + // Server side, exactly as core reaches a session's tunnel. + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().expect("a tunnel must be registered").clone() + }; + let call = tokio::spawn(async move { + handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + }); + + let framed = recv(&mut ws).await; + assert_eq!(framed["method"], json!("mcp/message")); + assert_eq!(framed["params"]["connectionId"], json!("conn-1")); + assert_eq!(framed["params"]["method"], json!("tools/call")); + assert_eq!(framed["params"]["params"]["name"], json!("katashiro.click")); + + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + })).await; + + let got = call.await.unwrap().expect("the call must succeed"); + assert_eq!(got["content"][0]["text"], json!("clicked")); + } + + /// The error half: a JSON-RPC error from the client surfaces as `Err`, not as a null result. + /// Asserting only the happy path would let a handler that swallows errors pass. + #[tokio::test] + async fn an_error_from_the_client_surfaces_as_an_error_not_an_empty_result() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "uuid-2", "katashiro", "conn-2").await; + wait_for_tunnels(®istry, 1).await; + + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().unwrap().clone() + }; + let call = tokio::spawn(async move { handle.mcp_message("tools/call", None, 5).await }); + + let framed = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": framed["id"].clone(), + "error": {"code": -32603, "message": "no active tab"} + })).await; + + let err = call.await.unwrap().expect_err("a remote error must not read as success"); + assert!( + err.contains("no active tab"), + "the client's message must reach the caller, got: {err}" + ); + } + + /// A tunnelled request that times out tells the peer, instead of just giving up quietly. + /// + /// Dropping the pending entry ends only OUR wait. The extension is still running the request + /// and still holding whatever it holds — a tab, a navigation, a script — and without a + /// cancellation it has no way to learn that nobody is listening. That is work and state + /// stranded on the peer for every timeout. + #[tokio::test] + async fn a_timed_out_tunnel_request_cancels_itself_on_the_peer() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + handshake(&mut ws, "srv-1", "katashiro", "conn-1").await; + wait_for_tunnels(®istry, 1).await; + + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().unwrap().clone() + }; + // One second, and deliberately never answered. + let call = tokio::spawn(async move { handle.mcp_message("tools/call", None, 1).await }); + + let request = recv(&mut ws).await; + let request_id = request["id"].clone(); + assert_eq!(request["method"], json!("mcp/message")); + + let cancel = tokio::time::timeout(std::time::Duration::from_secs(10), recv(&mut ws)) + .await + .expect( + "no `mcp/cancel` after the request timed out — the peer is still working on a \ + request nobody is waiting for", + ); + assert_eq!(cancel["method"], json!("mcp/cancel")); + assert_eq!( + cancel["params"]["requestId"], request_id, + "the cancellation must name the request it cancels, or the peer cannot tell which of \ + several in-flight requests to abandon" + ); + assert!( + cancel.get("id").is_none(), + "a cancellation is a notification: giving it an `id` would oblige a reply nobody reads" + ); + + let err = call.await.unwrap().expect_err("a timed-out call must not read as success"); + assert!(err.contains("timed out"), "got: {err}"); + } + + /// A prompt must not be refused because tunnels are still establishing. + /// + /// The two used to share `prompt_tasks` and `MAX_INFLIGHT_PROMPTS`, so a client with enough + /// slow `mcp/connect`s outstanding got "Too many in-flight prompts" — a limit it had not + /// reached, for work it had not asked for. + /// + /// It takes **more than `MAX_INFLIGHT_PROMPTS` parked establishes** to reach the old bug, and + /// one session cannot supply them: `MAX_ACP_SERVERS_PER_SESSION` is 8 against a prompt cap of + /// 32. A first version of this test used a single session, so it passed against the shared + /// budget too and proved nothing. Hence several sessions. + #[tokio::test] + async fn pending_tunnel_establishes_do_not_consume_the_prompt_budget() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + + // Enough sessions to park strictly more than MAX_INFLIGHT_PROMPTS establishes. + // + // There used to be an `assert!(want_connects > MAX_INFLIGHT_PROMPTS)` here. `want_connects` + // is DERIVED from the cap two lines above, so that assertion restated the integer-division + // identity `(P/S + 1) * S = P - (P % S) + S > P`, which holds for every positive P and S. + // It could not fire for any value of either constant — it looked like a guard on the + // test's premise and guarded nothing. What can actually go wrong is the server not parking + // the establishes we asked for, so the premise is checked below against the count the + // socket really delivered. + let sessions_needed = MAX_INFLIGHT_PROMPTS / MAX_ACP_SERVERS_PER_SESSION + 1; + let want_connects = sessions_needed * MAX_ACP_SERVERS_PER_SESSION; + + let mut last_session = None; + let mut connects = 0; + let mut answered_new = 0; + for n in 0..sessions_needed { + let req_id = 100 + n as i64; + let servers: Vec = (0..MAX_ACP_SERVERS_PER_SESSION) + .map(|i| json!({"type": "acp", "id": format!("s{n}-{i}"), "name": format!("n{n}-{i}")})) + .collect(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": req_id, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": servers} + })).await; + // Collect this session's response; mcp/connect frames are observed and NEVER answered, + // which is what keeps each establish task in flight. + let mut got_resp = false; + while !got_resp { + let f = recv(&mut ws).await; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + connects += 1; + } else if f.get("id") == Some(&json!(req_id)) { + last_session = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + answered_new += 1; + got_resp = true; + } + } + } + assert_eq!(answered_new, sessions_needed); + + // Drain any remaining mcp/connect frames so the parked count is what we think it is. + // Bounded: if the server parks fewer than we declared, this must fail naming the count, + // not sit in `recv` until its 30s guard panics. A failure named after the harness reads + // as a flake and gets dismissed; one naming the count points at the defect. + let mut frames = 0; + while connects < want_connects && frames < want_connects * 4 { + let f = recv(&mut ws).await; + frames += 1; + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + connects += 1; + } + } + assert!( + connects > MAX_INFLIGHT_PROMPTS, + "the budget defect is only observable with more than MAX_INFLIGHT_PROMPTS \ + ({MAX_INFLIGHT_PROMPTS}) establishes parked, but only {connects} were" + ); + + // With >32 establishes parked, a prompt must still be accepted. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/prompt", + "params": {"sessionId": last_session.unwrap(), "prompt": [{"type": "text", "text": "hi"}]} + })).await; + + let mut saw = None; + for _ in 0..40 { + let f = recv(&mut ws).await; + if f.get("id") == Some(&json!(3)) { + saw = Some(f); + break; + } + } + let f = saw.expect("no response to the prompt"); + + // The prompt is expected to pass the in-flight budget gate and then fail at the backend, + // because this harness attaches none. That specific error IS the evidence: the budget gate + // rejects earlier and with a different message. + // + // READ THIS BEFORE LOOSENING THE ASSERTION. The pinned string carries two different + // things, and only one of them is a contract: + // + // (a) `No agent backend connected` is INCIDENTAL — an artifact of this harness having no + // backend. It is not what the test protects. + // (b) That the prompt got far enough to fail there AT ALL is the property: establish + // tasks and prompt tasks hold separate budgets. + // + // So if a harness change makes this red, the fix is to RE-DERIVE what "reached the + // backend" now looks like and pin that — not to relax the check back toward + // `!msg.contains("in-flight prompts")`. That substring form is what this replaced, and it + // would pass for every other error and for any rewording of the budget message: it could + // only ever fail for one spelling of one regression. + // + // Pinned exactly, not asserted as `!msg.contains("in-flight prompts")`. A negative + // substring check is satisfied by every OTHER error too — a session-not-found, a params + // error, or the budget message itself once someone rewords it — so it could only ever fail + // for one spelling of one regression, and would pass silently through the rest. + let err = f + .get("error") + .unwrap_or_else(|| panic!("the prompt must reach the backend and fail there, got: {f}")); + assert_eq!( + err["message"], json!("No agent backend connected"), + "{connects} parked mcp/connects must not spend the prompt budget; the prompt must \ + reach the backend and fail only for the missing backend, got: {f}" + ); + } + + /// A resume that stops declaring a server must retire its tunnel. + /// + /// Driven through the real read loop on purpose. The defect this covers was that + /// `handle_session_resume` re-parsed the raw params and stored an uncapped list while the + /// tunnels were opened from the accepted one — a unit test of `accept_acp_servers` alone sees + /// neither half of that, which is how it survived the last review. + #[tokio::test] + async fn a_resume_that_withdraws_a_declaration_retires_its_tunnel() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + + // Declare TWO servers, answer both mcp/connect calls. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut ws).await; + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": {"cwd": "/w", "mcpServers": [ + {"type": "acp", "id": "keep-1", "name": "katashiro"}, + {"type": "acp", "id": "drop-1", "name": "other"} + ]} + })).await; + // Two servers: two `mcp/connect`s AND two lifecycle pairs (initialize + notification). + // Exiting on the connects alone leaves the handshakes unread and both establishes fail. + let mut session_id = None; + let mut connects = 0; + let mut lifecycle = 0; + while session_id.is_none() || connects < 2 || lifecycle < 4 { + let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await.is_some() { + lifecycle += 1; + continue; + } + if f.get("method").and_then(Value::as_str) == Some("mcp/connect") { + let acp_id = f["params"]["acpId"].as_str().unwrap().to_string(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": format!("conn-{acp_id}")} + })).await; + connects += 1; + } else if f.get("id") == Some(&json!(2)) { + session_id = Some(f["result"]["sessionId"].as_str().unwrap().to_string()); + } + } + wait_for_tunnels(®istry, 2).await; + + // Resume declaring ONLY the first — "other" is withdrawn. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 3, "method": "session/resume", + "params": { + "sessionId": session_id.unwrap(), + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "keep-2", "name": "katashiro"}] + } + })).await; + + // TWO disconnects are owed here, for different reasons, and both are correct: + // - `conn-drop-1` because the resume WITHDREW that declaration (this item), and + // - `conn-keep-1` because the resume re-declared the same NAME with a fresh id, so + // last-attach-wins evicts the stale tunnel (R7). + // Asserting on whichever arrives first tests the scheduler, not the behaviour. + let mut disconnected: Vec = Vec::new(); + let mut reconnected = false; + let mut relifecycle = 0; + while disconnected.len() < 2 || !reconnected || relifecycle < 2 { + let f = recv(&mut ws).await; + if handled_inner_lifecycle(&mut ws, &f).await.is_some() { + relifecycle += 1; + continue; + } + match f.get("method").and_then(Value::as_str) { + Some("mcp/disconnect") => { + disconnected.push(f["params"]["connectionId"].as_str().unwrap().to_string()); + } + Some("mcp/connect") => { + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": f["id"].clone(), + "result": {"connectionId": "conn-keep-2"} + })).await; + reconnected = true; + } + _ => {} + } + } + disconnected.sort(); + assert_eq!( + disconnected, + vec!["conn-drop-1".to_string(), "conn-keep-1".to_string()], + "the withdrawn declaration AND the superseded same-name tunnel are both owed a \ + disconnect; nothing else may be" + ); + + // Only the re-declared server survives: `drop-1` was withdrawn and `keep-1` superseded. + wait_for_tunnels(®istry, 1).await; + let keys: Vec = { + let reg = registry.lock().unwrap(); + reg.keys().map(|(_, id)| id.clone()).collect() + }; + assert_eq!( + keys, + vec!["keep-2".to_string()], + "the withdrawn declaration must not stay reachable, got {keys:?}" + ); + } + + /// The transport ceiling: a frame over `MAX_FRAME_BYTES` closes the connection. + /// + /// Deliberately paired with the test below, because the two ceilings have DIFFERENT outcomes + /// and a single test cannot show that. Over 8 MiB the frame cannot be parsed at all, so the + /// gateway cannot tell a request from a notification or recover an id — fabricating a response + /// would risk answering a notification, so it closes instead. + /// + /// `scripts/acp-ws-smoke.py` asserted this with a 1 MiB + 64 byte payload and a comment + /// reading `> MAX_FRAME_BYTES (1 MiB)`. The ceiling moved to 8 MiB, so that payload stopped + /// reaching it — and because the payload was a bare `x` string rather than JSON, the close it + /// still observed came from the parse path. The assertion kept passing while covering a + /// different mechanism, which is why this now lives here where the gate runs it. + #[tokio::test] + async fn a_frame_over_the_transport_ceiling_closes_the_connection() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let oversized = "x".repeat(super::MAX_FRAME_BYTES + 64); + send(&mut ws, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "pad": oversized})) + .await; + + // The connection must go away rather than answer. Bounded so a regression that keeps it + // open fails here instead of hanging. + let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + match ws.next().await { + None => return true, + Some(Err(_)) => return true, + Some(Ok(_)) => return false, + } + } + }) + .await; + assert_eq!( + closed, + Ok(true), + "a frame over the transport ceiling must close the connection, not answer it" + ); + } + + /// The per-kind ceiling: a METHOD-bearing frame over `MAX_NON_TUNNEL_FRAME_BYTES` is refused + /// with an error and the connection SURVIVES. + /// + /// This is the half the smoke script never covered. The 8 MiB allowance exists for tunnel + /// results, which arrive as client responses; letting a method-bearing frame use it would make + /// the allowance a way to park `MAX_INFLIGHT_PROMPTS` × 8 MiB of prompt text per connection. + #[tokio::test] + async fn a_method_frame_over_its_ceiling_is_refused_but_keeps_the_connection() { + let (url, _registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + // Over 1 MiB, comfortably under 8 MiB — so it reaches the per-kind check, not the + // transport one. Asserting the gap between the two ceilings is the point. + let pad = "y".repeat(super::MAX_NON_TUNNEL_FRAME_BYTES + 4096); + assert!(pad.len() < super::MAX_FRAME_BYTES, "must not trip the transport ceiling"); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 7, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}, "pad": pad} + })).await; + + let resp = recv(&mut ws).await; + assert_eq!(resp["id"], json!(7)); + assert!(resp.get("error").is_some(), "an oversized request must be answered with an error: {resp}"); + + // And the connection still works — the discriminating half. A regression that closed here + // would satisfy every assertion above. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 8, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let after = recv(&mut ws).await; + assert_eq!(after["id"], json!(8)); + assert!( + after.get("result").is_some(), + "the connection must survive a per-kind refusal: {after}" + ); + } + + /// The eviction branch's ONLY coverage — and the scenario that proves it is not dead code. + /// + /// `a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced` is named for + /// last-attach-wins but never reaches it: on the resume path the sweep runs before + /// `spawn_acp_tunnels`, so the same-name predecessor is already retired and `replaced` is + /// empty. That left `if !replaced.is_empty()` with no test at all. + /// + /// It IS reachable through a single `session/new`. `accept_acp_servers` dedups on `id` alone + /// (`seen.insert(s.id.clone())`), so one declaration may legally carry two servers sharing a + /// NAME with different ids, and `session/new` runs no sweep — the channel is a freshly minted + /// uuid that cannot collide. The two establishes race; whichever takes the registry lock + /// second sees its same-name predecessor, `same_name` matches, and the eviction disconnect + /// fires. Two same-name declarations are a client error, but the gateway accepts them, so the + /// path has to exist and has to behave. + /// + /// Which of the two wins is a genuine race, so this asserts the RELATION rather than a + /// winner: exactly one tunnel survives, and the disconnect names the one that did not. That + /// holds under either interleaving, so the test itself is stable. + /// + /// WHAT IT DOES NOT GUARANTEE — read before relying on this as eviction coverage. + /// + /// `generation` is stamped inside `establish_and_register_tunnel`, i.e. within the spawned + /// task, so declaration order does NOT determine generation order. Both establishes share a + /// `connection_generation`, so ordering falls to `generation`, and which arm runs depends on + /// the scheduler: + /// + /// - lower-generation task registers first → the later one evicts it → EVICTION arm; + /// - higher-generation task registers first → the earlier one is superseded and stands down, + /// closing its own connection → SUPERSEDED arm, and eviction never runs. + /// + /// Both produce "one survivor, loser gets the disconnect", which is why the assertions cannot + /// tell them apart. Measured on this machine: 20 of 20 runs took the EVICTION arm, so the + /// coverage is real in practice — but it is not guaranteed by construction, and a change that + /// broke eviction could pass on an unlucky-for-us schedule. + /// + /// The fix is to stamp `generation` in the declaration loop of `spawn_acp_tunnels` rather than + /// inside the task. Declaration order within one request is the only order the client ever + /// expressed, so that is arguably more faithful than task-scheduling order, and it would make + /// this test cover the eviction arm deterministically. Filed as a follow-up rather than done + /// here: it changes the ordering semantics the whole last-attach-wins design rests on, and + /// that deserves its own review rather than riding along with a test. + #[tokio::test] + async fn two_same_name_servers_in_one_session_evict_down_to_one() { + let (url, registry) = serve().await; + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let init = recv(&mut ws).await; + assert!(init.get("result").is_some(), "initialize failed: {init}"); + + // Same name, different ids — accepted, because the dedup key is the id. + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/new", + "params": { + "cwd": "/w", + "mcpServers": [ + {"type": "acp", "id": "uuid-a", "name": "katashiro"}, + {"type": "acp", "id": "uuid-b", "name": "katashiro"} + ] + } + })).await; + + // Answer both connects, absorb both inner lifecycles, and wait for the disconnect the + // loser is owed. Bounded so a regression fails naming what was missing rather than + // sitting in `recv` until its 30s guard trips — a failure named after the harness reads + // as a flake. + let mut connected = std::collections::HashMap::new(); + let mut disconnected: Option = None; + let mut session_seen = false; + for _ in 0..40 { + if disconnected.is_some() && session_seen && connected.len() == 2 { + break; + } + // The 40 bounds the frame COUNT; this bounds the WAIT, and only together do they do + // what the comment claims. `recv` blocks, so a regression that never sends the + // disconnect would not run out the loop and fail with the state below — it would sit + // on frame 9 until `recv`'s own 30s guard fired, reporting a harness timeout instead + // of the missing disconnect. Breaking out here keeps the failure attributable. + let Ok(frame) = + tokio::time::timeout(std::time::Duration::from_secs(2), recv(&mut ws)).await + else { + break; + }; + if handled_inner_lifecycle(&mut ws, &frame).await.is_some() { + continue; + } + match frame.get("method").and_then(Value::as_str) { + Some("mcp/connect") => { + let acp_id = frame["params"]["acpId"].as_str().expect("acpId").to_string(); + let conn = format!("conn-{}", acp_id.trim_start_matches("uuid-")); + connected.insert(acp_id, conn.clone()); + send(&mut ws, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": conn} + })).await; + } + Some("mcp/disconnect") => { + disconnected = Some( + frame["params"]["connectionId"].as_str().expect("connectionId").to_string(), + ); + } + _ => { + if frame.get("id") == Some(&json!(2)) { + assert!(frame.get("result").is_some(), "session/new refused: {frame}"); + session_seen = true; + } + } + } + } + assert_eq!(connected.len(), 2, "both declared servers must be asked to connect"); + let evicted = disconnected.expect( + "the evicted tunnel is owed an mcp/disconnect — if this is missing, the eviction \ + branch did not run and this scenario no longer covers it", + ); + + wait_for_tunnels(®istry, 1).await; + let survivors: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.connection_id.clone()).collect() + }; + assert_eq!(survivors.len(), 1, "last-attach-wins must collapse the name to one tunnel"); + assert_ne!( + survivors[0], evicted, + "the disconnect must name the tunnel that LOST, not the one still registered" + ); + assert!( + connected.values().any(|c| c == &evicted), + "the disconnected connection must be one of the two we opened, got {evicted}" + ); + } + + /// A reconnect that **resumes** the same session re-declares the same NAME with a fresh id. + /// That evicts the stale tunnel, and the client is owed an `mcp/disconnect` for the connection + /// it still believes is open (review R7). + /// + /// It has to be a resume, not a second `session/new`: eviction is scoped to one `channel_id` + /// (`c == &channel_id` in `establish_and_register_tunnel`), and a new session is a new channel, + /// so two independent sessions declaring the same name do not collide and must not evict each + /// other. Writing this as two `session/new` calls asserts nothing. + #[tokio::test] + async fn a_resume_replacing_a_same_name_tunnel_disconnects_the_one_it_replaced() { + let (url, registry) = serve().await; + let (mut first, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let session_id = handshake(&mut first, "uuid-old", "katashiro", "conn-old").await; + wait_for_tunnels(®istry, 1).await; + + // The client comes back on a fresh socket and resumes, re-declaring under the same name + // with the fresh id its runtime mints per connection. + let (mut second, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + send(&mut second, json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}} + })).await; + let _ = recv(&mut second).await; + send(&mut second, json!({ + "jsonrpc": "2.0", "id": 2, "method": "session/resume", + "params": { + "sessionId": session_id, + "cwd": "/w", + "mcpServers": [{"type": "acp", "id": "uuid-new", "name": "katashiro"}] + } + })).await; + let mut connected_new = false; + let mut new_lifecycle = 0; + while !connected_new || new_lifecycle < 2 { + let frame = recv(&mut second).await; + if handled_inner_lifecycle(&mut second, &frame).await.is_some() { + new_lifecycle += 1; + continue; + } + if frame.get("method").and_then(Value::as_str) == Some("mcp/connect") { + send(&mut second, json!({ + "jsonrpc": "2.0", "id": frame["id"].clone(), + "result": {"connectionId": "conn-new"} + })).await; + connected_new = true; + } + if frame.get("id") == Some(&json!(2)) { + assert!( + frame.get("result").is_some(), + "session/resume was refused, so no tunnel is opened: {frame}" + ); + } + } + + // WHAT THIS TEST ACTUALLY COVERS — measured, not assumed. + // + // The name says last-attach-wins eviction (R7). It does not exercise it. Instrumenting + // every `mcp/disconnect` emitter and running this test against unmodified code shows one + // line: the WITHDRAWAL-RETIREMENT path (`resume withdrew declarations`). The eviction + // branch never runs, because the resume drops `uuid-old` from the accepted set, so + // retirement removes that registry entry BEFORE the establish for `uuid-new` looks for + // same-name predecessors — `replaced` is empty and `if !replaced.is_empty()` is false. + // + // A mutation confirms it: redirecting the eviction disconnect from the stale handles to + // the newly registered one (preserving "empty unless something was replaced") leaves this + // test GREEN. So the assertions below cannot distinguish disconnecting the right tunnel + // from disconnecting the wrong one — they are satisfied by a different mechanism. + // + // The previous comment here claimed the opposite ("an implementation that disconnected the + // newly registered tunnel would pass too"), and the commit that added it reported the + // assertion as mutation-checked by flipping the expected `connectionId`. That mutates the + // TEST, not the code; negating a tautology also goes red. Both claims were wrong. + // + // Whether `same_name` eviction is reachable through a resume AT ALL is an open question + // filed for review, not something this test should paper over. + let framed = recv(&mut first).await; + assert_eq!(framed["method"], json!("mcp/disconnect")); + assert_eq!( + framed["params"]["connectionId"], json!("conn-old"), + "the evicted connection is the one owed a disconnect, not its replacement" + ); + + // And the survivor is the new one. + let names: Vec = { + let reg = registry.lock().unwrap(); + reg.values().map(|h| h.connection_id.clone()).collect() + }; + assert_eq!(names, vec!["conn-new".to_string()], "only the newest tunnel may remain"); + + // The survivor must WORK, not merely be present in the registry: a replacement that + // registers a handle whose socket is gone satisfies every assertion above. This is the + // `mcp/message` round trip the review request for this trio announced and this test never + // performed. + // + // It also pins frame ORDER on the surviving socket. Frames on one socket are ordered, so + // any implementation that wrote a disconnect to the replacement would have to write it + // before this response — asserting the next frame is the tool call fails fast and names + // the reason, where a missing-frame check could only time out. + let handle = { + let reg = registry.lock().unwrap(); + reg.values().next().expect("the survivor must be registered").clone() + }; + let call = tokio::spawn(async move { + handle.mcp_message("tools/call", Some(json!({"name": "katashiro.click"})), 5).await + }); + + let request = recv(&mut second).await; + assert_eq!( + request["method"], json!("mcp/message"), + "the next frame owed to the surviving connection is the tool call, not a disconnect" + ); + assert_eq!(request["params"]["connectionId"], json!("conn-new")); + send(&mut second, json!({ + "jsonrpc": "2.0", "id": request["id"].clone(), + "result": {"content": [{"type": "text", "text": "clicked"}]} + })).await; + let got = call.await.unwrap().expect("the surviving tunnel must carry a call"); + assert_eq!(got["content"][0]["text"], json!("clicked")); + } +} + +/// Teardown ownership (canonical item 2). +/// +/// Both registries are keyed by things that outlive a connection — the reply registry by +/// `channel_id`, the tunnel registry by `(channel_id, server_id)` — and `session/resume` hands the +/// same channel to a *new* connection. So "remove the keys for my sessions" deletes a successor's +/// live entry. "Only remove keys I inserted" is no better: the key is reused, so it cannot tell the +/// two apart. The owner can. +/// +/// All three cases below fail against key-only teardown. +/// Operator logs must not hand out a resume credential. +#[cfg(test)] +mod acp_log_redaction { + use super::*; + + /// The two ids are the same uuid under different prefixes, so either one in a log line is a + /// working `session/resume` credential. This is the property that makes redaction necessary — + /// if it ever stops holding, the redaction is solving a problem that moved. + #[test] + fn a_channel_id_yields_its_session_id() { + let uuid = Uuid::new_v4(); + let session_id = format!("sess_{uuid}"); + let channel_id = derive_channel_id(&session_id).unwrap(); + assert_eq!(channel_id, format!("acp_{uuid}")); + assert_eq!( + channel_id.strip_prefix("acp_"), + session_id.strip_prefix("sess_"), + "one is trivially derivable from the other — that is why neither may be logged raw" + ); + } + + #[test] + fn a_redacted_id_is_stable_but_does_not_contain_the_original() { + let uuid = Uuid::new_v4(); + let channel_id = format!("acp_{uuid}"); + let tag = redact_id(&channel_id); + + assert_eq!(tag, redact_id(&channel_id), "same id must tag identically, or logs stop \ + being correlatable across lines"); + assert!(!tag.contains(&uuid.to_string()), "the uuid must not survive into the tag"); + assert!( + !tag.contains(&channel_id) && !channel_id.contains(&tag[1..]), + "the tag must not be a substring of the id or vice versa" + ); + assert_ne!(tag, redact_id(&format!("acp_{}", Uuid::new_v4())), "different ids differ"); + } + + /// No `info!`/`warn!`/`error!` in this file may carry a raw channel or session id. + /// + /// Written as a source scan because the defect is a *class*, not a line: the item named two + /// sites, and a multi-line-aware sweep found four — the two extra ones added by later items in + /// this same round, and missed by a line-oriented grep because the macro and the field sat on + /// different lines. Pinning the four known sites would pass while the next wrapped `info!` + /// reintroduced the leak. + #[test] + fn no_operator_log_line_carries_a_raw_acp_id() { + let src = include_str!("acp_server.rs"); + let mut offenders: Vec = Vec::new(); + for macro_open in ["info!(", "warn!(", "error!("] { + let mut from = 0; + while let Some(rel) = src[from..].find(macro_open) { + let start = from + rel; + // Balance from the macro's own opening paren, so a mention of `info!` in prose + // cannot send this scanning for a close paren that was never opened. + let open = start + macro_open.len() - 1; + from = open + 1; + let mut depth = 0usize; + let mut end = open; + for (i, c) in src[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = open + i; + break; + } + } + _ => {} + } + } + let body = &src[start..=end]; + // `%channel_id` / `%session_id` interpolate the raw value; `redact_id(..)` does not. + if body.contains("%channel_id") || body.contains("%session_id") { + let line = src[..start].matches('\n').count() + 1; + offenders.push(format!( + "{line}: {}", + body.split_whitespace().take(8).collect::>().join(" ") + )); + } + } + } + assert!( + offenders.is_empty(), + "these operator-visible log lines carry a raw resume credential: {offenders:#?}" + ); + } +} + +#[cfg(test)] +mod acp_teardown_ownership { + use super::*; + + fn tunnel(owner: &str, connection_id: &str) -> TunnelHandle { + let (out_tx, _rx) = mpsc::unbounded_channel::(); + TunnelHandle { + out_tx, + pending: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + next_id: Arc::new(AtomicU64::new(1)), + connection_id: connection_id.into(), + server_name: "katashiro".into(), + owner: owner.into(), + generation: 0, + connection_generation: 0, + } + } + + /// Model the teardown predicate exactly as `cleanup` applies it, so the test exercises the + /// rule rather than a paraphrase of it. + fn teardown(reg: &AcpTunnelRegistry, channel_ids: &[&str], closing: &str) { + let mut reg = reg.lock().unwrap(); + reg.retain(|(cid, _), h| !(channel_ids.contains(&cid.as_str()) && h.owner == closing)); + } + + /// A connection that closes must not take its successor's tunnel with it. + /// + /// The old connection's cleanup runs late — after the client reconnected, resumed the same + /// channel and registered a fresh tunnel. Under key-only teardown the successor's handle is + /// removed and a working session loses its browser with no error anywhere. + #[test] + fn a_late_cleanup_does_not_remove_the_successors_tunnel() { + let reg = new_tunnel_registry(); + { + let mut r = reg.lock().unwrap(); + // The successor: same channel, fresh server id, different connection. + r.insert(("acp_x".into(), "srv-new".into()), tunnel("conn-B", "cid-new")); + } + teardown(®, &["acp_x"], "conn-A"); + + let r = reg.lock().unwrap(); + assert!( + r.contains_key(&("acp_x".to_string(), "srv-new".to_string())), + "conn-A's cleanup must not remove a tunnel owned by conn-B on the same channel" + ); + } + + /// Its own entries still go. + #[test] + fn a_cleanup_still_removes_the_tunnels_it_owns() { + let reg = new_tunnel_registry(); + { + let mut r = reg.lock().unwrap(); + r.insert(("acp_x".into(), "srv-a".into()), tunnel("conn-A", "cid-a")); + r.insert(("acp_x".into(), "srv-b".into()), tunnel("conn-B", "cid-b")); + } + teardown(®, &["acp_x"], "conn-A"); + + let r = reg.lock().unwrap(); + assert!(!r.contains_key(&("acp_x".to_string(), "srv-a".to_string())), "conn-A's own tunnel must go"); + assert!(r.contains_key(&("acp_x".to_string(), "srv-b".to_string())), "conn-B's must stay"); + assert_eq!(r.len(), 1); + } + + /// A reply sink installed by a successor survives the predecessor's cleanup. + /// + /// Same defect, quieter symptom: the sink is how a prompt's output reaches the client, so + /// deleting the wrong one produces a session that accepts prompts and answers none. + #[test] + fn a_late_cleanup_does_not_remove_the_successors_reply_sink() { + let reg = new_reply_registry(); + { + let (tx, _rx) = mpsc::unbounded_channel::(); + reg.lock().unwrap().insert( + "acp_x".into(), + ReplySink { turn_id: "evt_new".into(), tx, owner: "conn-B".into() }, + ); + } + { + let mut r = reg.lock().unwrap(); + let channel_ids = ["acp_x"]; + r.retain(|cid, s| !(channel_ids.contains(&cid.as_str()) && s.owner == "conn-A")); + } + let r = reg.lock().unwrap(); + assert_eq!( + r.get("acp_x").map(|s| s.turn_id.as_str()), + Some("evt_new"), + "conn-A's cleanup must leave conn-B's sink in place, or the live session goes mute" + ); + } + +} diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index faee818b6..74e4b0b6e 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -79,6 +79,8 @@ pub struct AppState { pub acp: Option, #[cfg(feature = "acp")] pub acp_reply_registry: Option, + #[cfg(feature = "acp")] + pub acp_tunnel_registry: Option, #[cfg(feature = "lineworks")] pub lineworks: Option>, pub ws_token: Option, @@ -131,6 +133,8 @@ impl AppState { acp: None, #[cfg(feature = "acp")] acp_reply_registry: None, + #[cfg(feature = "acp")] + acp_tunnel_registry: None, #[cfg(feature = "lineworks")] lineworks: None, ws_token: None, @@ -213,6 +217,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()); // LINE WORKS #[cfg(feature = "lineworks")] let lineworks = adapters::lineworks::LineWorksConfig::from_env().map(|config| { @@ -249,6 +255,8 @@ impl AppState { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, #[cfg(feature = "lineworks")] lineworks, ws_token, @@ -781,6 +789,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()); // LINE WORKS adapter #[cfg(feature = "lineworks")] let lineworks = adapters::lineworks::LineWorksConfig::from_env() @@ -825,6 +837,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { acp, #[cfg(feature = "acp")] acp_reply_registry, + #[cfg(feature = "acp")] + acp_tunnel_registry, #[cfg(feature = "lineworks")] lineworks, ws_token, @@ -973,7 +987,7 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: Ok(reply) => { info!( platform = %reply.platform, - channel = %reply.channel.id, + channel = %redact_channel(&reply.channel.id), command = ?reply.command.as_deref(), "OAB → gateway reply" ); @@ -1285,3 +1299,59 @@ mod l1_audit_tests { assert!(flagged(&s).is_empty()); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: the channel id printed here IS a resume credential. Anyone reading operator +/// logs could resume the session, and logs travel further than the sessions they describe. +/// +/// Only ACP ids are hashed. A Discord or Slack channel id is a public identifier that operators +/// legitimately grep for, and redacting it would cost real debuggability to protect nothing. +/// +/// Hashed rather than dropped so the same session still tags identically on every line — that +/// correlation is the whole reason the id is in the log. **The tag must match the one produced in +/// the other crates that log channel ids**, or a session cannot be followed across them; each copy +/// is pinned to the same vector by its own test. The copies exist because these crates deliberately +/// do not depend on one another, and adding an edge to share five lines would trade a documented +/// architectural boundary for a duplicate. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} 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/crates/openab-mcp/src/mcp/facade.rs b/crates/openab-mcp/src/mcp/facade.rs index bef76c6e6..b8af0aec3 100644 --- a/crates/openab-mcp/src/mcp/facade.rs +++ b/crates/openab-mcp/src/mcp/facade.rs @@ -278,7 +278,11 @@ impl McpFacade { // reason, never forwarded. meta_tool::validate_args(tool.input_schema.as_ref(), &args_map) .with_context(|| format!("execute_capability {name:?}"))?; - let channel = ctx.map(|c| c.channel_id.as_str()).unwrap_or("-"); + // Redacted for the same reason the arguments below are hashed, and the + // inconsistency was the tell: this line hashed the args because they "could carry + // secrets" while printing a resume credential beside them in cleartext. An ACP + // `channel_id` is `acp_` and the session id is `sess_`. + let channel = redact_channel(ctx.map(|c| c.channel_id.as_str()).unwrap_or("-")); // Same audit shape as the meta_tool dispatcher: hash of the // wire arguments, never plaintext (could carry secrets). let args_sha256 = { @@ -877,3 +881,57 @@ mod tests { assert!(err.to_string().contains("requires a `name`")); } } + +/// Render a channel id for the audit log, hashing it when it is an ACP channel. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: printed in full, this line hands out a resume credential. That sat directly +/// beside `args_sha256`, which exists because arguments "could carry secrets" — the audit line was +/// hashing the payload and publishing the capability. +/// +/// Only ACP ids are hashed; a Discord or Slack channel id is public and operators grep for it. +/// +/// **The tag must match the one produced in `openab-gateway` and `openab-core`**, or one session +/// cannot be followed across the reply log, the event log and this audit log — which is the only +/// reason to keep an identifier here at all. Each copy is pinned to the same vector by its own +/// test. The copies exist because these crates deliberately do not depend on one another. +fn redact_channel(id: &str) -> String { + if !id.starts_with("acp_") { + return id.to_string(); + } + use sha2::{Digest as _, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect(); + format!("#{short}") +} + +#[cfg(test)] +mod redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id. + /// + /// This exact vector and expectation are repeated in `openab-gateway`, `openab-core` and + /// `openab-mcp`. The three copies of `redact_channel` exist because those crates deliberately + /// do not depend on one another; pinning the same vector in each is what makes a divergence + /// fail a build instead of quietly splitting one session into three untraceable tags. + /// + /// If this assertion is ever changed, change it in all three or the redaction stops doing the + /// one job that justifies keeping an identifier in the log at all. + #[test] + fn an_acp_channel_hashes_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#850414fa", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("1234567890"), + "1234567890", + "a non-ACP channel id is a public identifier and must stay greppable" + ); + assert_eq!( + super::redact_channel("-"), + "-", + "the no-session sentinel must not be hashed into something that looks like a session" + ); + } +} diff --git a/crates/openab-mcp/src/mcp/sources.rs b/crates/openab-mcp/src/mcp/sources.rs index 137def26d..30bf679a6 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -88,15 +88,25 @@ impl SessionTokens { Self::default() } - /// Mint a fresh opaque token bound to `channel_id`. A prior token for - /// the same channel (e.g. a respawned session) is replaced — exactly one - /// live token per channel. + /// Mint a fresh opaque token bound to `channel_id`. + /// + /// Tokens for a channel **coexist**: a respawned or racing session gets its own credential and + /// any already-issued token keeps resolving. There is deliberately no "one live token per + /// channel" invariant — enforcing it here invalidated credentials that a running agent was + /// still presenting. Each token is retired individually through [`Self::revoke_token`], which + /// is what keeps the map bounded. pub fn mint(&self, channel_id: &str) -> String { let mut buf = [0u8; 32]; getrandom::fill(&mut buf).expect("os rng"); let token = B64_URL.encode(buf); let mut map = self.inner.write().expect("session token lock"); - map.retain(|_, ctx| ctx.channel_id != channel_id); + // Deliberately does NOT evict the channel's existing tokens. Session lifetimes overlap: + // two builders can race for one channel, and a pool reset can start a replacement while + // the predecessor is still serving. Clobbering here invalidated a token whose agent was + // still using it — the agent holds OPENAB_SESSION_TOKEN in its environment, so it cannot + // notice, and every facade call then fails auth with `requires_session` tools silently + // vanishing. Each mint is paired with a token-specific revoke on its own drop guard, so + // the map stays bounded without this. map.insert( token.clone(), SessionCtx { @@ -106,7 +116,11 @@ impl SessionTokens { token } - /// Revoke every token for `channel_id` (session evict / respawn). + /// Revoke **every** token for `channel_id` — a deliberate channel-wide eviction. + /// + /// Prefer [`Self::revoke_token`] when tearing down one specific session. Because tokens for a + /// channel coexist, revoking by channel here also destroys credentials belonging to any other + /// live session on it, which is only correct when the intent really is "end this channel". pub fn revoke_channel(&self, channel_id: &str) { self.inner .write() @@ -114,6 +128,18 @@ impl SessionTokens { .retain(|_, ctx| ctx.channel_id != channel_id); } + /// Revoke exactly one token, leaving every other token for that channel intact. + /// + /// This is the teardown a session's drop guard should use: it retires the credential that + /// session minted and nothing else, so a late teardown cannot cut off a session that started + /// alongside or after it. A no-op if the token was already revoked. + pub fn revoke_token(&self, token: &str) { + self.inner + .write() + .expect("session token lock") + .remove(token); + } + /// Resolve a presented token. Constant-time comparison over stored /// tokens so a colocated process can't probe a token byte-by-byte via /// response timing (session counts are small; the linear scan is noise). @@ -168,17 +194,78 @@ pub fn session_ctx_from_extensions( mod tests { use super::*; + /// Two builders racing for one channel must not invalidate each other (review round 4, T1). + /// + /// R1 made revocation token-specific but left `mint` evicting by channel, so the second mint + /// killed the first agent's live token. That agent holds `OPENAB_SESSION_TOKEN` in its + /// environment and cannot observe the change: every facade call simply starts failing auth and + /// its `requires_session` tools vanish from discovery, with nothing pointing at the cause. + #[test] + fn a_second_mint_for_one_channel_does_not_invalidate_the_first() { + let tokens = SessionTokens::new(); + let first = tokens.mint("chan-a"); + let second = tokens.mint("chan-a"); + + assert_ne!(first, second, "each mint is a distinct credential"); + assert_eq!( + tokens.resolve(&first).map(|c| c.channel_id), + Some("chan-a".to_string()), + "the first builder's token must survive a concurrent second mint" + ); + assert_eq!( + tokens.resolve(&second).map(|c| c.channel_id), + Some("chan-a".to_string()) + ); + + // Each is still independently revocable, so the map stays bounded by guard pairing + // rather than by eviction-on-mint. + tokens.revoke_token(&first); + assert!(tokens.resolve(&first).is_none()); + assert!( + tokens.resolve(&second).is_some(), + "revoking one credential must not disturb the other" + ); + } + + /// An evicted session's teardown must not cut off the session that replaced it (review R1). + /// + /// Session lifetimes overlap: the successor mints while the predecessor's drop guard is still + /// pending. Revoking by channel at that point removes the *live* token, and the new agent + /// loses facade access with nothing pointing at the cause. Revoking the specific token makes + /// the late teardown a no-op. + #[test] + fn a_replaced_sessions_teardown_cannot_revoke_its_successors_token() { + let tokens = SessionTokens::new(); + let old = tokens.mint("chan-a"); + let new = tokens.mint("chan-a"); // successor takes over the channel + + // The predecessor's guard fires late, carrying the token IT minted. + tokens.revoke_token(&old); + + assert_eq!( + tokens.resolve(&new).map(|c| c.channel_id), + Some("chan-a".to_string()), + "the successor's token must survive a late teardown of the session it replaced" + ); + + // And revoking the current token still works. + tokens.revoke_token(&new); + assert!(tokens.resolve(&new).is_none()); + } + #[test] fn mint_resolve_revoke_lifecycle() { let tokens = SessionTokens::new(); let t1 = tokens.mint("chan-a"); assert_eq!(tokens.resolve(&t1).unwrap().channel_id, "chan-a"); assert!(tokens.resolve("nope").is_none()); - // Re-mint for the same channel replaces the old token. + // A second mint for the channel coexists with the first — it used to evict it, which is + // the bug T1 fixes; see a_second_mint_for_one_channel_does_not_invalidate_the_first. let t2 = tokens.mint("chan-a"); - assert!(tokens.resolve(&t1).is_none(), "old token must be dead"); assert_eq!(tokens.resolve(&t2).unwrap().channel_id, "chan-a"); + // revoke_channel is the deliberate channel-wide evict and still clears both. tokens.revoke_channel("chan-a"); + assert!(tokens.resolve(&t1).is_none()); assert!(tokens.resolve(&t2).is_none()); } 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 deleted file mode 100644 index 7757cde7d..000000000 --- a/docs/adr/acp-server-websocket-mcp-browser.md +++ /dev/null @@ -1,104 +0,0 @@ -# ADR: Browser control via MCP-over-ACP (proposed) - -- **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 -- **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) - ---- - -## 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 -``` - 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] -``` - -### 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. 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..93e0dc188 --- /dev/null +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -0,0 +1,596 @@ +# ADR: Reverse MCP-over-ACP over WebSocket + +- **Status:** Accepted — the mechanism and the generic multi-server generalization (§6) are both + **as-built in #1447** (F1′, F3′, F4 and F5 landed; F6 e2e coverage remains). +- **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). + The browser extension's implementation contract: + [MCP-over-ACP tunnel contract](../mcp-over-acp-tunnel-contract.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 **§7** 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. Note the LLM's + own `tools/list` does **not** show them: it sees the facade's two meta-tools, and reaches the + client's tools through `search_capabilities` / `execute_capability` (§6.3). +- **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 §9). + +## 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
OAB MCP Facade"] + AGENT["agent CLI
Cursor · Kiro · Claude · Codex
LLM = MCP CLIENT"] + GW <--> CORE + CORE ==>|"OAB MCP Facade (only path)
one listener, requires [mcp]
static {url, Bearer ${OPENAB_SESSION_TOKEN}} → .cursor / .kiro mcp.json
token in agent env, revoked on evict"| 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 **one** delivery path: the OAB MCP Facade. It had two others — `proxy` +(HTTP MCP, once the default) and `bridge` (stdio relay) — and both were removed on 2026-07-28. + +## 5. MCP usage sequence (katashiro.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
OAB MCP Facade + participant LLM as agent LLM
MCP client + + Note over Ext,LLM: PHASE 1 — connect & agent wiring (no tool discovery yet) + 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 (mint facade session token) + Core->>Core: write static "openab" facade entry into agent's mcp.json
{url, Authorization: Bearer ${OPENAB_SESSION_TOKEN}} + LLM->>Core: MCP initialize + tools/list + Core-->>LLM: the facade's TWO meta-tools ONLY
(search_capabilities · execute_capability) — returns at once,
no upstream call; katashiro.* are NOT in the model's tool list + + Note over Tab,LLM: PHASE 2 — discovery is PULL-triggered by the model + LLM->>Core: search_capabilities("browser") + Core->>GW: tools/list (MCP-over-ACP: mcp/message frame)
spawned on first pull per (channel_id, declared_name), then cached + GW->>Ext: mcp/message → tools/list + Ext-->>GW: 5 tools: read_dom · screenshot · navigate · click · type + GW-->>Core: tools result + Core-->>LLM: capabilities: openab-browser:katashiro.* + + Note over Tab,LLM: PHASE 3 — one autonomous action (e.g. click) + LLM->>Core: execute_capability("openab-browser:katashiro.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 **§7.3**. + +## 6. Generalization — multiple client-side MCP servers + +The browser path wires **one** MCP server. This section is the accepted direction, **as-built in +#1447**, making reverse MCP-over-ACP **generic**: any ACP WS client may declare **one or more** +`type:acp` MCP servers on `session/new` (and re-declare them on `session/resume`) — **not** on +`initialize`, which never reads `mcpServers` — 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.~~ `ProxyHandler` was removed with the per-session proxy on + 2026-07-28. Forwarding is now `AcpTunnelSource::call` in the facade capability source, and it is + **not** unvalidated: the §6.4 trust gate refuses a tool whose server name is not allowlisted or + whose name is not pinned, before anything reaches the tunnel. + +### 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)`. +- On session teardown, evict only the `(channel_id, *)` entries **this connection owns** — + matched on `owner`, not on the channel. Evicting every entry for the channel would delete a + successor's live tunnel, because a client that reconnects and resumes takes over the same + `channel_id`. (The unqualified form was this document's original wording and describes a + defect that was fixed in the implementation; left uncorrected it is the copy that could get + the code "restored" back into the bug.) + +**`id` and `name` are different things, and routing needs both.** A declaration is +`{type:"acp", id, name}`, and the two fields have very different lifetimes — the reference client mints +`id` as a fresh `crypto.randomUUID()` **per connection** while `name` (`"katashiro"`) is stable +across reconnects. The registry key is the **`id`**; the `` segment of a tool name (`katashiro.click`) +and the §6.4 allowlist are the **`name`**. Consequences, all confirmed by review 2026-07-26: + +- The registry stays keyed by `(channel_id, id)` — keying by `name` would let two same-name tunnels + overwrite each other, reintroducing exactly the fan-out collapse this section fixes — but it must + **also record the declared `name`**, so a source can enumerate `(name, id)` for a channel and resolve + a tool prefix to a tunnel. Routing purely on the registry key cannot work: the key is a UUID the tool + name never contains. +- Trust gating (§6.4) is keyed by **`name`**. An allowlist of `id`s is meaningless when they are + per-connection UUIDs. +- **Same-name collisions resolve last-attach-wins (LWW):** a newly attached tunnel whose `name` matches + an existing one on the same channel **replaces and evicts** the older entry. Because the client mints + a new `id` on every reconnect, the stale entry would otherwise linger beside the live one; answering + "ambiguous, disambiguate by server_id" there would wedge the client out of its own tools on every + reconnect. LWW keeps reconnect self-healing; the eviction is what stops unbounded growth. + +### 6.2 Downstream exposure — one `CapabilitySource` behind the OAB MCP Facade + +> **Revised 2026-07-26.** An earlier draft of §6.2–§6.5 proposed a bespoke path: per-`(session, server)` +> loopback MCP proxies, openab writing N entries into the agent's MCP config, dynamic `tools/list` with +> `notifications/tools/list_changed`. That is **superseded**. The +> [OAB MCP Facade](../oab-mcp-facade.md) ([OAB MCP Adapter ADR](./oab-mcp-adapter.md), #1446; facade +> #1448/#1453) and its **session-aware in-process capability sources** (#1454) already provide the +> multi-provider catalog, discovery, policy runtime (schema validation, timeouts, circuit breaking, +> redaction, audit) and lifecycle this section was about to reinvent. Reverse-MCP-over-ACP contributes +> the one thing the facade lacks: a **transport for providers that cannot listen and are dialled in by +> the client**. As of 2026-07-26 the whole facade series is merged upstream (#1446/#1448/#1449/#1450/ +> #1453/#1454) and no facade PR remains open, so this section builds on a settled foundation. +> +> The adapter ADR reaches the same conclusion from the other side: its §6.2 states that the facade +> occupies "the same architectural role" this ADR assigns to OpenAB +> core… browser tools and external capabilities **share the delivery mechanism**", and its Alternative C +> rejects "a second generic inbound MCP server", i.e. **no agent-facing MCP server beyond this one +> aggregation point**. That makes retiring the bespoke per-session proxy (F5) a requirement of the +> upstream design, not merely cleanup. + +``` +Facade providers today: stdio(command) http(url) ← openab dials OUT +Reverse-MCP adds: acp-tunnel(channel_id, server_id) ← client dialled IN, openab tunnels +``` + +**Decision: expose every client-declared `type:acp` server through a single in-process +`CapabilitySource` — `AcpTunnelSource` — registered once with the facade.** + +- The seam is `openab-mcp`'s `CapabilitySource` (`provider()` / `tools(ctx)` / `call(ctx, tool, args)` / + `requires_session()`), with `SessionCtx { channel_id }` identifying the owning chat session. This is + precisely the case #1454 was built for ("browser control, where `browser.click` must reach *that + conversation's* browser tab"). +- `AcpTunnelSource` lives in the **root binary**, where the tunnel state (`AcpTunnelRegistry`) already + lives — keeping `openab-core` and `openab-gateway` sibling-independent, as with `RootBrowserTunnel`. +- `requires_session() == true`: anonymous facade clients neither discover nor can execute these tools. +- **One source, N servers.** Facade sources are registered **once at construction** + (`facade::serve_http_with(addr, sources, tokens)`; there is no runtime registration API), so a source + *per* client-declared server is not possible — and not needed. `AcpTunnelSource` fans out internally: + `tools(ctx)` returns the tools of **every** `type:acp` server declared by the client of that + `channel_id`, and `call` routes on the **`.`** prefix to the matching tunnel. Today's + names (`katashiro.click`, `katashiro.read_dom`) already carry the server segment, so this generalizes + with no renaming — but note the segment is the declared **`name`**, not the registry key: resolving + it to a tunnel goes `name` → `(channel_id, id)` via the recorded declaration (§6.1), never straight + to the key. The tool name forwarded over the tunnel stays the **full** name the server published + (`katashiro.click`), since that is what the server's own `tools/call` expects; the prefix selects the + tunnel, it is not stripped. The facade additionally publishes a `:` form to resolve + shadowing against `mcp.json` servers. +- **Adding another client-side MCP service is therefore declaration + policy work, not architecture + work.** The source must contain no browser-specific branch. + +**Session identity** is the facade's `SessionTokens`: the broker mints one opaque bearer per agent +session, injects it into that agent's process environment as `OPENAB_SESSION_TOKEN`, and revokes it +on session evict. The config file gets only the literal `${OPENAB_SESSION_TOKEN}` reference, never the +value — that is what keeps the secret out of a shared workdir; the facade resolves the header back to a `SessionCtx` per request. This **replaces** the +bespoke per-session loopback proxy, its self-minted port/bearer, and openab's own `openab-browser` +`mcp.json` write/strip logic. + +### 6.3 Tool discovery — fetch once per declared server, then serve from cache + +The facade's discovery is **pull-based**: the agent sees only `search_capabilities` / +`execute_capability` and re-reads the catalog on each call. Two consequences: + +- **`notifications/tools/list_changed` is dropped.** There is no cached client-side tool list to + invalidate, so the notification has no consumer. (The earlier draft's `list_changed` lifecycle, + debouncing included, is removed rather than deferred.) +- **Static-advertise is the right posture**, per the facade's source contract — but implemented as + *dynamically sourced, then cached*, because tools for arbitrary declared servers cannot be hardcoded: + fetch the server's real `tools/list` over its tunnel and **cache it per `(channel_id, name)`**; + serve `tools(ctx)` from that cache **regardless of current attach + state**. Backend unavailability surfaces as a **call error** ("browser not connected"), never as a + vanishing catalog entry. + +Distinguish two kinds of variation: **session scope** is legitimate; **attachment flapping** (is the +tab connected this second) must not reach the catalog. Note what `tools(ctx)` actually varies by +session is the **discovery cache**, not the declaration set — it iterates the *operator policy* map, +so a pinned server is advertised even when the client declared nothing, and a client-declared server +with no policy entry contributes nothing. An optional refinement, requiring a client wire change, is to +carry a tool manifest in the `session/new` declaration so the catalog is known without a round-trip. + +**Two layers, and the policy table is the lower one** (confirmed by review 2026-07-26; an earlier draft +of this section said an un-cached server "contributes an empty set", which contradicted the +static-advertise posture §6.4's pinned sets and D4 both depend on): + +- The §6.4 policy entry for a server is its **pre-attach seed** as well as its filter. A server the + operator has pinned advertises those tools from the moment the source is registered — it never drops + to empty just because nothing has attached yet. This is what preserves D4's "the browser tools are + discoverable before the extension connects". +- The per-`(channel_id, name)` cache holds what the server published and is read as + `fetched ∩ allowed`, **replacing the seed once a fetch succeeds**, so the catalog narrows to what the + server actually publishes (a server may publish fewer tools than the operator permitted) without ever + widening past the policy. Filtering on read rather than on write means tightening the policy takes + effect immediately instead of waiting for a cache entry to be invalidated — **caching is never itself + a grant**. +- **The cache is keyed by the declared `name`, not `server_id`** (corrected 2026-07-26; earlier drafts + of this section said `server_id`). Ids are minted per connection, so an id-keyed entry would be + orphaned by exactly the reconnect the cache exists to survive — it could never outlive the attach it + was populated from, which is the opposite of "serve regardless of current attach state". Same-name + collisions are impossible by §6.1's last-attach-wins rule, so the name is a safe key. +- Discovery is **pull-triggered**: a declared server with no cache entry has its fetch started from the + next `tools(ctx)` call, and its real set appears one discovery round later. The facade re-reads the + catalog on every call, so a single round of staleness is the entire cost, and it avoids threading an + attach hook from the gateway (which owns attach) into the root (which owns the source). +- A declared server with **no** policy entry contributes nothing — not because it is un-cached, but + because §6.4 is deny-all. Caching changes what an *allowed* server advertises; it is never itself a + grant. + +**Ordering consequence.** Because the filter is deny-all and pinned entries already carry full `Tool` +schemas, fetching cannot surface anything an operator has not already permitted — so the discovery +cache has no visible effect until the operator-facing configuration surface exists. The config surface +therefore lands **first**; the cache is what supplies real schemas once operators are allowed to list +tools by name alone. + +### 6.4 Trust — client-declared tool sets need an operator gate + +#1454 states that source registration *is* the operator's grant, and that sources therefore carry no +per-source `tool_filter`. That assumption holds for code-wired sources whose tool set the operator +chose. It **does not hold** for `AcpTunnelSource`, whose tool set is declared by a **remote client**: a +connected extension could otherwise publish arbitrary tools into the agent's capability catalog. + +Therefore this ADR requires, before the source is enabled by default: + +- an operator **allowlist** of accepted declared server names (default: `katashiro` only) — a + declaration outside it is refused by the capability source: it contributes no tools and its calls + return an error result. **Note it is not refused at declaration time** — the gateway still opens + and registers the tunnel — **and nothing is logged today** (see the gap noted below); and +- a per-declared-server **`tool_filter`**, mirroring `mcp.json` least-privilege semantics, which is + **deny-all by default**. + +> ⚠️ **Two gaps between this section and the code, recorded rather than quietly reworded.** +> +> **No logging.** This section said twice that a refused declaration and a dropped tool are "logged". +> `src/browser_source.rs` contains no logging call at all — both refusals are silent. An operator +> who mis-types a server name in `[[mcp.acp_servers]]` gets missing tools and no signal, which is +> the shape of failure this ADR spends §6.4 preventing. **Fixing this is a code change, so it is +> filed as follow-up F7 rather than made here.** +> +> **The policy runtime does not wrap in-process sources.** §6 claims the facade already provides +> "schema validation, timeouts, circuit breaking, redaction, audit" to capability sources. Only +> argument validation and audit apply on the source path (`facade.rs` `execute_capability`); +> timeout/cancellation, the circuit breaker and redaction live in `meta_tool::dispatch`, which only +> downstream `mcp.json` servers traverse. A hung browser tunnel is bounded by the tunnel's own +> timeout, not by the facade's. **Also F7.** + +The name allowlist is **not** a trust boundary on its own: the name is chosen by the same remote +client that declares the tools, so a client may declare a server under an allowlisted name — say +`katashiro` — and publish any tool set under it. Passing the allowlist therefore grants nothing by itself — the tool set is gated +separately: + +- the `katashiro` entry ships **pinned to its five known tools** (`katashiro.read_dom`, + `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, `katashiro.type`); any other tool name it + declares is dropped, so a same-name declaration cannot inject new tools (dropped silently today — + see the gap below); and +- every other allowlisted server starts **deny-all** and serves only the tools an operator has + explicitly listed. + +### 6.5 Backward compatibility & what this retires + +The browser extension is **unchanged**: it declares `{type:acp, id, name}` and serves its five DOM +tools over the tunnel. What changes is on the openab side — browser tools reach the agent through the +facade's meta-tools rather than a dedicated per-session MCP server. + +**Retired in this PR (2026-07-28):** the per-session `mcp_proxy` browser server, its port/bearer +minting, and the `openab-browser` `mcp.json` injection — along with the stdio bridge described +below. Both legacy transports are gone; the facade is the only downstream path. + +**Update — the operator call was made on 2026-07-28: bridge mode is removed.** The stdio bridge +(`OPENAB_BROWSER_MODE=bridge`, `openab browser-bridge`, the per-pod unix socket and its +process-ancestry channel resolver) existed because some CLIs preferred a stdio entry. The facade is +a loopback HTTP MCP server those CLIs read directly, so the premise no longer held. ~~Facade setup +deletes the leftover static entry, which is the only one whose exact shape proves we wrote it.~~ +That deletion was performed by editing the operator's file, and openab stopped doing that on +2026-07-30 (D-15): it authors `.openab/mcp-facade.json` and touches nothing else. **Removing a +leftover bridge entry is now the operator's step, and it is a policy question rather than tidiness +— while it is present there is a route to the browser that bypasses facade policy and audit.** The +`jq` snippet in `docs/browser-mcp-agent-setup.md` covers it, including the kiro agent-file +`@openab-browser` grant. + +This paragraph said `bridge` "degrades to `facade`", which was true for one commit. The per-session +proxy was removed hours later, taking `BrowserMode` and the whole `OPENAB_BROWSER_MODE` mechanism +with it: the variable now selects nothing at all and is merely reported at startup. + +**Open question (not decided).** Under the facade the LLM reaches a browser action via +`search_capabilities` → `execute_capability`, one hop more per turn than today's direct +`katashiro.click`. Recommendation: ship on the meta-tool path (uniform policy, one audit surface) and +revisit a per-provider "expose directly" option only if interactive browser latency proves it needed. + +### 6.6 Status — as-built vs remaining + +**As-built (`bf37d25e`, `74e23f0e`): the facade is the only transport.** `src/browser_source.rs` +implements `CapabilitySource` over the existing `AcpMcpTunnel` — `requires_session()`, static-advertise +per §6.3, tunnel failures surfaced as MCP error results — and a `FacadeRegistrar` adapts the facade's +`SessionTokens` to a `SessionTokenRegistrar` hook in core, so `openab-core` stays free of an +`openab-mcp` dependency. `write_facade_mcp_config` authors `.openab/mcp-facade.json` — the one file openab owns — containing +a **static `openab` entry** whose +`Authorization` references `${OPENAB_SESSION_TOKEN}`, so the per-session secret rides the agent's +process environment rather than a config file — which also removes the shared-workdir exposure of the +old per-session `mcp.json` write. Capabilities publish under the provider name `openab-browser` (`openab` is the mcp.json entry key, +a different thing). Both +legacy transports were removed on 2026-07-28 — bridge first, then the per-session proxy — and +`OPENAB_BROWSER_MODE` no longer selects anything; `[mcp]` is now required for browser control. +This covers §6.2's source seam and session identity for the **browser** case. + +⚠️ **Divergence to reconcile with the adapter ADR (not resolved here).** Adapter ADR §6.2 says delivery +is via ACP `session/new` `mcpServers`, and that "if a backing CLI does not honor ACP `mcpServers`, the +facade is unavailable for that CLI in the MVP **rather than falling back to editing the CLI's config +files**". The as-built `write_facade_mcp_config` does write a static entry into the CLI's config — +deliberately, because the browser path's D2 established that Cursor ignores ACP-passed `mcpServers` +(**§7.2** D2, [zed#50924](https://github.com/zed-industries/zed/issues/50924)). +~~Both positions are defensible; recording the conflict rather than silently picking a side. Owner of the +facade contract should confirm whether config-file injection is an accepted exception for CLIs that +ignore `mcpServers`, or whether Facade mode should be unavailable for them.~~ + +**RESOLVED 2026-07-30 (D-15), in favour of the adapter ADR.** openab does not edit a CLI's config +files, and does not invoke a vendor CLI to do it either. It authors `.openab/mcp-facade.json`; the +operator puts that entry in place (`kiro-cli mcp import --file … workspace` for kiro, by hand for +cursor, which has no include/extends and no launch flag). The cost is stated rather than hidden: +kiro and cursor both lose zero-config onboarding, which is wider than the cursor-only regression +first recorded. Whether Claude Code is pointed at the file with `--mcp-config` at spawn is a +separate open question — it needs spawn-time vendor identification, which this codebase has +deliberately never had. + +**Remaining to fulfil this section** — F1′, F3′, F4 and F5 all landed in #1447 and are struck +through; **F6 genuinely remains**: +- ~~**F1′ generalize the source to N client-declared servers.**~~ **Done in #1447**: the source + holds an N-entry policy map, enumerates `tunnel.servers(channel_id)` and routes on the + `.` prefix to `(channel_id, server_id)`. Browser-ness is data in `builtin_catalogs`, + not a branch. +- ~~**F3′ per-`(channel_id, name)` discovery cache**~~ **Done in #1447**: `ToolsCache` keyed + `(channel_id, declared_name)` with in-flight dedupe and pull-triggered discovery (§6.3). +- ~~**F4 trust gate** — operator allowlist + **deny-all-by-default** per-declared-server + `tool_filter` (§6.4).~~ **Done in #1447**: `ServerPolicy` / `policy_from_config` over + `[[mcp.acp_servers]]`, enforced in both `tools()` and `call()` before the tunnel is resolved; + default allowlist is `katashiro` pinned to its five tools. +- ~~**F5 cleanup** — retire the superseded per-session proxy path once Facade mode has soaked; + bridge-mode removal stays an explicit operator call (§6.5).~~ **Done 2026-07-28**: the operator + call was made and both transports were removed in this PR, so there is no soak period and no + remaining opt-out. +- **F7 close the two §6.4 gaps** — (a) log a warning when a declared server is refused by the + allowlist and when a fetched tool is dropped by the pin, since both are silent today and an + operator's only symptom is missing tools; (b) decide whether in-process capability sources should + traverse the same timeout / circuit-breaker / redaction path as downstream servers, or whether the + ADR should stop claiming they do. Both are code changes, deliberately not made in #1447. +- **F6 e2e** — browser + a second client-declared server + a host-level `mcp.json` provider coexisting, + and two concurrent sessions each reaching only their own browser. + +## 7. Worked example — browser control + +The driving consumer of this mechanism, and the design the **browser extension** implements. The +wire contract it codes against is [`mcp-over-acp-tunnel-contract.md`](../mcp-over-acp-tunnel-contract.md); +how the agent is wired to reach the tools is [`browser-mcp-agent-setup.md`](../browser-mcp-agent-setup.md). + +### 7.1 Toolset + +Five **DOM-semantic** MCP tools, served by the extension: `katashiro.read_dom` (snapshot), +`katashiro.screenshot`, `katashiro.navigate`, `katashiro.click(selector)`, +`katashiro.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) exceeded the **old** 1 MiB + cap, which is why JPEG was chosen; it fits within the 8 MiB cap that replaced it. +- The declared server name is `katashiro`; it was `browser` until 2026-07-26, when it was renamed + because Playwright MCP's `browser_*` tools sat beside it in the same catalog and the model could + not reliably tell "the user's real logged-in tab" from "a sandbox browser". + +### 7.2 Design decisions (D1–D6) + +> **Supersession notice.** D2, D3 and D5 record the **original** delivery path: a per-`acp:`-session +> loopback MCP proxy registered in each agent's native MCP config. That path is superseded by the +> facade integration in §6.2 — browser is now one session-aware `CapabilitySource`, and session +> identity is the facade's broker-minted `SessionTokens` rather than a per-session port plus a +> self-written `mcp.json` entry. They are kept because they explain *why* the shipped design looked +> the way it did. Neither `proxy` nor `bridge` is selectable any more — both were removed on +> 2026-07-28. D1, D4 and D6 carry over. + +- **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. (That direction was not green-field: `openab-core`'s + ACP connection already received `session/request_permission` from the agent and auto-replied it, so + the work was *relaying* those upstream rather than inventing the path.) +- **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 server is registered **per-agent, in that agent's native MCP config** (Cursor → + `.cursor/mcp.json`; Kiro → `.kiro/settings/mcp.json`). The **content** (an HTTP MCP entry: `url` + + `headers`) is portable across vendors. Under §6.2 this became a *static* entry referencing + `${OPENAB_SESSION_TOKEN}` instead of a freshly minted per-session URL. +- **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.** When `[mcp]` is configured the + facade listener is process-lifetime and decoupled from the extension WS — it is not + unconditionally always-on, since without `[mcp]` no listener starts at all and there is no browser + control. Given a listener, browser tools are **static-advertised** regardless of WS state; a + `tools/call` with no extension attached returns an MCP error ("browser not connected") rather than + the capability disappearing. `notifications/tools/list_changed` was designed but never implemented, + and is **dropped, not deferred** (§6.3): facade discovery is pull-based, so no cached tool list + exists for a notification to invalidate. The static-advertise posture is kept, implemented as + fetch-once-per-declared-server plus a per-`(channel_id, declared_name)` cache — keyed by NAME, not + `server_id`, so a reconnect that mints a fresh id does not lose the cache (§6.3). +- **D5 — per-session MCP server.** The pool started one loopback Streamable-HTTP MCP proxy per `acp:` + session at agent spawn, constructing the `ProxyHandler` with that session's `channel_id` so + correlation was implicit; lifetime was tied to the `AcpConnection` via a `CancellationToken` + `DropGuard`. Superseded by the single facade listener (§6.2). `proxy` mode kept this behaviour + until it too was removed on 2026-07-28, so this section is now purely historical. +- **D6 — tunnel trait in core, impl in root.** `openab-core` defines the tunnel trait (`AcpMcpTunnel`, + §6.1); 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, and is why the `CapabilitySource` in §6.2 also lives in the root binary. + +### 7.3 Runtime detail — one `katashiro.click` round-trip, and the two id spaces + +§5 gives the phase-level view; this is the message-level detail. Transports below are `proxy`-mode +(agent ↔ core over loopback HTTP); under facade mode that hop is the facade listener instead, and the +id bookkeeping is unchanged. + +``` +Participants A = agent/LLM (Cursor, MCP client) C = core (in-pod MCP server + 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 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=katashiro.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 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 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). +``` + +### 7.4 As-built history + +The OpenAB side was wired end-to-end on 2026-07-20 and live-validated on a real deployment: the full +loop (read_dom / screenshot / navigate / click / type), the side-panel status pill, and reconnect on +`session/resume`. At that point the realised path was +`agent → core per-session ProxyHandler → tunnel trait → root impl → AcpTunnelRegistry → extension`, +with per-agent config injection. `bridge` mode (stdio relay, Option C) shipped alongside and was +removed on 2026-07-28. + +The facade integration in §6 replaced the per-session proxy as the default on 2026-07-25/26 and was +live-validated the same way: with `[mcp]` enabled, `search_capabilities` returns provider +`openab-browser` carrying exactly the pinned `katashiro.*` capabilities, while anonymous facade +clients see only the two meta-tools. + +## 8. 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.~~ **Reversed:** static-advertise IS the implemented posture, + `list_changed` was dropped with no consumer (§6.3), and there is no opt-in — the source is + registered unconditionally whenever `[mcp]` is present. + +## 9. 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. + +## 10. 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. + +## 11. References + +- [Base ADR](./acp-server-websocket-base.md) · [Original proposal](./acp-server-websocket.md) · + [openab-agent MCP](./openab-agent-mcp.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/adr/oab-mcp-adapter.md b/docs/adr/oab-mcp-adapter.md index b8cbf73b9..17669b26a 100644 --- a/docs/adr/oab-mcp-adapter.md +++ b/docs/adr/oab-mcp-adapter.md @@ -443,7 +443,7 @@ listen = "127.0.0.1:8848" # loopback only; this is the default CLI the operator points at it. This is the same architectural role that -[`acp-server-websocket-mcp-browser.md`](./acp-server-websocket-mcp-browser.md) +[`acp-server-websocket-reverse-mcp.md`](./acp-server-websocket-reverse-mcp.md) assigns to OpenAB core: an MCP proxy/aggregator between the agent and upstream capability sources, delivered to the agent via `mcpServers`. The OAB MCP Facade is that inbound component for external service capabilities; browser tools and @@ -679,7 +679,7 @@ escape hatch for operators who intentionally configure a server outside OAB. Rejected as unnecessary for this MVP. The OAB MCP facade is the intentionally scoped inbound server for the coding agent, filling the MCP proxy/aggregator -role that [`acp-server-websocket-mcp-browser.md`](./acp-server-websocket-mcp-browser.md) +role that [`acp-server-websocket-reverse-mcp.md`](./acp-server-websocket-reverse-mcp.md) already assigns to OpenAB core (§6.2). A separate generic server for arbitrary OAB workflows would require another authentication, tenancy, and authorization design and would blur the two-method capability boundary. diff --git a/docs/browser-mcp-agent-setup.md b/docs/browser-mcp-agent-setup.md new file mode 100644 index 000000000..e7b68bd82 --- /dev/null +++ b/docs/browser-mcp-agent-setup.md @@ -0,0 +1,180 @@ +# Browser MCP — how the agent gets the browser tools + +The browser MCP server exposes five DOM-semantic tools — +`katashiro.read_dom`, `katashiro.screenshot`, `katashiro.navigate`, `katashiro.click`, +`katashiro.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. + +There is **one** transport: the OAB MCP Facade. The per-session `proxy` and the `openab +browser-bridge` stdio relay both existed before it and have been removed, along with the +`OPENAB_BROWSER_MODE` variable that selected between them. See [Removed +transports](#removed-transports) if you are upgrading from either. + +**Browser control now requires `[mcp]` in `config.toml`.** This is a breaking change. Without that +section there is no browser control — openab does **not** start a listener you did not configure. + +openab reports which of the two you are in once at startup, so it is never something you have to +infer from tools that do not appear: + +``` +INFO browser control: enabled via the OAB MCP Facade ([mcp] configured) +INFO browser control: unconfigured — no [mcp] section in config.toml, so browser tools are + unavailable and nothing was started. Add [mcp] to enable them. +``` + +> ⚠️ **A leftover `openab-browser` entry from either old transport can still sit in your agent's +> `mcp.json`,** and the two are handled differently. +> +> **You have to remove it yourself. openab no longer edits your MCP config at all**, so neither +> entry is cleaned up for you. +> +> Earlier versions deleted the **bridge** entry on the next session. That cleanup worked by +> editing your file, and openab no longer does that — it authors `.openab/mcp-facade.json` and +> nothing else. **This is worth acting on rather than ignoring:** a leftover `openab-browser` +> entry is a *working* path to the browser that does not pass through facade policy or the audit +> trail, so until you remove it the allowlist in `[[mcp.acp_servers]]` is not the only way in. The +> bridge entry also names a subcommand that no longer exists, so the agent's MCP client will fail +> to start it every session. +> +> The **proxy** entry was never removed automatically either: its url and bearer were minted per +> session and never recorded, so under that key openab cannot tell your server from its own. +> +> Remove both, and the kiro agent-file grant that made the bridge entry callable: +> +> These delete the entry only when it is **byte-identical to the bridge entry openab wrote** +> (`{"command":"openab","args":["browser-bridge"]}`). That exact shape is the only proof it is ours +> rather than a server you configured under the same key — the automation this replaces used the +> same test, and a manual step should not be more destructive than the automation it stands in for. +> +> ```sh +> # edits in place; check the diff before trusting it +> BRIDGE='{"command":"openab","args":["browser-bridge"]}' +> for f in "$HOME/.cursor/mcp.json" "$HOME/.kiro/settings/mcp.json"; do +> [ -f "$f" ] && jq --argjson bridge "$BRIDGE" \ +> 'if .mcpServers["openab-browser"] == $bridge then del(.mcpServers["openab-browser"]) else . end' \ +> "$f" > "$f.tmp" && mv "$f.tmp" "$f" +> done +> # kiro agent files carry a separate default-deny grant; the entry stays reachable while it is listed +> for f in "$HOME"/.kiro/agents/*.json; do +> [ -f "$f" ] && jq --argjson bridge "$BRIDGE" \ +> 'if .mcpServers["openab-browser"] == $bridge +> then del(.mcpServers["openab-browser"]) +> | .allowedTools = ((.allowedTools // []) - ["@openab-browser"]) +> else . end' \ +> "$f" > "$f.tmp" && mv "$f.tmp" "$f" +> done +> ``` +> +> If your entry under that key is a *different* shape, these leave it alone — openab cannot tell it +> from a server of yours, which is why the proxy entry was never removed automatically either. + +--- + +## Facade mode (default) + +Browser tools are a **session-aware in-process capability source** of the +[OAB MCP Facade](./oab-mcp-facade.md) — the same aggregation point that serves every other +provider in `mcp.json`. Enable the facade and it works: + +```toml +# config.toml +[mcp] +listen = "127.0.0.1:8848" + +# Operator gate for client-declared type:acp servers (reverse-MCP ADR §6.4). +# Omit entirely to keep the built-in default: `katashiro` only, pinned to its five tools. +[[mcp.acp_servers]] +name = "katashiro" +tools = ["katashiro.read_dom","katashiro.screenshot","katashiro.navigate","katashiro.click","katashiro.type"] +``` + +- **One listener** — the facade's. No per-session ports and no per-session config rewrites. +- **openab writes ONE file, and it is not yours.** It authors `/.openab/mcp-facade.json` + and never reads, merges into, or writes `.cursor/mcp.json`, `.kiro/settings/mcp.json` or a kiro + agent file. Putting that entry in front of your agent is your step — see + [Wiring it up](#wiring-it-up) below. +- **Identity** — the pool mints one token per chat session and injects it into the agent process as + `OPENAB_SESSION_TOKEN`. The entry is **static**, and references the variable rather than + embedding a secret: + + ```json + { + "mcpServers": { + "openab": { + "url": "http://127.0.0.1:8848/mcp", + "headers": { "Authorization": "Bearer ${OPENAB_SESSION_TOKEN}" } + } + } + } + ``` + + Tokens are revoked on session evict; calls route to that session's browser over the same + `channel_id` tunnel. Because the secret rides the process environment, it never lands in a file + a shared workdir could expose. +- **Discovery** — the agent does **not** see `katashiro.*` in its own `tools/list`. It sees the + facade's two meta-tools and finds browser tools through `search_capabilities`, then runs them via + `execute_capability`, alongside every other facade capability. A session-bound source is invisible + to anonymous facade clients — no token, no discovery, no execution. + +### Wiring it up + +openab authors `/.openab/mcp-facade.json` and stops there. It does not run your agent's +CLI for you either — configuring your own agent stays your decision. + +| Agent | What you do | +|---|---| +| **kiro** | `kiro-cli mcp import --file /.openab/mcp-facade.json workspace` — the vendor performs the merge with its own semantics. Do **not** pass `--force`: it would overwrite a same-named server of yours. | +| **cursor** | No import mechanism exists — there is no include/extends and no launch flag. Paste the `mcpServers` object below into `.cursor/mcp.json` yourself. | +| **any other MCP-capable CLI** | Point it at `http://127.0.0.1:8848/mcp` with the bearer header above. Because the entry is static, a hand-written one keeps working — the practical difference from proxy mode, where the endpoint was per-session ephemeral and a hand-written entry went stale on the next session. | + +The startup log prints the resolved path and these commands, so the value is not guessed from this +page. + +### Verify + +```sh +# facade listening? +grep "OAB MCP facade listening" + +# the file openab authored (the only one it writes) +cat "$HOME/.openab/mcp-facade.json" + +# and whether YOU have put it in front of the agent yet +cat "$HOME/.cursor/mcp.json" # Cursor — you paste it here +cat "$HOME/.kiro/settings/mcp.json" # Kiro — written by `kiro-cli mcp import` + +# does the catalog contain the browser capabilities for a session-bound client? +# -> call search_capabilities from the agent; expect provider "openab-browser" +# with exactly the pinned katashiro.* tools +``` + +Gateway log confirms the extension side: `ACP: browser tunnel registered — extension attached`. + +--- + +## Removed transports + +Both legacy transports are gone. This section is kept as a migration note, not as documentation of +anything you can still turn on. + +**`proxy` mode** terminated the gateway↔extension tunnel at a per-session loopback MCP server and +rewrote the agent CLI's MCP config with a freshly minted url and bearer on every session. It is +removed: the per-session server, its bearer, and the per-session config write and cleanup. + +**`bridge` mode (Option C)** ran a per-pod unix-socket server plus an `openab browser-bridge` +stdio-MCP relay that resolved its session channel by walking `/proc`. It is removed: the +subcommand, the socket server, the ancestry resolver and the static config entry. + +`OPENAB_BROWSER_MODE` no longer selects anything and can be unset. If it is still set to any value, +openab logs one warning at startup naming the value and reporting what is actually in force — +`facade` when `[mcp]` is configured, `disabled` when it is not. + +**What to do when upgrading:** configure `[mcp]` in `config.toml`. Without it there is no browser +control at all — nothing is auto-started, because starting a listener you did not ask for is the +coupling this design deliberately avoids. A leftover `openab-browser` bridge entry is deleted for +you on the next session; a leftover proxy entry is not, for the ownership reason given above. + +Proxy mode also only ever auto-wrote two of the five CLI variants (Cursor and Kiro); the other +three had to be configured by hand. The facade's static entry removes that gap rather than +extending it. diff --git a/docs/mcp-over-acp-tunnel-contract.md b/docs/mcp-over-acp-tunnel-contract.md new file mode 100644 index 000000000..b500980d3 --- /dev/null +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -0,0 +1,191 @@ +# 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 (today: the OAB MCP Facade and the agent subprocess) is out of scope +for the extension and may change without affecting this contract — as it already has: the +per-session MCP proxy and the stdio bridge that this line used to name are both gone. + +## 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": "katashiro" } + ] } } +``` + +- `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. + These travel in **both** directions. The extension sends them upward for server-originated MCP + notifications; the gateway sends them downward, and the extension must forward them to its inner + MCP server exactly as it forwards requests — see `notifications/initialized` below. + +### Lifecycle: the gateway initializes before it asks for anything + +Immediately after `mcp/connect` the gateway performs the MCP handshake on the new connection: + +1. `mcp/message` **request** carrying inner `initialize` — the extension forwards it to its MCP + server and returns the server's `InitializeResult`. +2. `mcp/message` **notification** (no outer `id`) carrying inner `notifications/initialized` — the + extension forwards it to the server and replies with nothing. + +Only then does the gateway send `tools/list` or `tools/call`. + +**A server that fails `initialize` is not registered**, so its tools never become reachable and no +later call is attempted against it. Forwarding the notification matters as much as answering the +request: MCP servers are entitled to reject work until they have received `initialized`, and an +extension that swallows it leaves its own server permanently un-initialized while the gateway +believes the handshake completed. + +Inner MCP methods the extension must handle as a server: +- `initialize` → forward to the server; return its `InitializeResult`. +- `notifications/initialized` → forward to the server; no reply. +- `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 | +|---|---|---| +| `katashiro.click` | `{ "selector": string }` | click the element matching the CSS selector | +| `katashiro.read_dom` | `{ "selector"?: string }` | return a DOM snapshot (optionally scoped) | +| `katashiro.navigate` | `{ "url": string }` | navigate the active tab to the URL | +| `katashiro.type` | `{ "selector": string, "text": string }` | type text into the matched element | +| `katashiro.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. Cancellation and limits + +The gateway bounds how long it will wait for any tunnelled request. When that bound is reached it +stops waiting **and tells you**, so the extension is never left working on a request nobody reads. + +### `mcp/cancel` (gateway → extension, notification) + +```json +{ "jsonrpc": "2.0", "method": "mcp/cancel", "params": { "requestId": 42 } } +``` + +`requestId` is the **outer ACP frame `id`** of the request being abandoned — the same id you would +have replied to. There is no `id` on this frame: it is a notification, so no reply is owed and none +is read. + +On receipt, stop the work and release whatever it holds (the tab, the navigation, the script). A +late reply to a cancelled `requestId` is discarded, so answering costs the gateway nothing and buys +you nothing. Cancellation is best-effort in one direction only: if the socket is already gone the +notification is simply never delivered, so do not treat its absence as "keep going". + +### Limits + +| Limit | Value | Meaning | +|---|---|---| +| Tunnel request timeout | `[mcp] tunnel_timeout_seconds`, default **180s** | one `mcp/message` request | +| Connect / handshake timeout | 30s | `mcp/connect` and the `initialize` that follows it | +| Servers per session | 8 (`MAX_ACP_SERVERS_PER_SESSION`) | `type:acp` entries accepted per `session/new` | +| In-flight establishes | 64 (`MAX_INFLIGHT_ESTABLISHES`) | concurrent tunnel setups per connection | +| Any inbound frame | 8 MiB (`MAX_FRAME_BYTES`) | checked **before** parsing. Exceeding it **closes the connection**, whatever the frame is — an unparseable frame has no recoverable `id` to answer | +| A `method` frame that is a **request** | 1 MiB (`MAX_NON_TUNNEL_FRAME_BYTES`) | checked **after** parsing. Answered with an error, **connection kept** | +| A `method` frame that is a **notification** | 1 MiB (same check) | **silently dropped.** Not a limitation — the gateway could answer with a null id and deliberately does not, because replying to a notification violates JSON-RPC. The refusal is required, and the cost is that you get no signal | + +Three failure modes, and the line is drawn by **which limit you crossed**, not by whether the frame +was a request or a response. The 8 MiB allowance exists for tool results, which arrive as responses +(`id`, no `method`); method-bearing frames are held at 1 MiB so that allowance cannot be reused to +hold prompt text. The case worth planning for is the third: an oversized notification produces no +error and no acknowledgement of any kind, so a sender that assumes silence means success will lose +data without noticing. + +The request timeout is operator-configurable because openab is the requester here and the peer is +an extension it neither ships nor controls. It sits beneath the ACP idle timeout, so raising it +past that bound moves the wall rather than removing it. + +### `session/resume` withdraws what it does not re-declare + +A resume re-presents the client's **whole** declaration set. Any `type:acp` server registered for +that session and absent from the new set is treated as withdrawn: its tunnel is retired and its +connection receives an `mcp/disconnect`. Re-declare every server you still want, on every resume — +including across a reconnect. + +## 8. 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/docs/oab-mcp-facade.md b/docs/oab-mcp-facade.md index 7fbee8409..8d5ca6c5f 100644 --- a/docs/oab-mcp-facade.md +++ b/docs/oab-mcp-facade.md @@ -21,7 +21,7 @@ flowchart LR end subgraph runtime["Shared MCP runtime (openab-mcp)"] - policy["tool_filter (least-privilege)
JSON-Schema arg validation
timeouts · circuit breaker
secret redaction · audit log"] + policy["tool_filter (least-privilege)
JSON-Schema arg validation
timeouts
secret redaction · audit log"] end adapter["gmail-native adapter
loopback :8850/mcp"] @@ -81,8 +81,8 @@ connections stay in `mcp.json` — `[mcp]` carries listener settings only by each server's `tool_filter` **before** anything reaches the agent. 2. `execute_capability` (`name` + `arguments`) — execute an exact capability returned by discovery. Arguments are validated against the capability's - JSON Schema before dispatch; timeouts, circuit breaking, and secret - redaction apply on the same path the native agent uses. + JSON Schema before dispatch; timeouts and secret redaction apply on the + same path the native agent uses. An audit line is logged for every dispatch (tool name + args SHA-256 — never plaintext arguments): @@ -92,6 +92,19 @@ mcp.audit: mcp call_tool entry server="gmail" tool="search_threads" args_sha256= mcp.audit: mcp call_tool exit server="gmail" tool="search_threads" … outcome="ok" ``` +> **`mcp.audit` must be enabled in `RUST_LOG`, or these lines are silently dropped.** +> The audit events use `mcp.audit` as a *bare* tracing target — it is not under the +> `openab` prefix, so a filter like `RUST_LOG=openab=debug,openab_agent=debug` matches +> nothing for them and no audit line is ever emitted. Nothing warns you that auditing +> is effectively off. Name the target explicitly: +> +> ``` +> RUST_LOG=openab=debug,openab_agent=debug,mcp.audit=info +> ``` +> +> Any filter that raises the global default (`RUST_LOG=info`, `RUST_LOG=debug`, …) also +> emits them. + ## Client registration examples Kiro CLI (`~/.kiro/settings/mcp.json`, or the agent file — see gotcha): diff --git a/scripts/acp-ws-smoke.py b/scripts/acp-ws-smoke.py index cfc313edd..2851c3ead 100755 --- a/scripts/acp-ws-smoke.py +++ b/scripts/acp-ws-smoke.py @@ -277,16 +277,63 @@ async def section_lifecycle(): except Exception as e: # noqa: BLE001 record("life", False, "valid token via Authorization: Bearer header accepted", repr(e)) - # oversized frame → the server closes the connection (no fabricated JSON-RPC response) + # There are TWO ceilings with DIFFERENT outcomes, and this used to cover neither. + # + # It sent 1 MiB + 64 bytes against a comment reading "> MAX_FRAME_BYTES (1 MiB)". The transport + # ceiling is 8 MiB, so that payload stopped reaching it — and being a bare "x" string rather + # than JSON, the close it still saw came from the parse path. Green, wrong mechanism. + # + # Both cases are also covered by Rust WS integration tests, which the gate runs; these are the + # end-to-end versions against a real deployment. + + # 1. Over the TRANSPORT ceiling (8 MiB) → connection closes, no JSON-RPC response. The frame + # cannot be parsed, so the server cannot tell request from notification or recover an id, + # and answering could mean answering a notification. async with await try_connect(TOKEN) as ws: - await ws.send("x" * ((1 << 20) + 64)) # > MAX_FRAME_BYTES (1 MiB) + oversized = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "pad": "x" * ((8 << 20) + 64)}) + await ws.send(oversized) try: await asyncio.wait_for(ws.recv(), timeout=8) - record("life", False, "oversized frame closes the connection", "got a frame back") + record("life", False, "frame over the transport ceiling closes the connection", + "got a frame back") except ConnectionClosed: - record("life", True, "oversized frame closes the connection") + record("life", True, "frame over the transport ceiling closes the connection") except asyncio.TimeoutError: - record("life", False, "oversized frame closes the connection", "no close within 8s") + record("life", False, "frame over the transport ceiling closes the connection", + "no close within 8s") + + # 2. Over the PER-KIND ceiling (1 MiB for anything carrying a `method`) but under the transport + # ceiling → answered with an error, and the connection SURVIVES. The 8 MiB allowance is for + # tunnel results, which are responses; letting method frames use it would turn the allowance + # into a way to park MAX_INFLIGHT_PROMPTS x 8 MiB of prompt text per connection. + async with await try_connect(TOKEN) as ws: + big_method = json.dumps({"jsonrpc": "2.0", "id": 7, "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}, + "pad": "y" * ((1 << 20) + 4096)}}) + await ws.send(big_method) + try: + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=8)) + if resp.get("error") is None: + record("life", False, "oversized method frame is refused with an error", + f"no error in {resp}") + else: + record("life", True, "oversized method frame is refused with an error") + # The discriminating half: a server that closed instead would pass the check above + # only by never getting here. + await ws.send(json.dumps({"jsonrpc": "2.0", "id": 8, "method": "initialize", + "params": {"protocolVersion": 1, + "clientCapabilities": {}}})) + after = json.loads(await asyncio.wait_for(ws.recv(), timeout=8)) + record("life", after.get("result") is not None, + "connection survives a per-kind refusal", + "" if after.get("result") is not None else f"got {after}") + except ConnectionClosed: + record("life", False, "oversized method frame is refused with an error", + "connection closed — that is the transport-ceiling behaviour, not this one") + except asyncio.TimeoutError: + record("life", False, "oversized method frame is refused with an error", + "no response within 8s") # session/cancel → the in-flight prompt ends with stopReason:"cancelled" async with await try_connect(TOKEN) as ws: @@ -314,6 +361,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 +431,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 +442,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_source.rs b/src/browser_source.rs new file mode 100644 index 000000000..28c0b1d2b --- /dev/null +++ b/src/browser_source.rs @@ -0,0 +1,926 @@ +//! ACP-tunnel capability source (Facade mode): serves **client-declared** MCP +//! servers 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 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). +//! +//! **One source, N servers** (ADR §6.2). Facade sources are registered once at +//! construction, so there is no source-per-declared-server; this one fans out +//! internally, routing the `.` prefix to the right tunnel. +//! +//! **The prefix is a declared `name`, not a registry key** (ADR §6.1). A +//! declaration is `{type:"acp", id, name}`; the reference client mints `id` as +//! a fresh UUID per connection while `name` (`"katashiro"`) is stable. Routing +//! therefore resolves `name` → `(channel_id, id)` through the tunnel +//! registry's enumeration, and forwards the **full** published tool name +//! (`katashiro.click`) — the prefix selects the tunnel, it is not stripped, +//! because the server's own `tools/call` expects its full name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use openab_core::mcp_proxy::AcpMcpTunnel; +use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use openab_mcp::rmcp::model::Tool; +use serde_json::{json, Map, Value}; + +/// Trust policy for one declared server name (ADR §6.4). +/// +/// #1454 treats source registration as the operator's grant, so facade sources +/// carry no `tool_filter`. That holds for code-wired sources whose tool set the +/// operator chose; it does **not** hold here, where the tool set is declared by +/// a remote client. The declared *name* is chosen by that same client, so +/// passing the allowlist grants nothing by itself — the tool set is gated +/// separately, and **deny-all is the default**: a name with no policy entry is +/// refused outright, and a listed name may only publish the tools pinned here. +#[derive(Clone)] +struct ServerPolicy { + /// Exactly the tool names this server may publish. Anything else it declares + /// is dropped from the catalog and refused on call, so a client cannot + /// inject new tools by re-declaring a trusted name. + allowed: HashSet, + /// **Pre-attach seed** (ADR §6.3): full `Tool` values advertised from the + /// moment the source is registered, so a permitted server is discoverable + /// before its client ever attaches — the property D4 relies on. Known + /// servers seed from the built-in catalog; a server an operator admitted by + /// name alone seeds empty until discovery caching supplies real schemas. + /// Always a subset of `allowed`: the seed narrows with the policy, never + /// past it. + seed: Vec, +} + +/// Built-in tool catalogs, by declared server name. The only source of full +/// `Tool` values (schemas included) before a server has been queried. +/// +/// Browser-ness lives here as **policy data**, deliberately not as a branch in +/// the routing code — that is what keeps the source generic (ADR §6.2: "the +/// source must contain no browser-specific branch"). +fn builtin_catalogs() -> HashMap> { + HashMap::from([( + "katashiro".to_string(), + openab_core::mcp_proxy::browser_tools(), + )]) +} + +/// The default policy when an operator has configured nothing: `katashiro` pinned +/// to its five known tools, every other declared name denied. +fn default_policy() -> HashMap { + builtin_catalogs() + .into_iter() + .map(|(name, tools)| { + let policy = ServerPolicy { + allowed: tools.iter().map(|t| t.name.to_string()).collect(), + seed: tools, + }; + (name, policy) + }) + .collect() +} + +/// Build the policy from the operator's `[[mcp.acp_servers]]` entries (§6.4). +/// +/// Each entry admits one declared **name** and lists exactly the tools it may +/// publish; deny-all means an entry with no tools admits the server but grants +/// nothing. Names are enough — schemas are taken from the built-in catalog +/// where one exists, narrowed to what the operator permitted, so an operator +/// can *restrict* a known server without restating its schemas. A permitted +/// tool with no known schema simply has no seed yet and appears once discovery +/// caching can fetch it. +fn policy_from_config(entries: &[openab_core::config::AcpServerPolicy]) -> HashMap { + let mut catalogs = builtin_catalogs(); + entries + .iter() + .map(|entry| { + let allowed: HashSet = entry.tools.iter().cloned().collect(); + let seed = catalogs + .remove(&entry.name) + .unwrap_or_default() + .into_iter() + .filter(|t| allowed.contains(t.name.as_ref())) + .collect(); + (entry.name.clone(), ServerPolicy { allowed, seed }) + }) + .collect() +} + +/// Facade capability source backed by MCP-over-ACP tunnels to client-declared +/// MCP servers. +pub struct AcpTunnelSource { + tunnel: Arc, + /// Trust policy keyed by declared server **name** (§6.4). Keyed by name + /// rather than id because ids are per-connection UUIDs — an allowlist of + /// ids could never match twice. + policy: HashMap, + /// Discovered tool sets, keyed `(channel_id, declared_name)` (§6.3). + /// + /// Keyed by **name**, not by `server_id` as an earlier draft of §6.3 said: + /// the client mints a fresh id per connection, so an id-keyed entry would be + /// orphaned by the very reconnect the cache exists to paper over. Keying by + /// name is what lets a discovered set survive a reconnect, which is the + /// cache's entire purpose. + /// + /// Holds what the server published; the policy filter is applied on read, so + /// tightening the policy takes effect immediately without invalidating it. + cache: ToolsCache, + /// Discovery fetches currently in flight, so repeated discovery rounds do + /// not pile up duplicate `tools/list` requests on one tunnel. + inflight: InflightKeys, +} + +/// Tool sets discovered from client servers, keyed `(channel_id, declared_name)`. +type ToolsCache = Arc>>>; + +/// `(channel_id, declared_name)` pairs with a discovery fetch already running. +type InflightKeys = Arc>>; + +/// Sort a tool set by name: the catalog is user-visible and `HashMap` iteration +/// order would otherwise reshuffle it between runs. +fn sorted(tools: impl IntoIterator) -> Vec { + let mut out: Vec = tools.into_iter().collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +impl AcpTunnelSource { + /// Source gated by the operator's `[[mcp.acp_servers]]` entries. An empty + /// list keeps the built-in default rather than denying everything, so + /// omitting the section leaves browser control working as before; an + /// operator who writes any entry takes over the allowlist wholesale. + pub fn with_config( + tunnel: Arc, + entries: &[openab_core::config::AcpServerPolicy], + ) -> Self { + let policy = if entries.is_empty() { + default_policy() + } else { + policy_from_config(entries) + }; + Self { + tunnel, + policy, + cache: Arc::new(std::sync::Mutex::new(HashMap::new())), + inflight: Arc::new(std::sync::Mutex::new(HashSet::new())), + } + } + + /// Fetch one server's real `tools/list` in the background and cache it + /// (§6.3). Cheap to call on every discovery round: it is a no-op while a + /// fetch for the same `(channel, name)` is already in flight. + /// + /// What lands in the cache is what the server published — **unfiltered**. + /// The policy is applied when the catalog is read, so caching is never + /// itself a grant: an entry for a tool the operator did not permit stays + /// invisible and uncallable. + fn spawn_discovery(&self, channel_id: &str, name: &str, server_id: &str) { + // Outside a tokio runtime (unit tests calling tools() directly) there is + // nothing to spawn onto; discovery is best-effort, so skip quietly. + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return; + }; + let key = (channel_id.to_string(), name.to_string()); + { + let mut inflight = self.inflight.lock().unwrap_or_else(|e| e.into_inner()); + if !inflight.insert(key.clone()) { + return; + } + } + let tunnel = self.tunnel.clone(); + let cache = self.cache.clone(); + let inflight = self.inflight.clone(); + let server_id = server_id.to_string(); + let channel = key.0.clone(); + handle.spawn(async move { + let fetched = tunnel + .call(&channel, &server_id, "tools/list", None) + .await + .ok() + .and_then(|v| serde_json::from_value::>(v.get("tools")?.clone()).ok()); + if let Some(tools) = fetched { + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.clone(), tools); + } + // Always clear the flag: a failed fetch must be retryable on the + // next discovery round, not wedged as permanently "in flight". + inflight + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key); + }); + } + + /// Split a published tool name into `(server_name, full_tool_name)`. The + /// full name is returned deliberately: the prefix picks the tunnel, and the + /// server's own `tools/call` expects the name it published. + fn split_prefix(tool: &str) -> Option<(&str, &str)> { + let (server, _rest) = tool.split_once('.')?; + if server.is_empty() { + return None; + } + Some((server, tool)) + } + + /// An error *result* (not a dispatch fault): the agent gets an actionable + /// message and the facade's own dispatch is considered to have succeeded — + /// matching how tunnel unavailability is reported. + fn error_result(message: String) -> (Value, bool) { + ( + json!({ "content": [{ "type": "text", "text": message }], "isError": true }), + true, + ) + } +} + +#[async_trait::async_trait] +impl CapabilitySource for AcpTunnelSource { + fn provider(&self) -> &str { + "openab-browser" + } + + /// The advertised catalog: for every allowlisted server, its discovered + /// tool set if one has been cached, else its policy seed. + /// + /// Deliberately **not** intersected with the tunnels currently attached. + /// Attachment flapping must not reach the catalog (§6.3) — a tab that is + /// closed for a second must not make the tools vanish and reappear — so + /// availability is reported by `call` ("browser not connected"), never by a + /// shrinking tool list. This keeps the pre-attach discovery behaviour the + /// static-advertise design (D4) already had. + /// + /// Discovery is *pull*-triggered rather than attach-triggered: a declared + /// server with no cache entry yet gets a background `tools/list` fetch + /// kicked off here, and its real set appears on the next discovery round. + /// The facade re-reads the catalog on every call, so one round of staleness + /// is the whole cost, and it avoids threading an attach hook from the + /// gateway (which owns attach) into the root (which owns this source). + fn tools(&self, ctx: Option<&SessionCtx>) -> Vec { + // Anonymous clients never reach here in practice (`requires_session`), + // and with no channel there is nothing to discover — serve the seeds. + let Some(ctx) = ctx else { + return sorted(self.policy.values().flat_map(|p| p.seed.iter().cloned())); + }; + + + let mut out: Vec = Vec::new(); + for (name, policy) in &self.policy { + let cached = self + .cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&(ctx.channel_id.clone(), name.clone())) + .cloned(); + match cached { + // `fetched ∩ allowed` — the cache narrows the catalog to what the + // server actually publishes and can never widen it past policy. + Some(fetched) => out.extend( + fetched + .into_iter() + .filter(|t| policy.allowed.contains(t.name.as_ref())), + ), + None => { + // Nothing discovered yet: advertise the seed (never empty out a + // seeded server) and kick off the fetch if it is attached. + out.extend(policy.seed.iter().cloned()); + // Resolved through the same call routing uses, not through a snapshot of + // `servers()`. Collecting that into a map keeps whichever entry the iterator + // yields LAST, and registry iteration is not ordered by generation — so in a + // duplicate-name state discovery could fetch its catalog from one tunnel while + // calls went to another, advertising tools the serving tunnel does not have. + // Uniqueness makes that unreachable today; using one resolution makes it + // unreachable by construction. + if let Some(server_id) = self.tunnel.resolve_by_name(&ctx.channel_id, name) { + self.spawn_discovery(&ctx.channel_id, name, &server_id); + } + } + } + } + sorted(out) + } + + 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!("ACP tunnel capabilities require a session token"))?; + + let Some((server_name, full_tool)) = Self::split_prefix(tool) else { + return Ok(Self::error_result(format!( + "malformed tool name {tool:?}: expected ." + ))); + }; + + // §6.4 gate, in two independent steps: the name must be allowlisted, + // and the tool must be one this server is pinned to. The second check + // is what stops a client injecting tools by re-declaring a trusted + // name, so it is not redundant with the first. + let Some(policy) = self.policy.get(server_name) else { + return Ok(Self::error_result(format!( + "server {server_name:?} is not in the operator allowlist" + ))); + }; + if !policy.allowed.contains(full_tool) { + return Ok(Self::error_result(format!( + "tool {full_tool:?} is not permitted for server {server_name:?}" + ))); + } + + // Resolve the declared name to the tunnel's registry key (§6.1). Delegated rather than + // done here: enumerating and taking the first name match is only correct while same-name + // entries cannot coexist, and that uniqueness is maintained in the gateway, out of this + // file's sight. A "take the first" caller does not fail when it breaks — it silently routes + // to an arbitrary tunnel. The resolution now lives beside the eviction that guarantees it. + let Some(server_id) = self.tunnel.resolve_by_name(&ctx.channel_id, server_name) else { + return Ok(Self::error_result(format!( + "{server_name} not connected: open the OpenAB side panel in your browser" + ))); + }; + + let params = json!({ "name": full_tool, "arguments": args }); + match self + .tunnel + .call(&ctx.channel_id, &server_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 (extension detached mid-call, session gone): + // an error result, not a dispatch fault. + Err(msg) => Ok(Self::error_result(msg)), + } + } + + 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, token: &str) { + // Token-specific: a late teardown must not strip the successor's live token (R1). + self.0.revoke_token(token) + } +} + +#[cfg(test)] +mod tests { + use super::{AcpTunnelSource, CapabilitySource, SessionCtx}; + use openab_core::mcp_proxy::AcpMcpTunnel; + use std::collections::HashSet; + use serde_json::{json, Map, Value}; + use std::sync::Arc; + + /// Tunnel double: reports declared servers, records forwarded `tools/call`s, + /// and can answer or fail `tools/list`. + struct FakeTunnel { + servers: std::sync::Mutex>, + forwarded: std::sync::Mutex>, + tools_list: std::sync::Mutex>>, + tools_list_calls: std::sync::atomic::AtomicUsize, + } + + impl FakeTunnel { + fn with(servers: &[(&str, &str)]) -> Arc { + Arc::new(Self { + servers: std::sync::Mutex::new( + servers + .iter() + .map(|(n, i)| (n.to_string(), i.to_string())) + .collect(), + ), + forwarded: std::sync::Mutex::new(Vec::new()), + tools_list: std::sync::Mutex::new(None), + tools_list_calls: std::sync::atomic::AtomicUsize::new(0), + }) + } + + /// Make `tools/list` answer with these tool names. + fn set_tools_list(&self, names: &[&str]) { + *self.tools_list.lock().unwrap() = + Some(names.iter().map(|n| n.to_string()).collect()); + } + + /// Make `tools/list` fail (no answer configured). + fn fail_tools_list(&self) { + *self.tools_list.lock().unwrap() = None; + } + + fn tools_list_calls(&self) -> usize { + self.tools_list_calls + .load(std::sync::atomic::Ordering::SeqCst) + } + + /// Simulate a server going away (tab closed, client disconnected). + fn detach(&self, name: &str) { + self.servers.lock().unwrap().retain(|(n, _)| n != name); + } + + /// Simulate a reconnect: same declared name, freshly minted id. + fn reattach_as(&self, name: &str, new_id: &str) { + let mut servers = self.servers.lock().unwrap(); + servers.retain(|(n, _)| n != name); + servers.push((name.to_string(), new_id.to_string())); + } + } + + #[async_trait::async_trait] + impl AcpMcpTunnel for FakeTunnel { + /// The double holds a controlled list, so matching it directly is honest here — the + /// reason the real implementation delegates to the gateway is that IT cannot rank two + /// same-name tunnels, not that matching is wrong in principle. + fn resolve_by_name(&self, _channel_id: &str, server_name: &str) -> Option { + self.servers + .lock() + .unwrap() + .iter() + .find(|(name, _)| name == server_name) + .map(|(_, id)| id.clone()) + } + + async fn call( + &self, + channel_id: &str, + server_id: &str, + method: &str, + params: Option, + ) -> Result { + if method == "tools/list" { + self.tools_list_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let Some(names) = self.tools_list.lock().unwrap().clone() else { + return Err("tools/list unavailable".into()); + }; + let tools: Vec = names + .iter() + .map(|n| json!({ "name": n, "inputSchema": { "type": "object" } })) + .collect(); + return Ok(json!({ "tools": tools })); + } + self.forwarded.lock().unwrap().push(( + channel_id.to_string(), + server_id.to_string(), + params.unwrap_or(Value::Null), + )); + Ok(json!({ "content": [{ "type": "text", "text": "ok" }] })) + } + } + + fn ctx() -> SessionCtx { + SessionCtx { + channel_id: "acp_x".into(), + } + } + + #[test] + fn catalog_is_the_pinned_policy_set_and_survives_detachment() { + // No tunnels attached at all: the catalog must NOT shrink (§6.3 — attachment + // flapping stays out of discovery; unavailability is reported on call). + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[]), &[]); + let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); + assert_eq!( + names, + [ + "katashiro.click", + "katashiro.navigate", + "katashiro.read_dom", + "katashiro.screenshot", + "katashiro.type" + ], + "the pinned browser set is advertised regardless of attach state" + ); + } + + #[tokio::test] + async fn call_routes_the_name_prefix_to_the_declared_id_keeping_the_full_tool_name() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(!is_err); + + let fwd = tunnel.forwarded.lock().unwrap(); + let (channel, server_id, params) = &fwd[0]; + assert_eq!(channel, "acp_x"); + assert_eq!( + server_id, "uuid-abc", + "the declared NAME must resolve to the registry id, not be used as the key" + ); + assert_eq!( + params["name"], "katashiro.click", + "the prefix selects the tunnel and is NOT stripped — the server published this name" + ); + } + + #[tokio::test] + async fn unlisted_server_name_is_refused_even_when_a_tunnel_is_attached() { + // A client declaring an un-allowlisted name must not reach the agent's catalog + // OR its dispatch, even though its tunnel is registered. + let tunnel = FakeTunnel::with(&[("evil", "uuid-evil")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let (v, is_err) = src + .call(Some(&ctx()), "evil.exfiltrate", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("not in the operator allowlist")); + assert!( + tunnel.forwarded.lock().unwrap().is_empty(), + "a denied call must never reach the tunnel" + ); + } + + #[tokio::test] + async fn unpinned_tool_on_an_allowlisted_server_is_refused() { + // The injection Falcon flagged: the client re-declares the trusted name + // `katashiro` but publishes a tool outside its pinned five. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let (v, is_err) = src + .call(Some(&ctx()), "katashiro.exec", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("is not permitted")); + assert!( + tunnel.forwarded.lock().unwrap().is_empty(), + "an unpinned tool must never reach the tunnel" + ); + } + + #[tokio::test] + async fn allowlisted_but_unattached_server_reports_not_connected() { + let tunnel = FakeTunnel::with(&[]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let (v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("not connected")); + } + + fn cfg(name: &str, tools: &[&str]) -> openab_core::config::AcpServerPolicy { + // Deserialized rather than struct-literalled so the test also pins the + // TOML shape operators actually write. + serde_json::from_value(json!({ + "name": name, + "tools": tools, + })) + .unwrap() + } + + #[test] + fn absent_config_keeps_the_builtin_browser_default() { + // Omitting [[mcp.acp_servers]] must not deny everything — existing + // browser control keeps working untouched. + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[]), &[]); + assert_eq!(src.tools(None).len(), 5); + } + + #[test] + fn operator_can_narrow_a_builtin_server_without_restating_schemas() { + let src = AcpTunnelSource::with_config( + FakeTunnel::with(&[("katashiro", "uuid-abc")]), + &[cfg("katashiro", &["katashiro.read_dom"])], + ); + let names: Vec = src.tools(None).iter().map(|t| t.name.to_string()).collect(); + assert_eq!( + names, + ["katashiro.read_dom"], + "the seed narrows to the operator's list, keeping the built-in schema" + ); + assert!( + src.tools(None)[0].input_schema.get("type").is_some(), + "narrowing must not lose the built-in schema" + ); + } + + #[tokio::test] + async fn narrowing_the_policy_actually_refuses_the_dropped_tool() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &["katashiro.read_dom"])]); + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(is_err, "a tool the operator removed must be refused"); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn operator_may_admit_an_unknown_server_by_name_alone() { + // No built-in catalog for "notes": it contributes no seed yet (schemas + // arrive with discovery caching), but its listed tool is permitted and + // routes to the declared id. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + assert!( + src.tools(None).is_empty(), + "a name-only server has no schemas to advertise until they are fetched" + ); + + let (_v, is_err) = src + .call(Some(&ctx()), "notes.list", &Map::new()) + .await + .unwrap(); + assert!(!is_err, "a permitted tool must dispatch even with no seed"); + assert_eq!(tunnel.forwarded.lock().unwrap()[0].1, "uuid-n"); + } + + #[tokio::test] + async fn an_entry_with_no_tools_admits_the_server_but_grants_nothing() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &[])]); + assert!(src.tools(None).is_empty(), "deny-all: no tools listed"); + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(is_err); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn configuring_other_servers_drops_the_browser_default() { + // Writing any entry takes over the allowlist wholesale — browser is not + // silently retained alongside the operator's list. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(is_err, "browser is no longer allowlisted once config is written"); + assert!(tunnel.forwarded.lock().unwrap().is_empty()); + } + + /// Let any spawned discovery task run to completion. + async fn settle() { + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + + #[tokio::test] + async fn discovery_fills_the_catalog_for_a_name_only_server() { + // `notes` was admitted by name alone, so it starts with no seed. After a + // discovery round its real tools/list is cached and appears. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + tunnel.set_tools_list(&["notes.list", "notes.get"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[cfg("notes", &["notes.list"])]); + + assert!(src.tools(Some(&ctx())).is_empty(), "nothing discovered yet"); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + names, + ["notes.list"], + "fetched ∩ allowed — notes.get was published but never permitted" + ); + } + + #[tokio::test] + async fn caching_is_never_itself_a_grant() { + // The server publishes a tool the operator did not permit. Caching it + // must not make it visible OR callable. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.read_dom", "katashiro.exec"]); + let src = + AcpTunnelSource::with_config(tunnel.clone(), &[cfg("katashiro", &["katashiro.read_dom"])]); + let _ = src.tools(Some(&ctx())); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!(names, ["katashiro.read_dom"], "the unpermitted tool stays out"); + + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.exec", &Map::new()) + .await + .unwrap(); + assert!(is_err, "and it stays uncallable even though it was cached"); + } + + #[tokio::test] + async fn a_seeded_server_keeps_its_seed_until_discovery_succeeds() { + // Fetch fails: the catalog must fall back to the seed, never to empty. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.fail_tools_list(); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + assert_eq!(src.tools(Some(&ctx())).len(), 5); + settle().await; + assert_eq!( + src.tools(Some(&ctx())).len(), + 5, + "a failed discovery must not empty out a seeded server" + ); + } + + #[tokio::test] + async fn discovery_is_not_repeated_while_one_is_in_flight() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.read_dom"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + for _ in 0..5 { + let _ = src.tools(Some(&ctx())); + } + settle().await; + assert_eq!( + tunnel.tools_list_calls(), + 1, + "repeated discovery rounds must not pile up tools/list requests" + ); + } + + #[tokio::test] + async fn cached_tools_survive_a_reconnect_that_changes_the_server_id() { + // The whole point of keying the cache by NAME: the client mints a new id + // on reconnect, and an id-keyed entry would be orphaned by it. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-old")]); + tunnel.set_tools_list(&["katashiro.read_dom"]); + let src = AcpTunnelSource::with_config(tunnel.clone(), &[]); + let _ = src.tools(Some(&ctx())); + settle().await; + assert_eq!(src.tools(Some(&ctx())).len(), 1); + + tunnel.reattach_as("katashiro", "uuid-new"); + assert_eq!( + src.tools(Some(&ctx())).len(), + 1, + "the discovered set must survive the id change" + ); + } + + // --- F6: two client-declared servers in one session --- + // + // The multi-server claim §6.2 makes, exercised through the real source: one + // session declares `browser` and a second, non-browser server; both are + // discovered and callable, tool names do not collide, and each server's + // policy is enforced independently. The agent-side leg (facade meta-tools → + // this source) is not covered here — facade mode is not live anywhere yet, + // which is the same precondition F5 is blocked on. + + fn two_server_src(tunnel: Arc) -> AcpTunnelSource { + AcpTunnelSource::with_config( + tunnel, + &[ + cfg("katashiro", &["katashiro.click", "katashiro.read_dom"]), + cfg("notes", &["notes.list"]), + ], + ) + } + + #[tokio::test] + async fn two_declared_servers_are_both_discovered_without_collision() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + // Both servers publish a tool literally called `list` under their own + // prefix — the case a naive un-prefixed catalog would collapse. + tunnel.set_tools_list(&["katashiro.click", "katashiro.read_dom", "notes.list"]); + let src = two_server_src(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + names, + ["katashiro.click", "katashiro.read_dom", "notes.list"], + "both servers contribute, each under its own prefix" + ); + assert_eq!( + names.len(), + names.iter().collect::>().len(), + "no duplicate tool names across servers" + ); + } + + #[tokio::test] + async fn each_server_receives_only_its_own_calls() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + + for tool in ["katashiro.click", "notes.list"] { + let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); + assert!(!is_err, "{tool} should dispatch"); + } + + let fwd = tunnel.forwarded.lock().unwrap(); + let routed: Vec<(String, String)> = fwd + .iter() + .map(|(_c, id, params)| (id.clone(), params["name"].as_str().unwrap().to_string())) + .collect(); + assert_eq!( + routed, + [ + ("uuid-b".to_string(), "katashiro.click".to_string()), + ("uuid-n".to_string(), "notes.list".to_string()) + ], + "each tool reaches the tunnel of the server that declared it" + ); + } + + #[tokio::test] + async fn one_servers_policy_does_not_leak_to_another() { + // `katashiro.click` is permitted, `notes.click` is not — a per-server gate, + // not a global tool-name gate. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + + let (_v, ok_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(!ok_err); + + let (v, denied) = src + .call(Some(&ctx()), "notes.click", &Map::new()) + .await + .unwrap(); + assert!(denied, "notes may not borrow browser's permissions"); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("is not permitted")); + assert_eq!( + tunnel.forwarded.lock().unwrap().len(), + 1, + "only the permitted call reached a tunnel" + ); + } + + #[tokio::test] + async fn one_server_detaching_leaves_the_other_callable() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + tunnel.detach("katashiro"); + + let (_v, browser_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(browser_err, "the detached server reports not connected"); + + let (_v, notes_err) = src + .call(Some(&ctx()), "notes.list", &Map::new()) + .await + .unwrap(); + assert!(!notes_err, "its neighbour is unaffected"); + } + + #[tokio::test] + async fn malformed_tool_name_without_a_prefix_is_rejected() { + let src = AcpTunnelSource::with_config(FakeTunnel::with(&[("katashiro", "uuid-abc")]), &[]); + let (v, is_err) = src.call(Some(&ctx()), "click", &Map::new()).await.unwrap(); + assert!(is_err); + assert!(v["content"][0]["text"] + .as_str() + .unwrap() + .contains("expected .")); + } +} diff --git a/src/browser_tunnel.rs b/src/browser_tunnel.rs new file mode 100644 index 000000000..805597029 --- /dev/null +++ b/src/browser_tunnel.rs @@ -0,0 +1,92 @@ +//! 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, + /// Operator-configurable ceiling for one tunnelled request (`[mcp] tunnel_timeout_seconds`). + /// Carried here rather than read per call so the value a session runs under is fixed when the + /// tunnel is wired, not re-resolved mid-flight. + timeout_secs: u64, +} + +impl RootBrowserTunnel { + pub fn new(registry: AcpTunnelRegistry, timeout_secs: u64) -> Self { + Self { + registry, + timeout_secs, + } + } +} + +#[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(_)) => { + // Redacted at construction, same as the `None` arm below and for the same + // reason. This one was MISSED when that one was fixed: the fix landed on + // the site a reviewer cited instead of on every site in the file, twelve + // lines away. + return Err(format!( + "multiple MCP servers attached to session {}; a server_id is \ + required to disambiguate", + openab_core::redact::redact_session_ids(channel_id) + )); + } + } + } else { + reg.get(&(channel_id.to_string(), server_id.to_string())) + .cloned() + } + }; + match handle { + Some(h) => h.mcp_message(method, params, self.timeout_secs).await, + // Redacted here, at construction. This string is RETURNED, not logged — it reaches a + // caller that may log it, surface it to a model, or put it in an error response, so no + // logging-side redaction point can ever catch it. An id embedded mid-sentence also + // escapes every field-name scan: the pattern that found the other sites cannot see it. + None => Err(format!( + "no browser attached to session {}", + openab_core::redact::redact_session_ids(channel_id) + )), + } + } + + /// Delegates to the gateway, which resolves under the registry lock and beside the eviction + /// that keeps declared names unique. Deliberately not implemented here by enumerating and + /// matching: this crate cannot rank two tunnels — the ordering fields are private to the gateway + /// — so a local implementation could only take an arbitrary match or refuse, and refusing is the + /// behaviour ADR §6.1 rejects. + fn resolve_by_name(&self, channel_id: &str, server_name: &str) -> Option { + openab_gateway::adapters::acp_server::resolve_by_name( + &self.registry, + channel_id, + server_name, + ) + } + +} diff --git a/src/main.rs b/src/main.rs index 236ce5ff2..529b63c2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,10 @@ mod ctl; feature = "lineworks", ))] mod unified_adapter; +#[cfg(feature = "acp")] +mod browser_tunnel; +#[cfg(feature = "acp")] +mod browser_source; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -486,30 +490,99 @@ 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(), + // Browser control requires `[mcp]`, so the absent case is unreachable in practice; + // fall back through the SAME function serde uses rather than repeating the literal. + { + let t = cfg + .mcp + .as_ref() + .map(|m| m.tunnel_timeout_seconds) + .unwrap_or_else(openab_core::config::default_tunnel_timeout_seconds); + // The comparison and the ceiling both live beside the constant in the gateway; this + // only hands over the configured value. + openab_gateway::adapters::acp_server::warn_if_tunnel_timeout_is_ineffective(t); + t + }, + ), + ); + // 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(); + // Startup, not per-session: OPENAB_BROWSER_MODE selects nothing any more, and an operator + // still setting it needs to be told that here rather than discovering it as missing tools. + // Gated on `acp` (the root feature that pulls in core's `acp-mcp`), not on `acp-mcp` itself — + // that is a core feature and naming it here is an unknown-cfg error. + #[cfg(feature = "acp")] + openab_core::mcp_proxy::report_browser_control(cfg.mcp.is_some(), &cfg.agent.working_dir); if let Some(mcp_cfg) = cfg.mcp.clone() { let listen = mcp_cfg.listen.clone(); + let tokens = facade_sessions.clone(); + // The ACP tunnel source is registered unconditionally under the `acp` feature. It used to + // be skipped in bridge mode; with the bridge gone there is no mode in which the facade + // runs without it. + #[cfg(feature = "acp")] + let sources: Vec> = + vec![Arc::new(browser_source::AcpTunnelSource::with_config( + browser_tunnel.clone(), + &mcp_cfg.acp_servers, + ))]; + #[cfg(not(feature = "acp"))] + let sources: Vec> = Vec::new(); 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, - )); + ); + // Facade session wiring: only when the facade is actually serving. With no `[mcp]` there is + // no registrar and no facade url, and the pool simply starts sessions without browser + // capabilities — there is no longer a proxy path for it to fall back to. + #[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") + ) + }), + ); + 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) @@ -1104,6 +1177,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()); + } // Pre-download identity probe: lets adapters consult the shared // L3 identity gate BEFORE spending resources on attachment @@ -1747,9 +1826,43 @@ fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { #[cfg(test)] mod tests { + use super::*; use clap::Parser; + /// The shipped tunnel-timeout default must stay strictly beneath the ceiling that overtakes it. + /// + /// This pairing can only be asserted here. The gateway owns the ceiling and cannot see the + /// default; the core crate owns the default and cannot see the ceiling, since the gateway does + /// not depend on it. The binary is the only place both are visible — which is also why the + /// warning that reports a violation is wired up here. + /// + /// Raising the default to or above the ceiling would silently restore the condition several + /// commits were spent removing: two clocks starting together, with the wrong one able to fire + /// first, and no cancellation reaching the peer when it does. + /// + /// Feature-gated because it names `openab_gateway`, which is an OPTIONAL dependency: the crate + /// is absent under default features, so without this gate the whole `openab` test binary fails + /// to compile for anyone building without `acp` — including CI, whose `check` job runs a plain + /// `cargo test --workspace`. The surrounding `mod tests` is `#[cfg(test)]` only, so the gate has + /// to be here. + #[cfg(feature = "acp")] + #[test] + fn the_default_tunnel_timeout_stays_beneath_the_idle_timeout() { + let default = openab_core::config::default_tunnel_timeout_seconds(); + let ceiling = openab_gateway::adapters::acp_server::ACP_PROMPT_IDLE_TIMEOUT_SECS; + assert!( + default < ceiling, + "the default tunnel timeout ({default}s) must be strictly beneath the ACP prompt idle \ + timeout ({ceiling}s); at or above it the turn ends there first and no `mcp/cancel` is \ + ever sent" + ); + assert!( + !openab_gateway::adapters::acp_server::tunnel_timeout_is_ineffective(default), + "the shipped default must not be a value the startup warning fires on" + ); + } + #[test] fn cli_no_args_defaults_to_run() { let cli = Cli::try_parse_from(["openab"]).unwrap();