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..f8d65f39e 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 `acp_mcp::` 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='acp_mcp::' + 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 `acp_mcp::`, 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 (acp_tunnel_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..86b2ee989 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -22,6 +22,14 @@ struct PoolState { /// Lock-free cancel handles: thread_key → (stdin, session_id). /// Stored separately so cancel can work without locking the connection. cancel_handles: HashMap, + /// Lock-free facade tokens: thread_key → the exact `OPENAB_SESSION_TOKEN` minted for the + /// connection currently under that key. Stored here, not just inside the connection, so hung + /// eviction can revoke the exact token **synchronously** — the `AcpConnection` DropGuard that + /// normally revokes it cannot fire while a hung streaming task still holds an Arc of the + /// connection, and `AcpTunnelSource` authorizes by channel alone, so an un-revoked predecessor + /// token would keep reaching whatever tunnel a successor registers for that channel (F3). + #[cfg(feature = "acp-mcp")] + facade_tokens: HashMap, /// Lock-free activity handles for hung-session detection without the connection mutex. activity: HashMap>, /// Child process-group ids, captured at insert time so hung eviction can @@ -53,6 +61,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); @@ -94,6 +106,22 @@ fn classify_hung( in_flight && last_active_age > threshold } +/// Emit the force-evict warning with **both** ids redacted. +/// +/// `key` is a pool key `:` (`acp_`) and `session_id` is `sess_`; +/// either resumes the session, so both are credentials. Extracted from the loop in `cleanup_idle` +/// so the redaction can be exercised by a test for real — R1 redacted the sites it enumerated and +/// this force-evict site was outside that list, logging both ids raw. +fn warn_force_evicting_hung(key: &str, session_id: Option<&str>, age_secs: u64, threshold_secs: u64) { + warn!( + thread_id = %crate::redact::redact_session_ids(key), + session_id = %session_id.map(crate::redact::redact_session_ids).unwrap_or_default(), + age_secs, + threshold_secs, + "force-evicting hung session" + ); +} + /// Returns true when `candidate_last_active` is a better eviction target than `current_oldest`. fn better_candidate(current_oldest: Option, candidate_last_active: Instant) -> bool { match current_oldest { @@ -102,13 +130,49 @@ 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 file carries +/// `Authorization: Bearer ${OPENAB_SESSION_TOKEN}`, and it is the artifact the OPERATOR wires in +/// — since D-15 openab writes only `.openab/mcp-facade.json`, which no agent reads on its own, so +/// the import or `--mcp-config` flag is what actually points the agent at the facade. The ordering +/// still holds for a narrower reason: if openab cannot even author that file, the session has no +/// path to the facade it could be wired to, and 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::acp_mcp::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); @@ -164,6 +228,49 @@ fn apply_hung_eviction( true } +/// Record `token` as the facade token for `key`, revoking whatever token it supersedes. +/// +/// A superseded token belongs to a predecessor connection under the same key. Its `AcpConnection` +/// DropGuard normally revokes it, but if that predecessor is hung (a stuck streaming task still +/// holds an Arc) the guard never fires — so revoking the superseded token here is what stops it +/// staying valid for the channel after a successor takes over (F3). Revocation is by exact token +/// and idempotent, so overlapping with the guard on a clean replacement is harmless. +#[cfg(feature = "acp-mcp")] +fn install_facade_token( + state: &mut PoolState, + key: &str, + token: String, + registrar: Option<&Arc>, +) { + if let Some(superseded) = state.facade_tokens.insert(key.to_string(), token) { + if let Some(registrar) = registrar { + registrar.revoke(&superseded); + } + } +} + +/// Revoke and forget the facade token recorded for `key`, if any. +/// +/// Called from every path that removes a connection from `active` (hung eviction, idle eviction, +/// reset, suspend). On the clean paths the connection also drops and its guard revokes the same +/// token — idempotent — but the hung path is the one that needs this: the guard cannot fire while +/// the hung task holds an Arc, so without a synchronous revoke here the token outlives the eviction +/// and `AcpTunnelSource` (channel-only authorization) would let the hung predecessor reach a +/// successor's tunnel (F3). `purge_session_entries` deliberately does NOT touch `facade_tokens`, so +/// this can run *after* `apply_hung_eviction` and still find the token to revoke. +#[cfg(feature = "acp-mcp")] +fn revoke_facade_token_for_key( + state: &mut PoolState, + key: &str, + registrar: Option<&Arc>, +) { + if let Some(token) = state.facade_tokens.remove(key) { + if let Some(registrar) = registrar { + registrar.revoke(&token); + } + } +} + impl SessionPool { pub fn new( config: AgentConfig, @@ -184,6 +291,8 @@ impl SessionPool { state: RwLock::new(PoolState { active: HashMap::new(), cancel_handles: HashMap::new(), + #[cfg(feature = "acp-mcp")] + facade_tokens: HashMap::new(), activity: HashMap::new(), pgids: HashMap::new(), persisted: suspended.clone(), @@ -197,9 +306,34 @@ 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, set by the root + /// when `[mcp]` is running. With both present the pool does its half: 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. + /// + /// That is necessary but NOT sufficient for browser capabilities to route + /// through the facade. The operator must still put the written entry in front + /// of the agent, and a `type:acp` server must actually attach over `/acp` — + /// admission is that transport auth, not a config allowlist (D-29 removed + /// `[[mcp.acp_servers]]`, reversing D-20). + #[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 +481,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 +557,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 +565,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 +573,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 +614,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 +629,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 +643,9 @@ impl SessionPool { state.cancel_handles.remove(&key); state.activity.remove(&key); state.pgids.remove(&key); - info!(evicted = %key, "pool full, suspending oldest idle session"); + #[cfg(feature = "acp-mcp")] + revoke_facade_token_for_key(&mut state, &key, self.session_registrar.as_ref()); + 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 +653,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!( @@ -493,6 +688,12 @@ impl SessionPool { .cancel_handles .insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); } + // Record this connection's exact token lock-free, revoking any predecessor token it + // supersedes under the same key (its guard cannot fire if that predecessor is hung). F3. + #[cfg(feature = "acp-mcp")] + if let Some(token) = session_token { + install_facade_token(&mut state, thread_id, token, self.session_registrar.as_ref()); + } self.save_mapping(&state.persisted); // Persist workspace override only after session spawn succeeded (口渡 F2). @@ -530,7 +731,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 +763,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 +779,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 +794,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 +827,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 +837,22 @@ 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); + // Resetting a hung session drops the map's Arc but not the one the stuck task holds, so the + // guard cannot revoke — do it synchronously here too (F3). + #[cfg(feature = "acp-mcp")] + revoke_facade_token_for_key(&mut state, thread_id, self.session_registrar.as_ref()); 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))) } } @@ -682,12 +885,11 @@ impl SessionPool { if let Some(activity) = activity_map.get(&key) { if classify_hung(activity.in_flight(), activity.age(), hung_threshold) { let session_id = cancel_map.get(&key).map(|(_, sid)| sid.clone()); - warn!( - thread_id = %key, - session_id = session_id.as_deref().unwrap_or(""), - age_secs = activity.age().as_secs(), - threshold_secs = self.hung_threshold_secs, - "force-evicting hung session" + warn_force_evicting_hung( + &key, + session_id.as_deref(), + activity.age().as_secs(), + self.hung_threshold_secs, ); // Best-effort session/cancel via the lock-free stdin // handle, detached so a wedged stdin can never block @@ -748,10 +950,12 @@ 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); + #[cfg(feature = "acp-mcp")] + revoke_facade_token_for_key(&mut state, &key, self.session_registrar.as_ref()); if let Some(sid) = sid { state.persisted.insert(key.clone(), sid.clone()); state.suspended.insert(key, sid); @@ -762,8 +966,16 @@ 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"); + if apply_hung_eviction(&mut state, &key, &expected_conn) { + // The DropGuard cannot fire — the hung streaming task still holds an Arc, so the + // connection never drops. Revoke the exact token synchronously, or it keeps + // resolving to the channel and a successor's tunnel becomes reachable by the hung + // predecessor (F3). Safe after `apply_hung_eviction`: its `purge_session_entries` + // leaves `facade_tokens` alone. + #[cfg(feature = "acp-mcp")] + revoke_facade_token_for_key(&mut state, &key, self.session_registrar.as_ref()); + } else { + warn!(thread_id = %crate::redact::redact_session_ids(&key), "hung session was replaced before eviction; maps untouched"); } } self.save_mapping(&state.persisted); @@ -818,6 +1030,148 @@ 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>, + revoked: std::sync::Mutex>, + } + + #[cfg(feature = "acp-mcp")] + impl CountingRegistrar { + fn revoked(&self) -> Vec { + self.revoked.lock().unwrap().clone() + } + } + + #[cfg(feature = "acp-mcp")] + impl crate::acp_mcp::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) { + self.revoked.lock().unwrap().push(token.to_string()); + } + } + + /// Build an empty `PoolState` for a helper-level test. + #[cfg(feature = "acp-mcp")] + fn empty_pool_state() -> super::PoolState { + super::PoolState { + active: HashMap::new(), + cancel_handles: HashMap::new(), + facade_tokens: HashMap::new(), + activity: HashMap::new(), + pgids: HashMap::new(), + suspended: HashMap::new(), + persisted: HashMap::new(), + creating: HashMap::new(), + session_workdirs: HashMap::new(), + } + } + + /// F3: replacing a hung predecessor's token revokes the predecessor's EXACT token and leaves + /// the successor's standing. Without the revoke the predecessor token keeps resolving to the + /// channel and — since `AcpTunnelSource` authorizes by channel — could reach the successor's + /// tunnel. Exercises the production `install_facade_token`. + #[cfg(feature = "acp-mcp")] + #[test] + fn installing_a_successor_token_revokes_only_the_superseded_predecessor() { + let reg = Arc::new(CountingRegistrar::default()); + let registrar: Arc = reg.clone(); + let mut state = empty_pool_state(); + + // Predecessor registers, then a successor takes over the SAME key. + super::install_facade_token(&mut state, "discord:acp_x", "T_pred".into(), Some(®istrar)); + assert!(reg.revoked().is_empty(), "nothing to revoke on the first install"); + super::install_facade_token(&mut state, "discord:acp_x", "T_succ".into(), Some(®istrar)); + + assert_eq!(reg.revoked(), vec!["T_pred"], "the predecessor token must be revoked"); + assert_eq!( + state.facade_tokens.get("discord:acp_x").map(String::as_str), + Some("T_succ"), + "the successor's token stands" + ); + } + + /// F3: hung eviction revokes the exact facade token synchronously (the DropGuard cannot fire + /// while the hung task holds an Arc). Exercises the production `revoke_facade_token_for_key`, + /// which the hung-eviction loop calls after `apply_hung_eviction`. + #[cfg(feature = "acp-mcp")] + #[test] + fn hung_eviction_revokes_the_exact_facade_token_and_forgets_it() { + let reg = Arc::new(CountingRegistrar::default()); + let registrar: Arc = reg.clone(); + let mut state = empty_pool_state(); + state.facade_tokens.insert("discord:acp_x".into(), "T_hung".into()); + // A different session's token must be untouched. + state.facade_tokens.insert("discord:acp_y".into(), "T_other".into()); + + super::revoke_facade_token_for_key(&mut state, "discord:acp_x", Some(®istrar)); + + assert_eq!(reg.revoked(), vec!["T_hung"], "only the evicted session's token is revoked"); + assert!(!state.facade_tokens.contains_key("discord:acp_x"), "and it is forgotten"); + assert_eq!( + state.facade_tokens.get("discord:acp_y").map(String::as_str), + Some("T_other"), + "an unrelated session's token is untouched" + ); + } + + /// 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)); @@ -927,11 +1281,58 @@ mod tests { assert!(!better_candidate(Some(ts), ts)); } + /// The force-evict warning must log NEITHER id raw — both the `acp_` channel (inside the + /// `:` pool key) and the `sess_` session id resume the session. A + /// capture subscriber exercises the real `warn!` macro, so a revert to raw fields fails here + /// rather than silently shipping a credential to the logs (F6 / round 6). + #[test] + fn force_evict_warning_redacts_both_ids() { + use std::io::Write; + use std::sync::{Arc as StdArc, Mutex as StdMutex}; + + #[derive(Clone)] + struct Cap(StdArc>>); + impl Write for Cap { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let uuid = "00000000-0000-0000-0000-000000000000"; + let buf = StdArc::new(StdMutex::new(Vec::new())); + let cap = Cap(buf.clone()); + let sub = tracing_subscriber::fmt() + .with_writer(move || cap.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(sub, || { + super::warn_force_evicting_hung( + &format!("discord:acp_{uuid}"), + Some(&format!("sess_{uuid}")), + 999, + 600, + ); + }); + + let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + assert!(out.contains("force-evicting hung session"), "the warning must fire: {out}"); + assert!(!out.contains(uuid), "no raw uuid may reach the log: {out}"); + assert!(!out.contains("acp_") && !out.contains("sess_"), "no raw id prefix either: {out}"); + assert!(out.contains('#'), "the redaction tag must be present: {out}"); + assert!(out.contains("discord"), "the readable platform half must survive: {out}"); + } + #[test] fn purge_session_entries_drops_all_entries_for_evicted_key_only() { let mut state = PoolState { active: HashMap::new(), cancel_handles: HashMap::new(), + #[cfg(feature = "acp-mcp")] + facade_tokens: HashMap::new(), activity: HashMap::from([ ("hung".to_string(), Arc::new(SessionActivity::new())), ("other".to_string(), Arc::new(SessionActivity::new())), diff --git a/crates/openab-core/src/acp_mcp.rs b/crates/openab-core/src/acp_mcp.rs new file mode 100644 index 000000000..ab82c3091 --- /dev/null +++ b/crates/openab-core/src/acp_mcp.rs @@ -0,0 +1,443 @@ +//! 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: +//! +//! - [`report_facade_status`] — startup report of whether browser control is on. + +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 facade's +/// capability source (`src/acp_tunnel_source.rs`) — nothing here consumes it, and this module +/// hosts no proxy. 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). An empty `server_id` is a sentinel meaning "the sole tunnel on this + /// channel". This used to say the "proxy/bridge callers" do not yet know the client-declared + /// id at spawn time; those callers were removed with the proxy and the bridge, so the sentinel + /// now exists for the facade source's own single-server path (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 declared **names** of the servers currently attached to this channel — the set the + /// facade's discovery iterates now that the operator allowlist is gone (D-29 removed + /// `[[mcp.acp_servers]]`; without it `tools()` had no source of names, because the allowlist + /// was doing double duty as the security gate AND the discovery list). + /// + /// **Names only, deduplicated — never `(name, id)`.** This is deliberately not the enumerating + /// `servers()` that `74315a60` removed: that returned a name→id mapping and its two callers + /// collapsed it by different rules. Here the caller resolves each name to an id through + /// [`AcpMcpTunnel::resolve_by_name`], so the name→id collapse stays in one place, beside the + /// eviction that makes the answer unique. Discovery is the only caller; routing never uses this. + /// A same-name collision (two tunnels mid-eviction) is one server to discover — hence dedup, and + /// `resolve_by_name` picks the ranked winner. + /// + /// Required, with no default — same reason as [`AcpMcpTunnel::resolve_by_name`]: a defaulted + /// empty answer would compile everywhere and then silently advertise nothing. + fn attached_server_names(&self, channel_id: &str) -> Vec; +} + + + + + +/// 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`, which the OPERATOR adds to `[agent] args` — openab does not +/// pass it and identifies no vendor at spawn time (D-21). openab never puts the entry in +/// place itself either (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. +/// +/// Call this from configuration/startup, **not** from a session path: nothing per-session is +/// decided by it, and a warning on that path would repeat for every spawn. +/// +/// There used to be a migration notice here for `OPENAB_BROWSER_MODE`. It was removed on +/// 2026-07-31 (D-23): that variable was introduced and retired entirely within this changeset and +/// never appeared in any release, so the notice told operators that something they never had is +/// now ignored. +pub fn report_facade_status(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." + ); + } +} + + + +/// The destructive cases for the facade config writer — the one file openab owns, +/// `.openab/mcp-facade.json`. It touches a user's `mcp.json` nowhere at all any more; the +/// boundary test below is what asserts that. +/// +/// 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::{ + }; + + + + + + + + + + /// 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 + } + + + + + + + + + + + + + + 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/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..a9bc26abd 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -71,13 +71,61 @@ 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 — a mistyped `listen` +/// or `tunnel_timeout_seconds` should stop startup, not be silently ignored into its default. +/// +/// **The operator allowlist this attribute used to guard was removed on 2026-07-31 (D-29), +/// reversing D-20's fail-closed default.** `[[mcp.acp_servers]]` is gone: admission for +/// client-declared `type:acp` servers is now the `/acp` transport auth alone +/// (`OPENAB_ACP_AUTH_KEY`, or loopback + `OPENAB_ACP_ALLOWED_ORIGINS`), because the extension +/// already authenticates to reach the tunnel and a second operator allowlist duplicated that +/// intent. `deny_unknown_fields` keeps a sharper edge for it: a config still carrying an +/// `[[mcp.acp_servers]]` block HARD-FAILS to parse rather than ignoring it — intended, so a stale +/// allowlist announces itself instead of looking effective. #[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, + /// 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 } fn default_mcp_listen() -> String { @@ -2449,6 +2497,41 @@ mod tests { assert_eq!(mcp.listen, "127.0.0.1:8848"); } + /// The operator allowlist was removed on 2026-07-31 (D-29), reversing D-20. `[[mcp.acp_servers]]` + /// is no longer a field on `McpFacadeConfig`, and `deny_unknown_fields` turns a leftover block + /// from a silent no-op into a hard parse failure — a stale allowlist must announce itself rather + /// than look effective. This replaces the pair of tests that pinned the allowlist's + /// mistyped-vs-correct spellings: there is no correct spelling any more, and the entry-key test + /// went with the `AcpServerPolicy` struct it exercised. + #[test] + fn an_acp_servers_block_is_now_refused_because_the_allowlist_was_removed() { + let err = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\n[[mcp.acp_servers]]\nname = \"katashiro\"\n\ + tools = [\"katashiro.read_dom\"]\n", + "test", + ) + .expect_err("a leftover allowlist block must fail the parse, not be silently ignored"); + let msg = err.to_string(); + assert!( + msg.contains("acp_servers"), + "the error must name the offending key so the operator can find it, got: {msg}" + ); + } + + /// Positive control for the test above: the `[mcp]` shape that SURVIVES D-29 — `listen` and + /// `tunnel_timeout_seconds` only — still parses, so the rejection above is about the removed + /// key and not a parser that refuses everything. + #[test] + fn a_bare_mcp_section_still_parses() { + let cfg = parse_config_str( + "[discord]\nbot_token = \"x\"\n[mcp]\nlisten = \"127.0.0.1:9000\"\n", + "test", + ) + .expect("the surviving [mcp] shape must keep working"); + let mcp = cfg.mcp.expect("[mcp] present"); + assert_eq!(mcp.listen, "127.0.0.1:9000"); + } + #[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..a3b74adbd 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,87 @@ mod tests { assert!(!should_skip_event(&event, &filter)); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel or session id. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: either form printed in full IS a resume credential. Anyone reading operator +/// logs could resume the session, and logs travel further than the sessions they describe. +/// +/// **The uuid is hashed, not the prefixed string.** One session reaches this function as +/// `acp_` and elsewhere as `sess_`; hashing the whole string gives those two forms a +/// different tag each, and a third different again from [`crate::redact::redact_session_ids`] and +/// the gateway's `redact_id`, which strip the prefix first. Several tags for one session defeat the +/// only purpose the tag has — following that session across logs — more completely than not +/// redacting would, and it has already read as zero overlap between two logs describing the same +/// session. +/// +/// 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. +/// +/// Copies of this function live in `openab-gateway` and `openab-mcp` because those crates +/// deliberately do not depend on this one. Each is pinned to the same vector; where a crate has a +/// redactor of its own, its test compares against that rather than against a copied literal. +fn redact_channel(id: &str) -> String { + let Some(uuid) = id + .strip_prefix("acp_") + .or_else(|| id.strip_prefix("sess_")) + .filter(|uuid| !uuid.is_empty()) + else { + return id.to_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 redact_channel_tests { + const CHANNEL: &str = "acp_00000000-0000-0000-0000-000000000000"; + const SESSION: &str = "sess_00000000-0000-0000-0000-000000000000"; + + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id, and + /// identical across the two forms one session is addressed by. + /// + /// `#12b9377c` is the uuid's tag, shared with `redact_session_ids` and the gateway's + /// `redact_id`. It used to be `#850414fa` here, the hash of the whole `acp_` string, which + /// is why one session could appear under two tags depending on which log you were reading. + /// + /// The literal is pinned in all three crates, but this crate has its own redactor, so the + /// assertion that matters is the comparison against it: a divergence then fails without anyone + /// having to remember to update three copied literals. + #[test] + fn an_acp_id_hashes_its_uuid_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel(CHANNEL), + "#12b9377c", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel(SESSION), + "#12b9377c", + "both forms of one session must share a tag — hashing the prefix is what split them" + ); + assert_eq!( + super::redact_channel(CHANNEL), + crate::redact::redact_session_ids(CHANNEL), + "this crate's two redactors must agree without relying on a copied literal" + ); + assert_eq!( + super::redact_channel(SESSION), + crate::redact::redact_session_ids(SESSION), + "including on the session form, which used to pass through here unredacted" + ); + 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..0e61e7cb2 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 acp_mcp; +pub mod redact; pub mod bot_turns; pub mod config; pub mod cron; 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..747132e28 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,66 @@ 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. Only the `acp`-transport ones are tunnelled over this WS. +/// +/// http/sse/stdio entries are **dropped, and nothing else ever sees them**. This used to say they +/// are "ignored here — the agent connects to those itself", which describes a hand-off that does +/// not exist: `session/new` calls this, keeps the `acp` entries, and then calls +/// `handle_session_new(&sessions, id)` without passing `req.params` anywhere. There is no +/// downstream consumer. That sentence had already misled a reader who reasoned from it before +/// checking, which is why `mcpCapabilities` now advertises `http: false` and `sse: false`. +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 +409,90 @@ 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, + /// Age of the CONNECTION that installed this sink (`connection_generation`, from + /// [`TUNNEL_GENERATION`], stamped when that connection was accepted). + /// + /// The reply registry is process-wide and keyed by `channel_id`, but session busy-state is + /// per-connection — so two connections resuming the same session can both start a turn and race + /// on this one key. Generation is what orders them: a NEWER connection (a reconnecting client + /// taking over the channel) may install over an older one, but an older connection arriving late + /// must not clobber the newer's sink. Paired with turn-scoped removal, this stops either turn + /// from deleting or overwriting the other's live sink (F4, was the F5-of-round-3 residual). + pub generation: u64, +} + +/// 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()) +} + +/// The distinct declared **names** of tunnels currently attached to `channel_id`. +/// +/// Drives the facade's discovery now that there is no operator allowlist to enumerate (D-29). It +/// returns names, never `(name, id)`: the caller resolves each through [`resolve_by_name`], so the +/// name→id collapse stays in the one place `74315a60` consolidated it — this is not a second route +/// back to the enumerate-and-match hazard that commit removed. Deduplicated because a same-name +/// collision (two tunnels mid-eviction) is one server to discover; `resolve_by_name` selects the +/// ranked winner when discovery resolves it. +pub fn attached_server_names(registry: &AcpTunnelRegistry, channel_id: &str) -> Vec { + let reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + let mut names: Vec = reg + .iter() + .filter(|((c, _), _)| c == channel_id) + .map(|(_, h)| h.server_name.clone()) + .collect(); + names.sort(); + names.dedup(); + names } /// Registry of active ACP sessions: channel_id → reply sink. @@ -287,6 +504,59 @@ pub fn new_reply_registry() -> AcpReplyRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } +/// Install `sink` as the reply sink for `channel_id`, unless a strictly NEWER connection already +/// owns it. Returns true if installed. +/// +/// A reconnecting client takes over the same `channel_id`, so a newer connection (higher +/// `generation`) is allowed to install over an older one — but an older connection whose prompt is +/// processed late must not clobber the newer connection's live sink. Same generation (the same +/// connection starting its next turn) installs, replacing its own prior sink. +fn install_reply_sink(registry: &AcpReplyRegistry, channel_id: &str, sink: ReplySink) -> bool { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + match reg.get(channel_id) { + Some(existing) if existing.generation > sink.generation => false, + _ => { + reg.insert(channel_id.to_string(), sink); + true + } + } +} + +/// Remove the reply sink for `channel_id` only if `turn_id` still owns it. +/// +/// A turn's completion must not remove a sink a successor turn (a reconnect, or a concurrent +/// same-session connection) has since installed — `turn_id` is a per-turn `evt_`, so matching +/// it identifies *this* turn's own sink and nothing else. The unconditional `remove` this replaces +/// was the F5-of-round-3 residual: either completion dropped whichever sink was there. +fn remove_reply_sink_if_owner(registry: &AcpReplyRegistry, channel_id: &str, turn_id: &str) { + let mut reg = registry.lock().unwrap_or_else(|e| e.into_inner()); + if reg.get(channel_id).is_some_and(|s| s.turn_id == turn_id) { + reg.remove(channel_id); + } +} + +/// 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 facade's capability source 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 +594,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 +730,629 @@ 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 facade's capability source can route a tool call +/// to the right browser. This was written as D5-agnostic, when a per-session MCP server and a +/// shared core server were both live options; only the shared facade exists now, and it uses +/// 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 facade's capability source 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 +1362,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 +1449,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 +1562,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 facade's capability source 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 +1612,135 @@ 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 facade's capability source reports "no browser + // attached" — there is no core proxy to report it. + // 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 +1787,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 +1813,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 +1823,8 @@ async fn handle_acp_connection(state: Arc, socket: WebSocket) { &out_tx_clone, session_id, cancel, + &conn_for_prompt, + connection_generation, ) .await; }); @@ -695,35 +1852,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"); @@ -764,6 +1942,30 @@ fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse { "protocolVersion": negotiated, "agentCapabilities": { "loadSession": false, + // Advertised because the serde default is {http:false, sse:false}, so saying + // NOTHING already claims "no MCP transport support" — while this gateway ships + // MCP-over-ACP. Silence was not neutral (R4). + // + // http and sse are FALSE, and that is verified rather than assumed: `session/new` + // runs `parse_acp_mcp_servers`, keeps only `"type":"acp"` entries, and then calls + // `handle_session_new(&sessions, id)` — `req.params` reaches nothing else. An http + // or sse declaration is silently dropped, so advertising `true` would be a claim + // with no implementation behind it. + // + // The ACP capability is declared as a **reverse-DNS-namespaced `_meta` key**, per + // the 2026-07-28 MCP `_meta` namespaced-key convention (SEP-1788 — its own reserved + // keys look like `io.modelcontextprotocol/logLevel`). `dev.openab/acp` is the + // reverse-DNS of openab's domain (openab.dev) plus the capability key. This is the + // `_meta` convention, NOT the separate typed `extensions` map (also new in the + // 2026-07-28 spec): the vendored v1 `McpCapabilities` has only `http`, `sse` and a + // free-form `_meta` (`acp_schema.rs:6201`), and adding an `extensions` field would + // fork the generated types, which F1(a) deliberately avoided. It would move to that + // typed map if and when the vendored schema gains it; the ADR carries that note. + "mcpCapabilities": { + "http": false, + "sse": false, + "_meta": { "dev.openab/acp": true } + }, "sessionCapabilities": { "resume": {} }, @@ -783,10 +1985,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 +2000,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 +2039,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 +2083,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 +2098,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,10 +2161,65 @@ 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. -fn derive_channel_id(session_id: &str) -> Option { - let uuid = session_id.strip_prefix("sess_")?; - Uuid::parse_str(uuid).ok()?; - Some(format!("acp_{uuid}")) +/// 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. +pub(crate) 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()?; + Some(format!("acp_{uuid}")) } /// Release a prompt reservation: clear `busy` and drop the cancel handle. Called on every @@ -935,6 +2234,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 +2247,13 @@ 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, + // Age of this connection (`connection_generation`), stamped on the reply sink so a newer + // connection resuming the same session takes over the channel and an older one cannot clobber + // it (F4). + connection_generation: u64, ) { // 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 +2271,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; @@ -994,10 +2307,16 @@ async fn handle_session_prompt( // turn's event id so `handle_reply` can drop a stale reply after timeout/cancel reuse. let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::(); if let Some(ref registry) = state.acp_reply_registry { - registry - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(channel_id.clone(), ReplySink { turn_id, tx: reply_tx }); + install_reply_sink( + registry, + &channel_id, + ReplySink { + turn_id: turn_id.clone(), + tx: reply_tx, + owner: connection_id.to_string(), + generation: connection_generation, + }, + ); } // Send event through the broadcast channel @@ -1013,9 +2332,9 @@ async fn handle_session_prompt( ); let _ = out_tx.send(serde_json::to_string(&resp).unwrap()); release_prompt(sessions, &session_id).await; - // Cleanup registry + // Cleanup registry — only this turn's own sink (F4). if let Some(ref registry) = state.acp_reply_registry { - registry.lock().unwrap_or_else(|e| e.into_inner()).remove(&channel_id); + remove_reply_sink_if_owner(registry, &channel_id, &turn_id); } return; } @@ -1029,19 +2348,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 +2390,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; } @@ -1080,14 +2400,13 @@ async fn handle_session_prompt( } // Cleanup: remove from registry, release busy flag, clear cancel signal. - // INVARIANT (R16-F1/F2): this unconditional remove/reset is safe ONLY because no newer - // turn can exist on this `session_id` while this one runs — both entry points that would - // start one are busy-gated (`session/prompt` and `session/resume` reject with -32001 when - // `s.busy`). If that gating is ever relaxed (e.g. multi-turn-per-session), this must become - // turn/owner-aware (compare the active `turn_id` before `remove`) or it will clobber the - // newer turn's sink. Cross-connection same-session races remain an accepted residual (F5). + // Turn-scoped removal (F4): only remove the sink if THIS turn still owns it. A newer connection + // resuming the same session (or a reconnect) can have taken over the channel key between this + // turn's start and its end; `remove_reply_sink_if_owner` matches on `turn_id` so this + // completion cannot delete the successor's live sink. This was the F5-of-round-3 residual — + // the per-connection busy gate does not serialize turns across two connections on one session. if let Some(ref registry) = state.acp_reply_registry { - registry.lock().unwrap_or_else(|e| e.into_inner()).remove(&channel_id); + remove_reply_sink_if_owner(registry, &channel_id, &turn_id); } if let Some(s) = sessions.lock().await.get_mut(&session_id) { s.busy = false; @@ -1099,7 +2418,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()); } @@ -1269,6 +2593,11 @@ mod acp_conformance { "protocolVersion": 1, "agentCapabilities": { "loadSession": false, + // Mirrors handle_initialize, INCLUDING mcpCapabilities — a mirror that stops + // mirroring is worse than no mirror, because it still looks like coverage. This + // also proves `_meta` is schema-legal here, which is what makes the ACP capability + // expressible before upstream has a real field for it. + "mcpCapabilities": { "http": false, "sse": false, "_meta": { "dev.openab/acp": true } }, "sessionCapabilities": { "resume": {} }, "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false } }, @@ -1585,10 +2914,634 @@ mod acp_streaming { // Handler-level tests — call the real handlers (not just literal round-trips) and // assert their actual output + side effects. // --------------------------------------------------------------------------- +#[cfg(test)] +mod acp_requests { + //! T1 — the agent→client REQUEST direction: server-initiated `send_request` (mints an + //! id, awaits the correlated response) and inbound `route_client_response`. + use super::{mcp_connect, mcp_message_request, route_client_response, send_request}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + use tokio::sync::{mpsc, oneshot}; + + /// 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; @@ -1617,6 +3570,22 @@ mod acp_handlers { assert_eq!(result["agentCapabilities"]["loadSession"], json!(false)); assert!(result["agentCapabilities"]["sessionCapabilities"]["resume"].is_object()); assert!(result["authMethods"].is_array()); + + // R4: advertised explicitly. Before this, `mcpCapabilities` was absent and clients read + // the serde default {http:false, sse:false} — the same VALUES, but arrived at by silence + // while the gateway ships MCP-over-ACP and says so nowhere. + let mcp = &result["agentCapabilities"]["mcpCapabilities"]; + assert_eq!(mcp["http"], json!(false), "no code forwards an http declaration anywhere"); + assert_eq!(mcp["sse"], json!(false), "same for sse — parse_acp_mcp_servers drops both"); + assert_eq!( + mcp["_meta"]["dev.openab/acp"], json!(true), + "the ACP capability is a reverse-DNS-namespaced extension under _meta (F1(b))" + ); + assert!( + mcp["_meta"]["acp"].is_null(), + "the bare `_meta.acp` key is gone — the informal convention the framework supersedes" + ); + assert!(mcp.get("acp").is_none(), "no core mcpCapabilities.acp field (would fork the schema)"); } #[test] @@ -1638,30 +3607,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 +3685,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,59 +3693,307 @@ 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"); } - fn reply(channel_id: &str, reply_to: &str, text: &str, command: Option<&str>) -> GatewayReply { - GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: reply_to.into(), - platform: "acp".into(), - channel: crate::schema::ReplyChannel { id: channel_id.into(), thread_id: None }, - content: crate::schema::Content { - content_type: "text".into(), - text: text.into(), - attachments: Vec::new(), - }, - command: command.map(|c| c.into()), - request_id: None, - quote_message_id: None, + // --- 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(), } } - // M2 — a late reply carrying a superseded turn's event id is dropped, not delivered - // into the current turn's stream; a reply matching the active turn is delivered. - #[tokio::test] - async fn handle_reply_fences_stale_turn() { - let registry = new_reply_registry(); - let (tx, mut rx) = mpsc::unbounded_channel::(); - registry - .lock() - .unwrap() - .insert("acp_chan".into(), ReplySink { turn_id: "evt_current".into(), tx }); + /// 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"); - // Stale reply (previous turn's event id) → dropped. - handle_reply(&reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), ®istry) - .await; - assert!(rx.try_recv().is_err(), "stale reply must not reach the active turn"); + 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" + ); + } - // Matching reply → delivered. - handle_reply(&reply("acp_chan", "evt_current", "hello", Some("edit_message")), ®istry) - .await; - match rx.try_recv() { - Ok(ReplyChunk::Text(t)) => assert_eq!(t, "hello"), - _ => panic!("expected the matching reply to be delivered"), + /// 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(), + reply_to: reply_to.into(), + platform: "acp".into(), + channel: crate::schema::ReplyChannel { id: channel_id.into(), thread_id: None }, + content: crate::schema::Content { + content_type: "text".into(), + text: text.into(), + attachments: Vec::new(), + }, + command: command.map(|c| c.into()), + request_id: None, + quote_message_id: None, + } + } + + // M2 — a late reply carrying a superseded turn's event id is dropped, not delivered + // into the current turn's stream; a reply matching the active turn is delivered. + #[tokio::test] + async fn handle_reply_fences_stale_turn() { + let registry = new_reply_registry(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + registry + .lock() + .unwrap() + .insert( + "acp_chan".into(), + ReplySink { turn_id: "evt_current".into(), tx, owner: "conn-test".into(), generation: 0 }, + ); + + // Stale reply (previous turn's event id) → dropped. + handle_reply(&reply("acp_chan", "evt_stale", "leaked", Some("edit_message")), ®istry) + .await; + assert!(rx.try_recv().is_err(), "stale reply must not reach the active turn"); + + // Matching reply → delivered. + handle_reply(&reply("acp_chan", "evt_current", "hello", Some("edit_message")), ®istry) + .await; + match rx.try_recv() { + Ok(ReplyChunk::Text(t)) => assert_eq!(t, "hello"), + _ => panic!("expected the matching reply to be delivered"), + } + } + + // F4 — two connections on one session race on the process-wide reply registry (session busy is + // per-connection). Generation orders them: a newer connection takes over, an older one arriving + // late cannot clobber it, and neither turn's completion removes the other's live sink. + #[test] + fn neither_connection_clobbers_the_others_reply_sink() { + let registry = new_reply_registry(); + let sink = |turn: &str, owner: &str, generation: u64| { + let (tx, _rx) = mpsc::unbounded_channel::(); + super::ReplySink { turn_id: turn.into(), tx, owner: owner.into(), generation } + }; + let current_turn = || registry.lock().unwrap().get("acp_x").map(|s| s.turn_id.clone()); + + // Connection A (gen 1) installs its sink. + assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_a", "conn-A", 1))); + // Newer connection B (gen 2) resumes the same session and takes over (reconnect semantics). + assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_b", "conn-B", 2))); + assert_eq!(current_turn().as_deref(), Some("evt_b"), "the newer connection owns the sink"); + + // The older connection A, its prompt processed late, must NOT clobber B's live sink. + assert!( + !super::install_reply_sink(®istry, "acp_x", sink("evt_a2", "conn-A", 1)), + "an older connection cannot install over a newer one" + ); + assert_eq!(current_turn().as_deref(), Some("evt_b"), "B's sink survives A's late install"); + + // A's turn completing must not remove B's sink (turn-scoped removal). + super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_a"); + assert_eq!(current_turn().as_deref(), Some("evt_b"), "A's completion must not remove B's sink"); + + // B's own completion removes B's sink. + super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_b"); + assert!(current_turn().is_none(), "the owner's completion removes its own sink"); + } + + // F4 — the SAME connection starting its next turn replaces its own sink; the stale prior turn's + // completion must not then remove the live new one. + #[test] + fn a_connections_new_turn_replaces_its_own_sink_without_the_old_turn_removing_it() { + let registry = new_reply_registry(); + let sink = |turn: &str| { + let (tx, _rx) = mpsc::unbounded_channel::(); + super::ReplySink { turn_id: turn.into(), tx, owner: "conn-A".into(), generation: 5 } + }; + assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_1"))); + assert!(super::install_reply_sink(®istry, "acp_x", sink("evt_2"))); + // Stale turn 1's completion must not remove turn 2's sink. + super::remove_reply_sink_if_owner(®istry, "acp_x", "evt_1"); + assert_eq!( + registry.lock().unwrap().get("acp_x").map(|s| s.turn_id.clone()).as_deref(), + Some("evt_2"), + "the stale turn must not remove the same connection's newer sink" + ); + } + // R17-F1 — keyless-mode browser `Origin` gating. A WS handshake bypasses the browser // same-origin policy, so on a keyless loopback bind an un-allowlisted browser origin // must be refused (ws_upgrade turns `false` into a 403). A non-browser client sends no @@ -1838,7 +4079,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", 0) .await; // The final response (matching our request id) must carry stopReason "cancelled". @@ -1878,10 +4119,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 +4161,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", 0) .await; }); @@ -1997,3 +4242,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(), generation: 0 }, + ); + } + { + 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..d2b257c7d 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,87 @@ mod l1_audit_tests { assert!(flagged(&s).is_empty()); } } + +/// Render a channel id for logs, hashing it when it is an ACP channel or session id. +/// +/// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are +/// mutually derivable: either form printed here IS a resume credential. Anyone reading operator logs +/// could resume the session, and logs travel further than the sessions they describe. +/// +/// **The uuid is hashed, not the prefixed string.** One session reaches this function as +/// `acp_` and elsewhere as `sess_`; hashing the whole string gives those two forms a +/// different tag each, and a third different again from this crate's own `redact_id` and +/// `openab-core`'s `redact_session_ids`, which strip the prefix first. Several tags for one session +/// defeat the only purpose the tag has — following that session across logs — more completely than +/// not redacting would. +/// +/// 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. +/// +/// Copies of this function live in `openab-core` and `openab-mcp` because those crates deliberately +/// do not depend on one another. Each is pinned to the same vector; where a crate has a redactor of +/// its own, its test compares against that rather than against a copied literal. +fn redact_channel(id: &str) -> String { + let Some(uuid) = id + .strip_prefix("acp_") + .or_else(|| id.strip_prefix("sess_")) + .filter(|uuid| !uuid.is_empty()) + else { + return id.to_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 redact_channel_tests { + const CHANNEL: &str = "acp_00000000-0000-0000-0000-000000000000"; + const SESSION: &str = "sess_00000000-0000-0000-0000-000000000000"; + + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id, and + /// identical across the two forms one session is addressed by. + /// + /// `#12b9377c` is the uuid's tag, shared with `redact_id` below and with `openab-core`'s + /// `redact_session_ids`. It used to be `#850414fa` here, the hash of the whole `acp_` + /// string, which is why one session could appear under two tags depending on which log you were + /// reading. + #[test] + fn an_acp_id_hashes_its_uuid_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel(CHANNEL), + "#12b9377c", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel(SESSION), + "#12b9377c", + "both forms of one session must share a tag — hashing the prefix is what split them" + ); + 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" + ); + } + + /// Two redactors in ONE crate disagreeing is what produced the split in the first place, so this + /// compares them directly instead of trusting that both literals were updated together. + #[cfg(feature = "acp")] + #[test] + fn the_channel_tag_matches_this_crates_other_redactor() { + for id in [CHANNEL, SESSION] { + assert_eq!( + super::redact_channel(id), + crate::adapters::acp_server::redact_id(id), + "redact_channel and redact_id must tag {id} identically" + ); + } + } +} 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..a18dd088f 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,70 @@ 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 uuid is hashed, not the prefixed string.** One session is addressed as `acp_` here +/// and as `sess_` in the gateway; hashing the whole string gives those two forms a different +/// tag each, and a third different again from `openab-gateway`'s `redact_id` and `openab-core`'s +/// `redact_session_ids`, which strip the prefix first. Several tags for one session defeat the only +/// reason to keep an identifier here at all — following that session from the audit log into the +/// tunnel log. +/// +/// Copies of this function live in `openab-gateway` and `openab-core` because these crates +/// deliberately do not depend on one another. This crate has no second redactor to compare against, +/// so the shared vector is asserted as a literal; the other two compare against their own. +fn redact_channel(id: &str) -> String { + let Some(uuid) = id + .strip_prefix("acp_") + .or_else(|| id.strip_prefix("sess_")) + .filter(|uuid| !uuid.is_empty()) + else { + return id.to_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 redact_channel_tests { + /// The tag for a given session must be IDENTICAL in every crate that logs a channel id, and + /// identical across the two forms one session is addressed by. + /// + /// `#12b9377c` is the uuid's tag, shared with `openab-gateway`'s `redact_id` and + /// `openab-core`'s `redact_session_ids`. It used to be `#850414fa` here, the hash of the whole + /// `acp_` string, which is why the facade audit log and the tunnel log could describe one + /// session under two different tags — and did, reading as zero overlap between them. + #[test] + fn an_acp_id_hashes_its_uuid_to_the_shared_vector_and_others_pass_through() { + assert_eq!( + super::redact_channel("acp_00000000-0000-0000-0000-000000000000"), + "#12b9377c", + "ACP channel ids must hash to the tag the other crates produce for the same session" + ); + assert_eq!( + super::redact_channel("sess_00000000-0000-0000-0000-000000000000"), + "#12b9377c", + "both forms of one session must share a tag — hashing the prefix is what split them" + ); + 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..c2f0e729e 100644 --- a/crates/openab-mcp/src/mcp/sources.rs +++ b/crates/openab-mcp/src/mcp/sources.rs @@ -53,10 +53,17 @@ pub trait CapabilitySource: Send + Sync { fn provider(&self) -> &str; /// The advertised tool set. `ctx` is `None` for anonymous clients. - /// Sources may vary the set by session, but static-advertising - /// regardless of backend attachment (D4, #1447) is the recommended - /// default — availability problems belong in call errors, not in - /// catalog flapping. + /// + /// Sources may vary the set by session. Availability problems belong in call + /// errors, not in catalog flapping — a backend that detaches for a moment + /// must not make its tools vanish and reappear. + /// + /// This used to recommend *static-advertising regardless of backend + /// attachment* (D4, #1447). That is no longer achievable for a tunnel-backed + /// source: D-20 deleted the built-in catalog that let one advertise before + /// its backend had ever spoken, so such a source now publishes nothing until + /// its first discovery round. The surviving rule is the narrower one above — + /// do not shrink a catalog you have already published. fn tools(&self, ctx: Option<&SessionCtx>) -> Vec; /// Execute one tool. Returns `(payload, is_error)` mirroring the MCP @@ -88,15 +95,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 +123,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 +135,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 +201,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..e30b0477c 100644 --- a/docs/adr/acp-server-websocket-base.md +++ b/docs/adr/acp-server-websocket-base.md @@ -217,9 +217,14 @@ 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 + the core-side capability source, and the generated v1 wire types). That +> source was called the "core MCP proxy" while a per-session proxy existed; both the proxy and the +> stdio bridge were removed in the same PR. 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 +334,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..69698933a --- /dev/null +++ b/docs/adr/acp-server-websocket-reverse-mcp.md @@ -0,0 +1,705 @@ +# 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). + +### 3.1 Advertising the capability + +`handle_initialize` advertises MCP-over-ACP support as a **reverse-DNS-namespaced `_meta` key** +under `agentCapabilities.mcpCapabilities._meta`: `"dev.openab/acp": true` — the reverse-DNS of +openab's domain (`openab.dev`) plus the capability key, following the **2026-07-28 MCP `_meta` +namespaced-key convention** (SEP-1788), whose own reserved keys look like +`io.modelcontextprotocol/logLevel`. + +Two 2026-07-28 mechanisms are easy to conflate, so name them apart: this is the **`_meta` +namespaced-key convention** (a reverse-DNS key on the free-form `_meta` map), **not** the separate +**typed `extensions` map**. openab uses the former. It rides `_meta` because the vendored v1 +`McpCapabilities` has only `http`, `sse` and a free-form `_meta`; adding an `extensions` field would +fork the generated wire types, which this PR deliberately avoids (F1(a)). This is alignment to the +`_meta` convention, **not** a core-field divergence — the review's original ask (a core +`mcpCapabilities.acp` field) is the pre-convention direction, and a bare `_meta.acp` key (an earlier +draft) is the informal form the reserved-key convention supersedes. The capability would move to the +typed `extensions` map if and when the vendored schema gains it. + +## 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]
openab authors .openab/mcp-facade.json — operator wires it in
static {url, Bearer ${OPENAB_SESSION_TOKEN}}
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, name:"katashiro" ] + 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: author .openab/mcp-facade.json (the ONE file openab owns)
{url, Authorization: Bearer ${OPENAB_SESSION_TOKEN}}
operator puts the entry in front of their agent + 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. Since D-29 + it applies no allowlist or tool-pin gate — admission is the `/acp` transport auth (§6.4) — so it + forwards any tool a connected server declares; the only refusal is not-connected. + +### 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`) +resolves by 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. +- Routing and discovery are keyed by **`name`**: `resolve_by_name` and `attached_server_names` both + work in declared names, since an `id` scheme would be meaningless when ids are per-connection UUIDs. + (This bullet once said "trust gating (§6.4) is keyed by name"; D-29 removed the allowlist, but the + name-keying it motivated survives in the routing path.) +- **Same-name collisions resolve by rank, `(connection_generation, generation)`:** a newly attached + tunnel whose `name` matches an existing one on the same channel **replaces and evicts** the older + entry only if it outranks it. 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. The rule keeps reconnect + self-healing; the eviction is what stops unbounded growth. + + **Not plain last-attach-wins, and the difference is the defect.** This document originally said + LWW unqualified. Attach order is stamped when an establish *starts*, so an older connection's LATE + `session/resume` spawns an establish with a HIGHER attach number — precisely because it ran later + — and under plain LWW it would evict the newer connection's live tunnel. **Connection age** is + what says which declaration set is current, so it is compared first; attach order is only the + tiebreak *within* one connection, where the connection ages are equal and last-attach-wins is + exactly right. Left uncorrected, this bullet is the copy that could get the code "restored" back + into that bug. + +### 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 `RootAcpTunnel`. +- `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~~ — + **routing is by what was discovered, not by the name's shape** (F5), and since 2026-08-01 both the + advertised names and the routes come from **one catalog** built per channel: every advertised name is + paired with the `(declared_server, published_tool)` that produced it. Resolving the route separately + from the advertised name is what let a colliding name be advertised with one server's schema and + dispatched to another's tunnel. The declared **`name`** (never the registry key) resolves to a tunnel + as `name` → `(channel_id, id)` via the recorded declaration (§6.1). The tool name forwarded over the + tunnel is the name the server **published** (`katashiro.click`), since that is what the server's own + `tools/call` expects. +- **Colliding tool names are named apart, not shadowed.** With no allowlist (D-29) and keyless + loopback (D-30), two declared servers may publish the same literal tool name. The **keeper** of a + published name advertises it verbatim — the prefix's namesake if the name is `.<...>` and one + publisher is `` (the D-34 shadowing mitigation, promoted from a routing tiebreak to a naming + rule), otherwise the lexicographically-first publisher — and every other publisher of that name is + advertised as `.`, routed to its own tunnel and forwarded under the + name it published. Before this the second publisher was advertised but unreachable: the facade's + `:` alias is built from the source's single provider string, which cannot tell two of + *its own* servers apart, so both names dispatched to the same one. That alias still resolves + shadowing against `mcp.json` servers, which is the collision it was designed for. +- **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.) The example extension (`katashiro`) has a + **static** tool set and never emits it, so nothing is lost today. +- **Server-initiated inbound MCP is deliberately not implemented, and this is spec-aligned + (2026-08-01, D-34).** The shipped tunnel is **gateway-initiated** — the gateway asks + (`initialize` / `tools/list` / `tools/call`) and the extension answers. Inbound `mcp/message` is + not carried: neither server-originated **requests** (`sampling/createMessage`, `elicitation/create`, + `roots/list`) nor push **notifications** (`tools/list_changed`) have a dispatch arm. This tracks + the 2026-07-28 MCP spec, which is retiring exactly that surface: server-initiated requests are + **deprecated** (SEP-2577) and redesigned into multi-round-trip requests (SEP-2322 — + `InputRequiredResult` / `inputRequests`, the client re-issuing with `inputResponses`); + change-notification **delivery** is moving off HTTP GET/SSE push to `subscriptions/listen` with + cache-expiry + refetch preferred; and the streamable-HTTP **session** model (`Mcp-Session-Id`) is + being removed as the transport goes stateless. openab will adopt the new mechanisms if and when a + real client-provided server needs them, rather than build a form already on a deprecation offramp. + See the tunnel contract §4. +- **A catalog that does not flap is the right posture**, per the facade's source contract — + implemented as *dynamically sourced, then cached*, because tools for arbitrary declared servers + cannot be hardcoded. (This bullet said "static-advertise is the right posture" until D-20 deleted + the built-in catalog; advertising before a server has spoken is no longer possible, so the posture + is discover-then-hold.): + 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. What `tools(ctx)` varies by session is the +**discovery cache** keyed by channel. Since D-29 it iterates the servers **attached** to the channel +(union the names already discovered for it), not an operator policy map — so what appears is what the +client actually connected and declared, and a name that is neither attached nor cached contributes +nothing. ~~A pinned server is advertised even when the client declared nothing~~ — that stopped +holding when D-20 deleted the seed, and D-29 makes it moot: there is no pin and no policy, only what +an attached server publishes. 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. + +**One layer now — the discovery cache** (an earlier draft described a lower *policy* layer as well; +D-29 removed the allowlist, so there is no filter table beneath the cache): + +- ~~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 seed mechanism was removed on 2026-07-30 + (D-20), and the policy entry itself on 2026-07-31 (D-29).** There is no seed and no filter: a server + advertises nothing until its first `tools/list` returns (the cold-start window), and then advertises + exactly what it published. D4's "discoverable before the extension connects" no longer holds. +- The per-`(channel_id, name)` cache holds what the server published, and since D-29 that is exactly + what is advertised — there is no allowlist to intersect against, so no narrowing on read. Caching is + not itself the admission: the `/acp` transport auth (§6.4) already admitted the server; the cache + only decides **when** its tools become visible, one discovery round after it attaches. +- **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 rank rule, so the name is a safe key. +- **An entry also records the `server_id` it was fetched from, and a changed id refetches** + (2026-08-01). Surviving a reconnect is the point of name-keying, but nothing compared the surviving + entry against the connection now attached, so a server that reconnected with a different tool set + served its predecessor's catalog for the rest of the session — and no `tools/list_changed` is coming + to invalidate it, the tunnel being gateway-initiated (§6.3 above). `tools()` therefore fetches when + the cached id differs from the one now resolved, and keeps serving the inherited set until the + refetch lands, so the refresh never shrinks the catalog. A fetch that started before the reconnect + and lands after it writes the superseded set under the *old* id, which the next round sees as a + mismatch and refetches — the staleness is self-correcting rather than sticky. This is + cache-expiry-and-refetch, the mechanism the 2026-07-28 spec prefers over push invalidation. +- Discovery is **pull-triggered**: an attached server with no cache entry has its fetch started from the + next `tools(ctx)` call, and its real set appears one discovery round later. `tools()` drives this from + `attached_server_names` (the per-channel enumeration re-introduced by D-29), resolving each name to a + tunnel through the single `resolve_by_name`. 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 name contributes nothing only when it is neither attached now nor already in the cache — because + there is nothing to show, not because a policy denied it. A momentary detach does not drop a name + that is still cached (the union above), which is what keeps flapping out of the catalog. + +**Ordering consequence (as reasoned at the time).** ~~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 landed **first**, which was the decision this +paragraph drove. + +Both halves of the premise are now false: entries carry no schemas (D-20 deleted the catalog) and the +configuration surface has shipped. The conclusion inverted with them — the discovery cache is not +invisible but **load-bearing**, because it is the only source of schemas. This is also the argument +retracted in the status comment: "discovery is unnecessary because the schemas are hardcoded" rested +on the seed that was deleted. + +### 6.4 Trust — the `/acp` transport is the gate (D-29, reversing D-20) + +#1454 states that source registration *is* the operator's grant, and that sources therefore carry no +per-source `tool_filter`. `AcpTunnelSource` was originally treated as the exception, because its tool +set is declared by a **remote client** rather than chosen by the operator: an earlier design added an +operator allowlist of accepted declared server names plus a per-server deny-all `tool_filter`, so a +connected extension could not publish arbitrary tools into the agent's catalog. + +**That allowlist was removed on 2026-07-31 (D-29), reversing the D-20 fail-closed default.** The +trust boundary is now the `/acp` transport alone, so it is worth stating plainly what that boundary +is **in the default configuration** rather than leaving it implicit in the base ADR. `/acp` has two +auth layers (base ADR §2): a shared bearer key (`OPENAB_ACP_AUTH_KEY`) and, in keyless mode, a +browser `Origin` allowlist (`OPENAB_ACP_ALLOWED_ORIGINS`). **By default both are unset.** In that +default keyless-loopback mode `/acp` binds loopback only, the `Origin` check rejects **browsers** +whose origin is not allowlisted — but a request with **no `Origin`, i.e. a non-browser local client, +is admitted with no credential at all** (base ADR §2; the endpoint is "unauthenticated by default"). +So in the shipped default the effective admission gate is **loopback reachability**: any non-browser +process on the host can attach a `type:acp` server and publish callable tools. Setting +`OPENAB_ACP_AUTH_KEY` is what turns that into actual authentication. + +Since D-29 removed the operator allowlist, this transport admission is the **only** gate — there is +no second `[[mcp.acp_servers]]` check behind it — which is exactly why the default posture is +load-bearing and named here. A connected server publishes every tool it declares, and the capability +source applies no name or tool filter; the one refusal left in the source is **not-connected**, a +liveness answer rather than a permission one. Where the transport IS keyed, the reasoning that +retired the allowlist still holds: the client a deployment trusts with the bearer is the same one +whose tools a `[[mcp.acp_servers]]` allowlist would have re-approved by name. + +History, so the reversal is not read as drift: the gate began (D-20 and before) as **fail-closed +deny-all** — an absent or empty `[[mcp.acp_servers]]` admitted nothing, and each listed server was +pinned to an explicit tool set (the `katashiro` entry to its five known tools; every other server +deny-all until an operator listed tools). D-29 removed the section entirely, and +`#[serde(deny_unknown_fields)]` now makes a config still carrying it fail to parse, so a stale +allowlist announces itself rather than looking effective. + +> ⚠️ **One gap between this section and the code remains, recorded rather than quietly reworded.** +> +> The allowlist-refusal logging gap is **gone with D-29**: there is no allowlist refusal or pinned-tool +> drop to log any more, only the visible not-connected error result the agent already receives. +> +> **The policy runtime still 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 (`[mcp] tunnel_timeout_seconds`), not by the facade's. **Filed as follow-up F7.** + +### 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 was deleted outright on 2026-07-31 (D-23), once it was verified never to have shipped outside this branch — not in `origin/main` and not in any release — so it is neither read nor reported, and nobody upgrading can have it set. + +**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/acp_tunnel_source.rs` +(renamed from `browser_source.rs`) implements `CapabilitySource` over the existing `AcpMcpTunnel` — +`requires_session()`, a catalog that does not shrink on detach 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 key of the entry +inside `.openab/mcp-facade.json` — a different thing, and no longer a key openab writes into anyone +else's `mcp.json`). 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)).~~ + +That premise is struck rather than rewritten, because it is the *statement of what the divergence +was*: editing it into present truth would leave a resolution with nothing to resolve. As of +`30e04758`, `write_facade_mcp_config` authors `.openab/mcp-facade.json` and edits no CLI config at +all, which is what removes the conflict — see the resolution note immediately below. +~~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 was SETTLED on +2026-07-31 (D-21, shipped in `54223aea`): openab does not pass it. `[agent]` is an opaque +command line spawned verbatim, so the operator adds the flag to `args` themselves. Having +openab identify the vendor at spawn time was rejected — this codebase negotiates capability +from the protocol and deliberately has no vendor-identity concept. + +**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 and routes on the `.` prefix, resolving the declared + name to its tunnel. It no longer *enumerates* — `tunnel.servers(channel_id)` was deleted because + enumerate-and-match was the wrong route (`74315a60`), and `builtin_catalogs` was deleted by D-20, + so browser-ness is not data here either: openab holds no catalog at all. What makes the source + generic is that it knows only names the operator listed. +- ~~**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).~~ **Implemented in #1447, then REMOVED (D-29, 2026-07-31).** It landed as + `ServerPolicy` / `policy_from_config` over `[[mcp.acp_servers]]`, enforced in both `tools()` and + `call()`; D-29 reversed the whole approach — the `/acp` transport auth is the gate, so the + allowlist and per-server `tool_filter` are gone and a connected server publishes every tool it + declares (§6.4). +- ~~**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 remaining §6.4 gap** — (a) ~~log a warning when a declared server is refused by the + allowlist and when a fetched tool is dropped by the pin~~ **dissolved by D-29**: there is no + allowlist refusal or pinned-tool drop any more, only the visible not-connected error result; (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 — a code + change, 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, an attached server's tools stay in the catalog regardless of WS + state once discovered; a `tools/call` with no extension attached returns an MCP error ("browser + not connected") rather than the capability disappearing. ~~Tools are **static-advertised** + regardless of WS state~~ — before discovery has run there is nothing to advertise (D-20). `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~~ — it is not; what + is kept is that a discovered catalog does not shrink, 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/acp_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. `AcpTunnelSource::call` in the facade + capability source maps mcp#7 <-> acp#55. + + This said "the proxy maps" until 2026-07-31. The per-session proxy was removed on + 2026-07-28 and this is §7.3 Runtime detail — a description of how the CURRENT + round-trip works, not history — so the sentence named a component that no longer + exists. The hop in step 4 is the facade's in-process capability source; the shape of + the diagram is unchanged, only what performs the hop. + - 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 the `katashiro.*` capabilities the connected extension declares, 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 (2026-07-26):** 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. **Reversed again (2026-07-30, D-20):** the + built-in catalog that made static-advertise possible was deleted, so the posture is now + discover-then-hold — nothing is advertised before a server's first `tools/list`. The `list_changed` + and no-opt-in halves still stand. + +## 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..451a39a40 100644 --- a/docs/adr/oab-mcp-adapter.md +++ b/docs/adr/oab-mcp-adapter.md @@ -428,11 +428,13 @@ listen = "127.0.0.1:8848" # loopback only; this is the default facade; `openab-agent` re-exports the same crate. One implementation, two hosts — no duplicated runtime, which this ADR forbids. - **Security posture:** the listener refuses to bind any non-loopback - address; the endpoint itself carries no authentication layer in the MVP, - so the host/pod boundary is the trust boundary. Every process on the host - that can reach loopback can call authorized capabilities — deployments - that colocate untrusted processes must not enable `[mcp]`. A token or - socket-permission scheme is a documented follow-up. + address; the endpoint itself carries no authentication layer, + so the host/pod boundary is the trust boundary for every capability that + does not require a session. Deployments that colocate untrusted processes + must not enable `[mcp]`. Session-bound sources are the exception: the + per-session bearer shipped in #1447, so the facade hides and refuses them + unless the request resolves to a session. A socket-permission scheme for + the remaining session-free capabilities is still a documented follow-up. - **Native `openab-agent`:** the dispatcher is invoked in-process via the existing `mcp` meta-tool. No HTTP hop is required because the facade contract and policy checks are implemented by the same dispatcher component @@ -443,7 +445,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 +681,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..75a885bd6 --- /dev/null +++ b/docs/browser-mcp-agent-setup.md @@ -0,0 +1,214 @@ +# 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. Two earlier designs — a per-session `proxy` and an +`openab browser-bridge` stdio relay — existed during development and were removed before any of this +shipped. See [Removed transports](#removed-transports) if you ran a development build of 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 at startup whether the facade is running, so that much is never something you have to +infer: + +``` +INFO browser control: the OAB MCP Facade is running ([mcp] configured), and openab has written + its entry to /.openab/mcp-facade.json. openab does NOT modify your agent's MCP + config, so browser tools stay unavailable until that entry is in place. +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 the facade or its audit +> trail, so until you remove it the transport-authed facade is not the only way to the browser. 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. + +--- + +## How browser control works + +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" +``` + +That is the whole `[mcp]` section. **There is no operator allowlist** — the `[[mcp.acp_servers]]` +block was removed in D-29 (reversing D-20's fail-closed default), so a config still carrying it now +fails to parse rather than being silently ignored. Any `type:acp` server that authenticates to +`/acp` and attaches may publish the tools it declares; admission is the transport auth +(`OPENAB_ACP_AUTH_KEY`, or loopback + `OPENAB_ACP_ALLOWED_ORIGINS`), because the extension already +authenticates to reach the tunnel and a second config allowlist duplicated that intent. + +- **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. | +| **Claude Code** | Add the flag to `[agent] args` in `config.toml` yourself — see below. openab does not add it for you. | +| **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. | + +#### Claude Code: `--mcp-config`, and why openab does not pass it for you + +`[agent]` is an opaque command line — `command` plus `args`, spawned verbatim — so **you already +control this with no code on our side**: + +```toml +[agent] +command = "claude-agent-acp" +args = ["--mcp-config", "/home/agent/.openab/mcp-facade.json"] +``` + +openab deliberately does not add that flag itself. Doing so would mean deciding *which vendor you +are running*, and nothing in openab identifies a vendor: by spawn time the agent is a command +string, and this codebase negotiates capability from the protocol rather than sniffing the binary. +Guessing from the command name would break for absolute paths, wrappers and renamed binaries, and +it would put openab back inside a decision that is yours. kiro runs an import, Claude Code takes a +flag; both are your step, and openab touches neither. + +**`--strict-mcp-config` is your call, and it is a real trade-off — read both halves:** + +| | What you get | What it costs | +|---|---|---| +| **with** `--strict-mcp-config` | *Only* the file you name is loaded, so the facade is the sole MCP source and nothing else can shadow it | **Every MCP server you configured for yourself is silently dropped.** Not an error — they simply do not appear | +| **without** it | Your own MCP servers keep working alongside the facade entry | A leftover `openab-browser` bridge entry from an older openab **also** keeps working — and that is a route to the browser that does **not** pass through facade policy or the audit trail | + +The second row is the one to act on rather than skim. openab no longer removes that stale entry +(it stopped editing your config), so if this deployment ever ran bridge mode, clear it with the +`jq` snippet above — until then, the facade and its audit trail are not the only way to the browser. + +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 the tools the connected server declares, once discovery has run +``` + +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. + +**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. + +**Then remove any leftover `openab-browser` entry yourself — neither the bridge one nor the proxy +one is cleaned up for you.** openab stopped editing files it does not own, and that cleanup went +with it. This is worth doing rather than deferring: a surviving bridge entry is a *working* route +to the browser that does not pass through facade policy or the audit trail, so until it is gone the +facade and its audit trail are not the only way to the browser. The shape-matched `jq` snippet earlier in +this document removes it without touching a same-named server of your own. + +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..4d1818f1c --- /dev/null +++ b/docs/mcp-over-acp-tunnel-contract.md @@ -0,0 +1,215 @@ +# 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` (gateway-initiated) + +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. In + the shipped tunnel these travel **gateway → extension only** — e.g. `notifications/initialized` + below. The wire is bidirectional and the extension MAY send a server-originated notification + upward, but the gateway does **not** consume an inbound `mcp/message` today (it has no dispatch + arm for it), by the deliberate choice recorded in the note below. + +> **Server-initiated MCP is deliberately not implemented, and this is spec-aligned.** The shipped +> tunnel is **gateway-initiated**: the gateway asks (`initialize`, `tools/list`, `tools/call`) and +> the extension answers — the only direction the example exercises. Server-originated **requests** +> (`sampling/createMessage`, `elicitation/create`, `roots/list`) and push **notifications** +> (`tools/list_changed`) are **not carried inbound**. This is not an oversight: the 2026-07-28 MCP +> spec is retiring exactly that surface. +> - Server-initiated requests are **deprecated** (SEP-2577) and redesigned into multi-round-trip +> requests (SEP-2322: the server returns `InputRequiredResult` / `inputRequests` and the client +> re-issues with `inputResponses`) — a 12-month offramp for the old server→client request form. +> - Change-notification **delivery** is moving off HTTP GET/SSE push to `subscriptions/listen`, with +> cache-expiry + refetch as the preferred, lower-push way to keep catalogs fresh. +> - The streamable-HTTP **session** model (`Mcp-Session-Id`, the initialize handshake) is being +> removed as the transport goes stateless. +> +> The example extension (`katashiro`) has a **static** tool set and never emits `tools/list_changed`, +> so there is no consumer to build for today. openab will align with the new mechanisms +> (`subscriptions/listen`, multi-round-trip requests) if and when a real client-provided server needs +> them, rather than shipping a form already on a deprecation offramp. + +### 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), as served by the example `katashiro` extension. OpenAB +ships no catalog of its own: what appears is what the connected extension publishes — there is no +operator allowlist to intersect against since D-29 removed `[[mcp.acp_servers]]`, so admission is +the `/acp` transport auth and every declared tool is served — and nothing appears until the +extension's first `tools/list` returns. `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 on the next +`tools/list` discovery fetch. The gateway does **not** consume an inbound `tools/list_changed` push +(see §4) — a changed set is picked up by re-discovery, not by a server-originated 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 **170s** | one `mcp/message` request. 180s is the *ceiling*, not the default: it is the agent-side `ACP_PROMPT_IDLE_TIMEOUT_SECS`, and the 170s default keeps a margin under it so a hung tunnel call fails as a tunnel timeout rather than as a prompt-idle timeout. A larger value is accepted and simply loses that margin | +| 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 facade listener 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..e3292303a 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): @@ -115,8 +128,10 @@ claude mcp add --transport http oab http://127.0.0.1:8848/mcp ## Trust model Loopback-only, **no authentication layer** — the host/pod boundary is the -trust boundary. Any process on the host can invoke every authorized -capability while the facade runs. Do not enable it on hosts that colocate +trust boundary. Any process on the host can invoke every capability that does **not** require a session. +Session-bound sources are different: since the per-session bearer shipped, the facade filters +them out unless the request resolves to a session, so a host process without that token can +neither discover nor call them. Do not enable it on hosts that colocate untrusted processes. Least-privilege is enforced per provider with `tool_filter` include/exclude lists in `mcp.json`; excluded tools are invisible to discovery and rejected at execution. 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/acp_tunnel.rs b/src/acp_tunnel.rs new file mode 100644 index 000000000..a93eac878 --- /dev/null +++ b/src/acp_tunnel.rs @@ -0,0 +1,98 @@ +//! 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::acp_mcp::AcpMcpTunnel; +use openab_gateway::adapters::acp_server::AcpTunnelRegistry; +use serde_json::Value; + +pub struct RootAcpTunnel { + 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 RootAcpTunnel { + pub fn new(registry: AcpTunnelRegistry, timeout_secs: u64) -> Self { + Self { + registry, + timeout_secs, + } + } +} + +#[async_trait::async_trait] +impl AcpMcpTunnel for RootAcpTunnel { + 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, + ) + } + + /// Delegates to the gateway, which owns the registry. Names only; the caller resolves each via + /// `resolve_by_name`, so this crate never collapses name→id itself (it cannot rank two tunnels — + /// the ordering fields are private to the gateway). + fn attached_server_names(&self, channel_id: &str) -> Vec { + openab_gateway::adapters::acp_server::attached_server_names(&self.registry, channel_id) + } +} diff --git a/src/acp_tunnel_source.rs b/src/acp_tunnel_source.rs new file mode 100644 index 000000000..25440f6bb --- /dev/null +++ b/src/acp_tunnel_source.rs @@ -0,0 +1,1265 @@ +//! 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. +//! +//! **Routing is by what the server published, not by the tool name's shape** +//! (F5). A tool is routed to the server whose discovered `tools/list` contained +//! it — a generic server may publish a bare `build`, or a name whose first +//! segment is not its own, and both stay callable. The declared server `name` +//! (from `{type:"acp", id, name}`; the client mints a fresh `id` per connection +//! while `name` is stable) then resolves to `(channel_id, id)` through the +//! registry's `resolve_by_name`, and the published tool name is forwarded +//! unchanged because the server's own `tools/call` expects it. The `.` +//! prefix is only a pre-discovery fallback for routing. +//! +//! **One catalog builds both the advertised names and the routes** ([`catalog`]). +//! Looking the publisher up per call was not enough once two servers could +//! publish the same name: the catalog advertised both, the second was +//! unreachable (nothing addressed it), and which of the two schemas was shown +//! for the shared name depended on iteration order while the call resolved +//! elsewhere. Every advertised name is now paired with the +//! `(declared_server, published_tool)` that produced it, in one deterministic +//! construction that `tools()` advertises and `call()` routes by — so a name +//! that was advertised is callable, and reaches the server whose schema was +//! shown for it. Collisions are named apart rather than dropped: the keeper of +//! a published name advertises it verbatim and the rest appear as +//! `.`. +//! +//! **Admission is the transport, not an allowlist** (D-29, reversing D-20). +//! There is no operator `[[mcp.acp_servers]]` gate: any server that authenticates +//! to `/acp` and declares itself may publish every tool it lists, because the +//! extension already authenticates to reach the tunnel and a second allowlist +//! duplicated that intent. Discovery is therefore driven by what is *attached*, +//! not by a configured list — `tools()` enumerates `attached_server_names` and +//! resolves each to an id through `resolve_by_name`. Names only, one collapse +//! rule: the enumerating `tunnel.servers(channel_id)` deleted in `74315a60` +//! (which collapsed same-name entries in registry order rather than by +//! generation, resolving to a stale tunnel) is *not* revived — the new +//! enumerator returns names, and the single `resolve_by_name` still does every +//! name → id resolution beside the eviction that makes it unique. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use openab_core::acp_mcp::AcpMcpTunnel; +use openab_mcp::mcp::sources::{CapabilitySource, SessionCtx}; +use openab_mcp::rmcp::model::Tool; +use serde_json::{json, Map, Value}; + +/// Facade capability source backed by MCP-over-ACP tunnels to client-declared +/// MCP servers. +pub struct AcpTunnelSource { + tunnel: Arc, + /// 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, and that is what is advertised — with the + /// allowlist gone (D-29) there is no read-time filter to narrow it. + /// + /// Each entry records the `server_id` it was fetched from, so a name-keyed + /// entry that survived a reconnect can still be recognised as describing the + /// *previous* connection and refetched. + 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>>; + +/// One server's discovered tool set, and the connection it was fetched from. +#[derive(Clone)] +struct Discovered { + /// The `server_id` whose `tools/list` produced `tools`. + /// + /// The cache is keyed by declared *name* so an entry survives a reconnect (the client mints a + /// fresh id each time), which is what keeps the catalog from collapsing mid-session. That same + /// property made a reconnected server serve its predecessor's catalog forever: nothing compared + /// the entry against the connection now attached. Recording the id is what lets `tools()` tell a + /// surviving entry from a current one. + server_id: String, + tools: Vec, +} + +/// `(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 +} + +/// One advertised capability, with the identity it routes by. +struct CatalogEntry { + /// The name the facade publishes: the published name when this server keeps it, otherwise + /// `.`. + advertised: String, + /// Declared name of the server that published it — what `resolve_by_name` takes. + server: String, + /// The name the server published, forwarded verbatim in `tools/call` because that is the only + /// name the server itself knows. + published: String, + /// The advertised `Tool`: the published one, renamed to `advertised`. Its schema therefore + /// always belongs to `server`, the tunnel the call will reach. + tool: Tool, +} + +/// Which server keeps a published name when several publish it: the prefix's namesake if there is +/// one, else the lexicographically-first. +/// +/// The namesake preference is the D-34 shadowing mitigation, promoted from a routing tiebreak to a +/// naming rule. Content routing + no allowlist (D-29) + keyless loopback (D-30) let a second local +/// server attach and publish the same literal name, so a tool published as `.<...>` must +/// stay with the server actually called `` rather than fall to whoever sorts earlier. +/// Key-gated: moot once `OPENAB_ACP_AUTH_KEY` is set. A truly bare name has no prefix to appeal to, +/// so it falls to the lexicographic minimum — arbitrary, but deterministic, and the loser is now +/// named apart rather than shadowed. +fn keeper<'a>(published: &str, publishers: &[&'a str]) -> &'a str { + if let Some((prefix, _)) = published.split_once('.') { + if let Some(namesake) = publishers.iter().copied().find(|server| *server == prefix) { + return namesake; + } + } + publishers + .iter() + .copied() + .min() + .expect("a published name has at least one publisher") +} + +/// Build the advertised catalog from one channel's discovered sets: one entry per +/// `(server, published tool)`, each under a **unique** advertised name. +/// +/// Deterministic by construction — `discovered` is sorted, each server's tools are sorted, and the +/// keeper of a colliding name is chosen by rule rather than by arrival. Two servers publishing the +/// same name previously produced two entries under one name: the facade advertised the second under +/// an alias built from the source's own provider string, which could not tell the two apart, so the +/// alias and the bare name both dispatched to the same server and the second server's tool was +/// advertised but unreachable. +fn catalog(discovered: &BTreeMap) -> Vec { + let mut publishers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (server, entry) in discovered { + for tool in &entry.tools { + publishers + .entry(tool.name.as_ref()) + .or_default() + .push(server.as_str()); + } + } + let keepers: BTreeMap<&str, &str> = publishers + .iter() + .map(|(published, servers)| (*published, keeper(published, servers.as_slice()))) + .collect(); + + // `(server, tool)` in the order both passes below walk them. + let mut entries: Vec<(&String, &Tool)> = Vec::new(); + for (server, entry) in discovered { + let mut published: Vec<&Tool> = entry.tools.iter().collect(); + published.sort_by(|a, b| a.name.cmp(&b.name)); + entries.extend(published.into_iter().map(|tool| (server, tool))); + } + + let mut advertised: Vec> = vec![None; entries.len()]; + let mut used: HashSet = HashSet::new(); + // Keepers first, so a rename below can never take a name some server actually published. + for (i, (server, tool)) in entries.iter().enumerate() { + if keepers.get(tool.name.as_ref()) == Some(&server.as_str()) + && used.insert(tool.name.to_string()) + { + advertised[i] = Some(tool.name.to_string()); + } + } + for (i, (server, tool)) in entries.iter().enumerate() { + if advertised[i].is_some() { + continue; + } + // Namespaced under the server that published it, which `keeper` then routes back to that + // server if the two ever collide again. The numeric suffix is the last resort for a server + // that publishes both `x` and its own `.x`, or the same name twice. + let mut candidate = format!("{server}.{}", tool.name); + let mut suffix = 2; + while !used.insert(candidate.clone()) { + candidate = format!("{server}.{}.{suffix}", tool.name); + suffix += 1; + } + advertised[i] = Some(candidate); + } + + entries + .into_iter() + .zip(advertised) + .map(|((server, tool), advertised)| { + let advertised = advertised.expect("both passes assign every entry"); + let mut renamed = tool.clone(); + renamed.name = advertised.clone().into(); + CatalogEntry { + advertised, + server: server.clone(), + published: tool.name.to_string(), + tool: renamed, + } + }) + .collect() +} + +impl AcpTunnelSource { + /// Source over the MCP-over-ACP tunnel. No operator allowlist (D-29): every + /// server that authenticates to `/acp` and declares itself may publish the + /// tools it lists, so what is admitted is decided by the transport, not by + /// config. The catalog is driven by what is actually attached (see + /// [`AcpTunnelSource::tools`]). + pub fn new(tunnel: Arc) -> Self { + Self { + tunnel, + 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, and since D-29 that + /// is also what is advertised — there is no allowlist to intersect against on + /// read. A tool appears in the catalog once discovery has fetched it and the + /// server is (or was) attached; see [`AcpTunnelSource::tools`]. + 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 { + // Stamped with the id it was fetched from. A fetch started before a reconnect can + // land after it and write the superseded set; the stamp makes that self-correcting, + // because the next round sees an id that no longer matches and refetches. + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.clone(), Discovered { server_id, 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); + }); + } + + /// This channel's discovered tool sets, `declared_name -> discovered`, in a **sorted** map so + /// the catalog built from it does not vary with `HashMap` iteration order. + fn discovered(&self, channel_id: &str) -> BTreeMap { + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + cache + .iter() + .filter(|((c, _), _)| c == channel_id) + .map(|((_, name), discovered)| (name.clone(), discovered.clone())) + .collect() + } + + /// The `(declared_server, published_tool)` an advertised name routes to, resolved through the + /// same [`catalog`] `tools()` advertises. + /// + /// One construction serves both directions, which is the point: resolving the route separately + /// from the advertised name is what let a colliding name be advertised with one server's schema + /// and dispatched to another's tunnel. + fn route(&self, channel_id: &str, advertised: &str) -> Option<(String, String)> { + catalog(&self.discovered(channel_id)) + .into_iter() + .find(|entry| entry.advertised == advertised) + .map(|entry| (entry.server, entry.published)) + } + + /// The `` segment of a `.` name, as a **pre-discovery fallback** for + /// routing. Once discovery has run, [`server_publishing`] is authoritative and this is not + /// consulted; before it, a prefixed name still yields a candidate server so a cold call reports + /// "not connected" (and the common browser case works) rather than "not available". A bare or + /// mismatched-prefix name has no candidate here and waits for discovery. + fn split_prefix(tool: &str) -> Option<&str> { + let (server, _rest) = tool.split_once('.')?; + if server.is_empty() { + return None; + } + Some(server) + } + + /// 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: every tool discovered from the servers connected + /// to this session, unfiltered (D-29 removed the allowlist — a connected + /// server publishes every tool it declares). + /// + /// Which servers to advertise is driven by what is **attached**, since there + /// is no configured list any more: the set of names is + /// `attached_server_names` UNION the names already discovered for this + /// channel. The union is what preserves §6.3 — attachment flapping must not + /// reach the catalog, so a tab closed for a second stays in the catalog via + /// its cache entry even while it is momentarily not in `attached_server_names`. + /// A newly attached server, conversely, is what introduces a new name. + /// + /// COLD START (§6.3, unchanged by D-29): with no seed, an attached server + /// publishes nothing until its first `tools/list` returns. Discovery is + /// *pull*-triggered — an attached server with no cache entry 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. + fn tools(&self, ctx: Option<&SessionCtx>) -> Vec { + // Anonymous clients never reach here in practice (`requires_session`), and + // with no channel there is nothing attached and nothing to discover. + let Some(ctx) = ctx else { + return Vec::new(); + }; + + // Snapshot this channel's discovered catalog once, rather than re-locking per name. + let discovered = self.discovered(&ctx.channel_id); + + // Servers to consider: attached now, UNION already-discovered (§6.3 no-shrink). Sorted, + // because the union decides which server keeps a colliding name. + let mut names: BTreeSet = self + .tunnel + .attached_server_names(&ctx.channel_id) + .into_iter() + .collect(); + names.extend(discovered.keys().cloned()); + + for name in &names { + // Resolve through the same route calls use (one resolution rule, §6.1); a name that + // appears only because it is still cached but detached resolves to None here and simply + // is not re-fetched, while its cached tools keep it in the catalog. + let Some(server_id) = self.tunnel.resolve_by_name(&ctx.channel_id, name) else { + continue; + }; + // Fetch when there is nothing cached (the cold-start window) and when what is cached + // came from a DIFFERENT connection: a name-keyed entry survives a reconnect by design, + // so a reconnected server would otherwise serve its predecessor's catalog for the rest + // of the session, with no `tools/list_changed` to invalidate it (and none coming — the + // tunnel is gateway-initiated). The stale set keeps being served until the refetch + // lands, so this refreshes without shrinking the catalog (§6.3). + if discovered.get(name).map(|d| d.server_id.as_str()) != Some(server_id.as_str()) { + self.spawn_discovery(&ctx.channel_id, name, &server_id); + } + } + + // Unfiltered: with the allowlist gone, every tool the server published is admitted — under + // the advertised name `call()` will route by. + sorted(catalog(&discovered).into_iter().map(|entry| entry.tool)) + } + + 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"))?; + + // Route through the advertised catalog, not by parsing the tool name (F5). A generic server + // may publish a bare `build` or a name whose first segment is not the server's own; both are + // callable because the catalog carries the publisher and the published name for every name + // it advertised. The `.` prefix is only a pre-discovery fallback (see + // `split_prefix`), where the name given is also the name to forward. No allowlist gate + // (D-29): the only refusal left is not-connected, a liveness answer. + let routed = self.route(&ctx.channel_id, tool).or_else(|| { + Self::split_prefix(tool).map(|prefix| (prefix.to_string(), tool.to_string())) + }); + let Some((server_name, published)) = routed else { + return Ok(Self::error_result(format!( + "tool {tool:?} is not available: no connected server has published it, and it \ + carries no . prefix to route by before discovery" + ))); + }; + + // 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" + ))); + }; + + // The PUBLISHED name, which differs from the advertised one when a collision named it apart: + // the server only knows what it published. + let params = json!({ "name": published, "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::acp_mcp::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, Tool}; + use openab_core::acp_mcp::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>>, + /// Per-declared-name tool lists returned verbatim (no prefix partition), so a test can give + /// a server an unprefixed tool (`build`) or a name whose first segment is not the server's. + server_tools: 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), + server_tools: std::sync::Mutex::new(std::collections::HashMap::new()), + 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 a specific server's `tools/list` return exactly `names`, verbatim — the tool names + /// need not be prefixed with the server's name. + fn set_server_tools(&self, server_name: &str, names: &[&str]) { + self.server_tools + .lock() + .unwrap() + .insert(server_name.to_string(), 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()) + } + + fn attached_server_names(&self, _channel_id: &str) -> Vec { + let mut names: Vec = self + .servers + .lock() + .unwrap() + .iter() + .map(|(name, _)| name.clone()) + .collect(); + names.sort(); + names.dedup(); + names + } + + 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); + // The name for this `server_id` comes from the registry. + let server_name = self + .servers + .lock() + .unwrap() + .iter() + .find(|(_, id)| id == server_id) + .map(|(n, _)| n.clone()); + // Prefer an explicit per-server list (returned verbatim, so it may be unprefixed); + // otherwise fall back to the shared list partitioned by the server's declared name. + // A real server returns only ITS OWN tools; the partition keeps one server's tunnel + // from appearing to publish another's now that the source applies no allowlist. + let explicit = server_name + .as_ref() + .and_then(|n| self.server_tools.lock().unwrap().get(n).cloned()); + let names = match explicit { + Some(list) => list, + None => { + let Some(shared) = self.tools_list.lock().unwrap().clone() else { + return Err("tools/list unavailable".into()); + }; + shared + .into_iter() + .filter(|n| match &server_name { + Some(name) => n + .split_once('.') + .is_some_and(|(prefix, _)| prefix == name), + None => true, + }) + .collect() + } + }; + // The schema carries its server's name, so a test can tell WHOSE schema was + // advertised for a name two servers published — the half of a collision that is + // invisible if you only check where the call landed. + let served_by = server_name.clone().unwrap_or_else(|| "-".to_string()); + let tools: Vec = names + .iter() + .map(|n| { + json!({ + "name": n, + "inputSchema": { + "type": "object", + "properties": { "served_by": { "const": served_by.as_str() } } + } + }) + }) + .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(), + } + } + + /// Let any spawned discovery task run to completion. + async fn settle() { + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + + // --- Routing (unchanged by D-29): the prefix selects the tunnel by NAME --- + + #[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::new(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" + ); + } + + // Inverted by F5: a bare name is no longer "malformed" — bare tools are legitimate and routed by + // discovery. What a bare name that NO connected server published earns is "not available", not a + // format complaint. (The tool here is never discovered — katashiro's tools/list is not fetched — + // and "click" has no prefix to fall back on, so both routing paths miss.) + #[tokio::test] + async fn an_undiscovered_bare_tool_is_reported_unavailable() { + let src = AcpTunnelSource::new(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("is not available")); + } + + // --- Admission is the transport, not an allowlist (D-29, reversing D-20) --- + + #[tokio::test] + async fn any_attached_server_is_callable_without_an_allowlist() { + // Inverted from `unlisted_server_name_is_refused_even_when_a_tunnel_is_attached`. With the + // operator allowlist gone (D-29), a server that authenticated to `/acp` and attached is + // admitted by the transport: its declared tool routes to its tunnel, not refused as + // "not in the allowlist". + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + let src = AcpTunnelSource::new(tunnel.clone()); + let (_v, is_err) = src + .call(Some(&ctx()), "notes.anything", &Map::new()) + .await + .unwrap(); + assert!(!is_err, "no allowlist gate: a connected server's declared tool dispatches"); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!(fwd[0].1, "uuid-n"); + assert_eq!(fwd[0].2["name"], "notes.anything"); + } + + #[tokio::test] + async fn every_tool_a_server_publishes_is_advertised_unfiltered() { + // Inverted from `caching_is_never_itself_a_grant`, which asserted that a published-but- + // unpermitted tool stayed OUT of the catalog and uncallable. There is no permit step any + // more: whatever a connected server publishes is advertised and callable. (Also supersedes + // `unpinned_tool_on_an_allowlisted_server_is_refused` — the "pinning" it guarded is gone.) + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.read_dom", "katashiro.exec"]); + let src = AcpTunnelSource::new(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.exec", "katashiro.read_dom"], + "both published tools appear — nothing is filtered out" + ); + + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.exec", &Map::new()) + .await + .unwrap(); + assert!(!is_err, "and the once-unpermitted tool is now callable"); + } + + // --- Liveness: not-connected is the only refusal left, and it is a liveness answer --- + + #[tokio::test] + async fn an_unattached_server_reports_not_connected() { + // Renamed from `allowlisted_but_unattached_server_reports_not_connected`. The refusal that + // survives D-29 is liveness, not permission: a name with no attached tunnel resolves to + // nothing. + let tunnel = FakeTunnel::with(&[]); + let src = AcpTunnelSource::new(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")); + } + + // --- Discovery drives the catalog from what is ATTACHED (§6.3, cold start) --- + + #[tokio::test] + async fn an_attached_server_has_no_schema_until_discovery_supplies_one() { + // Renamed from `an_allowlisted_tool_has_no_schema_until_discovery_supplies_one`. The + // cold-start window survives D-29; what admits the server is now attachment, not config, so + // the catalog is empty until the first `tools/list` returns over the tunnel. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + let src = AcpTunnelSource::new(tunnel.clone()); + assert!( + src.tools(Some(&ctx())).is_empty(), + "attached but not yet discovered: nothing to advertise until tools/list is fetched" + ); + } + + #[tokio::test] + async fn discovery_fills_the_catalog_for_an_attached_server() { + // Renamed from `discovery_fills_the_catalog_for_a_name_only_server`, and the assertion + // inverted: BOTH published tools now appear. The old test expected `["notes.list"]` because + // `notes.get` was "published but never permitted"; with no allowlist there is nothing to + // intersect against. + let tunnel = FakeTunnel::with(&[("notes", "uuid-n")]); + tunnel.set_tools_list(&["notes.list", "notes.get"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + 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.get", "notes.list"], + "every published tool appears — no allowlist to intersect against" + ); + } + + #[tokio::test] + async fn a_discovered_catalog_survives_detachment() { + // §6.3: attachment flapping stays out of the catalog. Discovery is driven by + // `attached_server_names`, but a name already in the cache keeps its tools even while it is + // momentarily not attached — the UNION in `tools()` is what preserves this. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.click", "katashiro.navigate", "katashiro.read_dom", "katashiro.screenshot", "katashiro.type"]); + let src = AcpTunnelSource::new(tunnel.clone()); + let _ = src.tools(Some(&ctx())); + settle().await; + let before: Vec = + src.tools(Some(&ctx())).iter().map(|t| t.name.to_string()).collect(); + assert!(!before.is_empty(), "precondition: discovery must have populated the catalog"); + + tunnel.detach("katashiro"); + let after: Vec = + src.tools(Some(&ctx())).iter().map(|t| t.name.to_string()).collect(); + assert_eq!(after, before, "a detached tunnel must not shrink the catalog"); + } + + #[tokio::test] + async fn a_failed_discovery_does_not_empty_an_already_populated_catalog() { + // Once a catalog has been discovered, a LATER failed fetch must not empty it — the + // invariant that protects a running deployment through a flap. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-abc")]); + tunnel.set_tools_list(&["katashiro.click", "katashiro.navigate", "katashiro.read_dom", "katashiro.screenshot", "katashiro.type"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + let discovered = src.tools(Some(&ctx())).len(); + assert!(discovered > 0, "precondition: discovery must have populated the catalog"); + + tunnel.fail_tools_list(); + let _ = src.tools(Some(&ctx())); + settle().await; + assert_eq!( + src.tools(Some(&ctx())).len(), + discovered, + "a failed discovery must not empty a catalog that was already populated" + ); + } + + #[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::new(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::new(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 + // `katashiro` and a second, non-browser server; both are discovered and callable, tool names do + // not collide, and each tool routes to the tunnel of the server that declared it. The agent-side + // leg (facade meta-tools → this source) is not covered here — facade mode is not live anywhere + // yet. + + fn two_server_src(tunnel: Arc) -> AcpTunnelSource { + AcpTunnelSource::new(tunnel) + } + + #[tokio::test] + async fn two_declared_servers_are_both_discovered_without_collision() { + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + 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 a_tool_routes_by_its_prefix_to_the_named_server() { + // Inverted from `one_servers_policy_does_not_leak_to_another`. There is no per-server policy + // gate to leak any more; what remains, and is worth pinning, is that the PREFIX selects the + // tunnel: `notes.click` reaches `notes`'s tunnel (whether `notes` implements `click` is the + // server's concern, not openab's), never `katashiro`'s. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-b"), ("notes", "uuid-n")]); + let src = two_server_src(tunnel.clone()); + + let (_v, err) = src + .call(Some(&ctx()), "notes.click", &Map::new()) + .await + .unwrap(); + assert!(!err, "prefix selects the tunnel; the server decides what it implements"); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!(fwd.len(), 1); + assert_eq!(fwd[0].1, "uuid-n", "routed to notes' tunnel, not katashiro's"); + assert_eq!(fwd[0].2["name"], "notes.click"); + } + + #[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"); + } + + // --- F5: routing by what was discovered, not by the tool name's shape --- + + #[tokio::test] + async fn an_unprefixed_tool_from_a_generic_server_is_callable() { + // A generic server "project-tools" publishing a bare "build" (no . prefix) is both + // discoverable and callable. The old `split_prefix` rejected it as malformed; discovery + // routing looks the publisher up from the cache. + let tunnel = FakeTunnel::with(&[("project-tools", "uuid-p")]); + tunnel.set_server_tools("project-tools", &["build"]); + let src = AcpTunnelSource::new(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, ["build"], "the bare tool is advertised"); + + let (_v, is_err) = src.call(Some(&ctx()), "build", &Map::new()).await.unwrap(); + assert!(!is_err, "a bare tool must dispatch, not be rejected as malformed"); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!(fwd[0].1, "uuid-p", "routed to the publishing server's tunnel"); + assert_eq!(fwd[0].2["name"], "build", "the original tool name is forwarded unchanged"); + } + + #[tokio::test] + async fn a_prefixed_tool_routes_to_its_namesake_not_a_same_name_impostor() { + // F5 shadowing mitigation (D-34): two servers publish the EXACT tool `katashiro.click` — the + // real one named `katashiro` and an impostor `aaa` that sorts earlier for min(). The tool's + // prefix (`katashiro`) matches the real server's name, so routing prefers it over the + // lexicographically-earlier impostor. Key-gated: moot once OPENAB_ACP_AUTH_KEY is set. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-k"), ("aaa", "uuid-a")]); + tunnel.set_server_tools("katashiro", &["katashiro.click"]); + tunnel.set_server_tools("aaa", &["katashiro.click"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let (_v, is_err) = src.call(Some(&ctx()), "katashiro.click", &Map::new()).await.unwrap(); + assert!(!is_err); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!( + fwd[0].1, "uuid-k", + "the prefix's namesake wins the tiebreak; the earlier-sorting impostor 'aaa' must not shadow it" + ); + } + + // --- Round 5 F1: a colliding name keeps a routable identity --- + // + // Two servers may publish the same name (no allowlist, D-29; keyless loopback, D-30). Before + // this, both were advertised under that one name: the facade published the second under an alias + // built from this source's single provider string, which cannot tell two of its servers apart, so + // the alias and the bare name both dispatched to the same server and the second server's tool was + // advertised but unreachable. Which of the two schemas was shown for the shared name also + // depended on `HashSet` iteration order, while the call resolved to the minimum — so the schema + // could belong to one server and the call reach another. + + /// The name the tunnel double was told to serve, from the advertised schema. + fn served_by(tool: &Tool) -> String { + tool.input_schema + .get("properties") + .and_then(|p| p.get("served_by")) + .and_then(|s| s.get("const")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + #[tokio::test] + async fn two_servers_publishing_one_bare_name_are_both_advertised_and_callable() { + let tunnel = FakeTunnel::with(&[("alpha", "uuid-a"), ("beta", "uuid-b")]); + tunnel.set_server_tools("alpha", &["screenshot"]); + tunnel.set_server_tools("beta", &["screenshot"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let advertised = src.tools(Some(&ctx())); + let names: Vec = advertised.iter().map(|t| t.name.to_string()).collect(); + assert_eq!( + names, + ["beta.screenshot", "screenshot"], + "the keeper holds the published name and the other is named apart, not dropped" + ); + // The schema shown for the shared name must belong to the server the call will reach. + assert_eq!( + advertised.iter().map(served_by).collect::>(), + ["beta", "alpha"], + "each advertised name carries its own publisher's schema" + ); + + for tool in ["screenshot", "beta.screenshot"] { + let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); + assert!(!is_err, "{tool} must dispatch — being advertised is the promise"); + } + 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-a".to_string(), "screenshot".to_string()), + ("uuid-b".to_string(), "screenshot".to_string()) + ], + "the two names reach DIFFERENT tunnels, each under the name its server published" + ); + } + + #[tokio::test] + async fn a_rename_never_takes_a_name_some_server_actually_published() { + // `beta` publishes both `screenshot` and, literally, `beta.screenshot`. `alpha` keeps the + // bare name (lexicographic), so beta's `screenshot` wants to be advertised as + // `beta.screenshot` — which beta itself published and keeps. The rename must step aside + // rather than shadow a real tool. + let tunnel = FakeTunnel::with(&[("alpha", "uuid-a"), ("beta", "uuid-b")]); + tunnel.set_server_tools("alpha", &["screenshot"]); + tunnel.set_server_tools("beta", &["screenshot", "beta.screenshot"]); + let src = AcpTunnelSource::new(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, + ["beta.screenshot", "beta.screenshot.2", "screenshot"], + "the literal `beta.screenshot` keeps its name; the renamed one gets the suffix" + ); + + for tool in ["beta.screenshot", "beta.screenshot.2"] { + let (_v, is_err) = src.call(Some(&ctx()), tool, &Map::new()).await.unwrap(); + assert!(!is_err, "{tool} must dispatch"); + } + let fwd = tunnel.forwarded.lock().unwrap(); + let published: Vec = fwd + .iter() + .map(|(_c, _id, params)| params["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + published, + ["beta.screenshot", "screenshot"], + "each is forwarded under the name its server published, not under the advertised one" + ); + } + + #[tokio::test] + async fn the_namesake_keeps_the_name_and_the_impostor_is_advertised_apart() { + // The D-34 mitigation as a NAMING rule: `katashiro` keeps `katashiro.click` against an + // impostor that sorts earlier, and the impostor's copy is still reachable under its own name + // rather than silently shadowed. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-k"), ("aaa", "uuid-a")]); + tunnel.set_server_tools("katashiro", &["katashiro.click"]); + tunnel.set_server_tools("aaa", &["katashiro.click"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let advertised = src.tools(Some(&ctx())); + let names: Vec = advertised.iter().map(|t| t.name.to_string()).collect(); + assert_eq!(names, ["aaa.katashiro.click", "katashiro.click"]); + assert_eq!( + advertised.iter().map(served_by).collect::>(), + ["aaa", "katashiro"], + "the namesake's schema is the one shown for the namesake's name" + ); + + let (_v, is_err) = src + .call(Some(&ctx()), "katashiro.click", &Map::new()) + .await + .unwrap(); + assert!(!is_err); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!( + fwd[0].1, "uuid-k", + "the prefix's namesake keeps the name; the earlier-sorting impostor must not take it" + ); + } + + // --- Round 5 F2: a reconnect refreshes the catalog it inherited --- + + #[tokio::test] + async fn a_reconnect_with_a_new_id_refreshes_the_cached_catalog() { + // The cache is keyed by NAME so an entry survives a reconnect (see `ToolsCache`). Nothing + // compared that surviving entry against the connection now attached, so a reconnected server + // served its predecessor's catalog for the rest of the session — and no `tools/list_changed` + // is coming to invalidate it, the tunnel being gateway-initiated. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-old")]); + tunnel.set_server_tools("katashiro", &["read_dom"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + assert_eq!(src.tools(Some(&ctx())).len(), 1, "discovered the first set"); + assert_eq!(tunnel.tools_list_calls(), 1); + + // Reconnect: same declared name, fresh id, and the server now publishes one tool more. + tunnel.reattach_as("katashiro", "uuid-new"); + tunnel.set_server_tools("katashiro", &["read_dom", "screenshot"]); + + let during = src.tools(Some(&ctx())); + assert_eq!( + during.len(), + 1, + "the inherited set is still served while the refetch is in flight (§6.3 no-shrink)" + ); + settle().await; + + let names: Vec = src + .tools(Some(&ctx())) + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!( + names, + ["read_dom", "screenshot"], + "the new connection's set replaces the one it inherited" + ); + assert_eq!( + tunnel.tools_list_calls(), + 2, + "exactly one refetch: the id change triggers it, and a matching id must not" + ); + } + + #[tokio::test] + async fn a_settled_catalog_is_not_refetched_while_the_connection_is_unchanged() { + // The other half of the rule above: refreshing on a CHANGED id must not turn into + // refetching on every discovery round. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-old")]); + tunnel.set_server_tools("katashiro", &["read_dom"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + for _ in 0..3 { + let _ = src.tools(Some(&ctx())); + settle().await; + } + assert_eq!( + tunnel.tools_list_calls(), + 1, + "one fetch for one connection, however many rounds read the catalog" + ); + } + + #[tokio::test] + async fn a_tool_whose_prefix_differs_from_its_server_name_is_callable() { + // Server "katashiro" publishing "browser.click": the first segment ("browser") is not the + // server's name. `split_prefix` would route to a phantom "browser" server; discovery routing + // sends it to katashiro, its actual publisher. + let tunnel = FakeTunnel::with(&[("katashiro", "uuid-k")]); + tunnel.set_server_tools("katashiro", &["browser.click"]); + let src = AcpTunnelSource::new(tunnel.clone()); + + let _ = src.tools(Some(&ctx())); + settle().await; + + let (_v, is_err) = src.call(Some(&ctx()), "browser.click", &Map::new()).await.unwrap(); + assert!(!is_err, "a mismatched-prefix tool must route to its publisher, not a phantom server"); + let fwd = tunnel.forwarded.lock().unwrap(); + assert_eq!(fwd[0].1, "uuid-k", "routed to katashiro, not the 'browser' prefix"); + assert_eq!(fwd[0].2["name"], "browser.click", "the original tool name is forwarded unchanged"); + } +} diff --git a/src/main.rs b/src/main.rs index 236ce5ff2..a2ee786ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,10 @@ mod ctl; feature = "lineworks", ))] mod unified_adapter; +#[cfg(feature = "acp")] +mod acp_tunnel; +#[cfg(feature = "acp")] +mod acp_tunnel_source; use openab_core::acp; use openab_core::adapter::{self, AdapterRouter}; use openab_core::bot_turns; @@ -486,30 +490,100 @@ 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 session; the + // core's `acp_mcp` module reads it through the `RootAcpTunnel` implementation below. + #[cfg(feature = "acp")] + let acp_tunnel_registry = openab_gateway::adapters::acp_server::new_tunnel_registry(); + #[cfg(feature = "acp")] + let acp_tunnel: Arc = Arc::new( + acp_tunnel::RootAcpTunnel::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: report whether the facade is serving, so an operator learns it + // here rather than by inferring it from tools that never appear. This is NOT the + // `OPENAB_BROWSER_MODE` migration notice — that was removed (see `acp_mcp`), and nothing + // reports the variable now. + // 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::acp_mcp::report_facade_status(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(acp_tunnel_source::AcpTunnelSource::new( + acp_tunnel.clone(), + ))]; + #[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(acp_tunnel_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 +1178,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 facade's capability source reads (D6-a'), so the gateway + // populates the same map the RootAcpTunnel 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 +1827,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();