Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 47 additions & 30 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,8 @@ impl DashboardConfig {
}

fn has_same_settings_as(&self, requested: &Self) -> bool {
self.port == requested.port && self.allowed_origins == requested.allowed_origins
(requested.port == 0 || self.port == requested.port)
&& self.allowed_origins == requested.allowed_origins
}

fn access_urls(&self) -> Vec<String> {
Expand Down Expand Up @@ -866,8 +867,10 @@ fn parse_dashboard_command(args: &[String]) -> Result<DashboardCommand, String>
.get(index + 1)
.filter(|value| !value.starts_with('-'))
.ok_or_else(|| "Dashboard option '--port' requires a value.".to_string())?;
port = value.parse::<u16>().ok().filter(|port| *port > 0).ok_or_else(|| {
format!("Invalid dashboard port '{value}'. Expected an integer from 1 to 65535.")
port = value.parse::<u16>().map_err(|_| {
format!(
"Invalid dashboard port '{value}'. Expected an integer from 0 to 65535."
)
})?;
saw_port = true;
index += 2;
Expand Down Expand Up @@ -997,21 +1000,18 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)
}
exit(1);
}
let running_config = running_config.expect("verified above");
if json_mode {
let access_urls = running_config
.as_ref()
.map(DashboardConfig::access_urls)
.unwrap_or_default();
print_json_value(json!({
"success": true,
"data": { "port": requested_config.port, "pid": pid, "already_running": true, "access_urls": access_urls },
"data": { "port": running_config.port, "pid": pid, "already_running": true, "access_urls": running_config.access_urls() },
}));
} else {
println!(
"Dashboard already running at http://localhost:{}",
requested_config.port
running_config.port
);
print_dashboard_access_urls(&running_config.expect("verified above"));
print_dashboard_access_urls(&running_config);
}
return;
}
Expand All @@ -1021,7 +1021,7 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)
let _ = fs::remove_file(get_dashboard_config_path());
}

let requested_config = match DashboardConfig::new(port, allowed_origins) {
let mut config = match DashboardConfig::new(port, allowed_origins) {
Ok(config) => config,
Err(error) => {
if json_mode {
Expand Down Expand Up @@ -1055,24 +1055,19 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)
};

let mut cmd = std::process::Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DASHBOARD", "1").env(
"AGENT_BROWSER_DASHBOARD_PORT",
requested_config.port.to_string(),
);
if requested_config.allowed_origins.is_empty() {
cmd.env("AGENT_BROWSER_DASHBOARD", "1")
.env("AGENT_BROWSER_DASHBOARD_PORT", config.port.to_string());
if config.allowed_origins.is_empty() {
cmd.env_remove("AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS");
cmd.env_remove("AGENT_BROWSER_DASHBOARD_ACCESS_TOKEN");
} else {
cmd.env(
"AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS",
requested_config.allowed_origins.join(","),
config.allowed_origins.join(","),
)
.env(
"AGENT_BROWSER_DASHBOARD_ACCESS_TOKEN",
requested_config
.access_token
.as_deref()
.expect("generated above"),
config.access_token.as_deref().expect("generated above"),
);
}

Expand All @@ -1098,22 +1093,27 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)

match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
let pid = child.id();
let write_result = write_dashboard_config(&requested_config)
let write_result = read_dashboard_bound_port(&mut child)
.and_then(|bound_port| {
config.port = bound_port;
write_dashboard_config(&config)
})
.and_then(|()| fs::write(&pid_path, pid.to_string()));
if let Err(error) = write_result {
let _ = child.kill();
let _ = child.wait();
remove_dashboard_sidecars();
if json_mode {
print_json_error(format!("Failed to save dashboard state: {error}"));
print_json_error(format!("Failed to start dashboard: {error}"));
} else {
eprintln!(
"{} Failed to save dashboard state: {}",
"{} Failed to start dashboard: {}",
color::error_indicator(),
error
);
Expand All @@ -1124,18 +1124,18 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)
if json_mode {
print_json_value(json!({
"success": true,
"data": { "port": requested_config.port, "pid": pid, "access_urls": requested_config.access_urls() },
"data": { "port": config.port, "pid": pid, "access_urls": config.access_urls() },
}));
} else {
println!(
"Dashboard started{}",
if requested_config.access_urls().is_empty() {
format!(" at http://localhost:{}", requested_config.port)
if config.access_urls().is_empty() {
format!(" at http://localhost:{}", config.port)
} else {
"; open one of the private access URLs below".to_string()
}
);
print_dashboard_access_urls(&requested_config);
print_dashboard_access_urls(&config);
}
}
Err(e) => {
Expand All @@ -1154,6 +1154,18 @@ fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool)
}
}

