1use 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
33pub 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 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 pub async fn connect(endpoint_ws_url: &str, token: Option<&str>) -> Result<Self, Error> {
93 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 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 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 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 pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
241 self.events_tx.subscribe()
242 }
243
244 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 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 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 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 #[tokio::test]
481 async fn connect_error_redacts_token_secret() {
482 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}