//! Relay between the `ab-connect` browser extension and the daemon's `CdpClient`. //! //! The extension speaks a small CDP-over-WebSocket "envelope" protocol (adapted //! from openclaw-browser-relay) and drives the user's real tabs via per-tab //! `chrome.debugger`. The daemon's `CdpClient`, however, expects a **browser- //! level** CDP endpoint (`Target.getTargets` / `Target.attachToTarget` → a //! `sessionId`, then per-session commands). This relay bridges the two: it //! tracks the targets the extension reports, answers the browser-level //! `Target.*` discovery commands LOCALLY, and forwards everything else to the //! extension as `forwardCDPCommand`. That keeps `CdpClient` and `browser.rs` //! unchanged. //! //! ## Multiple clients (concurrent agents on one shared browser) //! //! Several agent-browser daemons (one per `--session`) can connect to the same //! relay/Chrome at once. The extension is a single peer, so the relay must //! demultiplex: every forwarded command is re-keyed to a relay-global id mapped //! back to the originating client, and the extension's reply is routed to **only //! that client** (with its original id restored). Command ids from different //! clients therefore never collide, and one client never sees another's command //! replies. CDP *events* (no id) fan out to all clients, which ignore events for //! sessions they didn't attach. //! //! This module is the pure translation core (no I/O) so the protocol can be //! unit-tested; the tokio WebSocket server that drives it lives alongside. use std::collections::HashMap; use serde_json::{json, Value}; /// Protocol version advertised in the connect handshake (matches the extension). pub const RELAY_PROTOCOL: i64 = 3; /// Identifies one connected CDP client (agent-browser daemon) for routing. pub type ClientId = u64; /// One target (tab) the extension has attached, as the relay tracks it. #[derive(Clone)] struct TargetEntry { session_id: String, target_info: Value, } /// Relay translation state: the targets the extension exposes, plus the /// in-flight command map used to route extension replies back to the right /// client. #[derive(Default)] pub struct RelayState { /// targetId -> entry targets: HashMap, /// relay-global command id -> (client that sent it, its original id) pending: HashMap, /// monotonic source of relay-global command ids next_global_id: i64, } /// What to do with a raw CDP command received from a `CdpClient`. #[derive(Debug, PartialEq)] pub enum ClientRoute { /// Answer locally; the value is a raw CDP response `{id, result}` to send /// back to the originating client only. Local(Value), /// Forward to the extension; the value is a `forwardCDPCommand` envelope /// already re-keyed to a relay-global id. Forward(Value), } /// An output the relay emits while handling an extension message. #[derive(Debug, PartialEq)] pub enum RelayOut { /// Send this raw CDP message to clients. `to = Some(id)` targets one client /// (a command reply); `to = None` broadcasts (a CDP event). ToClient { to: Option, msg: Value }, /// Send this envelope message back to the extension. ToExt(Value), } impl RelayState { pub fn new() -> Self { Self::default() } /// The challenge the relay sends to the extension as soon as it connects, /// kicking off the connect handshake. pub fn connect_challenge(nonce: &str) -> Value { json!({ "type": "event", "event": "connect.challenge", "payload": { "nonce": nonce } }) } /// A keepalive ping for the extension. pub fn ping() -> Value { json!({ "method": "ping" }) } /// Forget a disconnected client's in-flight commands so its orphaned /// `pending` entries don't leak. pub fn drop_client(&mut self, client_id: ClientId) { self.pending.retain(|_, (cid, _)| *cid != client_id); } /// Route a raw CDP command `{id, method, params?, sessionId?}` from a /// `CdpClient`: answer browser-level `Target.*` discovery locally, forward /// the rest to the extension under a relay-global id keyed to `client_id`. pub fn route_client_command(&mut self, client_id: ClientId, raw: &Value) -> ClientRoute { let id = raw.get("id").cloned().unwrap_or(Value::Null); let method = raw.get("method").and_then(|m| m.as_str()).unwrap_or(""); let params = raw.get("params").cloned().unwrap_or_else(|| json!({})); let session_id = raw.get("sessionId").and_then(|s| s.as_str()); match method { // Browser-level command the daemon uses as its liveness probe // (`is_connection_alive` → `Browser.getVersion`). The extension only // speaks per-tab `chrome.debugger`, so forwarding it errors → the // daemon would deem the connection dead and reconnect+re-discover on // EVERY command, resetting the active tab (eval/screenshot drift). // Answer it locally so the relay connection reads as alive. "Browser.getVersion" => ClientRoute::Local(json!({ "id": id, "result": { "protocolVersion": "1.3", "product": "Chrome/ab-connect-relay", "revision": "", "userAgent": "", "jsVersion": "" } })), // Discovery is best-effort and event-driven in real CDP; abs only // reads the getTargets result, so an empty ack is enough here. "Target.setDiscoverTargets" | "Target.setAutoAttach" => { ClientRoute::Local(json!({ "id": id, "result": {} })) } "Target.getTargets" => { let infos: Vec = self .targets .values() .map(|t| t.target_info.clone()) .collect(); ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } })) } "Target.attachToTarget" => { let target_id = params .get("targetId") .and_then(|t| t.as_str()) .unwrap_or(""); match self.targets.get(target_id) { Some(entry) => ClientRoute::Local( json!({ "id": id, "result": { "sessionId": entry.session_id } }), ), None => ClientRoute::Local(json!({ "id": id, "error": { "code": -32602, "message": format!("No such target {target_id}") } })), } } // Everything else goes to the extension's chrome.debugger. Re-key the // id so this client's reply can be routed back unambiguously. _ => { self.next_global_id += 1; let gid = self.next_global_id; self.pending.insert(gid, (client_id, id)); ClientRoute::Forward(json!({ "id": gid, "method": "forwardCDPCommand", "params": { "method": method, "params": params, "sessionId": session_id }, })) } } } /// Handle one decoded message from the extension. Updates target state and /// returns the messages to emit (routed to a client and/or back to the /// extension). `expected_token` is matched against the connect handshake. pub fn handle_ext_message(&mut self, msg: &Value, expected_token: &str) -> Vec { // Connect handshake request from the extension. if msg.get("type").and_then(|t| t.as_str()) == Some("req") && msg.get("method").and_then(|m| m.as_str()) == Some("connect") { let id = msg.get("id").cloned().unwrap_or(Value::Null); let token = msg .get("params") .and_then(|p| p.get("auth")) .and_then(|a| a.get("token")) .and_then(|t| t.as_str()) .unwrap_or(""); let ok = !expected_token.is_empty() && token == expected_token; let mut res = json!({ "type": "res", "id": id, "ok": ok }); if !ok { res["error"] = json!({ "message": "invalid relay token" }); } return vec![RelayOut::ToExt(res)]; } // Keepalive. if msg.get("method").and_then(|m| m.as_str()) == Some("pong") { return vec![]; } // Response to a forwardCDPCommand we sent → route the raw CDP response // back to the client that issued it, with its original id restored. if msg.get("id").is_some() && (msg.get("result").is_some() || msg.get("error").is_some()) && msg.get("method").is_none() { let gid = msg.get("id").and_then(|i| i.as_i64()); let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) { Some((client_id, orig)) => (Some(client_id), orig), // No mapping (stale/unknown id) — fall back to broadcasting with // whatever id the extension echoed. None => (None, msg.get("id").cloned().unwrap_or(Value::Null)), }; let mut out = json!({ "id": orig_id }); if let Some(r) = msg.get("result") { out["result"] = r.clone(); } if let Some(e) = msg.get("error") { // CdpClient expects an error object; wrap a bare string. out["error"] = match e { Value::String(s) => json!({ "code": -32000, "message": s }), other => other.clone(), }; } return vec![RelayOut::ToClient { to, msg: out }]; } // CDP event forwarded from a tab. if msg.get("method").and_then(|m| m.as_str()) == Some("forwardCDPEvent") { let p = msg.get("params").cloned().unwrap_or_else(|| json!({})); let inner_method = p.get("method").and_then(|m| m.as_str()).unwrap_or(""); let inner_params = p.get("params").cloned().unwrap_or_else(|| json!({})); let session_id = p.get("sessionId").and_then(|s| s.as_str()); // Learn/forget targets from the extension's synthesized Target events. // We consume these to maintain state and do NOT forward them: abs // discovers targets by pulling getTargets, and forwarding a second // attachedToTarget would duplicate the one attachToTarget emits. match inner_method { "Target.attachedToTarget" => { if let Some(info) = inner_params.get("targetInfo") { if let Some(tid) = info.get("targetId").and_then(|t| t.as_str()) { let sid = inner_params .get("sessionId") .and_then(|s| s.as_str()) .unwrap_or("") .to_string(); self.targets.insert( tid.to_string(), TargetEntry { session_id: sid, target_info: info.clone(), }, ); } } return vec![]; } "Target.detachedFromTarget" => { let gone = inner_params.get("sessionId").and_then(|s| s.as_str()); if let Some(gone) = gone { self.targets.retain(|_, e| e.session_id != gone); } return vec![]; } _ => {} } // Regular CDP event → fan out to all clients (each filters by the // sessions it attached to). let mut ev = json!({ "method": inner_method, "params": inner_params }); if let Some(sid) = session_id { ev["sessionId"] = json!(sid); } return vec![RelayOut::ToClient { to: None, msg: ev }]; } vec![] } #[cfg(test)] fn seed_target(&mut self, target_id: &str, session_id: &str) { self.targets.insert( target_id.to_string(), TargetEntry { session_id: session_id.to_string(), target_info: json!({ "targetId": target_id, "type": "page", "title": "", "url": "about:blank", "attached": true, }), }, ); } } #[cfg(test)] mod tests { use super::*; fn attached_event(target_id: &str, session_id: &str) -> Value { json!({ "method": "forwardCDPEvent", "params": { "sessionId": session_id, "method": "Target.attachedToTarget", "params": { "sessionId": session_id, "targetInfo": { "targetId": target_id, "type": "page", "url": "https://x", "title": "X" } } } }) } #[test] fn learns_target_from_attached_event_and_does_not_forward_it() { let mut s = RelayState::new(); let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok"); assert!( out.is_empty(), "attachedToTarget should be consumed, not forwarded" ); // Now getTargets must report it. let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" })); match route { ClientRoute::Local(v) => { let infos = v["result"]["targetInfos"].as_array().unwrap(); assert_eq!(infos.len(), 1); assert_eq!(infos[0]["targetId"], "T1"); } _ => panic!("getTargets must be local"), } } #[test] fn browser_get_version_is_answered_locally() { // Liveness probe must NOT be forwarded (the extension can't do // browser-level commands) — else the daemon reconnects on every command. let mut s = RelayState::new(); let route = s.route_client_command(1, &json!({ "id": 7, "method": "Browser.getVersion" })); match route { ClientRoute::Local(v) => { assert_eq!(v["id"], 7); assert!(v["result"]["protocolVersion"].is_string()); } _ => panic!("Browser.getVersion must be answered locally"), } } #[test] fn attach_to_target_returns_known_session() { let mut s = RelayState::new(); s.seed_target("T1", "cb-tab-1"); let route = s.route_client_command( 7, &json!({ "id": 5, "method": "Target.attachToTarget", "params": { "targetId": "T1", "flatten": true } }), ); assert_eq!( route, ClientRoute::Local(json!({ "id": 5, "result": { "sessionId": "cb-tab-1" } })) ); } #[test] fn attach_to_unknown_target_errors_locally() { let mut s = RelayState::new(); let route = s.route_client_command( 1, &json!({ "id": 6, "method": "Target.attachToTarget", "params": { "targetId": "nope" } }), ); match route { ClientRoute::Local(v) => assert!(v.get("error").is_some()), _ => panic!("should answer locally"), } } #[test] fn other_commands_forward_under_global_id() { let mut s = RelayState::new(); let route = s.route_client_command( 42, &json!({ "id": 9, "method": "Page.navigate", "params": { "url": "https://x" }, "sessionId": "cb-tab-1" }), ); match route { ClientRoute::Forward(v) => { assert_eq!(v["method"], "forwardCDPCommand"); // id is re-keyed to a relay-global id (not the client's 9). assert_eq!(v["id"], 1); assert_eq!(v["params"]["method"], "Page.navigate"); assert_eq!(v["params"]["sessionId"], "cb-tab-1"); assert_eq!(v["params"]["params"]["url"], "https://x"); } _ => panic!("Page.navigate must forward"), } } #[test] fn reply_routes_back_to_the_issuing_client_with_original_id() { let mut s = RelayState::new(); // Two clients each send a command that happens to share original id 1. let r1 = s.route_client_command( 100, &json!({ "id": 1, "method": "Page.navigate", "params": {} }), ); let r2 = s.route_client_command( 200, &json!({ "id": 1, "method": "Page.reload", "params": {} }), ); let g1 = match r1 { ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), _ => panic!(), }; let g2 = match r2 { ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), _ => panic!(), }; assert_ne!(g1, g2, "global ids must be distinct across clients"); // Extension replies for g2 → must go to client 200 with original id 1. let out = s.handle_ext_message(&json!({ "id": g2, "result": { "ok": true } }), "tok"); assert_eq!( out, vec![RelayOut::ToClient { to: Some(200), msg: json!({ "id": 1, "result": { "ok": true } }) }] ); // And g1 → client 100. let out = s.handle_ext_message(&json!({ "id": g1, "result": { "ok": false } }), "tok"); assert_eq!( out, vec![RelayOut::ToClient { to: Some(100), msg: json!({ "id": 1, "result": { "ok": false } }) }] ); } #[test] fn forward_command_error_is_wrapped_and_routed() { let mut s = RelayState::new(); let r = s.route_client_command( 5, &json!({ "id": 3, "method": "Page.navigate", "params": {} }), ); let gid = match r { ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), _ => panic!(), }; let out = s.handle_ext_message(&json!({ "id": gid, "error": "boom" }), "tok"); match &out[0] { RelayOut::ToClient { to, msg } => { assert_eq!(*to, Some(5)); assert_eq!(msg["id"], 3); assert_eq!(msg["error"]["message"], "boom"); } _ => panic!("expected ToClient"), } } #[test] fn regular_event_broadcasts_with_session() { let mut s = RelayState::new(); let ev = json!({ "method": "forwardCDPEvent", "params": { "sessionId": "cb-tab-1", "method": "Page.loadEventFired", "params": { "timestamp": 1.0 } } }); let out = s.handle_ext_message(&ev, "tok"); assert_eq!( out, vec![RelayOut::ToClient { to: None, msg: json!({ "method": "Page.loadEventFired", "params": { "timestamp": 1.0 }, "sessionId": "cb-tab-1" }) }] ); } #[test] fn drop_client_clears_its_pending() { let mut s = RelayState::new(); let r = s.route_client_command( 9, &json!({ "id": 1, "method": "Page.navigate", "params": {} }), ); let gid = match r { ClientRoute::Forward(v) => v["id"].as_i64().unwrap(), _ => panic!(), }; s.drop_client(9); // Reply now has no mapping → broadcast fallback (to: None), echoed id. let out = s.handle_ext_message(&json!({ "id": gid, "result": {} }), "tok"); match &out[0] { RelayOut::ToClient { to, .. } => assert_eq!(*to, None), _ => panic!(), } } #[test] fn connect_handshake_validates_token() { let mut s = RelayState::new(); let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } }); let ok = s.handle_ext_message(&req, "good"); assert_eq!( ok, vec![RelayOut::ToExt( json!({ "type": "res", "id": "c1", "ok": true }) )] ); let bad = s.handle_ext_message(&req, "different"); match &bad[0] { RelayOut::ToExt(v) => { assert_eq!(v["ok"], false); assert!(v.get("error").is_some()); } _ => panic!("expected ToExt"), } } }