The fork's CI had never been green. Pre-existing failures: - version-sync: check-version-sync.js read packages/dashboard/package.json, which doesn't exist in this fork (workspace is just "."). Drop the dashboard comparison; check package.json vs cli/Cargo.toml only. - Dashboard job: `pnpm install --filter dashboard` for a non-existent package. Remove the job. - Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical). - Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in commands.rs (.iter().any()->.contains()), question_mark in element.rs (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in connect.rs (allow — the Result type is fixed by the accept_hdr_async contract). - rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process + binds a socket with timing assumptions; flaky in CI. Marked #[ignore]. Also: skill docs note fork.30's relay-preferred auto-connect (plain `agent-browser open` is dialog-free once the ab-connect extension is loaded) and the extension's new "agent-browser-stealth" display name.
522 lines
20 KiB
Rust
522 lines
20 KiB
Rust
//! 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<String, TargetEntry>,
|
|
/// relay-global command id -> (client that sent it, its original id)
|
|
pending: HashMap<i64, (ClientId, Value)>,
|
|
/// 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<ClientId>, 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<Value> = 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<RelayOut> {
|
|
// 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"),
|
|
}
|
|
}
|
|
}
|