mod browser_security;
mod ca_bundle;
mod chat;
mod color;
mod commands;
mod connection;
mod doctor;
mod flags;
mod install;
mod mcp;
mod native;
mod output;
mod plugins;
mod read;
mod session_files;
mod session_settings;
mod skills;
#[cfg(test)]
mod test_utils;
mod tls;
mod upgrade;
mod validation;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::env;
use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{exit, Command};
#[cfg(windows)]
use windows_sys::Win32::Foundation::CloseHandle;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::OpenProcess;
use commands::{gen_id, parse_cdp_headers, parse_command, ParseError};
use connection::{
cleanup_stale_files, daemon_unreachable, ensure_daemon, get_socket_dir,
is_agent_browser_process, is_pid_alive, send_command, walk_daemons, DaemonOptions, Response,
};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use session_settings::parse_proxy;
use upgrade::run_upgrade;
fn serialize_json_value(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| {
r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string()
})
}
fn print_json_value(value: serde_json::Value) {
println!("{}", serialize_json_value(&value));
}
fn print_json_error(message: impl AsRef<str>) {
print_json_value(json!({
"success": false,
"error": message.as_ref(),
}));
}
fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
print_json_value(json!({
"success": false,
"error": message.as_ref(),
"type": error_type,
}));
}
fn attach_pin_tab_to_command(cmd: &mut serde_json::Value, flags: &Flags) {
if flags.pin_tab {
cmd["pinTab"] = json!(true);
} else if flags.cli_pin_tab {
cmd["pinTab"] = json!(false);
}
}
fn attach_input_mode(cmd: &mut serde_json::Value, flags: &Flags) {
if flags.cli_input_mode {
cmd["defaultInputMode"] = json!(flags.input_mode);
}
}
fn attach_plugins_to_command(cmd: &mut serde_json::Value, plugins: &[plugins::PluginConfig]) {
cmd["plugins"] = json!(plugins);
}
fn command_is_cdp_url_metadata(cmd: &serde_json::Value) -> bool {
match cmd.get("action").and_then(|value| value.as_str()) {
Some("cdp_url") => true,
Some("batch") => {
if let Some(commands) = cmd.get("commands").and_then(|value| value.as_array()) {
!commands.is_empty()
&& commands.iter().all(|command| {
command.as_str().is_some_and(|command| {
commands::shell_words_split(command)
.starts_with(&["get".into(), "cdp-url".into()])
})
})
} else if let Some(argv) = cmd.get("argv").and_then(|value| value.as_array()) {
!argv.is_empty()
&& argv.iter().all(|args| {
args.as_array()
.is_some_and(|args| args.starts_with(&[json!("get"), json!("cdp-url")]))
})
} else {
false
}
}
_ => false,
}
}
fn command_settings(flags: &Flags, cmd: &serde_json::Value) -> session_settings::Patch {
let patch = session_settings::Patch::from_flags(flags);
if command_is_cdp_url_metadata(cmd) {
return patch.without_launch_settings();
}
let mut patch = patch;
if command_is_browserless(cmd) {
patch.remove(session_settings::Key::CaCert);
patch.remove(session_settings::Key::ClearCaCert);
}
patch
}
fn batch_item_settings(
flags: &Flags,
cmd: &serde_json::Value,
relaunch_applied: bool,
) -> session_settings::Patch {
let mut patch = command_settings(flags, cmd);
if relaunch_applied || !native::actions::command_launches_browser(cmd) {
patch.clear_relaunch();
}
patch
}
fn response_applied_relaunch(resp: &connection::Response) -> bool {
let Some(data) = resp.data.as_ref() else {
return false;
};
[
"/lifecycle/settings/forcedRelaunch",
"/result/data/lifecycle/settings/forcedRelaunch",
]
.iter()
.any(|path| data.pointer(path).and_then(|v| v.as_bool()) == Some(true))
}
fn validate_cdp_flags(flags: &Flags) -> Result<(), String> {
let Some(cdp_value) = flags.cdp.as_deref() else {
return Ok(());
};
let is_url = ["ws://", "wss://", "http://", "https://"]
.iter()
.any(|scheme| cdp_value.starts_with(scheme));
if !is_url {
match cdp_value.parse::<u32>() {
Ok(0) => return Err("Invalid CDP port: port must be greater than 0".to_string()),
Ok(port) if port > 65535 => {
return Err(format!(
"Invalid CDP port: {} is out of range (valid range: 1-65535)",
port
))
}
Ok(_) => {}
Err(_) => {
return Err(format!(
"Invalid CDP value: '{}' is not a valid port number or URL",
cdp_value
))
}
}
}
if let Some(raw) = flags.cdp_headers.as_deref() {
parse_cdp_headers(raw)?;
}
Ok(())
}
fn command_is_browserless(cmd: &serde_json::Value) -> bool {
match cmd.get("action").and_then(|value| value.as_str()) {
Some("read") => true,
Some("batch") => {
let first_words: Vec<Option<String>> =
if let Some(argv) = cmd.get("argv").and_then(|v| v.as_array()) {
argv.iter()
.map(|c| c.get(0).and_then(|w| w.as_str()).map(str::to_string))
.collect()
} else if let Some(commands) = cmd.get("commands").and_then(|v| v.as_array()) {
commands
.iter()
.map(|c| {
c.as_str()
.and_then(|c| commands::shell_words_split(c).into_iter().next())
})
.collect()
} else {
return false;
};
!first_words.is_empty() && first_words.iter().all(|w| w.as_deref() == Some("read"))
}
_ => false,
}
}
fn restore_key_from_flags(flags: &Flags) -> Option<&str> {
flags.restore.as_deref().or(flags.session_name.as_deref())
}
fn is_valid_restore_save_policy(policy: &str) -> bool {
matches!(policy, "auto" | "always" | "never")
}
fn incompatible_launch_mode_error(flags: &Flags) -> Option<&'static str> {
if flags.cdp.is_some() && flags.provider.is_some() {
return Some("Cannot use --cdp and -p/--provider together");
}
if flags.cli_auto_connect && flags.no_auto_connect {
return Some("Cannot use --auto-connect and --no-auto-connect together");
}
if flags.auto_connect && flags.cdp.is_some() {
return Some("Cannot use --auto-connect and --cdp together");
}
if flags.auto_connect && flags.provider.is_some() {
return Some("Cannot use --auto-connect and -p/--provider together");
}
if flags.provider.is_some() && !flags.extensions.is_empty() {
return Some(
"Cannot use --extension with -p/--provider (extensions require local browser)",
);
}
if flags.cdp.is_some() && !flags.extensions.is_empty() {
return Some("Cannot use --extension with --cdp (extensions require local browser)");
}
if flags.webgpu && flags.cdp.is_some() {
return Some(
"Cannot use --webgpu with --cdp (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
);
}
if flags.webgpu && flags.provider.is_some() {
return Some(
"Cannot use --webgpu with -p/--provider (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
);
}
if flags.webgpu && flags.auto_connect {
return Some(
"Cannot use --webgpu with --auto-connect (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
);
}
if flags.ca_cert.is_some() && flags.ignore_https_errors {
return Some("Cannot use --ca-cert with --ignore-https-errors");
}
if flags.ca_cert.is_some() && flags.clear_ca_cert {
return Some("Cannot use --ca-cert with --no-ca-cert");
}
if flags.ca_cert.is_some() && flags.cdp.is_some() {
return Some(
"Cannot use --ca-cert with --cdp (--ca-cert requires a locally launched Chromium browser on Linux)",
);
}
if flags.ca_cert.is_some() && flags.auto_connect {
return Some(
"Cannot use --ca-cert with --auto-connect (--ca-cert requires a locally launched Chromium browser on Linux)",
);
}
if flags.ca_cert.is_some() && flags.provider.is_some() {
return Some(
"Cannot use --ca-cert with -p/--provider (--ca-cert requires a locally launched Chromium browser on Linux)",
);
}
if flags.ca_cert.is_some() && flags.profile.is_some() {
return Some(
"Cannot use --ca-cert with --profile because isolated CA trust would change the profile's NSS environment",
);
}
if flags.ca_cert.is_some()
&& flags
.engine
.as_deref()
.is_some_and(|engine| !engine.eq_ignore_ascii_case("chrome"))
{
return Some("--ca-cert is supported only with the Chrome engine on Linux");
}
if flags.ca_cert.is_some() && !cfg!(target_os = "linux") {
return Some("--ca-cert browser trust is currently supported only on Linux (read, install, upgrade, and doctor accept it on every platform)");
}
None
}
fn attach_restore_config_to_command(cmd: &mut serde_json::Value, flags: &Flags) {
if let Some(restore_key) = restore_key_from_flags(flags) {
cmd["restoreKey"] = json!(restore_key);
cmd["restoreSave"] = json!(flags.restore_save.as_deref().unwrap_or("auto"));
cmd["restoreCheckUrl"] = flags
.restore_check_url
.as_ref()
.map(|check| json!(check))
.unwrap_or(serde_json::Value::Null);
cmd["restoreCheckText"] = flags
.restore_check_text
.as_ref()
.map(|check| json!(check))
.unwrap_or(serde_json::Value::Null);
cmd["restoreCheckFn"] = flags
.restore_check_fn
.as_ref()
.map(|check| json!(check))
.unwrap_or(serde_json::Value::Null);
}
}
fn mark_restarted_background(resp: &mut Response) {
if !resp.success {
return;
}
let Some(data) = resp.data.as_mut().and_then(|v| v.as_object_mut()) else {
return;
};
let lifecycle = data
.entry("lifecycle".to_string())
.or_insert_with(|| json!({}));
if !lifecycle.is_object() {
*lifecycle = json!({});
}
if let Some(obj) = lifecycle.as_object_mut() {
obj.insert("restartedBackground".to_string(), json!(true));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfirmationPrompt {
action: String,
category: String,
description: String,
confirmation_id: String,
}
fn confirmation_prompt_from_data(data: &serde_json::Value) -> Option<ConfirmationPrompt> {
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let action = data
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Some(ConfirmationPrompt {
action: action.clone(),
category: data
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
description: data
.get("description")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(action.as_str())
.to_string(),
confirmation_id: data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
});
}
data.get("result")
.and_then(|v| v.get("data"))
.and_then(confirmation_prompt_from_data)
}
fn confirmation_prompt_from_response(resp: &Response) -> Option<ConfirmationPrompt> {
resp.data.as_ref().and_then(confirmation_prompt_from_data)
}
fn run_interactive_confirmations(
mut resp: Response,
flags: &Flags,
output_opts: &OutputOptions,
) -> Response {
while let Some(prompt) = confirmation_prompt_from_response(&resp) {
eprintln!("[agent-browser] Action requires confirmation:");
if prompt.category.is_empty() {
eprintln!(" {}", prompt.description);
} else {
eprintln!(" {}: {}", prompt.category, prompt.description);
}
eprint!(" Allow? [y/N]: ");
let mut input = String::new();
let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
std::io::stdin().read_line(&mut input).is_ok()
&& matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
} else {
false
};
let confirm_cmd = if approved {
json!({
"id": gen_id(),
"action": "confirm",
"confirmationId": prompt.confirmation_id
})
} else {
json!({
"id": gen_id(),
"action": "deny",
"confirmationId": prompt.confirmation_id
})
};
match send_command(confirm_cmd, &flags.session) {
Ok(next_resp) => {
if !approved {
eprintln!("{} Action denied", color::error_indicator());
exit(1);
}
resp = next_resp;
}
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
print_response_with_opts(&resp, None, output_opts);
resp
}
fn run_profiles(json_mode: bool) {
use crate::native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles};
let user_data_dir = match find_chrome_user_data_dir() {
Some(dir) => dir,
None => {
if json_mode {
print_json_error("No Chrome user data directory found");
} else {
eprintln!("{}", color::red("No Chrome user data directory found"));
}
exit(1);
}
};
let profiles = list_chrome_profiles(&user_data_dir);
if profiles.is_empty() {
if json_mode {
print_json_value(json!({
"success": true,
"data": []
}));
} else {
println!("No Chrome profiles found");
}
return;
}
if json_mode {
let items: Vec<serde_json::Value> = profiles
.iter()
.map(|p| {
json!({
"directory": p.directory,
"name": p.name
})
})
.collect();
print_json_value(json!({
"success": true,
"data": items
}));
} else {
println!(
"{} ({}):\n",
color::bold("Chrome profiles"),
user_data_dir.display()
);
for p in &profiles {
println!(
" {} {}",
color::bold(&p.directory),
color::dim(&format!("({})", p.name))
);
}
}
}
fn canonical_path(path: PathBuf) -> PathBuf {
path.canonicalize().unwrap_or(path)
}
fn git_toplevel() -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
let path = raw.trim();
if path.is_empty() {
None
} else {
Some(canonical_path(PathBuf::from(path)))
}
}
fn resolve_session_id_scope(scope: &str) -> Result<(String, PathBuf), String> {
match scope {
"worktree" => {
let path = git_toplevel().unwrap_or_else(|| {
canonical_path(env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
});
Ok(("worktree".to_string(), path))
}
"cwd" => {
let path = canonical_path(env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
Ok(("cwd".to_string(), path))
}
"git-root" => git_toplevel()
.map(|path| ("git-root".to_string(), path))
.ok_or_else(|| "Not inside a Git working tree".to_string()),
other => Err(format!(
"Unknown session id scope '{}'. Use worktree, cwd, or git-root.",
other
)),
}
}
fn run_session_id(args: &[String], json_mode: bool) {
let mut scope = "worktree".to_string();
let mut prefix: Option<String> = None;
let mut json_output = json_mode;
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
"--scope" => {
if let Some(value) = args.get(i + 1) {
scope = value.clone();
i += 1;
}
}
"--prefix" => {
if let Some(value) = args.get(i + 1) {
prefix = Some(value.clone());
i += 1;
}
}
"--json" => json_output = true,
_ => {}
}
i += 1;
}
let (resolved_scope, path) = match resolve_session_id_scope(&scope) {
Ok(result) => result,
Err(e) => {
if json_output {
print_json_error(e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
};
let path_str = path.to_string_lossy().to_string();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let suffix = &hash[..12];
let prefix = prefix
.as_deref()
.map(validation::sanitize_session_component)
.filter(|s| !s.is_empty());
let session = match prefix {
Some(prefix) => format!("{}-{}", prefix, suffix),
None => suffix.to_string(),
};
if json_output {
print_json_value(json!({
"success": true,
"data": {
"session": session,
"scope": resolved_scope,
"path": path_str,
"hash": suffix
}
}));
} else {
println!("{}", session);
}
}
fn run_session_info(session: &str, json_mode: bool) {
let inventory = walk_daemons();
let active = inventory.sessions.iter().find(|s| s.name == session);
let runtime = active.and_then(|_| {
send_command(
json!({
"id": gen_id(),
"action": "session_info"
}),
session,
)
.ok()
});
let runtime_data = runtime.as_ref().and_then(|resp| resp.data.clone());
let runtime_error = runtime.as_ref().and_then(|resp| {
if resp.success {
None
} else {
resp.error.clone()
}
});
if json_mode {
print_json_value(json!({
"success": true,
"data": {
"session": session,
"namespace": env::var("AGENT_BROWSER_NAMESPACE").ok(),
"socketDir": get_socket_dir().to_string_lossy(),
"active": active.is_some(),
"pid": active.map(|s| s.pid),
"version": active.and_then(|s| s.version.clone()),
"runtime": runtime_data,
"runtimeError": runtime_error,
}
}));
return;
}
println!("Session: {}", session);
println!("Socket dir: {}", get_socket_dir().to_string_lossy());
if let Ok(namespace) = env::var("AGENT_BROWSER_NAMESPACE") {
println!("Namespace: {}", namespace);
}
if let Some(active) = active {
println!("Daemon: running (pid {})", active.pid);
if let Some(ref version) = active.version {
println!("Version: {}", version);
}
} else {
println!("Daemon: not running");
}
if let Some(data) = runtime_data {
if let Some(restore_status) = data.get("restoreStatus").and_then(|v| v.as_str()) {
println!("Restore status: {}", restore_status);
}
if let Some(save_status) = data.get("saveStatus").and_then(|v| v.as_str()) {
println!("Save status: {}", save_status);
}
if let Some(engine) = data.get("engine").and_then(|v| v.as_str()) {
println!("Engine: {}", engine);
}
if let Some(launched) = data.get("browserLaunched").and_then(|v| v.as_bool()) {
println!("Browser launched: {}", launched);
}
} else if let Some(err) = runtime_error {
println!("Runtime info unavailable: {}", err);
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
match subcommand {
Some("id") => run_session_id(args, json_mode),
Some("info") => run_session_info(session, json_mode),
Some("list") => {
let sessions: Vec<String> = walk_daemons()
.sessions
.into_iter()
.map(|s| s.name)
.collect();
if json_mode {
println!(
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
serde_json::to_string(&sessions).unwrap_or_default()
);
} else if sessions.is_empty() {
println!("No active sessions");
} else {
println!("Active sessions:");
for s in &sessions {
let marker = if s == session {
color::cyan("→")
} else {
" ".to_string()
};
println!("{} {}", marker, s);
}
}
}
None | Some(_) => {
if json_mode {
print_json_value(json!({
"success": true,
"data": {
"session": session,
},
}));
} else {
println!("{}", session);
}
}
}
}
fn get_dashboard_pid_path() -> std::path::PathBuf {
get_socket_dir().join("dashboard.pid")
}
fn get_dashboard_config_path() -> std::path::PathBuf {
get_socket_dir().join("dashboard.config")
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
struct DashboardConfig {
port: u16,
allowed_origins: Vec<String>,
#[serde(default)]
access_token: Option<String>,
}
impl DashboardConfig {
fn new(port: u16, allowed_origins: Vec<String>) -> Result<Self, String> {
let access_token = if allowed_origins.is_empty() {
None
} else {
Some(generate_dashboard_access_token()?)
};
Ok(Self {
port,
allowed_origins,
access_token,
})
}
fn allowed_origins_description(&self) -> String {
if self.allowed_origins.is_empty() {
"loopback origins only".to_string()
} else {
self.allowed_origins.join(",")
}
}
fn has_valid_access_token(&self) -> bool {
self.allowed_origins.is_empty()
|| self
.access_token
.as_deref()
.is_some_and(native::stream::is_valid_dashboard_access_token)
}
fn has_same_settings_as(&self, requested: &Self) -> bool {
self.port == requested.port && self.allowed_origins == requested.allowed_origins
}
fn access_urls(&self) -> Vec<String> {
let Some(token) = self.access_token.as_deref() else {
return Vec::new();
};
self.allowed_origins
.iter()
.map(|origin| format!("{origin}/#dashboard-access-token={token}"))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DashboardCommand {
Start {
port: u16,
allowed_origins: Option<String>,
},
Stop,
}
fn parse_dashboard_command(args: &[String]) -> Result<DashboardCommand, String> {
let (subcommand, mut index) = match args.first().map(String::as_str) {
Some("start") => ("start", 1),
Some("stop") => ("stop", 1),
Some(value) if !value.starts_with('-') => {
return Err(format!("Unknown dashboard subcommand: {value}"));
}
_ => ("start", 0),
};
if subcommand == "stop" {
if let Some(argument) = args.get(index) {
return Err(format!(
"Dashboard stop does not accept argument '{argument}'."
));
}
return Ok(DashboardCommand::Stop);
}
let mut port = 4848;
let mut saw_port = false;
let mut allowed_origins = None;
while let Some(argument) = args.get(index) {
match argument.as_str() {
"--port" => {
if saw_port {
return Err(
"Dashboard option '--port' was provided more than once.".to_string()
);
}
let value = args
.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.")
})?;
saw_port = true;
index += 2;
}
"--allowed-origins" => {
if allowed_origins.is_some() {
return Err(
"Dashboard option '--allowed-origins' was provided more than once."
.to_string(),
);
}
let value = args
.get(index + 1)
.filter(|value| !value.starts_with('-'))
.ok_or_else(|| {
"Dashboard option '--allowed-origins' requires a value.".to_string()
})?;
allowed_origins = Some(value.clone());
index += 2;
}
value if value.starts_with('-') => {
return Err(format!("Unknown dashboard option: {value}"));
}
value => {
return Err(format!("Unexpected dashboard argument: {value}"));
}
}
}
Ok(DashboardCommand::Start {
port,
allowed_origins,
})
}
fn generate_dashboard_access_token() -> Result<String, String> {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes)
.map_err(|error| format!("Failed to generate dashboard access token: {error}"))?;
Ok(hex::encode(bytes))
}
fn read_dashboard_config() -> Option<DashboardConfig> {
let contents = fs::read_to_string(get_dashboard_config_path()).ok()?;
serde_json::from_str(&contents).ok()
}
fn remove_dashboard_sidecars() {
let _ = fs::remove_file(get_dashboard_pid_path());
let _ = fs::remove_file(get_dashboard_config_path());
}
fn write_dashboard_config(config: &DashboardConfig) -> std::io::Result<()> {
let contents = serde_json::to_vec(config).map_err(std::io::Error::other)?;
let path = get_dashboard_config_path();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(&contents)
}
#[cfg(windows)]
{
fs::write(path, contents)
}
}
fn dashboard_config_issue(
running: Option<&DashboardConfig>,
requested: &DashboardConfig,
) -> Option<String> {
let Some(running) = running else {
return Some(
"Dashboard is already running, but its configuration could not be verified. Run 'agent-browser dashboard stop' before starting it again."
.to_string(),
);
};
if !running.has_valid_access_token() {
return Some(
"Dashboard is already running, but its access-token configuration could not be verified. Run 'agent-browser dashboard stop' before starting it again."
.to_string(),
);
}
if running.has_same_settings_as(requested) {
return None;
}
Some(format!(
"Dashboard is already running with port {} and {}. Run 'agent-browser dashboard stop' before starting it with different settings.",
running.port,
running.allowed_origins_description()
))
}
fn run_dashboard_start(port: u16, allowed_origins: Vec<String>, json_mode: bool) {
let pid_path = get_dashboard_pid_path();
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
if is_pid_alive(pid) {
let running_config = read_dashboard_config();
let requested_config = DashboardConfig {
port,
allowed_origins,
access_token: None,
};
if let Some(error) =
dashboard_config_issue(running_config.as_ref(), &requested_config)
{
if json_mode {
print_json_error(error);
} else {
eprintln!("{} {}", color::error_indicator(), error);
}
exit(1);
}
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 },
}));
} else {
println!(
"Dashboard already running at http://localhost:{}",
requested_config.port
);
print_dashboard_access_urls(&running_config.expect("verified above"));
}
return;
}
}
remove_dashboard_sidecars();
} else {
let _ = fs::remove_file(get_dashboard_config_path());
}
let requested_config = match DashboardConfig::new(port, allowed_origins) {
Ok(config) => config,
Err(error) => {
if json_mode {
print_json_error(error);
} else {
eprintln!("{} {}", color::error_indicator(), error);
}
exit(1);
}
};
let socket_dir = get_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
}
let exe_path = match env::current_exe() {
Ok(p) => p.canonicalize().unwrap_or(p),
Err(e) => {
if json_mode {
print_json_error(format!("Failed to get executable path: {}", e));
} else {
eprintln!(
"{} Failed to get executable path: {}",
color::error_indicator(),
e
);
}
exit(1);
}
};
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_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(","),
)
.env(
"AGENT_BROWSER_DASHBOARD_ACCESS_TOKEN",
requested_config
.access_token
.as_deref()
.expect("generated above"),
);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
connection::prevent_stdio_handle_inheritance();
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
}
match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
let pid = child.id();
let write_result = write_dashboard_config(&requested_config)
.and_then(|()| fs::write(&pid_path, pid.to_string()));
if let Err(error) = write_result {
let _ = child.kill();
remove_dashboard_sidecars();
if json_mode {
print_json_error(format!("Failed to save dashboard state: {error}"));
} else {
eprintln!(
"{} Failed to save dashboard state: {}",
color::error_indicator(),
error
);
}
exit(1);
}
if json_mode {
print_json_value(json!({
"success": true,
"data": { "port": requested_config.port, "pid": pid, "access_urls": requested_config.access_urls() },
}));
} else {
println!(
"Dashboard started{}",
if requested_config.access_urls().is_empty() {
format!(" at http://localhost:{}", requested_config.port)
} else {
"; open one of the private access URLs below".to_string()
}
);
print_dashboard_access_urls(&requested_config);
}
}
Err(e) => {
remove_dashboard_sidecars();
if json_mode {
print_json_error(format!("Failed to start dashboard: {}", e));
} else {
eprintln!(
"{} Failed to start dashboard: {}",
color::error_indicator(),
e
);
}
exit(1);
}
}
}
fn print_dashboard_access_urls(config: &DashboardConfig) {
let access_urls = config.access_urls();
if access_urls.is_empty() {
return;
}
eprintln!(
"{} Keep these reverse-proxy dashboard URLs private: they contain access tokens.",
color::warning_indicator()
);
for url in access_urls {
println!("{url}");
}
}
fn run_dashboard_stop(json_mode: bool) {
let pid_path = get_dashboard_pid_path();
let pid_str = match fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => {
remove_dashboard_sidecars();
if json_mode {
print_json_value(
json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
);
} else {
println!("Dashboard is not running");
}
return;
}
};
let pid: u32 = match pid_str.trim().parse() {
Ok(p) => p,
Err(_) => {
remove_dashboard_sidecars();
if json_mode {
print_json_value(
json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }),
);
} else {
println!("Dashboard is not running");
}
return;
}
};
if !is_agent_browser_process(pid) {
remove_dashboard_sidecars();
if json_mode {
print_json_value(
json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }),
);
} else {
println!("Dashboard is not running");
}
return;
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
#[cfg(windows)]
{
unsafe {
let handle = OpenProcess(1, 0, pid);
if handle != 0 {
windows_sys::Win32::System::Threading::TerminateProcess(handle, 0);
CloseHandle(handle);
}
}
}
remove_dashboard_sidecars();
if json_mode {
print_json_value(json!({ "success": true, "data": { "stopped": true } }));
} else {
println!("{} Dashboard stopped", color::green("✓"));
}
}
fn clear_closed_session(session: &str) {
tls::clear_session(session);
if let Err(e) = session_settings::clear_file(session) {
eprintln!("{} {}", color::warning_indicator(), e);
}
}
fn forget_before_close(session: &str) {
tls::clear_session(session);
}
fn ensure_daemon_for(
session: &str,
opts: &DaemonOptions,
is_close: bool,
) -> Result<connection::DaemonResult, String> {
match ensure_daemon(session, opts) {
Err(e) if is_close && session_settings::is_refusal(&e) => {
session_settings::clear_file(session)?;
ensure_daemon(session, opts)
}
other => other,
}
}
fn close_completed(resp: &connection::Response) -> bool {
resp.success
&& resp
.data
.as_ref()
.and_then(|d| d.get("closed"))
.and_then(|v| v.as_bool())
== Some(true)
}
fn run_close_all(flags: &Flags) {
let inventory = walk_daemons();
let mut sessions: Vec<(String, Option<u32>)> = inventory
.sessions
.iter()
.map(|s| (s.name.clone(), Some(s.pid)))
.collect();
let orphans = session_settings::orphan_sessions(&get_socket_dir(), |session| {
sessions.iter().any(|(name, _)| name == session) || connection::daemon_ready(session)
});
sessions.extend(orphans.into_iter().map(|name| (name, None)));
if sessions.is_empty() {
if flags.json {
print_json_value(json!({
"success": true,
"data": { "closed": 0, "sessions": [] },
}));
} else {
println!("No active sessions");
}
return;
}
let mut closed: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
for (session, pid) in &sessions {
if pid.is_none() {
if let Err(e) = ensure_daemon_for(session, &DaemonOptions::bare(), true) {
failed.push((session.clone(), e));
continue;
}
}
let cmd = json!({ "id": gen_id(), "action": "close" });
let result = send_command(cmd, session).and_then(|resp| {
if resp.code.as_deref() == Some("outcome_unknown") {
Err(resp.error.unwrap_or_default())
} else {
Ok(resp)
}
});
match result {
Ok(resp) if close_completed(&resp) => {
clear_closed_session(session);
closed.push(session.clone());
}
Ok(resp) => {
let waiting = resp
.data
.as_ref()
.and_then(|d| d.get("confirmation_required"))
.and_then(|v| v.as_bool())
== Some(true);
let mut err = match (resp.error, waiting) {
(Some(err), _) => err,
(None, true) => "close is waiting for confirmation".to_string(),
(None, false) => "Unknown error".to_string(),
};
if pid.is_none() {
connection::stop_daemon(session);
if waiting {
err = format!(
"close needs confirmation; run `agent-browser --session {s} close`, then `agent-browser --session {s} confirm <id>` with the id it prints",
s = session
);
}
}
failed.push((session.clone(), err));
}
Err(e) if pid.is_none() => {
connection::stop_daemon(session);
failed.push((session.clone(), e));
}
Err(_) => {
let pid = &pid.unwrap_or_default();
if is_agent_browser_process(*pid) {
#[cfg(unix)]
unsafe {
libc::kill(*pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
unsafe {
let handle = OpenProcess(1, 0, *pid);
if handle != 0 {
windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
}
cleanup_stale_files(session);
clear_closed_session(session);
closed.push(session.clone());
}
}
}
if flags.json {
print_json_value(json!({
"success": failed.is_empty(),
"data": {
"closed": closed.len(),
"sessions": closed,
"failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::<Vec<_>>(),
},
}));
} else {
for s in &closed {
println!("{} Closed session: {}", color::green("✓"), s);
}
for (s, e) in &failed {
eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e);
}
if closed.is_empty() && !failed.is_empty() {
exit(1);
}
}
if !failed.is_empty() {
exit(1);
}
}
fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
#[cfg(windows)]
{
env::set_var("MSYS_NO_PATHCONV", "1");
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
if !validation::is_valid_session_name(&session) {
let msg = validation::session_name_error(&session);
eprintln!("{} {}", color::error_indicator(), msg);
exit(1);
}
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::daemon::run_daemon(&session));
return;
}
if env::var("AGENT_BROWSER_DASHBOARD").is_ok() {
let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(4848);
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::stream::run_dashboard_server(port));
return;
}
let args: Vec<String> = env::args().skip(1).collect();
let mut flags = parse_flags(&args);
if flags.restore_uses_session {
flags.restore = Some(flags.session.clone());
}
if let Some(ref namespace) = flags.namespace {
env::set_var("AGENT_BROWSER_NAMESPACE", namespace);
}
let mut clean = clean_args(&args);
tls::configure_process(&flags);
let options = flags::before_end_of_options(&clean);
let has_help = options.iter().any(|a| a == "--help" || a == "-h");
let has_version = options.iter().any(|a| a == "--version" || a == "-V");
if has_help {
clean.retain(|arg| arg != "--help" && arg != "-h");
if clean.is_empty() || clean.iter().all(|arg| arg.starts_with('-')) {
print_help();
return;
}
if let Some(cmd) = clean.first() {
if commands::is_top_level_command(cmd) {
if !print_command_help(cmd) {
print_help();
}
return;
}
}
}
if has_version {
print_version();
return;
}
if clean.is_empty() {
print_help();
return;
}
if clean.first().map(|s| s.as_str()) == Some("install") {
let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d");
run_install(with_deps);
return;
}
if clean.first().map(|s| s.as_str()) == Some("upgrade") {
run_upgrade();
return;
}
if clean.first().map(|s| s.as_str()) == Some("doctor") {
let opts = doctor::DoctorOptions {
offline: args.iter().any(|a| a == "--offline"),
quick: args.iter().any(|a| a == "--quick"),
fix: args.iter().any(|a| a == "--fix"),
json: flags.json,
webgpu: flags.cli_webgpu && flags.webgpu,
debug: flags.debug,
headed: flags.headed,
};
exit(doctor::run_doctor(opts));
}
if clean.first().map(|s| s.as_str()) == Some("dashboard") {
let dashboard_args = &clean[1..];
let command = match parse_dashboard_command(dashboard_args) {
Ok(command) => command,
Err(error) => {
if flags.json {
print_json_error(error);
} else {
eprintln!("{} {}", color::error_indicator(), error);
}
exit(1);
}
};
match command {
DashboardCommand::Stop => {
run_dashboard_stop(flags.json);
return;
}
DashboardCommand::Start {
port,
allowed_origins,
} => {
let env_allowed_origins = env::var("AGENT_BROWSER_DASHBOARD_ALLOWED_ORIGINS").ok();
let configured_origins = allowed_origins
.as_deref()
.or(env_allowed_origins.as_deref());
let allowed_origins =
match native::stream::normalize_dashboard_allowed_origins(configured_origins) {
Ok(origins) => origins,
Err(error) => {
if flags.json {
print_json_error(error);
} else {
eprintln!("{} {}", color::error_indicator(), error);
}
exit(1);
}
};
run_dashboard_start(port, allowed_origins, flags.json);
return;
}
}
}
if clean.first().map(|s| s.as_str()) == Some("profiles") {
run_profiles(flags.json);
return;
}
if clean.first().map(|s| s.as_str()) == Some("skills") {
skills::run_skills(&clean, flags.json);
return;
}
if matches!(
clean.first().map(|s| s.as_str()),
Some("plugin") | Some("plugins")
) {
plugins::run_plugin_command(&clean, &flags.plugins, flags.json);
return;
}
if clean.first().map(|s| s.as_str()) == Some("mcp") {
if let Some(Some(path)) = flags::extract_config_path(&args) {
env::set_var("AGENT_BROWSER_CONFIG", path);
}
if clean.get(1).map(|s| s.as_str()) == Some("call") {
exit(mcp::run_mcp_call(&clean[2..]));
}
if let Err(err) = mcp::run_mcp(&clean[1..]) {
eprintln!("{} {}", color::error_indicator(), err);
exit(1);
}
return;
}
if !validation::is_valid_session_name(&flags.session) {
let msg = format!(
"{} Source: {}.",
validation::session_name_error(&flags.session),
flags.session_source
);
if flags.json {
print_json_error_with_type(msg, "invalid_session_name");
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if clean.first().map(|s| s.as_str()) == Some("session") {
run_session(&clean, &flags.session, flags.json);
return;
}
if matches!(
clean.first().map(|s| s.as_str()),
Some("close") | Some("quit") | Some("exit")
) && clean.iter().any(|a| a == "--all")
{
run_close_all(&flags);
return;
}
if clean.first().map(|s| s.as_str()) == Some("chat") {
let message = if clean.len() > 1 {
Some(clean[1..].join(" "))
} else {
None
};
chat::run_chat(&flags, message);
return;
}
let mut cmd = match parse_command(&clean, &flags) {
Ok(c) => c,
Err(e) => {
if flags.json {
let error_type = match &e {
ParseError::UnknownCommand { .. } => "unknown_command",
ParseError::UnknownSubcommand { .. } => "unknown_subcommand",
ParseError::MissingArguments { .. } => "missing_arguments",
ParseError::InvalidValue { .. } => "invalid_value",
ParseError::InvalidSessionName { .. } => "invalid_session_name",
};
print_json_error_with_type(e.format(), error_type);
} else {
eprintln!("{}", color::red(&e.format()));
}
exit(1);
}
};
attach_input_mode(&mut cmd, &flags);
if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") && cmd.get("commands").is_none()
{
cmd["argv"] = json!(read_batch_stdin(&flags));
}
match cmd.get("action").and_then(|v| v.as_str()) {
Some("read") => cmd["tls"] = json!(tls::session_options(&flags, &flags.session)),
Some("close") => forget_before_close(&flags.session),
_ => {}
}
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
if cmd.get("password").is_some() {
eprintln!(
"{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
color::warning_indicator()
);
}
if cmd
.get("passwordStdin")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!(
"{} Failed to read password from stdin",
color::error_indicator()
);
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
if pass.is_empty() {
eprintln!("{} Password from stdin is empty", color::error_indicator());
exit(1);
}
cmd["password"] = json!(pass);
cmd.as_object_mut().unwrap().remove("passwordStdin");
}
}
attach_plugins_to_command(&mut cmd, &flags.plugins);
attach_pin_tab_to_command(&mut cmd, &flags);
attach_restore_config_to_command(&mut cmd, &flags);
if let Some(name) = restore_key_from_flags(&flags) {
if !validation::is_valid_session_name(name) {
let msg = validation::session_name_error(name);
if flags.json {
print_json_error_with_type(msg, "invalid_session_name");
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
if let Some(ref policy) = flags.restore_save {
if !is_valid_restore_save_policy(policy) {
let msg = format!(
"Invalid --restore-save value '{}'. Use auto, always, or never.",
policy
);
if flags.json {
print_json_error_with_type(msg, "invalid_value");
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
if let Some(result) = native::state::dispatch_state_command(&cmd) {
let action = cmd.get("action").and_then(|v| v.as_str());
let resp = match result {
Ok(data) => connection::Response {
success: true,
data: Some(data),
error: None,
code: None,
warning: None,
recorded: None,
},
Err(e) => connection::Response {
success: false,
data: None,
error: Some(e),
code: None,
warning: None,
recorded: None,
},
};
let output_opts = OutputOptions::from_flags(&flags);
output::print_response_with_opts(&resp, action, &output_opts);
if !resp.success {
exit(1);
}
return;
}
let launch_mode_error = if command_is_browserless(&cmd) {
let ca_cert = flags.ca_cert.take();
let clear_ca_cert = std::mem::take(&mut flags.clear_ca_cert);
let error = incompatible_launch_mode_error(&flags);
flags.ca_cert = ca_cert;
flags.clear_ca_cert = clear_ca_cert;
error
} else {
incompatible_launch_mode_error(&flags)
};
if let Some(msg) = launch_mode_error {
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if let Some(ref ca_path) = flags.ca_cert {
if let Err(msg) = ca_bundle::load(ca_path) {
if flags.json {
print_json_error(&msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
let canonical = std::path::Path::new(ca_path)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(ca_path));
flags.ca_cert = Some(canonical.display().to_string());
}
let restore_key = restore_key_from_flags(&flags);
let (proxy_server, proxy_username, proxy_password) = if let Some(ref proxy_str) = flags.proxy {
let parsed = parse_proxy(proxy_str);
(Some(parsed.server), parsed.username, parsed.password)
} else {
(None, None, None)
};
let plugin_registry_json =
serde_json::to_string(&flags.plugins).unwrap_or_else(|_| "[]".to_string());
let daemon_opts = DaemonOptions {
headed: flags.headed,
debug: flags.debug,
executable_path: flags.executable_path.as_deref(),
extensions: &flags.extensions,
init_scripts: &flags.init_scripts,
enable: &flags.enable,
args: flags.args.as_deref(),
user_agent: flags.user_agent.as_deref(),
proxy: proxy_server.as_deref(),
proxy_bypass: flags.proxy_bypass.as_deref(),
proxy_username: proxy_username.as_deref(),
proxy_password: proxy_password.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
hide_scrollbars: flags.hide_scrollbars,
webgpu: flags.webgpu,
profile: flags.profile.as_deref(),
state: flags.state.as_deref(),
provider: flags.provider.as_deref(),
device: flags.device.as_deref(),
session_name: restore_key,
restore_save: flags.restore_save.as_deref(),
restore_check_url: flags.restore_check_url.as_deref(),
restore_check_text: flags.restore_check_text.as_deref(),
restore_check_fn: flags.restore_check_fn.as_deref(),
download_path: flags.download_path.as_deref(),
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
engine: flags.engine.as_deref(),
auto_connect: flags.auto_connect,
pin_tab: flags.pin_tab,
idle_timeout: flags.idle_timeout.as_deref(),
default_timeout: flags.default_timeout,
cdp: flags.cdp.as_deref(),
cdp_headers: flags.cdp_headers.as_deref(),
no_auto_dialog: flags.no_auto_dialog,
plugins: Some(plugin_registry_json.as_str()),
};
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let daemon_result = match ensure_daemon_for(&flags.session, &daemon_opts, is_close) {
Ok(result) => result,
Err(e) => {
if flags.json {
print_json_error(e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
};
let _daemon_was_already_running = daemon_result.already_running;
let daemon_restarted = daemon_result.restarted;
if flags.default_timeout.is_none() {
commands::inherit_wait_timeout(
&mut cmd,
connection::running_daemon_default_timeout(&flags.session),
);
}
if let Err(msg) = validate_cdp_flags(&flags) {
if flags.json {
print_json_error(&msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if let Some(wire) = command_settings(&flags, &cmd).to_wire() {
cmd["settings"] = wire;
}
if cmd.get("action").and_then(|v| v.as_str()) == Some("batch") {
let bail = cmd.get("bail").and_then(|v| v.as_bool()).unwrap_or(false);
let omit_command = cmd
.get("omitCommand")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let arg_commands = cmd
.get("commands")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(commands::shell_words_split)
.collect::<Vec<Vec<String>>>()
})
.or_else(|| serde_json::from_value(cmd.get("argv")?.clone()).ok());
run_batch(&flags, &daemon_opts, bail, omit_command, arg_commands);
return;
}
let output_opts = OutputOptions::from_flags(&flags);
match send_command_with_respawn(cmd.clone(), &flags.session, &daemon_opts) {
Ok(mut resp) => {
if daemon_restarted {
mark_restarted_background(&mut resp);
}
if flags.confirm_interactive && confirmation_prompt_from_response(&resp).is_some() {
resp = run_interactive_confirmations(resp, &flags, &output_opts);
if daemon_restarted {
mark_restarted_background(&mut resp);
}
if !resp.success {
exit(1);
}
return;
}
let success = resp.success;
let action = cmd.get("action").and_then(|v| v.as_str());
if action == Some("close") && close_completed(&resp) {
clear_closed_session(&flags.session);
}
print_response_with_opts(&resp, action, &output_opts);
if !success {
exit(1);
}
}
Err(e) => {
if flags.json {
print_json_error(e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn send_command_with_respawn(
cmd: serde_json::Value,
session: &str,
daemon_opts: &DaemonOptions,
) -> Result<connection::Response, String> {
let first_attempt = send_command(cmd.clone(), session);
match first_attempt {
Err(ref e) if daemon_unreachable(e) => match ensure_daemon(session, daemon_opts) {
Ok(_) => send_command(cmd, session),
Err(_) => first_attempt,
},
other => other,
}
}
fn read_batch_stdin(flags: &Flags) -> Vec<Vec<String>> {
use std::io::Read as _;
let mut input = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
if flags.json {
print_json_error(format!("Failed to read stdin: {}", e));
} else {
eprintln!("{} Failed to read stdin: {}", color::error_indicator(), e);
}
exit(1);
}
match serde_json::from_str(&input) {
Ok(c) => c,
Err(e) => {
if flags.json {
print_json_error(format!(
"Invalid JSON input: {}. Expected an array of string arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]",
e
));
} else {
eprintln!(
"{} Invalid JSON input: {}. Expected an array of string arrays.",
color::error_indicator(),
e
);
}
exit(1);
}
}
}
fn run_batch(
flags: &Flags,
daemon_opts: &DaemonOptions,
bail: bool,
omit_command: bool,
arg_commands: Option<Vec<Vec<String>>>,
) {
let commands: Vec<Vec<String>> = arg_commands.unwrap_or_else(|| read_batch_stdin(flags));
if commands.is_empty() {
if flags.json {
println!("[]");
}
return;
}
let output_opts = OutputOptions::from_flags(flags);
let mut results: Vec<serde_json::Value> = Vec::new();
let mut had_error = false;
let mut relaunch_applied = false;
for (i, cmd_args) in commands.iter().enumerate() {
if cmd_args.is_empty() {
continue;
}
let mut parsed = match commands::parse_batch_command(cmd_args, flags) {
Ok(mut c) => {
if flags.default_timeout.is_none() {
commands::inherit_wait_timeout(
&mut c,
connection::running_daemon_default_timeout(&flags.session),
);
}
c
}
Err(e) => {
had_error = true;
if flags.json {
let mut result = batch_result_identity(i, cmd_args, omit_command);
result["success"] = json!(false);
result["error"] = json!(e.format());
results.push(result);
if bail {
break;
}
} else {
eprintln!(
"{} Command {}: {}",
color::error_indicator(),
i + 1,
e.format()
);
if bail {
exit(1);
}
}
continue;
}
};
attach_input_mode(&mut parsed, flags);
match parsed.get("action").and_then(|v| v.as_str()) {
Some("read") => parsed["tls"] = json!(tls::session_options(flags, &flags.session)),
Some("close") => tls::clear_session(&flags.session),
_ => {}
}
let action = parsed
.get("action")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
attach_plugins_to_command(&mut parsed, &flags.plugins);
attach_restore_config_to_command(&mut parsed, flags);
let settings = batch_item_settings(flags, &parsed, relaunch_applied);
if let Some(wire) = settings.to_wire() {
parsed["settings"] = wire;
}
attach_pin_tab_to_command(&mut parsed, flags);
match send_command_with_respawn(parsed, &flags.session, daemon_opts) {
Ok(resp) => {
if action.as_deref() == Some("close") && close_completed(&resp) {
clear_closed_session(&flags.session);
}
relaunch_applied |= response_applied_relaunch(&resp);
if flags.json {
let mut result = batch_result_identity(i, cmd_args, omit_command);
result["success"] = json!(resp.success);
result["result"] = json!(resp.data);
result["error"] = json!(resp.error);
if let Some(ref code) = resp.code {
result["code"] = json!(code);
}
if let Some(ref warning) = resp.warning {
result["warning"] = json!(warning);
}
results.push(result);
} else {
if i > 0 {
println!();
}
print_response_with_opts(&resp, action.as_deref(), &output_opts);
}
if !resp.success {
had_error = true;
if bail {
if !flags.json {
exit(1);
}
break;
}
}
}
Err(e) => {
had_error = true;
if flags.json {
let mut result = batch_result_identity(i, cmd_args, omit_command);
result["success"] = json!(false);
result["error"] = json!(e.to_string());
results.push(result);
if bail {
break;
}
} else {
eprintln!("{} Command {}: {}", color::error_indicator(), i + 1, e);
if bail {
exit(1);
}
}
}
}
}
if flags.json {
println!(
"{}",
serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string())
);
}
if had_error {
exit(1);
}
}
fn batch_result_identity(
index: usize,
command: &[String],
omit_command: bool,
) -> serde_json::Value {
if omit_command {
json!({ "index": index })
} else {
json!({ "command": command })
}
}
#[cfg(test)]
mod tests {
#[test]
fn input_mode_session_setting_preserves_command_override() {
let args: Vec<String> = ["--input-mode", "smooth", "click", "#button", "--human"]
.into_iter()
.map(str::to_string)
.collect();
let flags = crate::flags::parse_flags(&args);
let mut command =
crate::commands::parse_command(&crate::flags::clean_args(&args), &flags).unwrap();
super::attach_input_mode(&mut command, &flags);
assert_eq!(command["defaultInputMode"], "smooth");
assert_eq!(command["inputMode"], "human");
}
use super::*;
#[test]
fn dashboard_config_comparison_rejects_unknown_or_changed_settings() {
let requested = DashboardConfig::new(
4848,
native::stream::normalize_dashboard_allowed_origins(Some(
"https://second.example.com, https://dashboard.example.com",
))
.unwrap(),
)
.unwrap();
let equivalent = DashboardConfig::new(
4848,
native::stream::normalize_dashboard_allowed_origins(Some(
"https://dashboard.example.com,https://second.example.com",
))
.unwrap(),
)
.unwrap();
let changed = DashboardConfig::new(
4849,
native::stream::normalize_dashboard_allowed_origins(Some(
"https://dashboard.example.com",
))
.unwrap(),
)
.unwrap();
assert_eq!(dashboard_config_issue(Some(&equivalent), &requested), None);
assert!(dashboard_config_issue(Some(&changed), &requested).is_some());
assert!(dashboard_config_issue(None, &requested).is_some());
}
#[test]
fn dashboard_parser_accepts_explicit_start_options() {
let args = vec![
"start".to_string(),
"--port".to_string(),
"8080".to_string(),
"--allowed-origins".to_string(),
"https://dashboard.example.com".to_string(),
];
assert_eq!(
parse_dashboard_command(&args),
Ok(DashboardCommand::Start {
port: 8080,
allowed_origins: Some("https://dashboard.example.com".to_string()),
})
);
}
#[test]
fn dashboard_parser_rejects_invalid_or_unknown_options() {
for args in [
vec!["--bogus"],
vec!["start", "--allowed-origin", "https://dashboard.example.com"],
vec!["start", "--port", "nope"],
vec!["start", "--port", "0"],
vec!["start", "--port"],
vec!["start", "--allowed-origins"],
vec!["stop", "--port", "4848"],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
parse_dashboard_command(&args).is_err(),
"unexpectedly accepted {args:?}"
);
}
}
fn settings_of(args: &[&str]) -> (serde_json::Value, serde_json::Value) {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let flags = crate::flags::parse_flags(&args);
let command =
crate::commands::parse_command(&crate::flags::clean_args(&args), &flags).unwrap();
let wire = command_settings(&flags, &command)
.to_wire()
.unwrap_or(serde_json::Value::Null);
(command, wire)
}
#[test]
fn bare_open_is_a_plain_launch_and_its_settings_ride_in_the_patch() {
let (command, wire) = settings_of(&[
"--profile",
"/tmp/profile",
"--args",
"--lang=en-US",
"--headed",
"open",
]);
assert_eq!(command["action"], "launch");
for field in ["profile", "args", "headless"] {
assert!(
command.get(field).is_none(),
"{field} must not ride on the launch"
);
}
assert_eq!(wire["profile"], "/tmp/profile");
assert_eq!(wire["args"], serde_json::json!(["--lang=en-US"]));
assert_eq!(wire["headed"], true);
}
#[test]
fn settings_distinguish_ca_trust_set_omitted_and_cleared() {
let (_, omitted) = settings_of(&["get", "url"]);
assert!(omitted.get("ca-cert").is_none() && omitted.get("clear-ca-cert").is_none());
let (_, cleared) = settings_of(&["--no-ca-cert", "get", "url"]);
assert_eq!(cleared["clear-ca-cert"], true);
assert!(cleared.get("ca-cert").is_none());
}
#[test]
fn browserless_commands_do_not_carry_ca_trust() {
let (_, wire) = settings_of(&["--no-ca-cert", "read", "https://example.com"]);
assert!(wire.get("clear-ca-cert").is_none(), "{wire}");
}
#[test]
fn cdp_flags_are_validated_before_the_command_runs() {
let flags = |args: &[&str]| {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
crate::flags::parse_flags(&args)
};
assert!(validate_cdp_flags(&flags(&["--cdp", "9222", "get", "url"])).is_ok());
assert!(
validate_cdp_flags(&flags(&["--cdp", "ws://127.0.0.1:9222/x", "get", "url"])).is_ok()
);
for (value, message) in [
("0", "greater than 0"),
("70000", "out of range"),
("nope", "not a valid port number or URL"),
] {
let error = validate_cdp_flags(&flags(&["--cdp", value, "get", "url"])).unwrap_err();
assert!(error.contains(message), "{value}: {error}");
}
let error = validate_cdp_flags(&flags(&[
"--cdp",
"9222",
"--cdp-headers",
"[]",
"get",
"url",
]))
.unwrap_err();
assert!(error.contains("JSON object"), "{error}");
}
#[test]
fn test_cdp_url_metadata_carries_no_launch_settings() {
let (command, wire) = settings_of(&[
"--profile",
"/tmp/p",
"--args",
"--disable-gpu",
"--no-auto-dialog",
"get",
"cdp-url",
]);
assert!(command_is_cdp_url_metadata(&command));
assert_eq!(wire["no-auto-dialog"], true);
assert!(
wire.get("profile").is_none() && wire.get("args").is_none(),
"{wire}"
);
let (command, wire) = settings_of(&["--profile", "/tmp/p", "snapshot"]);
assert!(!command_is_cdp_url_metadata(&command));
assert_eq!(wire["profile"], "/tmp/p");
}
#[test]
fn test_cdp_url_metadata_batches_carry_no_launch_settings() {
for command in [
json!({ "action": "batch", "commands": ["get cdp-url", "get 'cdp-url' ignored"] }),
json!({ "action": "batch", "argv": [["get", "cdp-url"]] }),
] {
assert!(command_is_cdp_url_metadata(&command), "{command}");
}
for command in [
json!({ "action": "batch", "commands": ["get cdp-url", "open about:blank"] }),
json!({ "action": "batch", "argv": [["get", "cdp-url"], ["snapshot"]] }),
json!({ "action": "batch", "commands": ["batch 'get cdp-url'"] }),
json!({ "action": "batch", "argv": [["batch", "get cdp-url"]] }),
json!({ "action": "batch", "commands": [] }),
json!({ "action": "batch", "argv": [[]] }),
json!({ "action": "batch" }),
] {
assert!(!command_is_cdp_url_metadata(&command), "{command}");
}
}
#[test]
fn test_relaunch_is_sent_with_the_first_batch_command_only() {
let args: Vec<String> = ["--relaunch", "--profile", "/tmp/p", "get", "url"]
.iter()
.map(|a| a.to_string())
.collect();
let flags = crate::flags::parse_flags(&args);
let cmd = serde_json::json!({ "action": "url" });
assert!(batch_item_settings(&flags, &cmd, false).forces_relaunch());
let second = batch_item_settings(&flags, &cmd, true);
assert!(!second.forces_relaunch());
assert!(second.value(session_settings::Key::Profile).is_some());
}
#[test]
fn test_relaunch_skips_batch_commands_that_cannot_launch() {
let args: Vec<String> = ["--relaunch", "batch", "read https://example.com", "get url"]
.iter()
.map(|a| a.to_string())
.collect();
let flags = crate::flags::parse_flags(&args);
for cmd in [
serde_json::json!({ "action": "read", "url": "https://example.com" }),
serde_json::json!({ "action": "auth_login", "noNavigate": true }),
serde_json::json!({ "action": "state_list" }),
] {
assert!(
!batch_item_settings(&flags, &cmd, false).forces_relaunch(),
"{cmd}"
);
}
let url = serde_json::json!({ "action": "url" });
assert!(batch_item_settings(&flags, &url, false).forces_relaunch());
}
#[test]
fn test_only_a_reported_relaunch_counts_as_applied() {
let response = |data: serde_json::Value| connection::Response {
success: true,
data: Some(data),
..Default::default()
};
assert!(response_applied_relaunch(&response(serde_json::json!({
"lifecycle": { "settings": { "changes": [], "forcedRelaunch": true } }
}))));
assert!(response_applied_relaunch(&response(serde_json::json!({
"confirmed": true,
"result": { "success": true, "data": {
"lifecycle": { "settings": { "changes": [], "forcedRelaunch": true } }
} }
}))));
assert!(!response_applied_relaunch(&response(serde_json::json!({
"confirmation_required": true, "confirmation_id": "c1"
}))));
assert!(!response_applied_relaunch(&connection::Response {
success: false,
error: Some("Action denied by policy".to_string()),
..Default::default()
}));
}
#[test]
fn test_parse_proxy_simple() {
let result = parse_proxy("http://proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_with_auth() {
let result = parse_proxy("http://user:pass@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_proxy_username_only() {
let result = parse_proxy("http://user@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert!(result.password.is_none());
}
#[test]
fn test_parse_proxy_no_protocol() {
let result = parse_proxy("proxy.com:8080");
assert_eq!(result.server, "proxy.com:8080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5() {
let result = parse_proxy("socks5://proxy.com:1080");
assert_eq!(result.server, "socks5://proxy.com:1080");
assert!(result.username.is_none());
}
#[test]
fn test_parse_proxy_socks5_with_auth() {
let result = parse_proxy("socks5://admin:secret@proxy.com:1080");
assert_eq!(result.server, "socks5://proxy.com:1080");
assert_eq!(result.username.as_deref(), Some("admin"));
assert_eq!(result.password.as_deref(), Some("secret"));
}
#[test]
fn test_parse_proxy_complex_password() {
let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080");
assert_eq!(result.server, "http://proxy.com:8080");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("p@ss:w0rd"));
}
#[test]
fn test_parse_proxy_decodes_encoded_credentials() {
for (proxy, username, password) in [
(
"http://user%40name+tag:p%3Ass%40word%252F+tail@proxy.com:8080",
Some("user@name+tag"),
Some("p:ss@word%2F+tail"),
),
("http://user%3Aname@proxy.com:8080", Some("user:name"), None),
(
"http://user:pass%FF@proxy.com:8080",
Some("user"),
Some("pass%FF"),
),
] {
let result = parse_proxy(proxy);
assert_eq!(result.username.as_deref(), username, "proxy: {proxy}");
assert_eq!(result.password.as_deref(), password, "proxy: {proxy}");
}
}
#[test]
fn test_serialize_json_value_escapes_control_characters() {
let payload = serialize_json_value(&json!({
"success": false,
"error": "Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m",
}));
let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert_eq!(parsed["success"], false);
assert_eq!(
parsed["error"],
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
);
}
fn neutral_launch_config_flags() -> Flags {
let mut flags = parse_flags(&[]);
flags.headed = false;
flags.cli_headed = false;
flags.executable_path = None;
flags.profile = None;
flags.state = None;
flags.proxy = None;
flags.args = None;
flags.user_agent = None;
flags.ignore_https_errors = false;
flags.ca_cert = None;
flags.clear_ca_cert = false;
flags.allow_file_access = false;
flags.hide_scrollbars = true;
flags.cli_hide_scrollbars = false;
flags.webgpu = false;
flags.cli_webgpu = false;
flags.no_webmcp = false;
flags.cli_no_webmcp = false;
flags.color_scheme = None;
flags.download_path = None;
flags.engine = None;
flags.allowed_domains = None;
flags.capture = None;
flags.init_scripts.clear();
flags.enable.clear();
flags.extensions.clear();
flags.cdp = None;
flags.provider = None;
flags.auto_connect = false;
flags
}
#[test]
fn test_attach_pin_tab_to_command_preserves_preference() {
let mut flags = neutral_launch_config_flags();
let mut absent_cmd = json!({ "action": "launch" });
attach_pin_tab_to_command(&mut absent_cmd, &flags);
assert!(absent_cmd.get("pinTab").is_none());
flags.pin_tab = true;
let mut enabled_cmd = json!({ "action": "launch" });
attach_pin_tab_to_command(&mut enabled_cmd, &flags);
assert_eq!(enabled_cmd["pinTab"], true);
flags.pin_tab = false;
flags.cli_pin_tab = true;
let mut disabled_cmd = json!({ "action": "launch" });
attach_pin_tab_to_command(&mut disabled_cmd, &flags);
assert_eq!(disabled_cmd["pinTab"], false);
}
#[test]
fn test_published_schemas_define_pin_tab_boolean() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("cli should have a repository parent");
let root_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
)
.unwrap();
let docs_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
)
.unwrap();
let root_pin_tab = &root_schema["properties"]["pinTab"];
let docs_pin_tab = &docs_schema["properties"]["pinTab"];
assert_eq!(root_pin_tab["type"], "boolean");
assert_eq!(docs_pin_tab["type"], "boolean");
assert_eq!(root_pin_tab, docs_pin_tab);
}
#[test]
fn test_published_schemas_define_matching_ca_cert_string() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("cli should have a repository parent");
let root_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
)
.unwrap();
let docs_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
)
.unwrap();
let root_ca_cert = &root_schema["properties"]["caCert"];
let docs_ca_cert = &docs_schema["properties"]["caCert"];
assert_eq!(root_ca_cert["type"], "string");
assert_eq!(docs_ca_cert["type"], "string");
assert_eq!(root_ca_cert, docs_ca_cert);
assert_eq!(root_schema["properties"]["clearCaCert"]["type"], "boolean");
assert_eq!(
root_schema["properties"]["clearCaCert"],
docs_schema["properties"]["clearCaCert"]
);
}
#[test]
fn test_published_schemas_define_matching_no_webmcp_boolean() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("cli should have a repository parent");
let root_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
)
.unwrap();
let docs_schema: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo_root.join("docs/public/schema.json")).unwrap(),
)
.unwrap();
assert_eq!(root_schema["properties"]["noWebmcp"]["type"], "boolean");
assert_eq!(
root_schema["properties"]["noWebmcp"],
docs_schema["properties"]["noWebmcp"]
);
}
#[test]
fn test_attach_plugins_to_command_adds_registry_payload() {
let plugins = vec![crate::plugins::PluginConfig {
name: "stealth".to_string(),
command: "agent-browser-plugin-stealth".to_string(),
capabilities: vec!["launch.mutate".to_string()],
..crate::plugins::PluginConfig::default()
}];
let mut cmd = json!({ "action": "navigate", "url": "https://example.com" });
attach_plugins_to_command(&mut cmd, &plugins);
assert_eq!(cmd["plugins"][0]["name"], "stealth");
assert_eq!(cmd["plugins"][0]["capabilities"][0], "launch.mutate");
}
#[test]
fn test_attach_plugins_to_command_adds_empty_registry_payload() {
let mut cmd = json!({ "action": "navigate", "url": "https://example.com" });
attach_plugins_to_command(&mut cmd, &[]);
assert_eq!(cmd["plugins"], json!([]));
}
#[test]
fn test_attach_restore_config_to_command_uses_session_for_bare_restore() {
let args = vec![
"--session".to_string(),
"next-loop".to_string(),
"--restore".to_string(),
"--restore-save".to_string(),
"always".to_string(),
"--restore-check-url".to_string(),
"**/dashboard".to_string(),
"--restore-check-text".to_string(),
"Dashboard".to_string(),
"--restore-check-fn".to_string(),
"!!localStorage.getItem('session')".to_string(),
"open".to_string(),
"http://localhost:3000".to_string(),
];
let mut flags = parse_flags(&args);
if flags.restore_uses_session {
flags.restore = Some(flags.session.clone());
}
let mut cmd = json!({ "action": "launch" });
attach_restore_config_to_command(&mut cmd, &flags);
assert_eq!(cmd["restoreKey"], "next-loop");
assert_eq!(cmd["restoreSave"], "always");
assert_eq!(cmd["restoreCheckUrl"], "**/dashboard");
assert_eq!(cmd["restoreCheckText"], "Dashboard");
assert_eq!(cmd["restoreCheckFn"], "!!localStorage.getItem('session')");
}
#[test]
fn test_attach_restore_config_to_command_sends_defaults_and_clears_checks() {
let args = vec![
"--session".to_string(),
"next-loop".to_string(),
"--restore".to_string(),
"open".to_string(),
"http://localhost:3000".to_string(),
];
let mut flags = parse_flags(&args);
if flags.restore_uses_session {
flags.restore = Some(flags.session.clone());
}
let mut cmd = json!({ "action": "navigate" });
attach_restore_config_to_command(&mut cmd, &flags);
assert_eq!(cmd["restoreKey"], "next-loop");
assert_eq!(cmd["restoreSave"], "auto");
assert!(cmd["restoreCheckUrl"].is_null());
assert!(cmd["restoreCheckText"].is_null());
assert!(cmd["restoreCheckFn"].is_null());
}
fn launch_mode_flags(auto_connect: bool, cdp: bool, provider: bool, extensions: bool) -> Flags {
let mut flags = parse_flags(&[]);
flags.webgpu = false;
flags.auto_connect = auto_connect;
flags.cdp = cdp.then(|| "9222".to_string());
flags.provider = provider.then(|| "ios".to_string());
flags.extensions = if extensions {
vec!["/tmp/ext".to_string()]
} else {
Vec::new()
};
flags
}
#[test]
fn test_incompatible_launch_mode_error_matches_existing_messages() {
let cases = [
(
launch_mode_flags(false, true, true, false),
"Cannot use --cdp and -p/--provider together",
),
(
launch_mode_flags(true, true, false, false),
"Cannot use --auto-connect and --cdp together",
),
(
launch_mode_flags(true, false, true, false),
"Cannot use --auto-connect and -p/--provider together",
),
(
launch_mode_flags(false, false, true, true),
"Cannot use --extension with -p/--provider (extensions require local browser)",
),
(
launch_mode_flags(false, true, false, true),
"Cannot use --extension with --cdp (extensions require local browser)",
),
];
for (flags, expected) in cases {
assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
}
}
#[test]
fn test_incompatible_launch_mode_error_rejects_no_auto_connect_with_auto_connect() {
let mut flags = launch_mode_flags(true, false, false, false);
flags.cli_auto_connect = true;
flags.no_auto_connect = true;
assert_eq!(
incompatible_launch_mode_error(&flags),
Some("Cannot use --auto-connect and --no-auto-connect together")
);
let mut reset = launch_mode_flags(false, false, false, false);
reset.no_auto_connect = true;
assert_eq!(incompatible_launch_mode_error(&reset), None);
}
#[test]
fn test_incompatible_launch_mode_error_rejects_webgpu_attach_modes() {
let with_webgpu = |mut flags: Flags| {
flags.webgpu = true;
flags
};
let cases = [
(
with_webgpu(launch_mode_flags(false, true, false, false)),
"Cannot use --webgpu with --cdp (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
),
(
with_webgpu(launch_mode_flags(false, false, true, false)),
"Cannot use --webgpu with -p/--provider (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
),
(
with_webgpu(launch_mode_flags(true, false, false, false)),
"Cannot use --webgpu with --auto-connect (the WebGPU preset requires a local browser launch; pass --webgpu false to override env/config)",
),
];
for (flags, expected) in cases {
assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
}
assert_eq!(
incompatible_launch_mode_error(&with_webgpu(launch_mode_flags(
false, false, false, false
))),
None
);
assert_eq!(
incompatible_launch_mode_error(&launch_mode_flags(false, true, false, false)),
None
);
}
#[test]
fn test_incompatible_launch_mode_error_rejects_ca_cert_modes() {
let with_ca = |mut flags: Flags| {
flags.ca_cert = Some("/tmp/ca.pem".to_string());
flags
};
let mut clear = with_ca(launch_mode_flags(false, false, false, false));
clear.clear_ca_cert = true;
let mut ignore_errors = with_ca(launch_mode_flags(false, false, false, false));
ignore_errors.ignore_https_errors = true;
let mut profile = with_ca(launch_mode_flags(false, false, false, false));
profile.profile = Some("/tmp/profile".to_string());
let mut lightpanda = with_ca(launch_mode_flags(false, false, false, false));
lightpanda.engine = Some("lightpanda".to_string());
let cases = [
(
with_ca(launch_mode_flags(false, true, false, false)),
"Cannot use --ca-cert with --cdp (--ca-cert requires a locally launched Chromium browser on Linux)",
),
(
with_ca(launch_mode_flags(true, false, false, false)),
"Cannot use --ca-cert with --auto-connect (--ca-cert requires a locally launched Chromium browser on Linux)",
),
(
with_ca(launch_mode_flags(false, false, true, false)),
"Cannot use --ca-cert with -p/--provider (--ca-cert requires a locally launched Chromium browser on Linux)",
),
(
ignore_errors,
"Cannot use --ca-cert with --ignore-https-errors",
),
(
profile,
"Cannot use --ca-cert with --profile because isolated CA trust would change the profile's NSS environment",
),
(
lightpanda,
"--ca-cert is supported only with the Chrome engine on Linux",
),
(clear, "Cannot use --ca-cert with --no-ca-cert"),
];
for (flags, expected) in cases {
assert_eq!(incompatible_launch_mode_error(&flags), Some(expected));
}
}
#[test]
fn test_docs_proxy_ca_config_uses_compatible_options() {
let docs = include_str!("../../docs/content/docs/configuration.mdx");
let marker = "### Behind a Proxy";
let example = docs
.split_once(marker)
.and_then(|(_, rest)| rest.split_once("```json"))
.and_then(|(_, rest)| rest.split_once("```"))
.map(|(json, _)| json.trim())
.expect("docs proxy CA configuration example");
let config: flags::Config = serde_json::from_str(example).unwrap();
let mut flags = neutral_launch_config_flags();
flags.profile = config.profile;
flags.ignore_https_errors = config.ignore_https_errors.unwrap_or(false);
flags.ca_cert = config.ca_cert;
flags.clear_ca_cert = config.clear_ca_cert.unwrap_or(false);
flags.cdp = config.cdp;
flags.auto_connect = config.auto_connect.unwrap_or(false);
flags.provider = config.provider;
flags.engine = config.engine;
let error = incompatible_launch_mode_error(&flags);
assert!(
error.is_none() || error == Some("--ca-cert browser trust is currently supported only on Linux (read, install, upgrade, and doctor accept it on every platform)"),
"docs proxy CA configuration is incompatible: {error:?}"
);
}
#[test]
fn test_incompatible_launch_mode_error_allows_compatible_flags() {
assert_eq!(
incompatible_launch_mode_error(&launch_mode_flags(true, false, false, false)),
None
);
assert_eq!(
incompatible_launch_mode_error(&launch_mode_flags(false, true, false, false)),
None
);
assert_eq!(
incompatible_launch_mode_error(&launch_mode_flags(false, false, true, false)),
None
);
}
#[test]
fn test_resolve_session_id_scope_accepts_cwd_and_rejects_unknown() {
let (scope, path) = resolve_session_id_scope("cwd").unwrap();
assert_eq!(scope, "cwd");
assert!(path.is_absolute());
assert!(resolve_session_id_scope("branch").is_err());
}
#[test]
fn test_confirmation_prompt_from_response_finds_nested_confirm_result() {
let resp = Response {
success: true,
code: None,
data: Some(json!({
"confirmed": true,
"action": "navigate",
"result": {
"id": "original-command",
"success": true,
"data": {
"confirmation_required": true,
"confirmation_id": "original-command",
"action": "plugin:stealth:launch.mutate"
}
}
})),
error: None,
warning: None,
recorded: None,
};
let prompt = confirmation_prompt_from_response(&resp).unwrap();
assert_eq!(prompt.action, "plugin:stealth:launch.mutate");
assert_eq!(prompt.description, "plugin:stealth:launch.mutate");
assert_eq!(prompt.confirmation_id, "original-command");
}
}