mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 07:03:31 +00:00
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:
parent
f8b4df9acb
commit
b328d6b214
2 changed files with 154 additions and 0 deletions
|
|
@ -417,6 +417,43 @@ impl BrowserRuntime {
|
||||||
f(cdp).await
|
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.
|
/// Get the streaming manager for WebRTC signaling.
|
||||||
pub fn streaming_manager(&self) -> &DesktopStreamingManager {
|
pub fn streaming_manager(&self) -> &DesktopStreamingManager {
|
||||||
&self.streaming_manager
|
&self.streaming_manager
|
||||||
|
|
|
||||||
|
|
@ -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/status", get(get_v1_browser_status))
|
||||||
.route("/browser/start", post(post_v1_browser_start))
|
.route("/browser/start", post(post_v1_browser_start))
|
||||||
.route("/browser/stop", post(post_v1_browser_stop))
|
.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", get(get_v1_agents))
|
||||||
.route("/agents/:agent", get(get_v1_agent))
|
.route("/agents/:agent", get(get_v1_agent))
|
||||||
.route("/agents/:agent/install", post(post_v1_agent_install))
|
.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,
|
get_v1_browser_status,
|
||||||
post_v1_browser_start,
|
post_v1_browser_start,
|
||||||
post_v1_browser_stop,
|
post_v1_browser_stop,
|
||||||
|
get_v1_browser_cdp_ws,
|
||||||
get_v1_agents,
|
get_v1_agents,
|
||||||
get_v1_agent,
|
get_v1_agent,
|
||||||
post_v1_agent_install,
|
post_v1_agent_install,
|
||||||
|
|
@ -804,6 +806,121 @@ async fn post_v1_browser_stop(
|
||||||
Ok(Json(status))
|
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.
|
/// Capture a full desktop screenshot.
|
||||||
///
|
///
|
||||||
/// Performs a health-gated full-frame screenshot of the managed desktop and
|
/// Performs a health-gated full-frame screenshot of the managed desktop and
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue