feat: [US-007] - Add CDP WebSocket proxy endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 05:11:57 -07:00
parent f8b4df9acb
commit b328d6b214
2 changed files with 154 additions and 0 deletions

View file

@ -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<String, BrowserProblem> {
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

View file

@ -275,6 +275,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.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<AppState>) {
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<Arc<AppState>>,
ws: WebSocketUpgrade,
) -> Result<Response, ApiError> {
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<BrowserRuntime>) {
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