Skip to main content

agent_first_http/sdk/cdp/
ws_client.rs

1//! Async CDP WebSocket client.
2//!
3//! Each `Connection` owns one WebSocket to the host's `/cdp` endpoint.
4//! Sent commands are tagged with monotonically increasing ids; replies and
5//! events are demuxed by the reader task into pending-request channels and
6//! a broadcast event stream respectively.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicI64, Ordering};
11use std::time::Duration;
12
13use futures::{SinkExt, StreamExt};
14use serde::Serialize;
15use serde_json::Value;
16use tokio::io::{AsyncRead, AsyncWrite};
17use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
18use tokio::task::JoinHandle;
19use tokio_tungstenite::WebSocketStream;
20use tokio_tungstenite::tungstenite::{
21    self,
22    client::IntoClientRequest,
23    handshake::client::generate_key,
24    http::{Request, Uri, header},
25};
26
27use crate::sdk::endpoint::Endpoint;
28use crate::shared::error::{Error, ErrorCode};
29
30type ReplySender = oneshot::Sender<Result<Value, CdpRemoteError>>;
31type PendingMap = Arc<Mutex<HashMap<i64, ReplySender>>>;
32
33/// One CDP connection.
34pub struct Connection {
35    tx: mpsc::UnboundedSender<OutMsg>,
36    pending: PendingMap,
37    events_tx: broadcast::Sender<CdpEvent>,
38    next_id: AtomicI64,
39    _reader: JoinHandle<()>,
40    _writer: JoinHandle<()>,
41}
42
43enum OutMsg {
44    Text(String),
45    Close,
46}
47
48#[derive(Debug, Clone)]
49pub struct CdpEvent {
50    pub method: String,
51    pub session_id: Option<String>,
52    pub params: Value,
53}
54
55#[derive(Debug, Clone)]
56pub struct CdpRemoteError {
57    pub code: i64,
58    pub message: String,
59}
60
61impl std::fmt::Display for CdpRemoteError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "CDP error {}: {}", self.code, self.message)
64    }
65}
66
67impl Connection {
68    /// Open a CDP connection from a parsed endpoint. `profile`, when set, is
69    /// sent as `?profile=` so the host switches its active profile.
70    pub async fn connect_endpoint(
71        endpoint: &Endpoint,
72        token: Option<&str>,
73        profile: Option<&str>,
74    ) -> Result<Self, Error> {
75        match endpoint {
76            #[cfg(unix)]
77            Endpoint::Unix { path } => Self::connect_unix(path, token, profile).await,
78            _ => {
79                let base = endpoint.cdp_ws_url();
80                let url = match profile {
81                    Some(p) => append_query_pairs(&base, &[("profile", p)])?,
82                    None => base,
83                };
84                Self::connect(&url, token).await
85            }
86        }
87    }
88
89    /// Open a CDP connection to `ws://endpoint/cdp` (or `wss://`),
90    /// attaching the optional bearer token via the `?token_secret=` query
91    /// parameter (the `_secret` suffix lets AFDATA redaction scrub it).
92    pub async fn connect(endpoint_ws_url: &str, token: Option<&str>) -> Result<Self, Error> {
93        // Append ?token_secret= if needed.
94        let url = match token {
95            Some(t) => append_query_pairs(endpoint_ws_url, &[("token_secret", t)])?,
96            None => endpoint_ws_url.to_string(),
97        };
98        let request = build_ws_request(&url)?;
99        let uri: Uri = url
100            .parse()
101            .map_err(|e| Error::new(ErrorCode::InvalidEndpoint, format!("CDP url {url:?}: {e}")))?;
102        let secure = uri
103            .scheme_str()
104            .is_some_and(|s| s.eq_ignore_ascii_case("wss"));
105        if !secure {
106            // Plaintext ws:// (all local CDP, and ws:// remote hosts): connect the
107            // TCP stream directly and run the handshake with client_async. We
108            // avoid connect_async because, with the rustls-tls-native-roots
109            // feature, it builds a TLS connector and loads the OS root-cert store
110            // even for ws:// — wasted work for every CDP connect, and stack-heavy
111            // enough to overflow Windows' 1 MiB main-thread stack.
112            let host = uri.host().ok_or_else(|| {
113                Error::new(
114                    ErrorCode::InvalidEndpoint,
115                    format!("CDP url has no host: {url:?}"),
116                )
117            })?;
118            let port = uri.port_u16().unwrap_or(80);
119            let stream = tokio::net::TcpStream::connect((host, port))
120                .await
121                .map_err(|e| {
122                    Error::new(
123                        ErrorCode::HostUnreachable,
124                        format!(
125                            "CDP connect {}: {e}",
126                            agent_first_data::redact_url_secrets(&url)
127                        ),
128                    )
129                })?;
130            let (ws, _resp) = tokio_tungstenite::client_async(request, stream)
131                .await
132                .map_err(|e| cdp_connect_error("CDP websocket", &url, e))?;
133            return Ok(Self::from_ws(ws));
134        }
135        // wss:// — keep the TLS-capable connector.
136        let (ws, _resp) = tokio_tungstenite::connect_async(request)
137            .await
138            .map_err(|e| cdp_connect_error("CDP connect", &url, e))?;
139        Ok(Self::from_ws(ws))
140    }
141
142    #[cfg(unix)]
143    async fn connect_unix(
144        path: &std::path::Path,
145        token: Option<&str>,
146        profile: Option<&str>,
147    ) -> Result<Self, Error> {
148        let mut pairs: Vec<(&str, &str)> = Vec::new();
149        if let Some(t) = token {
150            pairs.push(("token_secret", t));
151        }
152        if let Some(p) = profile {
153            pairs.push(("profile", p));
154        }
155        let url = if pairs.is_empty() {
156            "ws://localhost/cdp".to_string()
157        } else {
158            append_query_pairs("ws://localhost/cdp", &pairs)?
159        };
160        let request = build_ws_request(&url)?;
161        let stream = tokio::net::UnixStream::connect(path).await.map_err(|e| {
162            Error::new(
163                ErrorCode::HostUnreachable,
164                format!("CDP connect unix:{}: {e}", path.display()),
165            )
166        })?;
167        let (ws, _resp) = tokio_tungstenite::client_async(request, stream)
168            .await
169            .map_err(|e| {
170                Error::new(
171                    ErrorCode::HostUnreachable,
172                    format!("CDP websocket over unix:{}: {e}", path.display()),
173                )
174            })?;
175        Ok(Self::from_ws(ws))
176    }
177
178    fn from_ws<S>(ws: WebSocketStream<S>) -> Self
179    where
180        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
181    {
182        let (mut sink, mut stream) = ws.split();
183
184        let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
185        let (events_tx, _events_rx) = broadcast::channel::<CdpEvent>(256);
186        let (tx, mut rx) = mpsc::unbounded_channel::<OutMsg>();
187
188        let pending_w = pending.clone();
189        let events_w = events_tx.clone();
190        let reader = tokio::spawn(async move {
191            while let Some(Ok(msg)) = stream.next().await {
192                match msg {
193                    tungstenite::Message::Text(t) => {
194                        if let Ok(v) = serde_json::from_str::<Value>(t.as_str()) {
195                            dispatch(v, &pending_w, &events_w).await;
196                        }
197                    }
198                    tungstenite::Message::Binary(_)
199                    | tungstenite::Message::Ping(_)
200                    | tungstenite::Message::Pong(_) => {}
201                    tungstenite::Message::Close(_) | tungstenite::Message::Frame(_) => break,
202                }
203            }
204            // On close, fail all pending requests so callers stop waiting.
205            let mut map = pending_w.lock().await;
206            for (_, sender) in map.drain() {
207                let _ = sender.send(Err(CdpRemoteError {
208                    code: -1,
209                    message: "CDP connection closed".into(),
210                }));
211            }
212        });
213
214        let writer = tokio::spawn(async move {
215            while let Some(out) = rx.recv().await {
216                let msg = match out {
217                    OutMsg::Text(t) => tungstenite::Message::Text(t.as_str().into()),
218                    OutMsg::Close => {
219                        let _ = sink.send(tungstenite::Message::Close(None)).await;
220                        break;
221                    }
222                };
223                if sink.send(msg).await.is_err() {
224                    break;
225                }
226            }
227        });
228
229        Self {
230            tx,
231            pending,
232            events_tx,
233            next_id: AtomicI64::new(1),
234            _reader: reader,
235            _writer: writer,
236        }
237    }
238
239    /// Subscribe to events for the lifetime of this connection.
240    pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
241        self.events_tx.subscribe()
242    }
243
244    /// Send a CDP command and await the result. `session_id` is `Some` when
245    /// the call is scoped to a flattened session (after Target.attachToTarget).
246    pub async fn send<P: Serialize>(
247        &self,
248        method: &str,
249        params: &P,
250        session_id: Option<&str>,
251    ) -> Result<Value, Error> {
252        self.send_inner(method, params, session_id, None).await
253    }
254
255    /// Send a CDP command and fail with `cdp_timeout` if the browser does not
256    /// answer within `timeout`. Unlike wrapping [`Self::send`] externally, this
257    /// removes the pending reply slot on timeout.
258    pub async fn send_timeout<P: Serialize>(
259        &self,
260        method: &str,
261        params: &P,
262        session_id: Option<&str>,
263        timeout: Duration,
264    ) -> Result<Value, Error> {
265        self.send_inner(method, params, session_id, Some(timeout))
266            .await
267    }
268
269    async fn send_inner<P: Serialize>(
270        &self,
271        method: &str,
272        params: &P,
273        session_id: Option<&str>,
274        timeout: Option<Duration>,
275    ) -> Result<Value, Error> {
276        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
277        let body = match session_id {
278            Some(sid) => serde_json::json!({
279                "id": id,
280                "method": method,
281                "params": params,
282                "sessionId": sid,
283            }),
284            None => serde_json::json!({
285                "id": id,
286                "method": method,
287                "params": params,
288            }),
289        };
290        let serialized = serde_json::to_string(&body).map_err(|e| {
291            Error::new(
292                ErrorCode::InternalError,
293                format!("CDP send: serialize {method}: {e}"),
294            )
295        })?;
296        let (resp_tx, resp_rx) = oneshot::channel();
297        self.pending.lock().await.insert(id, resp_tx);
298        self.tx
299            .send(OutMsg::Text(serialized))
300            .map_err(|_| Error::new(ErrorCode::CdpUnavailable, "CDP writer closed before send"))?;
301        let received = if let Some(timeout) = timeout {
302            match tokio::time::timeout(timeout, resp_rx).await {
303                Ok(value) => value,
304                Err(_) => {
305                    self.pending.lock().await.remove(&id);
306                    return Err(Error::new(
307                        ErrorCode::CdpTimeout,
308                        format!("{method}: CDP reply timed out after {timeout:?}"),
309                    ));
310                }
311            }
312        } else {
313            resp_rx.await
314        };
315        let value = received
316            .map_err(|_| Error::new(ErrorCode::CdpUnavailable, "CDP reader closed"))?
317            .map_err(|e| Error::new(ErrorCode::CdpError, e.to_string()))?;
318        Ok(value)
319    }
320
321    /// Wait for a CDP event matching `predicate` (true = matches).
322    /// Returns `cdp_timeout` if `timeout` elapses first.
323    pub async fn wait_event<F>(
324        &self,
325        timeout: Duration,
326        mut predicate: F,
327    ) -> Result<CdpEvent, Error>
328    where
329        F: FnMut(&CdpEvent) -> bool,
330    {
331        let mut rx = self.events_tx.subscribe();
332        let deadline = tokio::time::Instant::now() + timeout;
333        loop {
334            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
335            if remaining.is_zero() {
336                return Err(Error::new(ErrorCode::CdpTimeout, "wait_event: timed out"));
337            }
338            match tokio::time::timeout(remaining, rx.recv()).await {
339                Ok(Ok(ev)) if predicate(&ev) => return Ok(ev),
340                Ok(Ok(_)) => continue,
341                Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
342                Ok(Err(broadcast::error::RecvError::Closed)) => {
343                    return Err(Error::new(
344                        ErrorCode::CdpUnavailable,
345                        "wait_event: events channel closed",
346                    ));
347                }
348                Err(_) => {
349                    return Err(Error::new(ErrorCode::CdpTimeout, "wait_event: timed out"));
350                }
351            }
352        }
353    }
354
355    pub fn close(&self) {
356        let _ = self.tx.send(OutMsg::Close);
357    }
358}
359
360impl Drop for Connection {
361    fn drop(&mut self) {
362        let _ = self.tx.send(OutMsg::Close);
363    }
364}
365
366async fn dispatch(msg: Value, pending: &PendingMap, events: &broadcast::Sender<CdpEvent>) {
367    if let Some(id) = msg.get("id").and_then(|v| v.as_i64()) {
368        let mut map = pending.lock().await;
369        if let Some(sender) = map.remove(&id) {
370            if let Some(err) = msg.get("error") {
371                let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
372                let message = err
373                    .get("message")
374                    .and_then(|v| v.as_str())
375                    .unwrap_or("")
376                    .to_string();
377                let _ = sender.send(Err(CdpRemoteError { code, message }));
378            } else {
379                let result = msg.get("result").cloned().unwrap_or(Value::Null);
380                let _ = sender.send(Ok(result));
381            }
382        }
383    } else if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
384        let params = msg.get("params").cloned().unwrap_or(Value::Null);
385        let session_id = msg
386            .get("sessionId")
387            .and_then(|v| v.as_str())
388            .map(str::to_string);
389        let _ = events.send(CdpEvent {
390            method: method.to_string(),
391            session_id,
392            params,
393        });
394    }
395}
396
397fn append_query_pairs(url: &str, pairs: &[(&str, &str)]) -> Result<String, Error> {
398    let mut parsed = url::Url::parse(url)
399        .map_err(|e| Error::new(ErrorCode::InvalidEndpoint, format!("CDP url {url:?}: {e}")))?;
400    {
401        let mut query = parsed.query_pairs_mut();
402        for (key, value) in pairs {
403            query.append_pair(key, value);
404        }
405    }
406    Ok(parsed.to_string())
407}
408
409fn build_ws_request(url: &str) -> Result<Request<()>, Error> {
410    // tokio_tungstenite::connect_async accepts &str directly via IntoClientRequest,
411    // but we go through the explicit Request type so we can attach headers later.
412    // The bearer token is already baked into the URL as `?token_secret=` by the
413    // caller; a bearer header would also work, but axum's WebSocketUpgrade
414    // ignores it for the upgrade handshake.
415    let uri: Uri = url
416        .parse()
417        .map_err(|e| Error::new(ErrorCode::InvalidEndpoint, format!("CDP url {url:?}: {e}")))?;
418    let host = uri.authority().map(|a| a.as_str()).unwrap_or("localhost");
419    let req = Request::builder()
420        .method("GET")
421        .uri(url)
422        .header(header::HOST, host)
423        .header(header::CONNECTION, "Upgrade")
424        .header(header::UPGRADE, "websocket")
425        .header(header::SEC_WEBSOCKET_VERSION, "13")
426        .header(header::SEC_WEBSOCKET_KEY, generate_key())
427        .body(())
428        .map_err(|e| Error::new(ErrorCode::InternalError, format!("CDP build request: {e}")))?;
429    req.into_client_request().map_err(|e| {
430        Error::new(
431            ErrorCode::InternalError,
432            format!("CDP into_client_request: {e}"),
433        )
434    })
435}
436
437fn cdp_connect_error(context: &str, url: &str, err: tungstenite::Error) -> Error {
438    let redacted = agent_first_data::redact_url_secrets(url);
439    if let tungstenite::Error::Http(resp) = err {
440        let status = resp.status();
441        if let Some(bytes) = resp.body().as_ref() {
442            if let Ok(remote) = serde_json::from_slice::<Error>(bytes) {
443                return Error::new(
444                    remote.error_code,
445                    format!("{context} {redacted}: HTTP {status}: {}", remote.detail),
446                )
447                .with_retryable(remote.retryable);
448            }
449            let body = String::from_utf8_lossy(bytes).trim().to_string();
450            if !body.is_empty() {
451                return Error::new(
452                    ErrorCode::HostUnreachable,
453                    format!("{context} {redacted}: HTTP {status}: {body}"),
454                );
455            }
456        }
457        return Error::new(
458            ErrorCode::HostUnreachable,
459            format!("{context} {redacted}: HTTP {status}"),
460        );
461    }
462    Error::new(
463        ErrorCode::HostUnreachable,
464        format!("{context} {redacted}: {err}"),
465    )
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn query_pairs_are_percent_encoded() {
474        let url = append_query_pairs("ws://localhost:9222/cdp", &[("token", "a+b&c%20")]).unwrap();
475        assert_eq!(url, "ws://localhost:9222/cdp?token=a%2Bb%26c%2520");
476    }
477
478    // The bearer travels as `?token_secret=`; AFDATA redaction must scrub it
479    // before the failed-connect URL reaches the error envelope.
480    #[tokio::test]
481    async fn connect_error_redacts_token_secret() {
482        // Port 1 refuses fast, so we exercise the map_err path deterministically.
483        let err = Connection::connect("ws://127.0.0.1:1/cdp", Some("supersecret"))
484            .await
485            .err()
486            .expect("connect to closed port must fail");
487        let msg = err.to_string();
488        assert!(
489            msg.contains("token_secret=***"),
490            "token not redacted: {msg}"
491        );
492        assert!(!msg.contains("supersecret"), "raw token leaked: {msg}");
493    }
494
495    #[test]
496    fn cdp_http_error_includes_profile_switch_body() {
497        let body = serde_json::to_vec(&Error::new(
498            ErrorCode::ProfileLocked,
499            "profile switch to \"contabo.com\" failed: profile contabo.com already locked",
500        ))
501        .unwrap();
502        let resp = tokio_tungstenite::tungstenite::http::Response::builder()
503            .status(503)
504            .body(Some(body))
505            .unwrap();
506        let err = cdp_connect_error(
507            "CDP websocket",
508            "ws://127.0.0.1:9222/cdp?profile=contabo.com&token_secret=supersecret",
509            tungstenite::Error::Http(Box::new(resp)),
510        );
511        assert_eq!(err.error_code, ErrorCode::ProfileLocked);
512        assert!(err.detail.contains("contabo.com"), "{}", err.detail);
513        assert!(err.detail.contains("token_secret=***"), "{}", err.detail);
514        assert!(!err.detail.contains("supersecret"), "{}", err.detail);
515    }
516}