Files
donutbrowser/src-tauri/src/cdp_target.rs
T
2026-08-03 18:44:24 +04:00

1381 lines
50 KiB
Rust

//! 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<CdpTarget, ResolveError> {
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<u16> {
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<String> {
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::<Vec<Value>>().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<String> {
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<String> {
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<MaybeTlsStream<TcpStream>>,
/// 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<String>,
closed: Option<CloseInfo>,
}
impl CdpConnection {
fn new(stream: WebSocketStream<MaybeTlsStream<TcpStream>>) -> 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<Result<String, CdpError>> {
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<Value, CdpError> {
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<Value, CdpError> {
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/<id>`. 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<Value, CdpError> {
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<Value, CdpError> {
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<CdpConnection, CdpError> {
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<CdpConnection, CdpError> {
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<MaybeTlsStream<TcpStream>>;
/// 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<RelaySocket, CdpError> {
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<CdpConnection, CdpError> {
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<WsRequest, CdpError> {
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<WebSocketConfig>,
) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, 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<String>,
/// Every message the client sent, in order.
received: Vec<Value>,
}
/// 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<RelayLog>) {
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::<String>));
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::<Value>(&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/<id>, 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());
}
}