/// Block until the spawned dashboard server reports the port it bound.
fn read_dashboard_bound_port(child: &mut std::process::Child) -> std::io::Result<u16> {
use std::io::BufRead;

let stdout = child.stdout.take().expect("stdout is piped");
let mut line = String::new();
std::io::BufReader::new(stdout).read_line(&mut line)?;
line.trim().parse().map_err(|_| {
std::io::Error::other("the dashboard server exited before it started listening")
})
}

fn print_dashboard_access_urls(config: &DashboardConfig) {
let access_urls = config.access_urls();
if access_urls.is_empty() {
Expand Down Expand Up @@ -2248,6 +2260,11 @@ mod tests {
.unwrap();

assert_eq!(dashboard_config_issue(Some(&equivalent), &requested), None);
let any_port = DashboardConfig {
port: 0,
..requested.clone()
};
assert_eq!(dashboard_config_issue(Some(&equivalent), &any_port), None);
assert!(dashboard_config_issue(Some(&changed), &requested).is_some());
assert!(dashboard_config_issue(None, &requested).is_some());
}
Expand Down Expand Up @@ -2277,7 +2294,7 @@ mod tests {
vec!["--bogus"],
vec!["start", "--allowed-origin", "https://dashboard.example.com"],
vec!["start", "--port", "nope"],
vec!["start", "--port", "0"],
vec!["start", "--port", "65536"],
vec!["start", "--port"],
vec!["start", "--allowed-origins"],
vec!["stop", "--port", "4848"],
Expand Down
6 changes: 3 additions & 3 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1999,9 +1999,9 @@ fn parity_tools() -> Vec<Value> {
tool(
TOOL_DASHBOARD_START,
"Dashboard start",
"Start dashboard server. Loopback access requires no token. When the dashboard is exposed through a reverse proxy, configure its exact browser origin with allowedOrigins and open the returned private URL. Stop a running dashboard before changing its port or allowed origins.",
"Start dashboard server and return the port it listens on. Loopback access requires no token. When the dashboard is exposed through a reverse proxy, configure its exact browser origin with allowedOrigins and open the returned private URL. Stop a running dashboard before changing its port or allowed origins.",
json!({
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"port": { "type": "integer", "minimum": 0, "maximum": 65535, "description": "Port to listen on (default 4848). Use 0 to pick a free port." },
"allowedOrigins": {
"type": "string",
"minLength": 1,
Expand Down Expand Up @@ -6229,7 +6229,7 @@ mod tests {
tool["inputSchema"]["properties"]["allowedOrigins"]["minLength"],
1
);
assert_eq!(tool["inputSchema"]["properties"]["port"]["minimum"], 1);
assert_eq!(tool["inputSchema"]["properties"]["port"]["minimum"], 0);
assert_eq!(tool["inputSchema"]["properties"]["port"]["maximum"], 65535);

let args = dashboard_start_args(&json!({
Expand Down
8 changes: 6 additions & 2 deletions cli/src/native/stream/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,8 @@ pub async fn run_dashboard_server(port: u16) {
}
};

// Port zero is useful for internal tests. Bind IPv6 to the same selected
// port so both loopback families reach one dashboard instance.
// Bind IPv6 to the port IPv4 selected, which may be OS-assigned for port
// zero, so both loopback families reach one dashboard instance.
let bound_port = ipv4_listener
.local_addr()
.map(|address| address.port())
Expand All @@ -647,6 +647,10 @@ pub async fn run_dashboard_server(port: u16) {
}
};

// `dashboard start` waits for this line to learn the port and that the
// listeners are ready.
println!("{bound_port}");

if let Some(ipv6_listener) = ipv6_listener {
tokio::join!(
run_dashboard_listener(ipv4_listener),
Expand Down
11 changes: 7 additions & 4 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3802,23 +3802,26 @@ receive this token. Configure a reverse proxy to redact cookies from logs.
Stop the dashboard before changing its port or allowed origins.

Options:
--port <n> Port for the dashboard server (default: 4848)
--port <n> Port for the dashboard server (default: 4848). Use 0 to
pick a free port
--allowed-origins <origins>
Comma-separated exact HTTPS origins allowed when the
dashboard is exposed through a reverse proxy. Loopback
origins are allowed by default. Can also be set with
AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS.

Ports must be integers from 1 to 65535. Every allowed origin must be valid.
Unknown options, missing values, and malformed origins fail without starting
the dashboard server.
Ports must be integers from 0 to 65535. Start returns once the server is
listening and prints the port it bound (data.port with --json). Every allowed
origin must be valid. Unknown options, missing values, and malformed origins
fail without starting the dashboard server.

Global Options:
--json Output as JSON

Examples:
agent-browser dashboard start
agent-browser dashboard start --port 8080
agent-browser dashboard start --port 0 --json
agent-browser dashboard start --allowed-origins https://dashboard.example.com
agent-browser dashboard stop
"##
Expand Down
39 changes: 38 additions & 1 deletion cli/tests/dashboard_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,43 @@ fn explicit_dashboard_start_accepts_mcp_style_arguments() {
);
}

#[test]
fn dashboard_start_on_port_zero_reports_a_listening_port() {
let tmp = TempDir::new().unwrap();
let _cleanup = DashboardCleanup(&tmp);

let started = run_dashboard(&tmp, &["dashboard", "start", "--port", "0", "--json"]);
assert!(
started.status.success(),
"dashboard start failed: {}",
String::from_utf8_lossy(&started.stderr)
);
let port = json_output(&started)["data"]["port"].as_u64().unwrap();
assert_ne!(port, 0);

// No retry: the command returns only after the server is listening.
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port as u16)).unwrap();
stream
.write_all(
format!(
"GET /api/sessions HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nConnection: close\r\n\r\n"
)
.as_bytes(),
)
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"unexpected dashboard response: {response}"
);

let repeated = run_dashboard(&tmp, &["dashboard", "start", "--port", "0", "--json"]);
let payload = json_output(&repeated);
assert_eq!(payload["data"]["already_running"], true);
assert_eq!(payload["data"]["port"], port);
}

#[test]
fn dashboard_listens_on_and_authorizes_ipv6_loopback() {
// Some minimal CI/container kernels disable IPv6 entirely. In that case
Expand Down Expand Up @@ -207,7 +244,7 @@ fn invalid_dashboard_options_fail_without_starting_a_server() {
"--json",
],
&["dashboard", "start", "--port", "nope", "--json"],
&["dashboard", "start", "--port", "0", "--json"],
&["dashboard", "start", "--port", "65536", "--json"],
&["dashboard", "start", "--port", "--json"],
&[
"dashboard",
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,9 @@ agent-browser dashboard start --allowed-origins <origins> # Allow exact HTTPS re
agent-browser dashboard stop # Stop the dashboard server
```

Loopback dashboard origins are accepted by default without an access token. For a reverse-proxied or forwarded URL, pass its exact browser origin with `--allowed-origins` or set `AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS`. Every configured origin must be a valid exact HTTPS origin. Ports must be integers from 1 to 65535. Unknown options, missing values, invalid ports, and malformed origins fail without starting the server. The command prints private tokenized access URLs only for external origins; open the matching URL once to establish the browser session and do not share it. Open the normal `http://localhost:<port>` URL for local access. 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. Stop the running dashboard before changing the port or allowed origins.
Loopback dashboard origins are accepted by default without an access token. For a reverse-proxied or forwarded URL, pass its exact browser origin with `--allowed-origins` or set `AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS`. Every configured origin must be a valid exact HTTPS origin. Ports must be integers from 0 to 65535, where 0 lets the system pick a free port. Unknown options, missing values, invalid ports, and malformed origins fail without starting the server. The command prints private tokenized access URLs only for external origins; open the matching URL once to establish the browser session and do not share it. Open the normal `http://localhost:<port>` URL for local access. 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. Stop the running dashboard before changing the port or allowed origins.

`dashboard start` returns once the server is listening and prints the port it bound, which `--json` reports as `data.port`.

## Doctor

Expand Down
7 changes: 7 additions & 0 deletions docs/content/docs/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Then open `http://localhost:4848` in your browser to see the live dashboard. If
agent-browser dashboard start --allowed-origins https://dashboard.example.com
```

To run it on a free port chosen by the system, pass `--port 0`. The command returns once the server is listening and prints the port it bound; with `--json`, read it from `data.port`:

```bash
agent-browser dashboard start --port 0 --json
# {"data":{"access_urls":[],"pid":41250,"port":53817},"success":true}
```

All sessions automatically stream to the dashboard. Local loopback origins are allowed automatically over IPv4 and IPv6 without an access token. For a reverse-proxied dashboard, use a comma-separated list of exact origins with `--allowed-origins` or `AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS`; this lets the dashboard reject cross-origin requests and DNS-rebinding attacks. Every configured origin must be valid, and custom ports must be integers from 1 to 65535. Unknown options, missing values, invalid ports, and malformed origins fail without starting the server. The command prints private tokenized access URLs only for external origins. Open the matching URL once to establish the browser session and do not share it. Its unguessable token stays in the initial URL fragment, then the browser stores it in a Secure, host-bound, same-site cookie for dashboard API and stream requests. Open `http://localhost:<port>` directly for local access; the external token is never written to a loopback cookie. Configure the reverse proxy to redact cookies from logs. 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. Repeated starts reuse the dashboard only when these settings match; stop it before changing the port or allowed origins.

### Custom stream port
Expand Down
4 changes: 4 additions & 0 deletions docs/tests/fixtures/docs-edits.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"pages": {
"/commands": [
{
"from": "Ports must be integers from 1 to 65535.",
"to": "Ports must be integers from 0 to 65535, where 0 lets the system pick a free port."
},
{
"from": "--auto-connect # Auto-discover and connect to running Chrome",
"to": "--auto-connect # Auto-discover and connect to running Chrome\n--no-auto-connect # Return an --auto-connect session to a local launch (overrides AGENT_BROWSER_AUTO_CONNECT)"
Expand Down
5 changes: 3 additions & 2 deletions packages/@agent-browser/sandbox/mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@
"openWorldHint": false,
"readOnlyHint": false
},
"description": "Start dashboard server. Loopback access requires no token. When the dashboard is exposed through a reverse proxy, configure its exact browser origin with allowedOrigins and open the returned private URL. Stop a running dashboard before changing its port or allowed origins.",
"description": "Start dashboard server and return the port it listens on. Loopback access requires no token. When the dashboard is exposed through a reverse proxy, configure its exact browser origin with allowedOrigins and open the returned private URL. Stop a running dashboard before changing its port or allowed origins.",
"inputSchema": {
"properties": {
"allowedOrigins": {
Expand All @@ -707,8 +707,9 @@
"type": "string"
},
"port": {
"description": "Port to listen on (default 4848). Use 0 to pick a free port.",
"maximum": 65535,
"minimum": 1,
"minimum": 0,
"type": "integer"
},
"session": {
Expand Down
Loading