diff --git a/server/packages/sandbox-agent/src/browser_runtime.rs b/server/packages/sandbox-agent/src/browser_runtime.rs index cec6a88..ccb29d7 100644 --- a/server/packages/sandbox-agent/src/browser_runtime.rs +++ b/server/packages/sandbox-agent/src/browser_runtime.rs @@ -417,6 +417,43 @@ impl BrowserRuntime { f(cdp).await } + /// Ensure the browser runtime is active. + /// + /// Returns `BrowserProblem::NotActive` if the browser is not running. + pub async fn ensure_active(&self) -> Result<(), BrowserProblem> { + let state = self.inner.lock().await; + if state.state != BrowserState::Active { + return Err(BrowserProblem::not_active()); + } + Ok(()) + } + + /// Discover the CDP WebSocket debugger URL from Chromium. + /// + /// Queries `http://127.0.0.1:9222/json/version` and extracts the + /// `webSocketDebuggerUrl` field. + pub async fn cdp_ws_url(&self) -> Result { + self.ensure_active().await?; + + let version_url = format!("http://127.0.0.1:{CDP_PORT}/json/version"); + let resp = reqwest::get(&version_url).await.map_err(|e| { + BrowserProblem::cdp_error(format!( + "failed to reach CDP endpoint at {version_url}: {e}" + )) + })?; + let version_info: serde_json::Value = resp.json().await.map_err(|e| { + BrowserProblem::cdp_error(format!("invalid JSON from {version_url}: {e}")) + })?; + version_info["webSocketDebuggerUrl"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + BrowserProblem::cdp_error( + "webSocketDebuggerUrl not found in /json/version response", + ) + }) + } + /// Get the streaming manager for WebRTC signaling. pub fn streaming_manager(&self) -> &DesktopStreamingManager { &self.streaming_manager diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 2f5de21..04986e4 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -275,6 +275,7 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) .route("/browser/status", get(get_v1_browser_status)) .route("/browser/start", post(post_v1_browser_start)) .route("/browser/stop", post(post_v1_browser_stop)) + .route("/browser/cdp", get(get_v1_browser_cdp_ws)) .route("/agents", get(get_v1_agents)) .route("/agents/:agent", get(get_v1_agent)) .route("/agents/:agent/install", post(post_v1_agent_install)) @@ -465,6 +466,7 @@ pub async fn shutdown_servers(state: &Arc) { get_v1_browser_status, post_v1_browser_start, post_v1_browser_stop, + get_v1_browser_cdp_ws, get_v1_agents, get_v1_agent, post_v1_agent_install, @@ -804,6 +806,121 @@ async fn post_v1_browser_stop( Ok(Json(status)) } +/// Open a CDP WebSocket proxy session. +/// +/// Upgrades the connection to a WebSocket that relays bidirectionally to +/// Chromium's internal CDP WebSocket endpoint. External tools like Playwright +/// or Puppeteer can connect via `ws://sandbox-host:2468/v1/browser/cdp`. +#[utoipa::path( + get, + path = "/v1/browser/cdp", + tag = "v1", + responses( + (status = 101, description = "WebSocket upgraded"), + (status = 409, description = "Browser runtime is not active", body = ProblemDetails), + (status = 502, description = "CDP connection failed", body = ProblemDetails) + ) +)] +async fn get_v1_browser_cdp_ws( + State(state): State>, + ws: WebSocketUpgrade, +) -> Result { + state.browser_runtime().ensure_active().await?; + Ok(ws + .on_upgrade(move |socket| browser_cdp_ws_session(socket, state.browser_runtime())) + .into_response()) +} + +/// CDP WebSocket proxy session. +/// +/// Proxies the WebSocket bidirectionally between the external client and +/// Chromium's internal CDP WebSocket endpoint. All CDP commands and events +/// are relayed transparently. +async fn browser_cdp_ws_session(mut client_ws: WebSocket, browser_runtime: Arc) { + use futures::SinkExt; + use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; + + // Discover the actual CDP WebSocket URL from Chromium. + let cdp_ws_url = match browser_runtime.cdp_ws_url().await { + Ok(url) => url, + Err(_) => { + let _ = send_ws_error(&mut client_ws, "browser CDP endpoint is not available").await; + let _ = client_ws.close().await; + return; + } + }; + + // Connect to Chromium's internal CDP WebSocket. + let (cdp_ws, _) = match tokio_tungstenite::connect_async(&cdp_ws_url).await { + Ok(conn) => conn, + Err(err) => { + let _ = send_ws_error( + &mut client_ws, + &format!("failed to connect to CDP endpoint: {err}"), + ) + .await; + let _ = client_ws.close().await; + return; + } + }; + + let (mut cdp_sink, mut cdp_stream) = cdp_ws.split(); + + // Relay messages bidirectionally between client and CDP. + loop { + tokio::select! { + // Client → CDP + client_msg = client_ws.recv() => { + match client_msg { + Some(Ok(Message::Text(text))) => { + if cdp_sink.send(TungsteniteMessage::Text(text.into())).await.is_err() { + break; + } + } + Some(Ok(Message::Binary(data))) => { + if cdp_sink.send(TungsteniteMessage::Binary(data.into())).await.is_err() { + break; + } + } + Some(Ok(Message::Ping(payload))) => { + let _ = client_ws.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Pong(_))) => {} + Some(Err(_)) => break, + } + } + // CDP → Client + cdp_msg = cdp_stream.next() => { + match cdp_msg { + Some(Ok(TungsteniteMessage::Text(text))) => { + if client_ws.send(Message::Text(text.into())).await.is_err() { + break; + } + } + Some(Ok(TungsteniteMessage::Binary(data))) => { + if client_ws.send(Message::Binary(data.into())).await.is_err() { + break; + } + } + Some(Ok(TungsteniteMessage::Ping(payload))) => { + if cdp_sink.send(TungsteniteMessage::Pong(payload.clone())).await.is_err() { + break; + } + } + Some(Ok(TungsteniteMessage::Close(_))) | None => break, + Some(Ok(TungsteniteMessage::Pong(_))) => {} + Some(Ok(TungsteniteMessage::Frame(_))) => {} + Some(Err(_)) => break, + } + } + } + } + + let _ = cdp_sink.close().await; + let _ = client_ws.close().await; +} + /// Capture a full desktop screenshot. /// /// Performs a health-gated full-frame screenshot of the managed desktop and