Make dashboard work from proxied origins via same-origin proxy (#1111)

* Restore dashboard session proxy routes

Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* Port dashboard frontend and docs

Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* docs: restore dashboard note in skill

Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* fix: tighten dashboard proxy same-origin checks

Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5
Signed-off-by: Thomas Kosiewski <tk@coder.com>

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
Thomas Kosiewski
2026-05-07 09:08:12 -05:00
committed by GitHub
parent 3bb1d43f8b
commit d33bdb36f3
10 changed files with 776 additions and 54 deletions
+1 -1
View File
@@ -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
+705 -40
View File
@@ -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<String>) -> Self {
Self {
status: "404 Not Found",
message: message.into(),
}
}
fn bad_gateway(message: impl Into<String>) -> 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<String> {
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<bool> {
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/<port>/<endpoint>`.
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::<u16>()
.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<bool, String> {
let sessions: Vec<Value> = 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<u8>), 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<u8>), 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<u8>) = 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::<usize>().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::<usize>().ok())
}
})
.unwrap_or(0);
@@ -239,11 +744,15 @@ async fn kill_session(body: &str) -> Result<String, String> {
#[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<String, String> {
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());
}
}
+5
View File
@@ -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 <n> Port for the dashboard server (default: 4848)
+2
View File
@@ -381,6 +381,8 @@ agent-browser dashboard start --port <n> # 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.
+3 -3
View File
@@ -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
@@ -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() {
</span>
{browserConnected && (
<span className="text-xs text-muted-foreground/60 font-mono">
ws://localhost:{streamPort}
{getSessionStreamUrl(streamPort)}
</span>
)}
<div className="ml-auto flex items-center gap-2">
@@ -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}`);
}
}
+9 -7
View File
@@ -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) {
+3 -2
View File
@@ -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);
+4
View File
@@ -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.