From d33bdb36f3f7793c977e8c503e5962721b275db8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 7 May 2026 16:08:12 +0200 Subject: [PATCH] Make dashboard work from proxied origins via same-origin proxy (#1111) * Restore dashboard session proxy routes Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc Signed-off-by: Thomas Kosiewski * Port dashboard frontend and docs Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac Signed-off-by: Thomas Kosiewski * docs: restore dashboard note in skill Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487 Signed-off-by: Thomas Kosiewski * fix: tighten dashboard proxy same-origin checks Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5 Signed-off-by: Thomas Kosiewski --------- Signed-off-by: Thomas Kosiewski --- README.md | 2 +- cli/src/native/stream/dashboard.rs | 745 +++++++++++++++++- cli/src/output.rs | 5 + docs/src/app/commands/page.mdx | 2 + docs/src/app/dashboard/page.mdx | 6 +- .../dashboard/src/components/viewport.tsx | 3 +- .../dashboard/src/lib/dashboard-routes.ts | 42 + packages/dashboard/src/store/sessions.ts | 16 +- packages/dashboard/src/store/stream.ts | 5 +- skills/agent-browser/SKILL.md | 4 + 10 files changed, 776 insertions(+), 54 deletions(-) create mode 100644 packages/dashboard/src/lib/dashboard-routes.ts diff --git a/README.md b/README.md index 1705166..75fa8d2 100644 --- a/README.md +++ b/README.md @@ -752,7 +752,7 @@ agent-browser open example.com agent-browser dashboard stop ``` -The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard. +The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running, and it works from `http://localhost:4848` or a proxied/forwarded URL that reaches the dashboard server, such as `https://dashboard.agent-browser.localhost` or a Coder workspace URL. The browser stays on the dashboard origin; session-specific tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. The dashboard displays: - **Live viewport** -- real-time JPEG frames from the browser diff --git a/cli/src/native/stream/dashboard.rs b/cli/src/native/stream/dashboard.rs index a19c459..0f04e5e 100644 --- a/cli/src/native/stream/dashboard.rs +++ b/cli/src/native/stream/dashboard.rs @@ -1,7 +1,8 @@ +use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; - -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; use crate::connection::get_socket_dir; @@ -9,6 +10,418 @@ use super::chat::{chat_status_json, handle_chat_request, handle_models_request}; use super::discovery::discover_sessions; use super::http::{serve_embedded_file, CORS_HEADERS}; +/// Dashboard same-origin proxy endpoints for session metadata and streams. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionProxyEndpoint { + Tabs, + Status, + Stream, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DashboardProxyError { + status: &'static str, + message: String, +} + +impl DashboardProxyError { + fn not_found(message: impl Into) -> Self { + Self { + status: "404 Not Found", + message: message.into(), + } + } + + fn bad_gateway(message: impl Into) -> Self { + Self { + status: "502 Bad Gateway", + message: message.into(), + } + } +} + +const PROXY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const PROXY_MAX_RESPONSE_SIZE: u64 = 16 * 1024 * 1024; + +fn build_json_error_body(error: &str) -> String { + let escaped = serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error)); + format!(r#"{{"success":false,"error":{escaped}}}"#) +} + +async fn write_http_response_inner( + stream: &mut tokio::net::TcpStream, + status: &str, + content_type: &str, + body: &[u8], + include_cors: bool, +) { + let cors_headers = if include_cors { CORS_HEADERS } else { "" }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(body).await; +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + status: &str, + content_type: &str, + body: &[u8], +) { + write_http_response_inner(stream, status, content_type, body, true).await; +} + +async fn write_http_response_no_cors( + stream: &mut tokio::net::TcpStream, + status: &str, + content_type: &str, + body: &[u8], +) { + write_http_response_inner(stream, status, content_type, body, false).await; +} + +async fn write_json_error_response_no_cors( + stream: &mut tokio::net::TcpStream, + status: &'static str, + error: &str, +) { + let body = build_json_error_body(error); + write_http_response_no_cors( + stream, + status, + "application/json; charset=utf-8", + body.as_bytes(), + ) + .await; +} + +fn parse_request_method_and_path(request: &str) -> (&str, &str) { + let first_line = request.lines().next().unwrap_or(""); + let method = first_line.split_whitespace().next().unwrap_or("GET"); + let path = first_line.split_whitespace().nth(1).unwrap_or("/"); + (method, path) +} + +fn is_websocket_upgrade(request: &str) -> bool { + request.lines().any(|line| { + if let Some((name, value)) = line.split_once(':') { + name.trim().eq_ignore_ascii_case("upgrade") + && value.trim().eq_ignore_ascii_case("websocket") + } else { + false + } + }) +} + +fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (header_name, value) = line.split_once(':')?; + if header_name.trim().eq_ignore_ascii_case(name) { + Some(value.trim()) + } else { + None + } + }) +} + +fn normalize_origin_authority(origin: &str) -> Option { + let url = url::Url::parse(origin).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + let host = if host.contains(':') { + format!("[{host}]") + } else { + host + }; + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) +} + +fn normalize_host_authority(host: &str) -> String { + let host = host.trim().to_ascii_lowercase(); + + if let Some(bracket_end) = host.rfind(']') { + if bracket_end == host.len() - 1 { + return host; + } + + if host.as_bytes().get(bracket_end + 1) == Some(&b':') { + let port = &host[bracket_end + 2..]; + if port == "80" || port == "443" { + return host[..=bracket_end].to_string(); + } + } + + return host; + } + + if let Some((name, port)) = host.rsplit_once(':') { + if !name.contains(':') && (port == "80" || port == "443") { + return name.to_string(); + } + } + + host +} + +fn header_matches_host(request: &str, header_name: &str) -> Option { + let authority = + request_header_value(request, header_name).and_then(normalize_origin_authority)?; + let host = request_header_value(request, "host").map(normalize_host_authority)?; + Some(authority == host) +} + +/// Validates that a proxied WebSocket request either has no Origin header or +/// presents an Origin whose authority matches the request Host header. +fn is_same_origin_ws_request(request: &str) -> bool { + match header_matches_host(request, "origin") { + Some(matches) => matches, + None => request_header_value(request, "origin").is_none(), + } +} + +/// Validates that an HTTP session-proxy request came from a same-origin page. +/// +/// For GET requests we require either a same-origin `Origin` or a same-origin +/// `Referer` so browsers cannot hit the proxy routes via side-channel tags or +/// arbitrary cross-origin fetches. +fn is_same_origin_http_request(request: &str) -> bool { + matches!(header_matches_host(request, "origin"), Some(true)) + || matches!(header_matches_host(request, "referer"), Some(true)) +} + +/// Parse a dashboard route of the form `/api/session//`. +fn parse_session_proxy_route(path: &str) -> Result<(u16, SessionProxyEndpoint), &'static str> { + if !path.starts_with("/api/session/") { + return Err("Invalid session proxy route."); + } + + let mut parts = path.split('/'); + if parts.next() != Some("") || parts.next() != Some("api") || parts.next() != Some("session") { + return Err("Invalid session proxy route."); + } + + let port_str = parts.next().ok_or("Missing session proxy port.")?; + if port_str.is_empty() { + return Err("Missing session proxy port."); + } + + let endpoint = match parts.next().ok_or("Missing session proxy endpoint.")? { + "tabs" => SessionProxyEndpoint::Tabs, + "status" => SessionProxyEndpoint::Status, + "stream" => SessionProxyEndpoint::Stream, + _ => return Err("Unknown session proxy endpoint."), + }; + + if parts.next().is_some() { + return Err("Unexpected path segments in session proxy route."); + } + + let port = port_str + .parse::() + .map_err(|_| "Session proxy port must be a valid TCP port.")?; + if port == 0 { + return Err("Session proxy port must be a valid TCP port."); + } + + Ok((port, endpoint)) +} + +fn sessions_json_has_active_port(sessions_json: &str, port: u16) -> Result { + let sessions: Vec = serde_json::from_str(sessions_json) + .map_err(|e| format!("Failed to parse active sessions: {e}"))?; + Ok(sessions.iter().any(|session| { + session + .get("port") + .and_then(|value| value.as_u64()) + .map(|value| value == u64::from(port)) + .unwrap_or(false) + })) +} + +fn require_active_session_port(port: u16) -> Result<(), DashboardProxyError> { + let sessions_json = discover_sessions(); + let is_active = sessions_json_has_active_port(&sessions_json, port) + .map_err(DashboardProxyError::bad_gateway)?; + if is_active { + Ok(()) + } else { + Err(DashboardProxyError::not_found(format!( + "No active session is listening on port {port}." + ))) + } +} + +fn split_http_response(response: &[u8]) -> Result<(&[u8], &[u8]), String> { + if let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") { + let body_start = header_end + 4; + return Ok((&response[..header_end], &response[body_start..])); + } + + if let Some(header_end) = response.windows(2).position(|window| window == b"\n\n") { + let body_start = header_end + 2; + return Ok((&response[..header_end], &response[body_start..])); + } + + Err("Upstream response was missing an HTTP header terminator.".to_string()) +} + +fn parse_upstream_http_response(response: &[u8]) -> Result<(String, String, Vec), String> { + let (header_bytes, body) = split_http_response(response)?; + let header_str = std::str::from_utf8(header_bytes) + .map_err(|e| format!("Upstream response headers were not valid UTF-8: {e}"))?; + + let mut lines = header_str.lines(); + let status_line = lines + .next() + .ok_or_else(|| "Upstream response was missing a status line.".to_string())?; + let status = status_line + .split_once(' ') + .map(|(_, status)| status.trim().to_string()) + .filter(|status| !status.is_empty()) + .ok_or_else(|| "Upstream response status line was malformed.".to_string())?; + let content_type = lines + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-type") { + Some(value.trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| "application/json; charset=utf-8".to_string()); + + Ok((status, content_type, body.to_vec())) +} + +/// Proxy dashboard-origin HTTP requests for session tabs or status to the loopback session server. +async fn proxy_session_http_route( + port: u16, + endpoint: SessionProxyEndpoint, +) -> Result<(String, String, Vec), DashboardProxyError> { + debug_assert!(matches!( + endpoint, + SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status + )); + + require_active_session_port(port)?; + + let upstream_path = match endpoint { + SessionProxyEndpoint::Tabs => "/api/tabs", + SessionProxyEndpoint::Status => "/api/status", + SessionProxyEndpoint::Stream => unreachable!("stream routes use the WebSocket proxy"), + }; + let request = format!( + "GET {upstream_path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + + tokio::time::timeout(PROXY_TIMEOUT, async { + let mut upstream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .map_err(|e| { + DashboardProxyError::bad_gateway(format!( + "Failed to connect to session {port}: {e}" + )) + })?; + upstream.write_all(request.as_bytes()).await.map_err(|e| { + DashboardProxyError::bad_gateway(format!( + "Failed to proxy request to session {port}: {e}" + )) + })?; + + let mut response = Vec::new(); + (&mut upstream) + .take(PROXY_MAX_RESPONSE_SIZE + 1) + .read_to_end(&mut response) + .await + .map_err(|e| { + DashboardProxyError::bad_gateway(format!( + "Failed to read session {port} response: {e}" + )) + })?; + if response.len() as u64 > PROXY_MAX_RESPONSE_SIZE { + return Err(DashboardProxyError::bad_gateway(format!( + "Session {port} response exceeded {PROXY_MAX_RESPONSE_SIZE} bytes." + ))); + } + + parse_upstream_http_response(&response).map_err(DashboardProxyError::bad_gateway) + }) + .await + .map_err(|_| { + DashboardProxyError::bad_gateway(format!( + "Session {port} proxy request timed out after {}s.", + PROXY_TIMEOUT.as_secs() + )) + })? +} + +/// Bridge a dashboard-origin WebSocket upgrade to the loopback session stream. +async fn proxy_session_stream(mut stream: tokio::net::TcpStream, port: u16) { + let upstream_url = format!("ws://127.0.0.1:{port}"); + let (upstream_ws, _) = match tokio_tungstenite::connect_async(&upstream_url).await { + Ok(ws) => ws, + Err(error) => { + write_json_error_response_no_cors( + &mut stream, + "502 Bad Gateway", + &format!("Failed to connect to session {port}: {error}"), + ) + .await; + return; + } + }; + let client_ws = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(_) => return, + }; + + let (mut client_tx, mut client_rx) = client_ws.split(); + let (mut upstream_tx, mut upstream_rx) = upstream_ws.split(); + + loop { + tokio::select! { + message = client_rx.next() => { + match message { + Some(Ok(message)) => { + let is_close = matches!(message, Message::Close(_)); + if upstream_tx.send(message).await.is_err() { + break; + } + if is_close { + break; + } + } + Some(Err(_)) | None => { + let _ = upstream_tx.send(Message::Close(None)).await; + break; + } + } + } + message = upstream_rx.next() => { + match message { + Some(Ok(message)) => { + let is_close = matches!(message, Message::Close(_)); + if client_tx.send(message).await.is_err() { + break; + } + if is_close { + break; + } + } + Some(Err(_)) | None => { + let _ = client_tx.send(Message::Close(None)).await; + break; + } + } + } + } + } +} + pub async fn run_dashboard_server(port: u16) { let addr = format!("127.0.0.1:{}", port); let listener = match TcpListener::bind(&addr).await { @@ -30,25 +443,82 @@ pub async fn run_dashboard_server(port: u16) { } async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) { - use tokio::io::AsyncReadExt; - let mut buf = vec![0u8; 8192]; + let peeked_len = match stream.peek(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + let peeked_request = String::from_utf8_lossy(&buf[..peeked_len]); + let (peeked_method, peeked_path) = parse_request_method_and_path(&peeked_request); + + if peeked_path.starts_with("/api/session/") { + let (port, endpoint) = match parse_session_proxy_route(peeked_path) { + Ok(route) => route, + Err(error) => { + write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await; + return; + } + }; + + match endpoint { + SessionProxyEndpoint::Stream => { + if peeked_method != "GET" { + write_json_error_response_no_cors( + &mut stream, + "400 Bad Request", + "Session stream proxy only supports GET WebSocket upgrades.", + ) + .await; + return; + } + if !is_websocket_upgrade(&peeked_request) { + write_json_error_response_no_cors( + &mut stream, + "400 Bad Request", + "Session stream proxy requires a WebSocket upgrade request.", + ) + .await; + return; + } + if !is_same_origin_ws_request(&peeked_request) { + write_json_error_response_no_cors( + &mut stream, + "403 Forbidden", + "Origin does not match Host header.", + ) + .await; + return; + } + if let Err(error) = require_active_session_port(port) { + write_json_error_response_no_cors(&mut stream, error.status, &error.message) + .await; + return; + } + proxy_session_stream(stream, port).await; + return; + } + SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => { + if peeked_method != "GET" { + write_json_error_response_no_cors( + &mut stream, + "400 Bad Request", + "Session proxy routes only support GET requests.", + ) + .await; + return; + } + } + } + } + let n = match stream.read(&mut buf).await { Ok(n) if n > 0 => n, _ => return, }; - let header_str = std::str::from_utf8(&buf[..n]).unwrap_or(""); - let first_line = header_str.lines().next().unwrap_or("").to_string(); - let method = first_line.split_whitespace().next().unwrap_or("GET"); - let path = first_line.split_whitespace().nth(1).unwrap_or("/"); - let origin = header_str.lines().find_map(|line| { - if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") { - Some(line[8..].trim().to_string()) - } else { - None - } - }); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let (method, path) = parse_request_method_and_path(&request); + let origin = request_header_value(&request, "origin").map(|value| value.to_string()); if method == "OPTIONS" { let response = format!( @@ -80,23 +550,67 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) { }; let (status, resp_body) = match result { Ok(msg) => ("200 OK", msg), - Err(e) => ( - "400 Bad Request", - format!( - r#"{{"success":false,"error":{}}}"#, - serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e)) - ), - ), + Err(e) => ("400 Bad Request", build_json_error_body(&e)), }; - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", - resp_body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.write_all(resp_body.as_bytes()).await; + write_http_response( + &mut stream, + status, + "application/json; charset=utf-8", + resp_body.as_bytes(), + ) + .await; return; } + if path.starts_with("/api/session/") { + let (port, endpoint) = match parse_session_proxy_route(path) { + Ok(route) => route, + Err(error) => { + write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await; + return; + } + }; + + match endpoint { + SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => { + if !is_same_origin_http_request(&request) { + write_json_error_response_no_cors( + &mut stream, + "403 Forbidden", + "Origin or Referer does not match Host header.", + ) + .await; + return; + } + + match proxy_session_http_route(port, endpoint).await { + Ok((status, content_type, body)) => { + write_http_response_no_cors(&mut stream, &status, &content_type, &body) + .await; + } + Err(error) => { + write_json_error_response_no_cors( + &mut stream, + error.status, + &error.message, + ) + .await; + } + } + return; + } + SessionProxyEndpoint::Stream => { + write_json_error_response_no_cors( + &mut stream, + "400 Bad Request", + "Session stream proxy requires a WebSocket upgrade request.", + ) + .await; + return; + } + } + } + let (status, content_type, body): (&str, &str, Vec) = if path == "/api/sessions" { ( "200 OK", @@ -113,19 +627,10 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) { serve_embedded_file(path) }; - let response = format!( - "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", - status, - content_type, - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.write_all(&body).await; + write_http_response(&mut stream, status, content_type, &body).await; } async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: usize) -> String { - use tokio::io::AsyncReadExt; - let header_end = initial[..n] .windows(4) .position(|w| w == b"\r\n\r\n") @@ -145,12 +650,12 @@ async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: u .lines() .find_map(|l| { if l.len() > 16 && l[..16].eq_ignore_ascii_case("content-length: ") { - l[16..].trim().parse().ok() + l[16..].trim().parse::().ok() } else { let lower = l.to_lowercase(); lower .strip_prefix("content-length:") - .and_then(|v| v.trim().parse().ok()) + .and_then(|v| v.trim().parse::().ok()) } }) .unwrap_or(0); @@ -239,11 +744,15 @@ async fn kill_session(body: &str) -> Result { #[cfg(unix)] { + // SAFETY: The PID came from the daemon-managed pidfile and is only used + // to send standard termination signals to that process. unsafe { libc::kill(pid as i32, libc::SIGTERM); } tokio::time::sleep(std::time::Duration::from_millis(500)).await; + // SAFETY: A signal value of 0 performs an existence check on the same pid. if unsafe { libc::kill(pid as i32, 0) } == 0 { + // SAFETY: The process still exists after SIGTERM, so escalate to SIGKILL. unsafe { libc::kill(pid as i32, libc::SIGKILL); } @@ -293,3 +802,159 @@ pub(super) async fn spawn_session(body: &str) -> Result { Err(format!("Session process exited with {}", status)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_same_origin_ws_request_matching() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\nUpgrade: websocket\r\n\r\n"; + assert!(is_same_origin_ws_request(req)); + } + + #[test] + fn test_same_origin_ws_request_proxied() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n"; + assert!(is_same_origin_ws_request(req)); + } + + #[test] + fn test_normalize_origin_authority_https_without_port() { + assert_eq!( + normalize_origin_authority("https://dashboard.agent-browser.localhost"), + Some("dashboard.agent-browser.localhost".to_string()) + ); + } + + #[test] + fn test_same_origin_ws_request_default_https_port() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n"; + assert!(is_same_origin_ws_request(req)); + } + + #[test] + fn test_same_origin_http_request_matching_origin() { + let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\n\r\n"; + assert!(is_same_origin_http_request(req)); + } + + #[test] + fn test_same_origin_http_request_matching_referer() { + let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nReferer: https://dashboard.agent-browser.localhost/sessions\r\n\r\n"; + assert!(is_same_origin_http_request(req)); + } + + #[test] + fn test_same_origin_http_request_rejects_missing_origin_and_referer() { + let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\n\r\n"; + assert!(!is_same_origin_http_request(req)); + } + + #[test] + fn test_same_origin_http_request_rejects_cross_origin_referer() { + let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nReferer: https://evil.com/path\r\n\r\n"; + assert!(!is_same_origin_http_request(req)); + } + + #[test] + fn test_same_origin_ws_request_coder() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: workspace.coder.com\r\nOrigin: https://workspace.coder.com\r\nUpgrade: websocket\r\n\r\n"; + assert!(is_same_origin_ws_request(req)); + } + + #[test] + fn test_cross_origin_ws_request_rejected() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: https://evil.com\r\nUpgrade: websocket\r\n\r\n"; + assert!(!is_same_origin_ws_request(req)); + } + + #[test] + fn test_no_origin_header_allowed() { + let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nUpgrade: websocket\r\n\r\n"; + assert!(is_same_origin_ws_request(req)); + } + + #[test] + fn test_parse_session_proxy_route_valid() { + assert_eq!( + parse_session_proxy_route("/api/session/9222/tabs"), + Ok((9222, SessionProxyEndpoint::Tabs)) + ); + assert_eq!( + parse_session_proxy_route("/api/session/1337/status"), + Ok((1337, SessionProxyEndpoint::Status)) + ); + assert_eq!( + parse_session_proxy_route("/api/session/65535/stream"), + Ok((65535, SessionProxyEndpoint::Stream)) + ); + } + + #[test] + fn test_parse_session_proxy_route_invalid() { + assert!(parse_session_proxy_route("/api/session/0/tabs").is_err()); + assert!(parse_session_proxy_route("/api/session/not-a-port/tabs").is_err()); + assert!(parse_session_proxy_route("/api/session/70000/tabs").is_err()); + assert!(parse_session_proxy_route("/api/session/9222").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/unknown").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/tabs/extra").is_err()); + } + + #[test] + fn test_parse_session_proxy_route_path_traversal() { + assert!(parse_session_proxy_route("/api/session/9222/tabs/..").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/tabs/../status").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/../../etc/passwd").is_err()); + assert!(parse_session_proxy_route("/api/session/../session/9222/tabs").is_err()); + } + + #[test] + fn test_parse_session_proxy_route_double_slashes() { + assert!(parse_session_proxy_route("/api/session//9222/tabs").is_err()); + assert!(parse_session_proxy_route("/api//session/9222/tabs").is_err()); + assert!(parse_session_proxy_route("//api/session/9222/tabs").is_err()); + } + + #[test] + fn test_parse_session_proxy_route_trailing_slash() { + assert!(parse_session_proxy_route("/api/session/9222/tabs/").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/status/").is_err()); + assert!(parse_session_proxy_route("/api/session/9222/stream/").is_err()); + } + + #[test] + fn test_parse_session_proxy_route_encoded_paths() { + assert!(parse_session_proxy_route("/api/session/9222/tabs%20extra").is_err()); + assert!(parse_session_proxy_route("/api/session/%39%32%32%32/tabs").is_err()); + } + + #[test] + fn test_sessions_json_has_active_port() { + let sessions_json = r#"[ + {"session":"alpha","port":9222,"engine":"chrome"}, + {"session":"beta","port":9333,"engine":"chrome"} + ]"#; + + assert_eq!(sessions_json_has_active_port(sessions_json, 9222), Ok(true)); + assert_eq!( + sessions_json_has_active_port(sessions_json, 9444), + Ok(false) + ); + } + + #[test] + fn test_sessions_json_has_active_port_invalid_json() { + assert!(sessions_json_has_active_port("{", 9222).is_err()); + } + + #[test] + fn test_parse_upstream_http_response() { + let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n{\"ok\":true}"; + let parsed = parse_upstream_http_response(response).expect("response should parse"); + + assert_eq!(parsed.0, "200 OK"); + assert_eq!(parsed.1, "application/json; charset=utf-8"); + assert_eq!(parsed.2, b"{\"ok\":true}".to_vec()); + } +} diff --git a/cli/src/output.rs b/cli/src/output.rs index 6b7bcc7..09cf679 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2554,6 +2554,11 @@ Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard The dashboard runs as a standalone background process, independent of browser sessions. All sessions automatically stream to the dashboard. +It works from http://localhost:4848 or a proxied/forwarded URL that +reaches the dashboard server, such as https://dashboard.agent-browser.localhost +or a Coder workspace URL. The browser stays on the dashboard origin; +session tabs, status, and stream traffic are proxied internally, so +session ports do not need to be exposed. Options: --port Port for the dashboard server (default: 4848) diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index e3634c2..5030a0d 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -381,6 +381,8 @@ agent-browser dashboard start --port # Start on a specific port agent-browser dashboard stop # Stop the dashboard server ``` +Open the dashboard through `http://localhost:4848` or a proxied/forwarded dashboard URL such as `https://dashboard.agent-browser.localhost`. The browser stays on the dashboard origin; per-session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. + ## Doctor Diagnose your install, auto-clean stale daemon files, and optionally repair common problems. diff --git a/docs/src/app/dashboard/page.mdx b/docs/src/app/dashboard/page.mdx index 9846696..d2bcd47 100644 --- a/docs/src/app/dashboard/page.mdx +++ b/docs/src/app/dashboard/page.mdx @@ -11,9 +11,9 @@ agent-browser dashboard start agent-browser open example.com ``` -Then open `http://localhost:4848` in your browser to see the live dashboard. +Then open `http://localhost:4848` or a proxied/forwarded dashboard URL such as `https://dashboard.agent-browser.localhost` in your browser to see the live dashboard. -All sessions automatically stream to the dashboard. No extra flags are needed. +All sessions automatically stream to the dashboard. No extra flags are needed. The browser stays on the dashboard origin while the server proxies per-session tabs, status, and stream traffic internally, so session ports do not need to be exposed. ### Custom stream port @@ -122,7 +122,7 @@ The dashboard is a Next.js static export (`output: 'export'`) that produces plai pnpm build:dashboard ``` -The dashboard is embedded into the CLI binary at compile time using `rust-embed`. Plain HTTP requests serve the embedded dashboard assets, while WebSocket upgrade requests are handled as before. +The dashboard is embedded into the CLI binary at compile time using `rust-embed`. Plain HTTP requests serve the embedded dashboard assets and same-origin API routes. Session-specific tabs, status, and stream WebSocket traffic are proxied through the dashboard server to loopback-only session ports. ## AI Chat diff --git a/packages/dashboard/src/components/viewport.tsx b/packages/dashboard/src/components/viewport.tsx index 9ee31ff..81fec1e 100644 --- a/packages/dashboard/src/components/viewport.tsx +++ b/packages/dashboard/src/components/viewport.tsx @@ -5,6 +5,7 @@ import { useAtomValue, useSetAtom } from "jotai/react"; import { ArrowLeft, ArrowRight, Camera, Circle, FileCode, Maximize, Moon, RotateCw, Smartphone, Square, Sun, Wifi, WifiOff } from "lucide-react"; import { cn } from "@/lib/utils"; import { execCommand, sessionArgs } from "@/lib/exec"; +import { getSessionStreamUrl } from "@/lib/dashboard-routes"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { @@ -550,7 +551,7 @@ export function Viewport() { {browserConnected && ( - ws://localhost:{streamPort} + {getSessionStreamUrl(streamPort)} )}
diff --git a/packages/dashboard/src/lib/dashboard-routes.ts b/packages/dashboard/src/lib/dashboard-routes.ts new file mode 100644 index 0000000..1f033dd --- /dev/null +++ b/packages/dashboard/src/lib/dashboard-routes.ts @@ -0,0 +1,42 @@ +/** + * Centralized route building for dashboard API calls. + * All routes stay on the current dashboard origin so the UI also works + * behind forwarded or reverse-proxied URLs. + */ + +/** Build a dashboard API path such as "/api/sessions". */ +export function getDashboardApiPath(path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + assertDashboardApiPath(normalizedPath); + return normalizedPath; +} + +/** Build the same-origin per-session tabs endpoint proxied through the dashboard. */ +export function getSessionTabsPath(port: number): string { + assertValidPort(port); + return `/api/session/${port}/tabs`; +} + +/** Build the same-origin WebSocket URL for a session stream. */ +export function getSessionStreamUrl(port: number): string { + assertValidPort(port); + const streamPath = `/api/session/${port}/stream`; + if (typeof window === "undefined") { + return streamPath; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${streamPath}`; +} + +function assertDashboardApiPath(path: string): asserts path is string { + if (!path.startsWith("/api/")) { + throw new Error(`Assertion failed: Expected dashboard API path, got: ${path}`); + } +} + +function assertValidPort(port: number): asserts port is number { + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Assertion failed: Invalid session port: ${port}`); + } +} diff --git a/packages/dashboard/src/store/sessions.ts b/packages/dashboard/src/store/sessions.ts index 9cfc688..0bac92a 100644 --- a/packages/dashboard/src/store/sessions.ts +++ b/packages/dashboard/src/store/sessions.ts @@ -5,20 +5,22 @@ import { useCallback, useEffect, useRef } from "react"; import { useAtomCallback } from "jotai/utils"; import type { SessionInfo } from "@/types"; import { type ExecResult, execCommand, killSession, sessionArgs } from "@/lib/exec"; +import { getDashboardApiPath, getSessionTabsPath } from "@/lib/dashboard-routes"; import { tabCacheAtom, engineCacheAtom } from "@/store/tabs"; import { streamTabsAtom, streamEngineAtom } from "@/store/stream"; function getPort(): number { - if (typeof window === "undefined") return 9223; + if (typeof window === "undefined") return 0; const params = new URLSearchParams(window.location.search); - const p = params.get("port"); - return p ? parseInt(p, 10) || 9223 : 9223; + const portParam = params.get("port"); + const port = portParam ? Number.parseInt(portParam, 10) : 0; + return Number.isInteger(port) && port > 0 ? port : 0; } export const newSessionDialogAtom = atom(false); function getSessionsUrl(): string { - return "/api/sessions"; + return getDashboardApiPath("/api/sessions"); } // --------------------------------------------------------------------------- @@ -254,9 +256,9 @@ export function useSessionsSync(pollInterval = 5000) { // Poll tabs for all sessions for (const s of data) { try { - const tabsResp = await fetch( - `http://localhost:${s.port}/api/tabs`, - ).catch(() => null); + const tabsResp = await fetch(getSessionTabsPath(s.port)).catch( + () => null, + ); if (tabsResp?.ok) { const tabs = await tabsResp.json(); if (tabs.length > 0) { diff --git a/packages/dashboard/src/store/stream.ts b/packages/dashboard/src/store/stream.ts index c856522..412e9af 100644 --- a/packages/dashboard/src/store/stream.ts +++ b/packages/dashboard/src/store/stream.ts @@ -9,7 +9,7 @@ import type { StreamMessage, TabInfo, } from "@/types"; -import { activePortAtom } from "@/store/sessions"; +import { getSessionStreamUrl } from "@/lib/dashboard-routes"; import { tabCacheAtom, engineCacheAtom } from "@/store/tabs"; const MAX_EVENTS = 500; @@ -117,9 +117,10 @@ export function useStreamSync(port: number) { }, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]); const connect = useCallback(() => { + if (port <= 0) return; if (wsRef.current?.readyState === WebSocket.OPEN) return; - const ws = new WebSocket(`ws://localhost:${port}`); + const ws = new WebSocket(getSessionStreamUrl(port)); wsRef.current = ws; setWsRef(ws); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 997b66e..cefd752 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -49,3 +49,7 @@ installed version. - Accessibility-tree snapshots with element refs for reliable interaction - Sessions, authentication vault, state persistence, video recording - Specialized skills for Electron apps, Slack, exploratory testing, cloud providers + +## Observability Dashboard + +The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.