//! Where a profile's browser actually is, and how to talk to it. //! //! Until this module existed, every automation tool answered "where is this //! browser?" by reading a LOCAL debugging port out of the LOCAL profile //! directory. A profile launched on a leased host has no local port and no //! local process, so a customer who paid for remote execution could start a //! session and then do nothing with it — the one thing the feature exists for. //! //! There is exactly one resolver here, [`resolve`], and one connection type, //! [`CdpConnection`]. Tools ask for a target and get either a page socket on //! this machine or a relayed socket to the fleet; nothing above this module //! branches on which. That is deliberate: a parallel set of remote-only tools //! would drift from the local ones within a release. //! //! The remote arm reaches donutbrowser-infra with the USER's own access token. //! The desktop holds no fleet credential and knows no fleet hostname — infra //! verifies the session belongs to the caller and relays onward with its own //! service credential. That boundary is why this is a relay and not a direct //! connection. use crate::profile::types::BrowserProfile; use serde_json::Value; use std::time::Duration; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::handshake::client::Request as WsRequest; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; /// How long the WebSocket handshake may take. /// /// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this /// is far longer than a loopback connect needs. It matches the relay's own /// upstream handshake budget: waiting longer than the server does can only /// report a timeout the server already reported. const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); /// How long one CDP command may wait for its reply. /// /// Without a cap, a browser that never answers holds the caller until the /// socket dies — 90 seconds on the relay, indefinitely on loopback. An /// automation client that hangs is worse than one that fails. const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); /// Attempts at establishing a connection before giving up. const CONNECT_ATTEMPTS: u32 = 3; /// Delay before the second connect attempt; doubles for the third. const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400); /// Ceiling on a relayed CDP message. /// /// Matches the relay's client-facing cap, which matches the fleet's upstream /// frame cap. Lower, and a screenshot the server was willing to carry is /// dropped on arrival; higher buys nothing, because the frame never crosses the /// relay in the first place. const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; /// Command ids for the two messages the remote arm sends before any tool does. /// /// Held far above anything a caller uses, so a late reply to the attach /// handshake can never be mistaken for a tool's answer: both travel on one /// socket, the handshake on the BROWSER session and every tool on the page /// session. const HANDSHAKE_GET_TARGETS_ID: u64 = 9_000_001; const HANDSHAKE_ATTACH_ID: u64 = 9_000_002; /// Where a profile's browser is, and what is needed to reach it. #[derive(Debug, Clone)] pub enum CdpTarget { /// A browser on this machine. The URL is a PAGE-level socket, so commands /// carry no CDP session id. Local { ws_url: String }, /// A browser on the fleet, reached through the infra relay. The relay bridges /// a BROWSER-level socket, so the connection attaches to a page and stamps /// every subsequent message with the resulting session id. Remote { ws_url: String, bearer: String, session_id: String, }, } impl CdpTarget { /// True when this browser is on the leased fleet rather than this machine. pub fn is_remote(&self) -> bool { matches!(self, Self::Remote { .. }) } /// A short label for logs and errors. Never carries the credential. pub fn describe(&self) -> String { match self { Self::Local { .. } => "local browser".to_string(), Self::Remote { session_id, .. } => format!("remote session {session_id}"), } } } /// Why a target could not be reached, or a command could not be run. /// /// The variants exist so a caller can tell "come back when it is up" from "that /// credential is no good" from "the socket broke". Collapsing them into one /// string is how a session that is merely still provisioning gets reported as a /// broken one. #[derive(Debug)] pub enum CdpError { /// Nothing is listening, or the relay could not reach the browser. Unreachable(String), /// The relay refused the credential. Unauthorized(String), /// The session exists but is not in a state that can be driven. NotDrivable(String), /// The socket broke, or a reply never arrived. Transport(String), /// The browser answered with a CDP `error` object. Protocol(String), } impl std::fmt::Display for CdpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Unreachable(m) => write!(f, "browser unreachable: {m}"), Self::Unauthorized(m) => write!(f, "not authorised to drive this browser: {m}"), Self::NotDrivable(m) => write!(f, "browser is not drivable yet: {m}"), Self::Transport(m) => write!(f, "CDP transport failed: {m}"), Self::Protocol(m) => write!(f, "CDP error: {m}"), } } } impl CdpError { /// Whether a fresh connection attempt could plausibly succeed. /// /// A refused credential and a session that is still provisioning are answers, /// not failures. Retrying either spends the caller's time and, on the relay, /// burns one of the four attachments a session is allowed — so the retry can /// make the next honest attempt fail too. fn is_retryable(&self) -> bool { matches!(self, Self::Unreachable(_) | Self::Transport(_)) } } /// Why a profile could not be resolved to a browser at all. #[derive(Debug)] pub enum ResolveError { /// The profile is not one this app can drive. Unsupported(String), /// Neither a local process nor a live remote session. NotRunning(String), /// A remote session exists but its endpoint could not be read. Endpoint(String), } impl std::fmt::Display for ResolveError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Unsupported(m) | Self::NotRunning(m) | Self::Endpoint(m) => write!(f, "{m}"), } } } /// Whether the caller is willing to wait for a browser that is still coming up. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Patience { /// One attempt, used to decide local-vs-remote without stalling. Immediate, /// The full retry budget, used once the answer is known to be local. WaitForLaunch, } impl Patience { fn attempts(self, waiting: u32) -> u32 { match self { Self::Immediate => 1, Self::WaitForLaunch => waiting, } } } /// Find the browser for `profile`, wherever it is running. /// /// Local wins when both look possible: the profile lock makes a genuine overlap /// impossible, and a browser on this machine is free to drive while a relayed /// one crosses two networks. /// /// The local check is deliberately split in two. One cheap probe decides the /// arm, so a profile running on the fleet is not held behind twenty-five /// seconds of local retries; only once remote has been ruled out does the local /// probe spend its full budget waiting for a browser that is still starting. /// The same split covers a stale `process_id` left by a crash — nothing answers /// on the recorded port, so the fleet session is found instead of a dead one. pub async fn resolve(profile: &BrowserProfile) -> Result { if profile.browser != "wayfern" { return Err(ResolveError::Unsupported(format!( "Profile '{}' runs {}, which cannot be driven over CDP", profile.name, profile.browser ))); } let has_local_process = profile.process_id.is_some(); if has_local_process { if let Some(ws_url) = local_page_ws_url(profile, Patience::Immediate).await { return Ok(CdpTarget::Local { ws_url }); } } if let Some(session) = crate::remote_session::live_session_for_profile(&profile.id.to_string()).await { let endpoint = crate::remote_session::cdp_endpoint(&session.session_id) .await .map_err(|e| ResolveError::Endpoint(e.to_string()))?; let bearer = crate::remote_session::access_token_for_cdp().map_err(ResolveError::Endpoint)?; log::info!( "Driving profile '{}' through remote session {}", profile.name, session.session_id ); return Ok(CdpTarget::Remote { ws_url: endpoint.ws_url, bearer, session_id: session.session_id, }); } if has_local_process { return match local_page_ws_url(profile, Patience::WaitForLaunch).await { Some(ws_url) => Ok(CdpTarget::Local { ws_url }), None => Err(ResolveError::NotRunning(format!( "No CDP connection available for profile '{}'. Make sure the browser is running.", profile.name ))), }; } Err(ResolveError::NotRunning(format!( "Profile '{}' is not running", profile.name ))) } /// The debugging port a locally launched browser registered for this profile. async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option { let profiles_dir = crate::profile::manager::ProfileManager::instance().get_profiles_dir(); let profile_path = profile.get_profile_data_path(&profiles_dir); let profile_path_str = profile_path.to_string_lossy().to_string(); // Port info is written once the process is up, so a tool called straight // after a launch has to wait for it. for attempt in 0..patience.attempts(10) { if attempt > 0 { tokio::time::sleep(Duration::from_secs(1)).await; } if let Some(port) = crate::wayfern_manager::WayfernManager::instance() .get_cdp_port(&profile_path_str) .await { return Some(port); } } None } /// A page-level socket on a locally running browser. /// /// Returns `None` rather than an error: a miss is how the resolver decides the /// browser is not local, and a port whose process has since died answers /// nothing, which is exactly the signal that decision needs. async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Option { let port = local_cdp_port(profile, patience).await?; let listing = format!("http://127.0.0.1:{port}/json"); let client = reqwest::Client::new(); let mut last_err = String::new(); for attempt in 0..patience.attempts(15) { if attempt > 0 { tokio::time::sleep(Duration::from_secs(1)).await; } match client .get(&listing) .timeout(Duration::from_secs(3)) .send() .await { Ok(response) => match response.json::>().await { Ok(targets) => { if let Some(ws_url) = pick_local_page_socket(&targets) { return Some(ws_url); } last_err = "no page target found in browser".to_string(); } Err(e) => last_err = format!("failed to parse CDP targets: {e}"), }, Err(e) => last_err = format!("failed to reach the browser's CDP endpoint: {e}"), } } if patience == Patience::WaitForLaunch { log::warn!("Local CDP discovery on port {port} gave up: {last_err}"); } None } /// Pick a drivable page from what `/json` lists on a local browser. pub fn pick_local_page_socket(targets: &[Value]) -> Option { targets .iter() .find(|t| t.get("type").and_then(Value::as_str) == Some("page")) .and_then(|t| t.get("webSocketDebuggerUrl")) .and_then(Value::as_str) .map(str::to_string) } /// Pick a drivable page from a `Target.getTargets` reply. /// /// DevTools' own frontend is a page target too, and attaching to it drives the /// inspector instead of the site — a failure that reports success and moves /// nothing. pub fn pick_remote_page_target(result: &Value) -> Option { result .get("targetInfos") .and_then(Value::as_array)? .iter() .find(|info| { let is_page = info.get("type").and_then(Value::as_str) == Some("page"); let url = info.get("url").and_then(Value::as_str).unwrap_or_default(); is_page && !url.starts_with("devtools://") }) .and_then(|info| info.get("targetId")) .and_then(Value::as_str) .map(str::to_string) } /// One outgoing CDP message, addressed to a page when a session id is in play. /// /// Flattened sessions keep `method` at the top level on the way back, so event /// matching is identical on both arms and no caller has to know which it is on. pub fn cdp_frame(session: Option<&str>, id: u64, method: &str, params: Value) -> Value { let mut message = serde_json::json!({ "id": id, "method": method, "params": params }); if let Some(session) = session { message["sessionId"] = Value::String(session.to_string()); } message } /// Why the peer hung up. #[derive(Debug, Clone)] struct CloseInfo { code: u16, reason: String, } /// An open CDP conversation with one browser, local or relayed. pub struct CdpConnection { stream: WebSocketStream>, /// Set only for a relayed connection: stamped onto every outgoing message so /// page-level commands reach the page rather than the browser. cdp_session: Option, closed: Option, } impl CdpConnection { fn new(stream: WebSocketStream>) -> Self { Self { stream, cdp_session: None, closed: None, } } /// Send `method` as command `id`. pub async fn send_command( &mut self, id: u64, method: &str, params: Value, ) -> Result<(), CdpError> { use futures_util::sink::SinkExt; let frame = cdp_frame(self.cdp_session.as_deref(), id, method, params); self .stream .send(Message::Text(frame.to_string().into())) .await .map_err(|e| CdpError::Transport(format!("failed to send CDP command: {e}"))) } /// The next text message, or `None` once the peer has gone. /// /// Binary frames, pings and pongs are consumed silently; a close is recorded /// so its reason survives into whatever error the caller builds. pub async fn next_text(&mut self) -> Option> { use futures_util::stream::StreamExt; loop { match self.stream.next().await? { Ok(Message::Text(text)) => return Some(Ok(text.to_string())), Ok(Message::Close(frame)) => { self.closed = frame.map(|f| CloseInfo { code: u16::from(f.code), reason: f.reason.to_string(), }); return None; } Ok(_) => continue, Err(e) => { return Some(Err(CdpError::Transport(format!( "CDP WebSocket error: {e}" )))) } } } } /// Turn a hang-up into the error it means. /// /// The relay's close codes are its whole vocabulary: 1008 is "that credential /// is no good", 1013 is "come back when the session is up". Reporting either /// as a generic transport failure throws away the only actionable thing the /// server said. pub fn closed_error(&self, context: &str) -> CdpError { match &self.closed { Some(info) if info.reason.is_empty() => { classify_close(info.code, format!("{context} (close {})", info.code)) } Some(info) => classify_close( info.code, format!("{context} ({}: {})", info.code, info.reason), ), None => CdpError::Transport(format!("{context} (connection closed)")), } } /// Send a command and read its reply, bounded by [`COMMAND_TIMEOUT`]. pub async fn call(&mut self, id: u64, method: &str, params: Value) -> Result { self.send_command(id, method, params).await?; self.await_reply(id, COMMAND_TIMEOUT).await } /// Read until the reply to `id` arrives, discarding events on the way. pub async fn await_reply(&mut self, id: u64, timeout: Duration) -> Result { let deadline = tokio::time::Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Err(CdpError::Transport( "timed out waiting for a CDP response".to_string(), )); } let text = match tokio::time::timeout(remaining, self.next_text()).await { Err(_) => { return Err(CdpError::Transport( "timed out waiting for a CDP response".to_string(), )) } Ok(None) => return Err(self.closed_error("no response received from CDP")), Ok(Some(result)) => result?, }; let response: Value = serde_json::from_str(&text) .map_err(|e| CdpError::Protocol(format!("failed to parse CDP response: {e}")))?; if response.get("id") != Some(&Value::from(id)) { continue; } if let Some(error) = response.get("error") { return Err(CdpError::Protocol(error.to_string())); } return Ok( response .get("result") .cloned() .unwrap_or_else(|| serde_json::json!({})), ); } } /// Hang up politely so the peer releases its side immediately. /// /// On the relay every open socket costs a real stream on the leased host and /// counts against the session's attachment cap, so dropping the TCP /// connection and letting it time out is not good enough. pub async fn close(mut self) { let _ = self.stream.close(None).await; } /// Move a browser-level socket onto a page. /// /// The relay bridges `/devtools/browser/`. Every tool here speaks /// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with /// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting /// session id onto everything after it, is what makes the tools this app /// already has work remotely without a single per-tool change. async fn attach_to_page(&mut self) -> Result<(), CdpError> { let targets = self .call( HANDSHAKE_GET_TARGETS_ID, "Target.getTargets", serde_json::json!({}), ) .await?; let target_id = pick_remote_page_target(&targets) .ok_or_else(|| CdpError::NotDrivable("the remote browser has no page open".to_string()))?; let attached = self .call( HANDSHAKE_ATTACH_ID, "Target.attachToTarget", serde_json::json!({ "targetId": target_id, "flatten": true }), ) .await?; let session = attached .get("sessionId") .and_then(Value::as_str) .ok_or_else(|| { CdpError::Protocol("Target.attachToTarget returned no sessionId".to_string()) })?; self.cdp_session = Some(session.to_string()); Ok(()) } } /// Ids used by the one-shot command runners below. /// /// A caller never picks these, so they are stated once here rather than being /// re-derived at each call site. const RUN_PAGE_ENABLE_ID: u64 = 1; const RUN_COMMAND_ID: u64 = 2; const RUN_PAGE_DISABLE_ID: u64 = 3; /// The attach handshake and the commands after it share one socket, so a /// handshake reply carrying a command's id would be handed back as that /// command's result. Checked at compile time because the failure it prevents is /// silent: the wrong reply is still a well-formed reply. const _: () = { assert!(HANDSHAKE_GET_TARGETS_ID != HANDSHAKE_ATTACH_ID); assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_ENABLE_ID); assert!(HANDSHAKE_GET_TARGETS_ID != RUN_COMMAND_ID); assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_DISABLE_ID); assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_ENABLE_ID); assert!(HANDSHAKE_ATTACH_ID != RUN_COMMAND_ID); assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_DISABLE_ID); }; /// Run one command on a fresh connection and hand back its result. pub async fn run_command( target: &CdpTarget, method: &str, params: Value, ) -> Result { let mut connection = target.connect().await?; let result = connection.call(RUN_COMMAND_ID, method, params).await; connection.close().await; result } /// Run one command, then wait for the page to finish loading. /// /// Used for anything that might navigate: `Page.navigate` obviously, but also a /// click or a script that turns out to follow a link. When nothing navigates, /// the wait simply expires and the command's own result is returned — that is /// the intended path, not a failure. pub async fn run_command_awaiting_load( target: &CdpTarget, method: &str, params: Value, timeout_secs: u64, ) -> Result { let mut connection = target.connect().await?; // Page events have to be on before the command runs, or `loadEventFired` // for a fast navigation is missed and the wait runs to its full timeout. connection .call(RUN_PAGE_ENABLE_ID, "Page.enable", serde_json::json!({})) .await?; connection .send_command(RUN_COMMAND_ID, method, params) .await?; let mut command_result = None; let mut failure = None; let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { break; } let text = match tokio::time::timeout(remaining, connection.next_text()).await { Ok(Some(Ok(text))) => text, Ok(Some(Err(e))) => { failure = Some(e); break; } // The peer hung up, or the wait expired. Either way whatever the command // already answered is the best result available. Ok(None) | Err(_) => break, }; let response: Value = serde_json::from_str(&text).unwrap_or_default(); if response.get("id") == Some(&Value::from(RUN_COMMAND_ID)) { if let Some(error) = response.get("error") { failure = Some(CdpError::Protocol(error.to_string())); break; } command_result = Some( response .get("result") .cloned() .unwrap_or_else(|| serde_json::json!({})), ); } // Flattened remote sessions keep `method` at the top level, so this match // is identical on both arms. if response.get("method") == Some(&Value::from("Page.loadEventFired")) { break; } } let _ = connection .send_command(RUN_PAGE_DISABLE_ID, "Page.disable", serde_json::json!({})) .await; let closed = connection.closed_error("no response received from CDP"); connection.close().await; if let Some(error) = failure { return Err(error); } command_result.ok_or(closed) } /// Point a browser at a URL and wait for it to settle. /// /// This is what "open a URL in that profile" means once the browser is already /// up, wherever it is. A remote session navigates its existing page rather than /// opening a tab: a tab opened on a leased host that nobody can see or close is /// not a feature, it is litter on hardware the user is paying for by the hour. pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> { run_command_awaiting_load( target, "Page.navigate", serde_json::json!({ "url": url }), timeout_secs, ) .await .map(|_| ()) } /// Map a WebSocket close code onto what the caller should do about it. pub fn classify_close(code: u16, detail: String) -> CdpError { match code { 1008 => CdpError::Unauthorized(detail), 1013 => CdpError::NotDrivable(detail), 1009 => CdpError::Transport(format!("{detail} — message too large")), 1011 => CdpError::Unreachable(detail), _ => CdpError::Transport(detail), } } impl CdpTarget { /// Open a conversation with this browser. /// /// Retries a connection that failed for a reason a retry could fix, and never /// one that failed because the answer was no. pub async fn connect(&self) -> Result { let mut attempt = 0u32; loop { let error = match self.connect_once().await { Ok(connection) => return Ok(connection), Err(e) => e, }; attempt += 1; if attempt >= CONNECT_ATTEMPTS || !error.is_retryable() { return Err(error); } let delay = CONNECT_RETRY_BASE * 2u32.pow(attempt - 1); log::warn!( "CDP connect to {} failed ({error}); retrying in {}ms", self.describe(), delay.as_millis() ); tokio::time::sleep(delay).await; } } async fn connect_once(&self) -> Result { match self { Self::Local { ws_url } => { let request = ws_url .as_str() .into_client_request() .map_err(|e| CdpError::Unreachable(format!("invalid CDP endpoint: {e}")))?; Ok(CdpConnection::new(dial(request, None).await?)) } Self::Remote { ws_url, bearer, session_id, } => { let mut connection = dial_relay(ws_url, bearer).await?; if let Err(e) = connection.attach_to_page().await { log::warn!("Could not attach to a page in remote session {session_id}: {e}"); return Err(e); } Ok(connection) } } } } /// A relay socket with nothing done to it yet. pub type RelaySocket = WebSocketStream>; /// Open a session's BROWSER-level relay socket and hand it back untouched. /// /// Deliberately skips the page attach that [`CdpTarget::connect`] performs. The /// tools in this app all speak `Page.*` and need a page session stamped onto /// every message; an external automation client does not, and must not have /// one. Playwright's `connectOverCDP` expects the browser endpoint: it drives /// `Target.setAutoAttach` and `Target.getTargets` itself and builds its own /// session map, so a socket already attached to one page would hide every other /// target from it and stamp a session id onto messages it did not address. /// /// This is what makes a remote session usable from outside the app at all. The /// relay only accepts the user's cloud credential, which no API consumer holds /// and none should — so the socket is opened here, with the credential this /// process already has, and proxied to the caller. pub async fn open_relay_socket(session_id: &str) -> Result { let endpoint = crate::remote_session::cdp_endpoint(session_id) .await .map_err(endpoint_lookup_error)?; let bearer = crate::remote_session::access_token_for_cdp().map_err(CdpError::Unauthorized)?; let config = relay_socket_config(); let refused = match dial(relay_request(&endpoint.ws_url, &bearer)?, Some(config)).await { Ok(stream) => return Ok(stream), Err(CdpError::Unauthorized(reason)) => reason, Err(e) => return Err(e), }; log::info!("The CDP relay refused the stored access token; refreshing and retrying once"); crate::cloud_auth::CLOUD_AUTH .refresh_access_token() .await .map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?; let token = crate::remote_session::access_token_for_cdp() .map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?; dial(relay_request(&endpoint.ws_url, &token)?, Some(config)).await } /// Why the backend would not say where to attach. /// /// Collapsing this into "unreachable" is what made a session the user had /// already stopped answer 502, so an automation client read a finished session /// as a broken gateway and retried it. A session that is over, or that is not /// the caller's, is a 404: there is no browser at this address. fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpError { use crate::remote_session::RemoteSessionError; match err { RemoteSessionError::NotAuthorised(m) => CdpError::Unauthorized(m), RemoteSessionError::Conflict(m) => CdpError::NotDrivable(m), RemoteSessionError::NoCapacity(m) => CdpError::Unreachable(m), RemoteSessionError::Other(m) => { // `Other` carries the backend's own envelope. A 404 for a closed or // foreign session arrives here, and it is the common case rather than an // exotic one, so it is read back out rather than lumped in with a // genuine transport failure. if m.contains("REMOTE_SESSION_NOT_FOUND") || m.contains("404") { CdpError::NotDrivable(m) } else { CdpError::Unreachable(m) } } } } /// Frame limits for a relay socket. Matches the relay's own client-facing cap. pub fn relay_socket_config() -> WebSocketConfig { WebSocketConfig::default() .max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES)) .max_frame_size(Some(REMOTE_MAX_MESSAGE_BYTES)) } /// The ceiling a proxied CDP message may reach, so both ends agree. pub const MAX_RELAY_MESSAGE_BYTES: usize = REMOTE_MAX_MESSAGE_BYTES; /// Open the relay socket, refreshing the access token once if it is refused. /// /// The access token lives long enough that an app left open overnight still /// holds a valid-looking one after it has been rotated. Failing a whole tool /// call for that — when the very next HTTP request would have refreshed it /// silently — is a bug the user reads as "remote driving is flaky". async fn dial_relay(ws_url: &str, bearer: &str) -> Result { let config = relay_socket_config(); let refused = match dial(relay_request(ws_url, bearer)?, Some(config)).await { Ok(stream) => return Ok(CdpConnection::new(stream)), Err(CdpError::Unauthorized(reason)) => reason, Err(e) => return Err(e), }; log::info!("The CDP relay refused the stored access token; refreshing and retrying once"); crate::cloud_auth::CLOUD_AUTH .refresh_access_token() .await .map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?; let token = crate::remote_session::access_token_for_cdp() .map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?; let stream = dial(relay_request(ws_url, &token)?, Some(config)).await?; Ok(CdpConnection::new(stream)) } /// Build the upgrade request for the relay. /// /// The credential goes in a header, never the query string: a URL that grants /// full control of a live browser must not reach a proxy access log. fn relay_request(ws_url: &str, bearer: &str) -> Result { let mut request = ws_url .into_client_request() .map_err(|e| CdpError::Unreachable(format!("invalid relay endpoint: {e}")))?; let value = format!("Bearer {bearer}").parse().map_err(|_| { CdpError::Unauthorized("the stored access token is not a valid header value".to_string()) })?; request.headers_mut().insert( tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, value, ); Ok(request) } /// Perform the handshake, bounded by [`CONNECT_TIMEOUT`]. async fn dial( request: WsRequest, config: Option, ) -> Result>, CdpError> { let connect = tokio_tungstenite::connect_async_with_config(request, config, false); match tokio::time::timeout(CONNECT_TIMEOUT, connect).await { Err(_) => Err(CdpError::Unreachable(format!( "the CDP endpoint did not answer within {}s", CONNECT_TIMEOUT.as_secs() ))), Ok(Ok((stream, _response))) => Ok(stream), Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => { Err(classify_handshake_status(response.status().as_u16())) } Ok(Err(e)) => Err(CdpError::Unreachable(e.to_string())), } } /// Map a refused upgrade onto the error it means. /// /// A 401 is a credential problem the caller can fix by signing in again; a 404 /// means the session is not theirs or no longer exists. Surfacing either as /// "connection failed" is what makes an automation client retry forever. pub fn classify_handshake_status(status: u16) -> CdpError { match status { 401 | 403 => { CdpError::Unauthorized(format!("the relay refused the credential (HTTP {status})")) } 404 => CdpError::NotDrivable( "no such remote session, or it does not belong to this account".to_string(), ), 409 => CdpError::NotDrivable("the remote session is not drivable yet".to_string()), 429 => CdpError::NotDrivable("too many attachments to this remote session".to_string()), other => CdpError::Unreachable(format!("the relay answered HTTP {other}")), } } #[cfg(test)] mod tests { use super::*; #[test] fn a_page_target_is_preferred_over_the_devtools_frontend() { // Attaching to devtools:// drives the inspector, not the site, and the // failure is silent: navigate returns success and nothing moves. let targets = serde_json::json!({ "targetInfos": [ { "targetId": "t-devtools", "type": "page", "url": "devtools://devtools/bundled/x.html" }, { "targetId": "t-page", "type": "page", "url": "https://example.com/" }, ] }); assert_eq!(pick_remote_page_target(&targets).as_deref(), Some("t-page")); } #[test] fn service_workers_and_browser_targets_are_not_pages() { let targets = serde_json::json!({ "targetInfos": [ { "targetId": "t-sw", "type": "service_worker", "url": "https://example.com/sw.js" }, { "targetId": "t-browser", "type": "browser", "url": "" }, ] }); assert!(pick_remote_page_target(&targets).is_none()); } #[test] fn a_fresh_browser_showing_only_about_blank_is_still_drivable() { // The first thing a remote launch has open is about:blank. Refusing it // would leave every session unusable until the user navigated by hand — // which they cannot do, because navigating is what needs the attach. let targets = serde_json::json!({ "targetInfos": [{ "targetId": "t-blank", "type": "page", "url": "about:blank" }] }); assert_eq!( pick_remote_page_target(&targets).as_deref(), Some("t-blank") ); } #[test] fn an_empty_reply_resolves_to_no_target_rather_than_panicking() { assert!(pick_remote_page_target(&serde_json::json!({})).is_none()); assert!(pick_remote_page_target(&serde_json::json!({ "targetInfos": [] })).is_none()); } #[test] fn the_local_page_socket_is_read_from_the_json_listing() { let targets = vec![ serde_json::json!({ "type": "background_page", "webSocketDebuggerUrl": "ws://x/bg" }), serde_json::json!({ "type": "page", "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/A" }), ]; assert_eq!( pick_local_page_socket(&targets).as_deref(), Some("ws://127.0.0.1:1/devtools/page/A") ); assert!(pick_local_page_socket(&[]).is_none()); } #[test] fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() { // A page-level command sent on the relay's BROWSER socket comes back as // "'Page.navigate' wasn't found". One missing sessionId on one message is // enough to make a single tool fail while every other tool works — a // partial failure that reads as a flaky VM. let remote = cdp_frame( Some("SESSION-42"), 7, "Page.navigate", serde_json::json!({ "url": "https://example.com" }), ); assert_eq!(remote["sessionId"], "SESSION-42"); assert_eq!(remote["id"], 7); assert_eq!(remote["method"], "Page.navigate"); assert_eq!(remote["params"]["url"], "https://example.com"); let local = cdp_frame(None, 7, "Page.navigate", serde_json::json!({})); assert!(local.get("sessionId").is_none()); assert_eq!(local["id"], 7); } #[test] fn a_relay_close_says_what_the_caller_should_do_about_it() { // These codes are the relay's entire vocabulary. Collapsing them into one // transport failure is how "your session is still provisioning" and "you // are signed out" both become "something went wrong". assert!(matches!( classify_close(1008, "x".into()), CdpError::Unauthorized(_) )); assert!(matches!( classify_close(1013, "x".into()), CdpError::NotDrivable(_) )); assert!(matches!( classify_close(1011, "x".into()), CdpError::Unreachable(_) )); assert!(matches!( classify_close(1009, "x".into()), CdpError::Transport(_) )); assert!(matches!( classify_close(1000, "x".into()), CdpError::Transport(_) )); } #[test] fn only_the_failures_a_retry_could_fix_are_retried() { assert!(CdpError::Unreachable("x".into()).is_retryable()); assert!(CdpError::Transport("x".into()).is_retryable()); assert!(!CdpError::Unauthorized("x".into()).is_retryable()); assert!(!CdpError::NotDrivable("x".into()).is_retryable()); assert!(!CdpError::Protocol("x".into()).is_retryable()); } #[test] fn a_refused_upgrade_is_not_reported_as_an_unreachable_browser() { assert!(matches!( classify_handshake_status(401), CdpError::Unauthorized(_) )); assert!(matches!( classify_handshake_status(404), CdpError::NotDrivable(_) )); assert!(matches!( classify_handshake_status(409), CdpError::NotDrivable(_) )); assert!(matches!( classify_handshake_status(429), CdpError::NotDrivable(_) )); assert!(matches!( classify_handshake_status(502), CdpError::Unreachable(_) )); } #[test] fn a_relay_endpoint_carries_the_credential_in_a_header() { // In the query string it would reach every proxy log between here and the // origin, and this credential grants full control of a live browser. let request = relay_request( "wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1", "secret-token", ) .expect("a wss endpoint must build a request"); assert_eq!( request .headers() .get("authorization") .and_then(|v| v.to_str().ok()), Some("Bearer secret-token") ); assert!(!request.uri().to_string().contains("secret-token")); } #[test] fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() { // Observed against the real backend: attaching to a session the user had // just stopped answered 502, so a CDP client read "this is finished" as // "the gateway is down" and retried it. use crate::remote_session::RemoteSessionError; assert!(matches!( endpoint_lookup_error(RemoteSessionError::Other( r#"(404) {"code":"REMOTE_SESSION_NOT_FOUND"}"#.to_string() )), CdpError::NotDrivable(_) )); assert!(matches!( endpoint_lookup_error(RemoteSessionError::Conflict("already open".into())), CdpError::NotDrivable(_) )); assert!(matches!( endpoint_lookup_error(RemoteSessionError::NotAuthorised("signed out".into())), CdpError::Unauthorized(_) )); // A genuine transport failure must still read as one. assert!(matches!( endpoint_lookup_error(RemoteSessionError::Other( "reach backend: connection refused".to_string() )), CdpError::Unreachable(_) )); } #[test] fn a_malformed_endpoint_is_refused_rather_than_dialled() { assert!(relay_request("not a url", "t").is_err()); } #[test] fn a_target_describes_itself_without_leaking_the_credential() { let remote = CdpTarget::Remote { ws_url: "wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1".to_string(), bearer: "secret-token".to_string(), session_id: "s1".to_string(), }; assert!(remote.is_remote()); let described = remote.describe(); assert!(described.contains("s1")); assert!(!described.contains("secret-token")); let local = CdpTarget::Local { ws_url: "ws://127.0.0.1:1/devtools/page/A".to_string(), }; assert!(!local.is_remote()); } #[test] fn a_hasty_probe_tries_once_and_a_patient_one_waits() { // The split is what stops a profile running on the fleet from being held // behind twenty-five seconds of local retries before anyone looks remote. assert_eq!(Patience::Immediate.attempts(10), 1); assert_eq!(Patience::WaitForLaunch.attempts(10), 10); } // --- Against a real socket ----------------------------------------------- // // Everything above is pure. These drive the client against a WebSocket // server that answers the way the relay does, because the failure this whole // module exists to fix — page commands sent on a browser-level socket coming // back as "'Page.navigate' wasn't found" — cannot be caught by inspecting a // JSON value. It only shows up when something actually answers. /// What the fake relay observed. #[derive(Debug, Default)] struct RelayLog { /// The credential the client presented on the upgrade. authorization: Option, /// Every message the client sent, in order. received: Vec, } /// How the fake relay should behave once a client connects. #[derive(Clone, Copy, PartialEq, Eq)] enum RelayBehaviour { /// Answer the attach handshake, then echo every command back. Cooperative, /// Hang up the way a session that is not yet up does. RefuseAsNotDrivable, } /// The CDP session id the fake relay hands out for a flat attach. const FAKE_CDP_SESSION: &str = "CDP-SESSION-1"; /// A stand-in for the infra relay bridged onto a browser-level socket. /// /// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real /// browser endpoint does, then echoes each command back so the test can read /// what was actually on the wire. // // The large-Err allow is forced by tungstenite's server-callback signature: // its `ErrorResponse` is a full `http::Response`, and the callback is the // only place the upgrade request's headers are visible. #[allow(clippy::result_large_err)] async fn fake_relay(behaviour: RelayBehaviour) -> (String, tokio::task::JoinHandle) { use futures_util::sink::SinkExt; use futures_util::stream::StreamExt; use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use tokio_tungstenite::tungstenite::protocol::CloseFrame; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("the fake relay must bind"); let port = listener.local_addr().expect("a bound port").port(); let handle = tokio::spawn(async move { let mut log = RelayLog::default(); let Ok((socket, _)) = listener.accept().await else { return log; }; let seen = std::sync::Arc::new(std::sync::Mutex::new(None::)); let captured = seen.clone(); let Ok(mut stream) = tokio_tungstenite::accept_hdr_async( socket, |request: &WsRequest, response: tokio_tungstenite::tungstenite::handshake::server::Response| { *captured.lock().unwrap() = request .headers() .get("authorization") .and_then(|value| value.to_str().ok()) .map(str::to_string); Ok(response) }, ) .await else { return log; }; log.authorization = seen.lock().unwrap().clone(); if behaviour == RelayBehaviour::RefuseAsNotDrivable { let _ = stream .close(Some(CloseFrame { code: CloseCode::Library(1013), reason: "session is provisioning, not drivable".into(), })) .await; return log; } while let Some(Ok(message)) = stream.next().await { let Message::Text(text) = message else { continue; }; let Ok(request) = serde_json::from_str::(&text) else { continue; }; log.received.push(request.clone()); let id = request.get("id").cloned().unwrap_or(Value::Null); let reply = match request.get("method").and_then(Value::as_str) { Some("Target.getTargets") => serde_json::json!({ "id": id, "result": { "targetInfos": [ { "targetId": "page-1", "type": "page", "url": "https://example.com/" } ]} }), Some("Target.attachToTarget") => serde_json::json!({ "id": id, "result": { "sessionId": FAKE_CDP_SESSION } }), // Everything else is handed straight back, so the test can assert on // the exact frame the client put on the wire. _ => serde_json::json!({ "id": id, "sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null), "result": { "echo": request } }), }; if stream .send(Message::Text(reply.to_string().into())) .await .is_err() { break; } // A real browser follows a navigation with the load event, flattened // onto the same socket. Emitting it here is what proves the wait // actually terminates on the event rather than on its timeout. if request.get("method").and_then(Value::as_str) == Some("Page.navigate") { let loaded = serde_json::json!({ "method": "Page.loadEventFired", "sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null), "params": { "timestamp": 1.0 } }); if stream .send(Message::Text(loaded.to_string().into())) .await .is_err() { break; } } } log }); (format!("ws://127.0.0.1:{port}"), handle) } #[tokio::test] async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() { // This is the whole feature. The relay bridges /devtools/browser/, so // without the flat attach and the sessionId stamp every existing tool // answers "'Page.navigate' wasn't found" and a paid remote session cannot // be used for anything. let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; let target = CdpTarget::Remote { ws_url, bearer: "user-access-token".to_string(), session_id: "sess-1".to_string(), }; let result = run_command( &target, "Page.navigate", serde_json::json!({ "url": "https://example.com" }), ) .await .expect("a relayed navigate must succeed"); assert_eq!(result["echo"]["method"], "Page.navigate"); assert_eq!(result["echo"]["sessionId"], FAKE_CDP_SESSION); assert_eq!(result["echo"]["params"]["url"], "https://example.com"); let log = server.await.expect("the fake relay must finish"); assert_eq!( log.authorization.as_deref(), Some("Bearer user-access-token") ); let methods: Vec<&str> = log .received .iter() .filter_map(|m| m.get("method").and_then(Value::as_str)) .collect(); assert_eq!( methods, vec![ "Target.getTargets", "Target.attachToTarget", "Page.navigate" ] ); // The handshake runs on the BROWSER session and must not be addressed to a // page, or the browser answers it with "no such session". assert!(log.received[0].get("sessionId").is_none()); assert!(log.received[1].get("sessionId").is_none()); } #[tokio::test] async fn a_relayed_navigation_waits_for_the_page_to_load() { // The load wait shares one implementation with the local arm, so a // flattened event that failed to match here would strand every navigate // for its full timeout. let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; let target = CdpTarget::Remote { ws_url, bearer: "t".to_string(), session_id: "sess-1".to_string(), }; let started = std::time::Instant::now(); navigate(&target, "https://example.com", 30) .await .expect("a relayed navigate must resolve"); // It must return on the load event, not by outliving the timeout: a client // that always waits the full budget turns every navigation into a stall. assert!( started.elapsed() < Duration::from_secs(10), "navigate waited out its timeout instead of matching the load event" ); let log = server.await.expect("the fake relay must finish"); let methods: Vec<&str> = log .received .iter() .filter_map(|m| m.get("method").and_then(Value::as_str)) .collect(); assert!(methods.contains(&"Page.enable")); assert!(methods.contains(&"Page.navigate")); // Every page-domain message must carry the session, not just the command. for message in &log.received { let method = message.get("method").and_then(Value::as_str).unwrap_or(""); if method.starts_with("Page.") { assert_eq!( message.get("sessionId").and_then(Value::as_str), Some(FAKE_CDP_SESSION), "{method} was not addressed to the attached page" ); } } } #[tokio::test] async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() { // 1013 is the relay saying "come back when it is live". Surfacing it as a // transport failure would send an automation client into a retry loop // against a session that is doing exactly what it should. let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await; let target = CdpTarget::Remote { ws_url, bearer: "t".to_string(), session_id: "sess-1".to_string(), }; let error = run_command(&target, "Page.navigate", serde_json::json!({})) .await .expect_err("a refused session must not look like a success"); assert!( matches!(error, CdpError::NotDrivable(_)), "expected NotDrivable, got {error:?}" ); } #[tokio::test] async fn a_local_command_skips_the_attach_and_carries_no_session() { // The local arm talks to a PAGE socket. Sending it a sessionId, or making // it pay for an attach handshake it does not need, would be a regression // in the path that already worked. let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; let target = CdpTarget::Local { ws_url }; let result = run_command( &target, "Runtime.evaluate", serde_json::json!({ "expression": "1" }), ) .await .expect("a local command must succeed"); assert_eq!(result["echo"]["method"], "Runtime.evaluate"); let log = server.await.expect("the fake relay must finish"); let methods: Vec<&str> = log .received .iter() .filter_map(|m| m.get("method").and_then(Value::as_str)) .collect(); assert_eq!(methods, vec!["Runtime.evaluate"]); assert!(log.received[0].get("sessionId").is_none()); } }