use crate::color;
use crate::plugins::PluginConfig;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
pub(crate) const PROJECT_CONFIG_ALLOWED_KEYS: &[&str] = &[
"headed",
"json",
"debug",
"userAgent",
"hideScrollbars",
"annotate",
"colorScheme",
"maxOutput",
"screenshotQuality",
"screenshotFormat",
"idleTimeout",
];
pub fn split_launch_args(input: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
let ends_arg = match ch {
'\n' | '\r' => true,
',' => {
let mut rest = chars.clone().skip_while(|c| *c == ' ' || *c == '\t');
rest.next() == Some('-') && rest.next() == Some('-')
}
_ => false,
};
if ends_arg {
push_launch_arg(&mut args, &mut current);
} else {
current.push(ch);
}
}
push_launch_arg(&mut args, &mut current);
args
}
fn push_launch_arg(args: &mut Vec<String>, current: &mut String) {
let arg = current.trim_matches(|c: char| c == ',' || c.is_whitespace());
if !arg.is_empty() {
args.push(arg.to_string());
}
current.clear();
}
fn split_list(s: &str) -> Vec<String> {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
}
fn parse_idle_timeout(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Empty idle timeout".to_string());
}
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
let multiplier: u64 = match unit {
"s" => 1000,
"m" => 60 * 1000,
"h" => 60 * 60 * 1000,
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
};
let ms = num
.checked_mul(multiplier)
.ok_or("Idle timeout too large")?;
return Ok(ms.to_string());
}
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
Ok(s.to_string())
}
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
value.and_then(|raw| match parse_idle_timeout(&raw) {
Ok(ms) => Some(ms),
Err(e) => {
eprintln!(
"{} invalid idle timeout from {}: {}",
color::warning_indicator(),
source,
e
);
None
}
})
}
fn parse_max_output_value(value: &str, source: &str) -> Option<usize> {
match value.parse::<usize>() {
Ok(limit) => Some(limit),
Err(_) => {
eprintln!(
"{} Invalid {} value: {}",
color::warning_indicator(),
source,
value
);
None
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Config {
pub headed: Option<bool>,
pub json: Option<bool>,
pub debug: Option<bool>,
pub session: Option<String>,
pub session_name: Option<String>,
pub restore: Option<String>,
pub restore_save: Option<String>,
pub restore_check_url: Option<String>,
pub restore_check_text: Option<String>,
pub restore_check_fn: Option<String>,
pub namespace: Option<String>,
pub executable_path: Option<String>,
pub extensions: Option<Vec<String>>,
pub init_scripts: Option<Vec<String>>,
pub enable: Option<Vec<String>>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub args: Option<String>,
pub user_agent: Option<String>,
pub provider: Option<String>,
pub device: Option<String>,
pub hide_scrollbars: Option<bool>,
pub webgpu: Option<bool>,
pub no_webmcp: Option<bool>,
pub ignore_https_errors: Option<bool>,
pub ca_cert: Option<String>,
pub clear_ca_cert: Option<bool>,
pub use_system_ca: Option<bool>,
pub allow_file_access: Option<bool>,
pub cdp: Option<String>,
pub cdp_headers: Option<String>,
pub auto_connect: Option<bool>,
pub pin_tab: Option<bool>,
pub headers: Option<String>,
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub capture: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
pub no_auto_dialog: Option<bool>,
pub model: Option<String>,
pub plugins: Option<Vec<PluginConfig>>,
}
impl Config {
fn merge(self, other: Config) -> Config {
let (ca_cert, clear_ca_cert) = if other.ca_cert.is_some() {
(other.ca_cert, Some(false))
} else {
match other.clear_ca_cert {
Some(true) => (None, Some(true)),
Some(false) => (self.ca_cert, Some(false)),
None => (self.ca_cert, self.clear_ca_cert),
}
};
Config {
headed: other.headed.or(self.headed),
json: other.json.or(self.json),
debug: other.debug.or(self.debug),
session: other.session.or(self.session),
session_name: other.session_name.or(self.session_name),
restore: other.restore.or(self.restore),
restore_save: other.restore_save.or(self.restore_save),
restore_check_url: other.restore_check_url.or(self.restore_check_url),
restore_check_text: other.restore_check_text.or(self.restore_check_text),
restore_check_fn: other.restore_check_fn.or(self.restore_check_fn),
namespace: other.namespace.or(self.namespace),
executable_path: other.executable_path.or(self.executable_path),
extensions: match (self.extensions, other.extensions) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(a, b) => b.or(a),
},
init_scripts: match (self.init_scripts, other.init_scripts) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(a, b) => b.or(a),
},
enable: match (self.enable, other.enable) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(a, b) => b.or(a),
},
profile: other.profile.or(self.profile),
state: other.state.or(self.state),
proxy: other.proxy.or(self.proxy),
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
args: other.args.or(self.args),
user_agent: other.user_agent.or(self.user_agent),
provider: other.provider.or(self.provider),
device: other.device.or(self.device),
hide_scrollbars: other.hide_scrollbars.or(self.hide_scrollbars),
webgpu: other.webgpu.or(self.webgpu),
no_webmcp: other.no_webmcp.or(self.no_webmcp),
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
ca_cert,
clear_ca_cert,
use_system_ca: other.use_system_ca.or(self.use_system_ca),
allow_file_access: other.allow_file_access.or(self.allow_file_access),
cdp: other.cdp.or(self.cdp),
cdp_headers: other.cdp_headers.or(self.cdp_headers),
auto_connect: other.auto_connect.or(self.auto_connect),
pin_tab: other.pin_tab.or(self.pin_tab),
headers: other.headers.or(self.headers),
annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme),
download_path: other.download_path.or(self.download_path),
content_boundaries: other.content_boundaries.or(self.content_boundaries),
max_output: other.max_output.or(self.max_output),
allowed_domains: other.allowed_domains.or(self.allowed_domains),
capture: other.capture.or(self.capture),
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
engine: other.engine.or(self.engine),
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
screenshot_format: other.screenshot_format.or(self.screenshot_format),
idle_timeout: other.idle_timeout.or(self.idle_timeout),
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
model: other.model.or(self.model),
plugins: match (self.plugins, other.plugins) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(a, b) => b.or(a),
},
}
}
}
fn read_config_file(path: &Path, is_auto_project_config: bool) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
let parsed = if is_auto_project_config {
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content).and_then(
|mut object| {
let mut ignored = 0;
object.retain(|key, value| {
let allowed = PROJECT_CONFIG_ALLOWED_KEYS.contains(&key.as_str());
if !allowed && key != "$schema" && !value.is_null() {
ignored += 1;
}
allowed
});
if ignored > 0 {
eprintln!(
"{} ignoring {} unsupported settings from ./{} (automatic project config is untrusted). Set required settings in ~/{}/{} or select a trusted file with --config or AGENT_BROWSER_CONFIG when starting the CLI or MCP server. An explicitly selected file replaces default config discovery.",
color::warning_indicator(),
ignored,
PROJECT_CONFIG_FILENAME,
CONFIG_DIR,
CONFIG_FILENAME
);
}
serde_json::from_value::<Config>(serde_json::Value::Object(object))
},
)
} else {
serde_json::from_str::<Config>(&content)
};
match parsed {
Ok(mut config) => {
config.idle_timeout = parse_idle_timeout_value(
config.idle_timeout.take(),
&format!("config file {}", path.display()),
);
Some(config)
}
Err(e) => {
if is_auto_project_config {
eprintln!(
"{} invalid config file ./{}: expected a JSON object with correctly typed preferences",
color::warning_indicator(),
PROJECT_CONFIG_FILENAME
);
} else {
eprintln!(
"{} invalid config file {}: {}",
color::warning_indicator(),
path.display(),
e
);
}
None
}
}
}
fn env_var_is_truthy(name: &str) -> bool {
match env::var(name) {
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
Err(_) => false,
}
}
fn env_var_bool(name: &str) -> Option<bool> {
env::var(name)
.ok()
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""))
}
fn resolve_ca_cert(
config_ca_cert: Option<String>,
config_clear_ca_cert: Option<bool>,
env_ca_cert: Option<String>,
env_clear_ca_cert: Option<bool>,
) -> (Option<String>, bool) {
if let Some(ca_cert) = env_ca_cert {
(Some(ca_cert), false)
} else if let Some(clear_ca_cert) = env_clear_ca_cert {
(
if clear_ca_cert { None } else { config_ca_cert },
clear_ca_cert,
)
} else if let Some(ca_cert) = config_ca_cert {
(Some(ca_cert), false)
} else {
(None, config_clear_ca_cert.unwrap_or(false))
}
}
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
if let Some(v) = args.get(i + 1) {
match v.as_str() {
"true" => (true, true),
"false" => (false, true),
_ => (true, false),
}
} else {
(true, false)
}
}
pub(crate) fn extract_config_path(args: &[String]) -> Option<Option<String>> {
const FLAGS_WITH_VALUE: &[&str] = &[
"--session",
"--restore-save",
"--restore-check-url",
"--restore-check-text",
"--restore-check-fn",
"--namespace",
"--headers",
"--executable-path",
"--cdp",
"--cdp-headers",
"--extension",
"--init-script",
"--enable",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
"--args",
"--user-agent",
"-p",
"--provider",
"--device",
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--capture",
"--action-policy",
"--confirm-actions",
"--engine",
"--input-mode",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
"--ca-cert",
"--model",
];
let mut i = 0;
while i < args.len() {
if args[i] == "--" {
break;
}
if args[i] == "--config" {
return Some(args.get(i + 1).cloned());
}
if FLAGS_WITH_VALUE.contains(&args[i].as_str()) {
i += 1;
}
i += 1;
}
None
}
pub fn load_config(args: &[String]) -> Result<Config, String> {
let explicit = extract_config_path(args)
.map(|p| ("--config", p))
.or_else(|| {
env::var("AGENT_BROWSER_CONFIG")
.ok()
.map(|p| ("AGENT_BROWSER_CONFIG", Some(p)))
});
if let Some((source, maybe_path)) = explicit {
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path = PathBuf::from(&path_str);
if !path.exists() {
return Err(format!("config file not found: {}", path_str));
}
return read_config_file(&path, false)
.ok_or_else(|| format!("failed to load config from {}", path_str));
}
let user_config = std::env::home_dir()
.map(|d| d.join(CONFIG_DIR).join(CONFIG_FILENAME))
.and_then(|p| read_config_file(&p, false))
.unwrap_or_default();
let project_config = read_config_file(Path::new(PROJECT_CONFIG_FILENAME), true);
Ok(match project_config {
Some(project) => user_config.merge(project),
None => user_config,
})
}
pub struct Flags {
pub json: bool,
pub headed: bool,
pub debug: bool,
pub session: String,
pub session_source: &'static str,
pub restore: Option<String>,
pub restore_save: Option<String>,
pub restore_check_url: Option<String>,
pub restore_check_text: Option<String>,
pub restore_check_fn: Option<String>,
pub namespace: Option<String>,
pub restore_uses_session: bool,
pub headers: Option<String>,
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub cdp_headers: Option<String>,
pub extensions: Vec<String>,
pub init_scripts: Vec<String>,
pub enable: Vec<String>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
pub args: Option<String>,
pub user_agent: Option<String>,
pub provider: Option<String>,
pub ignore_https_errors: bool,
pub ca_cert: Option<String>,
pub clear_ca_cert: bool,
pub use_system_ca: bool,
pub allow_file_access: bool,
pub hide_scrollbars: bool,
pub webgpu: bool,
pub no_webmcp: bool,
pub no_xvfb: bool,
pub no_xvfb_set: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub pin_tab: bool,
pub session_name: Option<String>,
pub annotate: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub capture: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
pub default_timeout: Option<u64>,
pub no_auto_dialog: bool,
pub relaunch: bool,
pub model: Option<String>,
pub plugins: Vec<PluginConfig>,
pub verbose: bool,
pub quiet: bool,
pub input_mode: String,
pub cli_executable_path: bool,
pub cli_extensions: bool,
pub cli_init_scripts: bool,
pub cli_enable: bool,
pub cli_profile: bool,
pub cli_state: bool,
pub cli_args: bool,
pub cli_user_agent: bool,
pub cli_proxy: bool,
pub cli_proxy_bypass: bool,
pub cli_ca_cert: bool,
pub cli_use_system_ca: bool,
pub cli_allow_file_access: bool,
pub cli_hide_scrollbars: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_headed: bool,
pub cli_webgpu: bool,
pub cli_no_webmcp: bool,
pub cli_auto_connect: bool,
pub no_auto_connect: bool,
pub cli_restore: bool,
pub cli_pin_tab: bool,
pub cli_input_mode: bool,
}
pub fn parse_flags(args: &[String]) -> Flags {
try_parse_flags(args).unwrap_or_else(|e| {
eprintln!("{} {}", color::warning_indicator(), e);
std::process::exit(1);
})
}
pub fn try_parse_flags(args: &[String]) -> Result<Flags, String> {
let config = load_config(args)?;
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
.ok()
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let extensions = if !extensions_env.is_empty() {
extensions_env
} else {
config.extensions.unwrap_or_default()
};
let init_scripts_env = env::var("AGENT_BROWSER_INIT_SCRIPTS")
.ok()
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let init_scripts = if !init_scripts_env.is_empty() {
init_scripts_env
} else {
config.init_scripts.unwrap_or_default()
};
let enable_env = env::var("AGENT_BROWSER_ENABLE")
.ok()
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let enable = if !enable_env.is_empty() {
enable_env
} else {
config.enable.unwrap_or_default()
};
let plugins = env::var("AGENT_BROWSER_PLUGINS")
.ok()
.and_then(
|raw| match serde_json::from_str::<Vec<PluginConfig>>(&raw) {
Ok(plugins) => Some(plugins),
Err(e) => {
eprintln!(
"{} invalid AGENT_BROWSER_PLUGINS value: {}",
color::warning_indicator(),
e
);
None
}
},
)
.unwrap_or_else(|| config.plugins.unwrap_or_default());
let (ca_cert, clear_ca_cert) = resolve_ca_cert(
config.ca_cert,
config.clear_ca_cert,
env::var("AGENT_BROWSER_CA_CERT").ok(),
env_var_bool("AGENT_BROWSER_CLEAR_CA_CERT"),
);
let session_env = env::var("AGENT_BROWSER_SESSION").ok();
let session_source = if session_env.is_some() {
"AGENT_BROWSER_SESSION"
} else if config.session.is_some() {
"config session"
} else {
"default"
};
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: session_env
.or(config.session)
.unwrap_or_else(|| "default".to_string()),
session_source,
restore: env::var("AGENT_BROWSER_RESTORE").ok().or(config.restore),
restore_save: env::var("AGENT_BROWSER_RESTORE_SAVE")
.ok()
.or(config.restore_save),
restore_check_url: env::var("AGENT_BROWSER_RESTORE_CHECK_URL")
.ok()
.or(config.restore_check_url),
restore_check_text: env::var("AGENT_BROWSER_RESTORE_CHECK_TEXT")
.ok()
.or(config.restore_check_text),
restore_check_fn: env::var("AGENT_BROWSER_RESTORE_CHECK_FN")
.ok()
.or(config.restore_check_fn),
namespace: env::var("AGENT_BROWSER_NAMESPACE")
.ok()
.or(config.namespace),
restore_uses_session: false,
headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
.ok()
.or(config.executable_path),
cdp: env::var("AGENT_BROWSER_CDP").ok().or(config.cdp),
cdp_headers: env::var("AGENT_BROWSER_CDP_HEADERS")
.ok()
.or(config.cdp_headers),
extensions,
init_scripts,
enable,
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY")
.ok()
.or(config.proxy)
.or_else(|| env::var("HTTP_PROXY").ok())
.or_else(|| env::var("http_proxy").ok())
.or_else(|| env::var("HTTPS_PROXY").ok())
.or_else(|| env::var("https_proxy").ok())
.or_else(|| env::var("ALL_PROXY").ok())
.or_else(|| env::var("all_proxy").ok()),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
.ok()
.or(config.proxy_bypass)
.or_else(|| env::var("NO_PROXY").ok())
.or_else(|| env::var("no_proxy").ok()),
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
.ok()
.or(config.user_agent),
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|| config.ignore_https_errors.unwrap_or(false),
ca_cert,
clear_ca_cert,
use_system_ca: env_var_bool("AGENT_BROWSER_USE_SYSTEM_CA")
.or(config.use_system_ca)
.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false),
hide_scrollbars: env_var_bool("AGENT_BROWSER_HIDE_SCROLLBARS")
.or(config.hide_scrollbars)
.unwrap_or(true),
webgpu: env_var_is_truthy("AGENT_BROWSER_WEBGPU") || config.webgpu.unwrap_or(false),
no_webmcp: env_var_is_truthy("AGENT_BROWSER_NO_WEBMCP")
|| config.no_webmcp.unwrap_or(false),
no_xvfb: env_var_is_truthy("AGENT_BROWSER_NO_XVFB"),
no_xvfb_set: env::var_os("AGENT_BROWSER_NO_XVFB").is_some(),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false),
pin_tab: env_var_is_truthy("AGENT_BROWSER_PIN_TAB") || config.pin_tab.unwrap_or(false),
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
.ok()
.or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
.ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
.ok()
.and_then(|value| parse_max_output_value(&value, "AGENT_BROWSER_MAX_OUTPUT"))
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
.ok()
.map(|s| {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
})
.or(config.allowed_domains),
capture: env::var("AGENT_BROWSER_CAPTURE")
.ok()
.map(|s| split_list(&s))
.or(config.capture),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
.ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
.ok()
.or(config.screenshot_dir),
screenshot_quality: env::var("AGENT_BROWSER_SCREENSHOT_QUALITY")
.ok()
.and_then(|s| s.parse().ok())
.or(config.screenshot_quality),
screenshot_format: env::var("AGENT_BROWSER_SCREENSHOT_FORMAT")
.ok()
.or(config.screenshot_format)
.filter(|s| s == "png" || s == "jpeg"),
idle_timeout: parse_idle_timeout_value(
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
)
.or(config.idle_timeout),
default_timeout: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
.ok()
.and_then(|s| s.parse::<u64>().ok()),
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|| config.no_auto_dialog.unwrap_or(false),
relaunch: false,
model: env::var("AI_GATEWAY_MODEL").ok().or(config.model),
plugins,
verbose: false,
quiet: false,
input_mode: "instant".to_string(),
cli_executable_path: false,
cli_extensions: false,
cli_init_scripts: false,
cli_enable: false,
cli_profile: false,
cli_state: false,
cli_args: false,
cli_user_agent: false,
cli_proxy: false,
cli_proxy_bypass: false,
cli_ca_cert: false,
cli_use_system_ca: false,
cli_allow_file_access: false,
cli_hide_scrollbars: false,
cli_annotate: false,
cli_download_path: false,
cli_headed: false,
cli_webgpu: false,
cli_no_webmcp: false,
cli_auto_connect: false,
no_auto_connect: false,
cli_restore: false,
cli_pin_tab: false,
cli_input_mode: false,
};
let mut i = 0;
let mut seen_command = false;
let mut wait_command_seen = false;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--" {
break;
}
if !arg.starts_with('-') && looks_like_command(arg) {
if !seen_command {
wait_command_seen = arg == "wait";
}
seen_command = true;
}
match arg {
s if s.starts_with("--restore=") => {
flags.cli_restore = true;
let value = s.trim_start_matches("--restore=");
if value.is_empty() {
flags.restore_uses_session = true;
} else {
flags.restore = Some(value.to_string());
flags.restore_uses_session = false;
}
}
"--json" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.json = val;
if consumed {
i += 1;
}
}
"--headed" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val;
flags.cli_headed = true;
if consumed {
i += 1;
}
}
"--webgpu" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.webgpu = val;
flags.cli_webgpu = true;
if consumed {
i += 1;
}
}
"--no-webmcp" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.no_webmcp = val;
flags.cli_no_webmcp = true;
if consumed {
i += 1;
}
}
"--debug" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.debug = val;
if consumed {
i += 1;
}
}
"--session" => {
if let Some(s) = args.get(i + 1) {
flags.session = s.clone();
flags.session_source = "--session";
i += 1;
}
}
"--restore" => {
flags.cli_restore = true;
let next = args.get(i + 1);
if seen_command {
flags.restore_uses_session = true;
} else if let Some(s) = next {
if !s.starts_with('-') && !looks_like_command(s) {
flags.restore = Some(s.clone());
flags.restore_uses_session = false;
i += 1;
} else {
flags.restore_uses_session = true;
}
} else {
flags.restore_uses_session = true;
}
}
"--restore-save" => {
if let Some(s) = args.get(i + 1) {
flags.restore_save = Some(s.clone());
i += 1;
}
}
"--restore-check-url" => {
if let Some(s) = args.get(i + 1) {
flags.restore_check_url = Some(s.clone());
i += 1;
}
}
"--restore-check-text" => {
if let Some(s) = args.get(i + 1) {
flags.restore_check_text = Some(s.clone());
i += 1;
}
}
"--restore-check-fn" => {
if let Some(s) = args.get(i + 1) {
flags.restore_check_fn = Some(s.clone());
i += 1;
}
}
"--namespace" => {
if let Some(s) = args.get(i + 1) {
flags.namespace = Some(s.clone());
i += 1;
}
}
"--idle-timeout" => {
if let Some(s) = args.get(i + 1) {
match parse_idle_timeout(s) {
Ok(ms) => flags.idle_timeout = Some(ms),
Err(e) => eprintln!(
"{} Invalid --idle-timeout: {}",
color::warning_indicator(),
e
),
}
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
i += 1;
}
}
"--executable-path" => {
if let Some(s) = args.get(i + 1) {
flags.executable_path = Some(s.clone());
flags.cli_executable_path = true;
i += 1;
}
}
"--extension" => {
if let Some(s) = args.get(i + 1) {
flags.extensions.push(s.clone());
flags.cli_extensions = true;
i += 1;
}
}
"--init-script" => {
if let Some(s) = args.get(i + 1) {
flags.init_scripts.push(s.clone());
flags.cli_init_scripts = true;
i += 1;
}
}
"--enable" => {
if let Some(s) = args.get(i + 1) {
for item in s.split(',') {
let trimmed = item.trim();
if !trimmed.is_empty() {
flags.enable.push(trimmed.to_string());
}
}
flags.cli_enable = true;
i += 1;
}
}
"--cdp" => {
if let Some(s) = args.get(i + 1) {
flags.cdp = Some(s.clone());
i += 1;
}
}
"--cdp-headers" => {
if let Some(h) = args.get(i + 1) {
flags.cdp_headers = Some(h.clone());
i += 1;
}
}
"--profile" => {
if let Some(s) = args.get(i + 1) {
flags.profile = Some(s.clone());
flags.cli_profile = true;
i += 1;
}
}
"--state" => {
if !wait_command_seen {
if let Some(s) = args.get(i + 1) {
flags.state = Some(s.clone());
flags.cli_state = true;
i += 1;
}
}
}
"--proxy" => {
if let Some(p) = args.get(i + 1) {
flags.proxy = Some(p.clone());
flags.cli_proxy = true;
i += 1;
}
}
"--proxy-bypass" => {
if let Some(s) = args.get(i + 1) {
flags.proxy_bypass = Some(s.clone());
flags.cli_proxy_bypass = true;
i += 1;
}
}
"--args" => {
if let Some(s) = args.get(i + 1) {
flags.args = Some(s.clone());
flags.cli_args = true;
i += 1;
}
}
"--user-agent" => {
if let Some(s) = args.get(i + 1) {
flags.user_agent = Some(s.clone());
flags.cli_user_agent = true;
i += 1;
}
}
"-p" | "--provider" => {
if let Some(p) = args.get(i + 1) {
flags.provider = Some(p.clone());
i += 1;
}
}
"--ignore-https-errors" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.ignore_https_errors = val;
if consumed {
i += 1;
}
}
"--ca-cert" => {
if let Some(s) = args.get(i + 1) {
flags.ca_cert = Some(s.clone());
flags.clear_ca_cert = false;
flags.cli_ca_cert = true;
i += 1;
}
}
"--no-ca-cert" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.clear_ca_cert = val;
if val {
flags.ca_cert = None;
}
flags.cli_ca_cert = true;
if consumed {
i += 1;
}
}
"--use-system-ca" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.use_system_ca = val;
flags.cli_use_system_ca = true;
if consumed {
i += 1;
}
}
"--allow-file-access" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.allow_file_access = val;
flags.cli_allow_file_access = true;
if consumed {
i += 1;
}
}
"--hide-scrollbars" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.hide_scrollbars = val;
flags.cli_hide_scrollbars = true;
if consumed {
i += 1;
}
}
"--device" => {
if let Some(d) = args.get(i + 1) {
flags.device = Some(d.clone());
i += 1;
}
}
"--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val;
if val {
flags.cli_auto_connect = true;
} else {
flags.no_auto_connect = true;
}
if consumed {
i += 1;
}
}
"--no-auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i);
if val {
flags.auto_connect = false;
flags.no_auto_connect = true;
}
if consumed {
i += 1;
}
}
"--pin-tab" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.pin_tab = val;
flags.cli_pin_tab = true;
if consumed {
i += 1;
}
}
"--no-pin-tab" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.pin_tab = !val;
flags.cli_pin_tab = true;
if consumed {
i += 1;
}
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone());
i += 1;
}
}
"--annotate" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.annotate = val;
flags.cli_annotate = true;
if consumed {
i += 1;
}
}
"--color-scheme" => {
if let Some(s) = args.get(i + 1) {
flags.color_scheme = Some(s.clone());
i += 1;
}
}
"--download-path" => {
if let Some(s) = args.get(i + 1) {
flags.download_path = Some(s.clone());
flags.cli_download_path = true;
i += 1;
}
}
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed {
i += 1;
}
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
if let Some(n) = parse_max_output_value(s, "--max-output") {
flags.max_output = Some(n);
}
i += 1;
}
}
"--allowed-domains" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect(),
);
i += 1;
}
}
"--capture" => {
if let Some(s) = args.get(i + 1) {
flags.capture = Some(split_list(s));
i += 1;
}
}
"--action-policy" => {
if let Some(s) = args.get(i + 1) {
flags.action_policy = Some(s.clone());
i += 1;
}
}
"--confirm-actions" => {
if let Some(s) = args.get(i + 1) {
flags.confirm_actions = Some(s.clone());
i += 1;
}
}
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed {
i += 1;
}
}
"--engine" => {
if let Some(s) = args.get(i + 1) {
flags.engine = Some(s.clone());
i += 1;
}
}
"--input-mode" => {
if let Some(s) = args.get(i + 1) {
if matches!(s.as_str(), "instant" | "smooth" | "human") {
flags.input_mode = s.clone();
flags.cli_input_mode = true;
} else {
eprintln!(
"{} --input-mode must be instant, smooth, or human, got '{}'",
color::warning_indicator(),
s
);
}
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
i += 1;
}
}
"--screenshot-quality" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<u32>() {
if n <= 100 {
flags.screenshot_quality = Some(n);
} else {
eprintln!(
"{} --screenshot-quality must be 0-100, got {}",
color::warning_indicator(),
n
);
}
}
i += 1;
}
}
"--screenshot-format" => {
if let Some(s) = args.get(i + 1) {
if s == "png" || s == "jpeg" {
flags.screenshot_format = Some(s.clone());
} else {
eprintln!(
"{} --screenshot-format must be png or jpeg, got '{}'",
color::warning_indicator(),
s
);
}
i += 1;
}
}
"--no-auto-dialog" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.no_auto_dialog = val;
if consumed {
i += 1;
}
}
"--relaunch" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.relaunch = val;
if consumed {
i += 1;
}
}
"--model" => {
if let Some(s) = args.get(i + 1) {
flags.model = Some(s.clone());
i += 1;
}
}
"-v" | "--verbose" => {
flags.verbose = true;
}
"-q" | "--quiet" => {
flags.quiet = true;
}
"--config" => {
i += 1;
}
_ => {}
}
i += 1;
}
if let (Some(path), Ok(cwd)) = (flags.action_policy.as_mut(), env::current_dir()) {
*path = absolute_policy_path(path, &cwd).map_err(|e| format!("--action-policy: {}", e))?;
}
Ok(flags)
}
pub(crate) fn absolute_policy_path(path: &str, cwd: &std::path::Path) -> Result<String, String> {
if path.trim().is_empty() {
return Ok(path.to_string());
}
let joined = cwd.join(path);
joined.to_str().map(str::to_string).ok_or_else(|| {
format!(
"the policy path {} must be valid UTF-8; pass an absolute path to a file whose directories are valid UTF-8",
joined.display()
)
})
}
fn looks_like_command(value: &str) -> bool {
crate::commands::is_top_level_command(value)
}
pub fn before_end_of_options(args: &[String]) -> &[String] {
&args[..args
.iter()
.position(|arg| arg == "--")
.unwrap_or(args.len())]
}
pub fn clean_args(args: &[String]) -> Vec<String> {
let mut result = Vec::new();
let mut skip_next = false;
const GLOBAL_BOOL_FLAGS: &[&str] = &[
"--json",
"--headed",
"--webgpu",
"--no-webmcp",
"--debug",
"--ignore-https-errors",
"--allow-file-access",
"--hide-scrollbars",
"--auto-connect",
"--no-auto-connect",
"--pin-tab",
"--no-pin-tab",
"--no-ca-cert",
"--use-system-ca",
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--no-auto-dialog",
"--relaunch",
"-v",
"--verbose",
"-q",
"--quiet",
"--offline",
"--quick",
"--fix",
];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
"--session",
"--restore-save",
"--restore-check-url",
"--restore-check-text",
"--restore-check-fn",
"--namespace",
"--headers",
"--executable-path",
"--cdp",
"--cdp-headers",
"--extension",
"--init-script",
"--enable",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
"--args",
"--user-agent",
"-p",
"--provider",
"--device",
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--capture",
"--action-policy",
"--confirm-actions",
"--config",
"--engine",
"--input-mode",
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
"--ca-cert",
"--model",
];
let mut i = 0;
let mut seen_command = false;
let mut wait_command_seen = false;
while i < args.len() {
let arg = &args[i];
if skip_next {
skip_next = false;
i += 1;
continue;
}
if arg == "--" {
result.extend_from_slice(&args[i..]);
break;
}
if arg.starts_with("--restore=") {
i += 1;
continue;
}
if arg == "--state" && wait_command_seen {
result.push(arg.clone());
i += 1;
continue;
}
if GLOBAL_FLAGS_WITH_VALUE.contains(&arg.as_str()) {
skip_next = true;
i += 1;
continue;
}
if arg == "--restore" {
if !seen_command {
if let Some(v) = args.get(i + 1) {
if !v.starts_with('-') && !looks_like_command(v) {
i += 1;
}
}
}
i += 1;
continue;
}
if GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) {
if let Some(v) = args.get(i + 1) {
if matches!(v.as_str(), "true" | "false") {
i += 1;
}
}
i += 1;
continue;
}
if !arg.starts_with('-') && looks_like_command(arg) {
if !seen_command {
wait_command_seen = arg == "wait";
}
seen_command = true;
}
result.push(arg.clone());
i += 1;
}
result
}
pub fn is_global_flag(arg: &str) -> bool {
matches!(arg, "--help" | "-h" | "--version" | "-V") || clean_args(&[arg.to_string()]).is_empty()
}
#[cfg(test)]
mod tests {
#[test]
fn test_policy_path_is_made_absolute_against_the_given_directory() {
use std::path::{Path, PathBuf};
let (a, b) = (
std::env::temp_dir().join("a"),
std::env::temp_dir().join("b"),
);
let abs =
|path: &str, cwd: &Path| PathBuf::from(super::absolute_policy_path(path, cwd).unwrap());
assert_eq!(abs("policy.json", &a), a.join("policy.json"));
assert_eq!(abs("policy.json", &b), b.join("policy.json"));
let absolute = std::env::temp_dir().join("abs").join("p.json");
assert_eq!(abs(absolute.to_str().unwrap(), &a), absolute);
assert_eq!(super::absolute_policy_path("", &a).unwrap(), "");
assert_eq!(super::absolute_policy_path(" ", &a).unwrap(), " ");
}
#[cfg(unix)]
#[test]
fn test_policy_path_in_a_non_utf8_directory_is_an_error() {
use std::os::unix::ffi::OsStrExt;
let cwd = std::path::Path::new(std::ffi::OsStr::from_bytes(b"/tmp/\xff\xfe"));
let err = super::absolute_policy_path("policy.json", cwd).unwrap_err();
assert!(err.contains("valid UTF-8"), "{err}");
assert!(err.contains("absolute path"), "{err}");
assert_eq!(
super::absolute_policy_path("/ok/p.json", cwd).unwrap(),
"/ok/p.json"
);
}
#[test]
fn test_parsed_action_policy_flag_is_absolute() {
let args: Vec<String> = ["--action-policy", "rel/policy.json", "open"]
.into_iter()
.map(str::to_string)
.collect();
let flags = parse_flags(&args);
let policy = flags.action_policy.unwrap();
assert!(std::path::Path::new(&policy).is_absolute(), "{policy}");
assert!(
std::path::Path::new(&policy)
.ends_with(std::path::Path::new("rel").join("policy.json")),
"{policy}"
);
}
use super::*;
use crate::test_utils::EnvGuard;
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
}
#[test]
fn test_split_launch_args_splits_flags_on_commas() {
assert_eq!(
split_launch_args("--no-sandbox,--disable-gpu"),
vec!["--no-sandbox", "--disable-gpu"]
);
}
#[test]
fn test_split_launch_args_keeps_commas_inside_values() {
assert_eq!(
split_launch_args("--disable-features=HttpsUpgrades,HttpsFirstModeV2,Translate"),
vec!["--disable-features=HttpsUpgrades,HttpsFirstModeV2,Translate"]
);
assert_eq!(
split_launch_args("--window-size=1280,720,--no-sandbox"),
vec!["--window-size=1280,720", "--no-sandbox"]
);
}
#[test]
fn test_split_launch_args_keeps_negative_numbers_inside_values() {
assert_eq!(
split_launch_args("--window-position=-32000,-32000"),
vec!["--window-position=-32000,-32000"]
);
assert_eq!(
split_launch_args("--window-position=-30, -40,--no-sandbox"),
vec!["--window-position=-30, -40", "--no-sandbox"]
);
}
#[test]
fn test_split_launch_args_mixed_values_and_flags() {
assert_eq!(
split_launch_args("--disable-features=A,B,C, --no-sandbox"),
vec!["--disable-features=A,B,C", "--no-sandbox"]
);
}
#[test]
fn test_split_launch_args_newlines_always_split() {
assert_eq!(
split_launch_args("--disable-features=A,B\r\n--enable-features=C,D\nplain"),
vec!["--disable-features=A,B", "--enable-features=C,D", "plain"]
);
}
#[test]
fn test_split_launch_args_ignores_empty_and_trailing_separators() {
assert_eq!(
split_launch_args(" --no-sandbox , --disable-gpu, \n\n"),
vec!["--no-sandbox", "--disable-gpu"]
);
assert!(split_launch_args(" , \n").is_empty());
}
#[test]
fn test_parse_headers_flag() {
let flags = parse_flags(&args(r#"open example.com --headers {"Auth":"token"}"#));
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
}
#[test]
fn test_wait_state_is_not_parsed_as_global_state() {
for input in [
"wait #spinner --state hidden",
"wait --state hidden #spinner",
] {
let flags = parse_flags(&args(input));
assert_eq!(flags.state, None);
assert!(!flags.cli_state);
}
}
#[test]
fn test_global_state_before_wait_and_on_other_commands_is_preserved() {
let before_wait = parse_flags(&args("--state /tmp/auth.json wait #spinner"));
assert_eq!(before_wait.state.as_deref(), Some("/tmp/auth.json"));
assert!(before_wait.cli_state);
let other_command = parse_flags(&args("open example.com --state /tmp/auth.json"));
assert_eq!(other_command.state.as_deref(), Some("/tmp/auth.json"));
assert!(other_command.cli_state);
}
#[test]
fn test_clean_args_keeps_wait_state_and_strips_global_state() {
for input in [
"wait #spinner --state hidden",
"wait --state hidden #spinner",
] {
assert_eq!(clean_args(&args(input)), args(input));
}
assert_eq!(
clean_args(&args("--state /tmp/auth.json wait #spinner")),
args("wait #spinner")
);
assert_eq!(
clean_args(&args("open example.com --state /tmp/auth.json")),
args("open example.com")
);
}
#[test]
fn test_parse_cdp_headers_flag() {
let flags = parse_flags(&[
"snapshot".to_string(),
"--cdp".to_string(),
"wss://example.com/cdp".to_string(),
"--cdp-headers".to_string(),
r#"{"Authorization":"Bearer t"}"#.to_string(),
]);
assert_eq!(flags.cdp.as_deref(), Some("wss://example.com/cdp"));
assert_eq!(
flags.cdp_headers.as_deref(),
Some(r#"{"Authorization":"Bearer t"}"#)
);
}
#[test]
fn test_parse_idle_timeout_raw_ms() {
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
}
#[test]
fn test_parse_idle_timeout_seconds() {
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
}
#[test]
fn test_parse_idle_timeout_minutes() {
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
}
#[test]
fn test_parse_idle_timeout_hours() {
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
}
#[test]
fn test_parse_idle_timeout_rejects_overflow_and_accepts_boundary() {
assert!(parse_idle_timeout("2305843009213693957s").is_err());
assert!(parse_idle_timeout(&format!("{}m", u64::MAX)).is_err());
assert!(parse_idle_timeout("144115188075855872h").is_err());
let max_seconds = u64::MAX / 1000;
assert_eq!(
parse_idle_timeout(&format!("{}s", max_seconds)).unwrap(),
(max_seconds * 1000).to_string()
);
}
#[test]
fn test_parse_idle_timeout_rejects_capital_m() {
assert!(parse_idle_timeout("10M").is_err());
}
#[test]
fn test_parse_idle_timeout_rejects_unknown_unit() {
assert!(parse_idle_timeout("10x").is_err());
}
#[test]
fn test_parse_headers_flag_with_spaces() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Authorization": "Bearer token"}"#.to_string(),
];
let flags = parse_flags(&input);
assert_eq!(
flags.headers,
Some(r#"{"Authorization": "Bearer token"}"#.to_string())
);
}
#[test]
fn test_parse_no_headers_flag() {
let flags = parse_flags(&args("open example.com"));
assert!(flags.headers.is_none());
}
#[test]
fn test_clean_args_removes_headers() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_headers_at_start() {
let input: Vec<String> = vec![
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
"open".to_string(),
"example.com".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_headers_with_other_flags() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--headers".to_string(),
r#"{"Auth":"token"}"#.to_string(),
"--json".to_string(),
"--headed".to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
assert!(flags.json);
assert!(flags.headed);
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_parse_restore_without_value_uses_session() {
let input = args("--session next-loop --restore open http://localhost:3000");
let flags = parse_flags(&input);
assert_eq!(flags.session, "next-loop");
assert!(flags.restore_uses_session);
assert!(flags.restore.is_none());
assert_eq!(clean_args(&input), vec!["open", "http://localhost:3000"]);
}
#[test]
fn test_parse_restore_with_explicit_key() {
let input = args("--restore login-state open http://localhost:3000");
let flags = parse_flags(&input);
assert_eq!(flags.restore.as_deref(), Some("login-state"));
assert!(!flags.restore_uses_session);
assert_eq!(clean_args(&input), vec!["open", "http://localhost:3000"]);
}
#[test]
fn test_parse_restore_after_command_does_not_consume_url() {
let input = args("--session next-loop open --restore http://localhost:3000");
let flags = parse_flags(&input);
assert_eq!(flags.session, "next-loop");
assert!(flags.restore_uses_session);
assert!(flags.restore.is_none());
assert_eq!(clean_args(&input), vec!["open", "http://localhost:3000"]);
}
#[test]
fn test_parse_restore_equals_with_explicit_key_after_command() {
let input = args("open --restore=login-state http://localhost:3000");
let flags = parse_flags(&input);
assert_eq!(flags.restore.as_deref(), Some("login-state"));
assert!(!flags.restore_uses_session);
assert_eq!(clean_args(&input), vec!["open", "http://localhost:3000"]);
}
#[test]
fn test_parse_bare_restore_before_auth_command_uses_session() {
let input = args("--session next-loop --restore auth list");
let flags = parse_flags(&input);
assert_eq!(flags.session, "next-loop");
assert!(flags.restore_uses_session);
assert!(flags.restore.is_none());
assert_eq!(clean_args(&input), vec!["auth", "list"]);
}
#[test]
fn test_parse_bare_restore_before_hover_command_uses_session() {
let input = args("--session next-loop --restore hover button");
let flags = parse_flags(&input);
assert_eq!(flags.session, "next-loop");
assert!(flags.restore_uses_session);
assert!(flags.restore.is_none());
assert_eq!(clean_args(&input), vec!["hover", "button"]);
}
#[test]
fn test_parse_restore_policy_checks_and_namespace() {
let input = args(
"--namespace work-a --restore-save never --restore-check-url **/dashboard --restore-check-text Dashboard --restore-check-fn window.ok open",
);
let flags = parse_flags(&input);
assert_eq!(flags.namespace.as_deref(), Some("work-a"));
assert_eq!(flags.restore_save.as_deref(), Some("never"));
assert_eq!(flags.restore_check_url.as_deref(), Some("**/dashboard"));
assert_eq!(flags.restore_check_text.as_deref(), Some("Dashboard"));
assert_eq!(flags.restore_check_fn.as_deref(), Some("window.ok"));
assert_eq!(clean_args(&input), vec!["open"]);
}
#[test]
fn test_parse_executable_path_flag() {
let flags = parse_flags(&args(
"--executable-path /path/to/chromium open example.com",
));
assert_eq!(flags.executable_path, Some("/path/to/chromium".to_string()));
}
#[test]
fn test_parse_executable_path_flag_no_value() {
let flags = parse_flags(&args("--executable-path"));
assert_eq!(flags.executable_path, None);
}
#[test]
fn test_clean_args_removes_executable_path() {
let cleaned = clean_args(&args(
"--executable-path /path/to/chromium open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_executable_path_with_other_flags() {
let cleaned = clean_args(&args(
"--json --executable-path /path/to/chromium --headed open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_idle_timeout_before_command() {
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_keeps_record_fps() {
let cleaned = clean_args(&args("--json record start demo.webm --fps 60"));
assert_eq!(cleaned, vec!["record", "start", "demo.webm", "--fps", "60"]);
}
#[test]
fn test_parse_idle_timeout_flag_converts_to_ms() {
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args(
"--session test --executable-path /custom/chrome open example.com",
));
assert_eq!(flags.session, "test");
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
}
#[test]
fn test_cli_executable_path_tracking() {
let flags = parse_flags(&args("--executable-path /path/to/chrome snapshot"));
assert!(flags.cli_executable_path);
assert_eq!(flags.executable_path, Some("/path/to/chrome".to_string()));
}
#[test]
fn test_cli_executable_path_not_set_without_flag() {
let flags = parse_flags(&args("snapshot"));
assert!(!flags.cli_executable_path);
}
#[test]
fn test_cli_extension_tracking() {
let flags = parse_flags(&args("--extension /path/to/ext snapshot"));
assert!(flags.cli_extensions);
}
#[test]
fn test_cli_profile_tracking() {
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
assert!(flags.cli_profile);
}
#[test]
fn test_cli_annotate_tracking() {
let flags = parse_flags(&args("--annotate screenshot"));
assert!(flags.cli_annotate);
assert!(flags.annotate);
}
#[test]
fn test_cli_annotate_not_set_without_flag() {
let flags = parse_flags(&args("screenshot"));
assert!(!flags.cli_annotate);
}
#[test]
fn test_cli_download_path_tracking() {
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
assert!(flags.cli_download_path);
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
}
#[test]
fn test_cli_download_path_not_set_without_flag() {
let flags = parse_flags(&args("snapshot"));
assert!(!flags.cli_download_path);
}
#[test]
fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args(
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
));
assert!(flags.cli_executable_path);
assert!(flags.cli_profile);
assert!(flags.cli_proxy);
assert!(!flags.cli_extensions);
assert!(!flags.cli_state);
}
#[test]
fn test_config_deserialize_full() {
let json = r#"{
"headed": true,
"json": true,
"debug": true,
"session": "test-session",
"sessionName": "my-app",
"executablePath": "/usr/bin/chromium",
"extensions": ["/ext1", "/ext2"],
"profile": "/tmp/profile",
"state": "/tmp/state.json",
"proxy": "http://proxy:8080",
"proxyBypass": "localhost",
"args": "--no-sandbox",
"userAgent": "test-agent",
"provider": "ios",
"device": "iPhone 15",
"hideScrollbars": false,
"ignoreHttpsErrors": true,
"allowFileAccess": true,
"cdp": "9222",
"autoConnect": true,
"headers": "{\"Auth\":\"token\"}",
"plugins": [
{
"name": "onepassword",
"command": "agent-browser-plugin-1password",
"args": ["--account", "team"],
"capabilities": ["credential.read"]
}
]
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.json, Some(true));
assert_eq!(config.debug, Some(true));
assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app"));
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
assert_eq!(
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
assert_eq!(config.args.as_deref(), Some("--no-sandbox"));
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
assert_eq!(config.provider.as_deref(), Some("ios"));
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
assert_eq!(config.hide_scrollbars, Some(false));
assert_eq!(config.ignore_https_errors, Some(true));
assert_eq!(config.allow_file_access, Some(true));
assert_eq!(config.cdp.as_deref(), Some("9222"));
assert_eq!(config.auto_connect, Some(true));
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
let plugin = &config.plugins.as_ref().unwrap()[0];
assert_eq!(plugin.name, "onepassword");
assert_eq!(plugin.command, "agent-browser-plugin-1password");
assert_eq!(
plugin.args,
vec!["--account".to_string(), "team".to_string()]
);
assert_eq!(plugin.capabilities, vec!["credential.read".to_string()]);
}
#[test]
fn test_config_deserialize_partial() {
let json = r#"{"headed": true, "proxy": "http://localhost:8080"}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.proxy.as_deref(), Some("http://localhost:8080"));
assert_eq!(config.session, None);
assert_eq!(config.extensions, None);
assert_eq!(config.debug, None);
}
#[test]
fn test_config_deserialize_empty() {
let config: Config = serde_json::from_str("{}").unwrap();
assert_eq!(config.headed, None);
assert_eq!(config.session, None);
assert_eq!(config.proxy, None);
}
#[test]
fn test_config_ignores_unknown_keys() {
let json = r#"{"headed": true, "unknownFutureKey": "value", "anotherOne": 42}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
}
#[test]
fn test_config_merge_overrides_values() {
let user = Config {
headed: Some(true),
proxy: Some("http://user-proxy:8080".to_string()),
profile: Some("/user/profile".to_string()),
..Config::default()
};
let project = Config {
proxy: Some("http://project-proxy:9090".to_string()),
debug: Some(true),
..Config::default()
};
let merged = user.merge(project);
assert_eq!(merged.headed, Some(true));
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090"));
assert_eq!(merged.profile.as_deref(), Some("/user/profile"));
assert_eq!(merged.debug, Some(true));
}
#[test]
fn test_project_config_only_applies_allowed_preferences() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(PROJECT_CONFIG_FILENAME);
fs::write(
&config_path,
r#"{
"$schema": "https://example.com/config.schema.json",
"headed": false,
"json": true,
"debug": true,
"userAgent": "test-agent",
"hideScrollbars": false,
"annotate": true,
"colorScheme": "dark",
"maxOutput": 500,
"screenshotQuality": 80,
"screenshotFormat": "jpeg",
"idleTimeout": "10s",
"session": "test-session",
"executablePath": "/test/chrome",
"args": "test-argument",
"plugins": [{"name": "test", "command": "test-plugin"}],
"extensions": ["/test/extension"],
"profile": "/test/profile",
"proxy": "http://test:1234",
"allowedDomains": ["example.com"],
"clearCaCert": true,
"unknownFutureKey": "value"
}"#,
)
.unwrap();
let project = read_config_file(&config_path, true).unwrap();
assert_eq!(project.headed, Some(false));
assert_eq!(project.json, Some(true));
assert_eq!(project.debug, Some(true));
assert_eq!(project.user_agent.as_deref(), Some("test-agent"));
assert_eq!(project.hide_scrollbars, Some(false));
assert_eq!(project.annotate, Some(true));
assert_eq!(project.color_scheme.as_deref(), Some("dark"));
assert_eq!(project.max_output, Some(500));
assert_eq!(project.screenshot_quality, Some(80));
assert_eq!(project.screenshot_format.as_deref(), Some("jpeg"));
assert_eq!(project.idle_timeout.as_deref(), Some("10000"));
assert!(project.session.is_none());
assert!(project.executable_path.is_none());
assert!(project.args.is_none());
assert!(project.plugins.is_none());
assert!(project.extensions.is_none());
assert!(project.profile.is_none());
assert!(project.proxy.is_none());
assert!(project.allowed_domains.is_none());
assert!(project.clear_ca_cert.is_none());
let mut user = read_config_file(&config_path, false).unwrap();
assert_eq!(user.session.as_deref(), Some("test-session"));
assert_eq!(user.extensions, Some(vec!["/test/extension".to_string()]));
assert_eq!(user.profile.as_deref(), Some("/test/profile"));
assert_eq!(user.idle_timeout.as_deref(), Some("10000"));
user.headed = Some(true);
let merged = user.merge(project);
assert_eq!(merged.headed, Some(false));
assert_eq!(merged.executable_path.as_deref(), Some("/test/chrome"));
assert_eq!(merged.args.as_deref(), Some("test-argument"));
assert_eq!(merged.plugins.as_ref().unwrap()[0].command, "test-plugin");
assert_eq!(merged.proxy.as_deref(), Some("http://test:1234"));
assert_eq!(
merged.allowed_domains,
Some(vec!["example.com".to_string()])
);
assert_eq!(merged.clear_ca_cert, Some(true));
}
#[test]
fn test_project_config_ignores_disallowed_fields_before_deserialization() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(PROJECT_CONFIG_FILENAME);
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("cli should have a repository parent");
let schema: serde_json::Value = serde_json::from_str(
&fs::read_to_string(repo_root.join("agent-browser.schema.json")).unwrap(),
)
.unwrap();
let expected_allowed_keys = [
"headed",
"json",
"debug",
"userAgent",
"hideScrollbars",
"annotate",
"colorScheme",
"maxOutput",
"screenshotQuality",
"screenshotFormat",
"idleTimeout",
];
for key in schema["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.filter(|key| !expected_allowed_keys.contains(key))
.chain(["unknownFutureKey", "$schema"])
{
fs::write(
&config_path,
serde_json::json!({"headed": true, key: {}}).to_string(),
)
.unwrap();
let config = read_config_file(&config_path, true).unwrap();
assert_eq!(config.headed, Some(true), "{key}");
}
}
#[test]
fn test_config_merge_none_does_not_override() {
let user = Config {
headed: Some(true),
proxy: Some("http://proxy:8080".to_string()),
..Config::default()
};
let project = Config::default();
let merged = user.merge(project);
assert_eq!(merged.headed, Some(true));
assert_eq!(merged.proxy.as_deref(), Some("http://proxy:8080"));
}
#[test]
fn test_load_config_from_file() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-config");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("test-config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"headed": true, "proxy": "http://test:1234"}}"#).unwrap();
let config = read_config_file(&config_path, false).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.proxy.as_deref(), Some("http://test:1234"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_missing_file_returns_none() {
let result = read_config_file(Path::new("/nonexistent/agent-browser.json"), false);
assert!(result.is_none());
}
#[test]
fn test_load_config_malformed_json_returns_none() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-malformed");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("bad-config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, "{{not valid json}}").unwrap();
for is_auto_project_config in [false, true] {
assert!(read_config_file(&config_path, is_auto_project_config).is_none());
}
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_rejects_invalid_allowed_fields() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(PROJECT_CONFIG_FILENAME);
for content in [
r#"{"headed": "true"}"#,
r#"{"maxOutput": -1}"#,
r#"{"idleTimeout": 10}"#,
"null",
] {
fs::write(&config_path, content).unwrap();
for is_auto_project_config in [false, true] {
assert!(read_config_file(&config_path, is_auto_project_config).is_none());
}
}
}
#[test]
fn test_project_config_requires_an_object() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(PROJECT_CONFIG_FILENAME);
for content in ["[]", "[true]", "true", "1", r#""config""#] {
fs::write(&config_path, content).unwrap();
assert!(read_config_file(&config_path, true).is_none());
}
}
#[test]
fn test_load_config_discards_invalid_idle_timeout() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(PROJECT_CONFIG_FILENAME);
for timeout in ["", "1d", "18446744073709551615h"] {
fs::write(
&config_path,
serde_json::json!({"headed": true, "idleTimeout": timeout}).to_string(),
)
.unwrap();
for is_auto_project_config in [false, true] {
let config = read_config_file(&config_path, is_auto_project_config).unwrap();
assert_eq!(config.headed, Some(true));
assert!(config.idle_timeout.is_none());
}
}
}
#[test]
fn test_extract_config_path() {
assert_eq!(
extract_config_path(&args("--config ./my-config.json open example.com")),
Some(Some("./my-config.json".to_string()))
);
}
#[test]
fn test_extract_config_path_missing() {
assert_eq!(extract_config_path(&args("open example.com")), None);
}
#[test]
fn test_extract_config_path_no_value() {
assert_eq!(extract_config_path(&args("--config")), Some(None));
}
#[test]
fn test_extract_config_path_skips_flag_values() {
assert_eq!(extract_config_path(&args("--args --config open")), None);
}
#[test]
fn test_end_of_options_stops_global_and_config_parsing() {
let env = EnvGuard::new(&["AGENT_BROWSER_CONFIG"]);
env.remove("AGENT_BROWSER_CONFIG");
let input = args("fill #field -- --config /missing --json --session literal");
let baseline = try_parse_flags(&args("fill #field")).unwrap();
let flags = try_parse_flags(&input).unwrap();
assert_eq!(flags.json, baseline.json);
assert_eq!(flags.session, baseline.session);
assert_eq!(extract_config_path(&input), None);
assert_eq!(
clean_args(&input),
args("fill #field -- --config /missing --json --session literal")
);
let help_args = args("fill #field --help -- --version");
assert_eq!(before_end_of_options(&help_args), &help_args[..3]);
let version_args = args("fill #field --version -- --help");
assert_eq!(before_end_of_options(&version_args), &version_args[..3]);
}
#[test]
fn test_clean_args_removes_config() {
let cleaned = clean_args(&args("--config ./config.json open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_load_config_with_config_flag() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-flag-config");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("custom.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"headed": true, "session": "custom"}}"#).unwrap();
let flag_args = vec![
"--config".to_string(),
config_path.to_string_lossy().to_string(),
"open".to_string(),
"example.com".to_string(),
];
let config = load_config(&flag_args).unwrap();
assert_eq!(config.headed, Some(true));
assert_eq!(config.session.as_deref(), Some("custom"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_error_missing_config_value() {
let result = load_config(&args("--config"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("requires a file path"));
}
#[test]
fn test_load_config_error_nonexistent_file() {
let result = load_config(&args("--config /nonexistent/config.json open"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("config file not found"));
}
#[test]
fn test_load_config_error_malformed_explicit() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-explicit-malformed");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("bad.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, "{{not valid}}").unwrap();
let flag_args = vec![
"--config".to_string(),
config_path.to_string_lossy().to_string(),
];
let result = load_config(&flag_args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("failed to load config"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_headed_false() {
let flags = parse_flags(&args("--headed false open example.com"));
assert!(!flags.headed);
}
#[test]
fn test_headed_true_explicit() {
let flags = parse_flags(&args("--headed true open example.com"));
assert!(flags.headed);
}
#[test]
fn test_headed_bare_defaults_true() {
let flags = parse_flags(&args("--headed open example.com"));
assert!(flags.headed);
}
#[test]
fn test_webgpu_default_false() {
let guard = EnvGuard::new(&["AGENT_BROWSER_WEBGPU"]);
guard.remove("AGENT_BROWSER_WEBGPU");
let flags = parse_flags(&args("open example.com"));
assert!(!flags.webgpu);
assert!(!flags.cli_webgpu);
}
#[test]
fn test_webgpu_bare_defaults_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_WEBGPU"]);
guard.remove("AGENT_BROWSER_WEBGPU");
let flags = parse_flags(&args("--webgpu open example.com"));
assert!(flags.webgpu);
assert!(flags.cli_webgpu);
}
#[test]
fn test_webgpu_false_explicit() {
let guard = EnvGuard::new(&["AGENT_BROWSER_WEBGPU"]);
guard.remove("AGENT_BROWSER_WEBGPU");
let flags = parse_flags(&args("--webgpu false open example.com"));
assert!(!flags.webgpu);
assert!(flags.cli_webgpu);
}
#[test]
fn test_cdp_env_var_resolves_into_flags() {
let guard = EnvGuard::new(&["AGENT_BROWSER_CDP"]);
guard.set("AGENT_BROWSER_CDP", "9222");
let flags = parse_flags(&args("open example.com"));
assert_eq!(flags.cdp.as_deref(), Some("9222"));
guard.remove("AGENT_BROWSER_CDP");
let flags = parse_flags(&args("open example.com"));
assert!(flags.cdp.is_none());
}
#[test]
fn test_webgpu_env_var() {
let guard = EnvGuard::new(&["AGENT_BROWSER_WEBGPU"]);
guard.set("AGENT_BROWSER_WEBGPU", "1");
let flags = parse_flags(&args("open example.com"));
assert!(flags.webgpu);
assert!(!flags.cli_webgpu);
}
#[test]
fn test_debug_false() {
let flags = parse_flags(&args("--debug false open example.com"));
assert!(!flags.debug);
}
#[test]
fn test_json_false() {
let flags = parse_flags(&args("--json false open example.com"));
assert!(!flags.json);
}
#[test]
fn test_ignore_https_errors_false() {
let flags = parse_flags(&args("--ignore-https-errors false open"));
assert!(!flags.ignore_https_errors);
}
#[test]
fn test_allow_file_access_false() {
let flags = parse_flags(&args("--allow-file-access false open"));
assert!(!flags.allow_file_access);
assert!(flags.cli_allow_file_access);
}
#[test]
fn test_hide_scrollbars_default_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("open example.com"));
assert!(flags.hide_scrollbars);
assert!(!flags.cli_hide_scrollbars);
}
#[test]
fn test_hide_scrollbars_false() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("--hide-scrollbars false open"));
assert!(!flags.hide_scrollbars);
assert!(flags.cli_hide_scrollbars);
}
#[test]
fn test_hide_scrollbars_bare_defaults_true() {
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
let flags = parse_flags(&args("--hide-scrollbars open"));
assert!(flags.hide_scrollbars);
assert!(flags.cli_hide_scrollbars);
}
#[test]
fn test_auto_connect_false() {
let flags = parse_flags(&args("--auto-connect false open"));
assert!(!flags.auto_connect);
}
#[test]
fn test_clean_args_removes_bool_flag_with_value() {
let cleaned = clean_args(&args(
"--headed false --debug true --hide-scrollbars false open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_bare_bool_flag() {
let cleaned = clean_args(&args("--headed --debug open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_config_merge_extensions_concatenated() {
let user = Config {
extensions: Some(vec!["/ext1".to_string()]),
..Config::default()
};
let project = Config {
extensions: Some(vec!["/ext2".to_string(), "/ext3".to_string()]),
..Config::default()
};
let merged = user.merge(project);
assert_eq!(
merged.extensions,
Some(vec![
"/ext1".to_string(),
"/ext2".to_string(),
"/ext3".to_string()
])
);
}
#[test]
fn test_config_merge_extensions_user_only() {
let user = Config {
extensions: Some(vec!["/ext1".to_string()]),
..Config::default()
};
let project = Config::default();
let merged = user.merge(project);
assert_eq!(merged.extensions, Some(vec!["/ext1".to_string()]));
}
#[test]
fn test_config_merge_extensions_project_only() {
let user = Config::default();
let project = Config {
extensions: Some(vec!["/ext2".to_string()]),
..Config::default()
};
let merged = user.merge(project);
assert_eq!(merged.extensions, Some(vec!["/ext2".to_string()]));
}
#[test]
fn test_parse_ca_cert_flag() {
let flags = parse_flags(&args("--ca-cert /path/to/ca.crt open example.com"));
assert_eq!(flags.ca_cert, Some("/path/to/ca.crt".to_string()));
assert!(!flags.clear_ca_cert);
}
#[test]
fn test_parse_use_system_ca_flag() {
let flags = parse_flags(&args("--use-system-ca read https://example.com"));
assert!(flags.use_system_ca);
assert!(flags.cli_use_system_ca);
}
#[test]
fn test_parse_use_system_ca_explicit_false() {
let flags = parse_flags(&args("--use-system-ca false read https://example.com"));
assert!(!flags.use_system_ca);
assert!(flags.cli_use_system_ca);
}
#[test]
fn test_clean_args_removes_use_system_ca() {
let cleaned = clean_args(&args("--use-system-ca false read https://example.com"));
assert_eq!(cleaned, vec!["read", "https://example.com"]);
}
#[test]
fn test_config_merge_use_system_ca() {
let user = Config {
use_system_ca: Some(true),
..Config::default()
};
assert_eq!(user.merge(Config::default()).use_system_ca, Some(true));
let user = Config {
use_system_ca: Some(true),
..Config::default()
};
let project = Config {
use_system_ca: Some(false),
..Config::default()
};
assert_eq!(user.merge(project).use_system_ca, Some(false));
}
#[test]
fn test_parse_no_ca_cert_flag() {
let flags = parse_flags(&args(
"--ca-cert /path/to/ca.crt --no-ca-cert open example.com",
));
assert_eq!(flags.ca_cert, None);
assert!(flags.clear_ca_cert);
assert!(flags.cli_ca_cert);
}
#[test]
fn test_parse_ca_cert_flag_no_value() {
let flags = parse_flags(&args("--ca-cert"));
assert_eq!(flags.ca_cert, None);
}
#[test]
fn test_clean_args_removes_ca_cert() {
let cleaned = clean_args(&args(
"--ca-cert /path/to/ca.crt --no-ca-cert open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_config_merge_ca_cert() {
let user = Config {
ca_cert: Some("/user/ca.crt".to_string()),
..Config::default()
};
let project = Config::default();
let merged = user.merge(project);
assert_eq!(merged.ca_cert, Some("/user/ca.crt".to_string()));
let user = Config {
ca_cert: Some("/user/ca.crt".to_string()),
..Config::default()
};
let project = Config {
ca_cert: Some("/project/ca.crt".to_string()),
..Config::default()
};
let merged = user.merge(project);
assert_eq!(merged.ca_cert, Some("/project/ca.crt".to_string()));
}
#[test]
fn test_config_ca_cert_and_clear_merge_as_one_decision() {
let user = Config {
ca_cert: Some("/user/ca.crt".to_string()),
..Config::default()
};
let project_clear = Config {
clear_ca_cert: Some(true),
..Config::default()
};
let cleared = user.merge(project_clear);
assert_eq!(cleared.ca_cert, None);
assert_eq!(cleared.clear_ca_cert, Some(true));
let user_clear = Config {
clear_ca_cert: Some(true),
..Config::default()
};
let project_ca = Config {
ca_cert: Some("/project/ca.crt".to_string()),
..Config::default()
};
let selected = user_clear.merge(project_ca);
assert_eq!(selected.ca_cert, Some("/project/ca.crt".to_string()));
assert_eq!(selected.clear_ca_cert, Some(false));
let user_clear = Config {
clear_ca_cert: Some(true),
..Config::default()
};
let project_disable_clear = Config {
clear_ca_cert: Some(false),
..Config::default()
};
let disabled = user_clear.merge(project_disable_clear);
assert_eq!(disabled.ca_cert, None);
assert_eq!(disabled.clear_ca_cert, Some(false));
}
#[test]
fn test_ca_cert_precedence() {
let cases = [
(
None,
Some(true),
Some("/env/ca.crt".to_string()),
None,
Some("/env/ca.crt".to_string()),
false,
),
(
Some("/config/ca.crt".to_string()),
None,
None,
Some(true),
None,
true,
),
(
Some("/config/ca.crt".to_string()),
None,
None,
Some(false),
Some("/config/ca.crt".to_string()),
false,
),
(
Some("/config/ca.crt".to_string()),
Some(true),
None,
None,
Some("/config/ca.crt".to_string()),
false,
),
(None, Some(true), None, None, None, true),
(None, None, None, None, None, false),
];
for (
config_ca_cert,
config_clear_ca_cert,
env_ca_cert,
env_clear_ca_cert,
expected_ca_cert,
expected_clear_ca_cert,
) in cases
{
assert_eq!(
resolve_ca_cert(
config_ca_cert,
config_clear_ca_cert,
env_ca_cert,
env_clear_ca_cert,
),
(expected_ca_cert, expected_clear_ca_cert)
);
}
}
#[test]
fn test_no_auto_dialog_flag() {
let flags = parse_flags(&args("open example.com --no-auto-dialog"));
assert!(flags.no_auto_dialog);
}
#[test]
fn test_no_auto_dialog_default_false() {
let flags = parse_flags(&args("open example.com"));
assert!(!flags.no_auto_dialog);
}
#[test]
fn test_clean_args_removes_no_auto_dialog() {
let input: Vec<String> = vec![
"open".to_string(),
"example.com".to_string(),
"--no-auto-dialog".to_string(),
];
let clean = clean_args(&input);
assert_eq!(clean, vec!["open", "example.com"]);
}
#[test]
fn test_no_auto_connect_flag() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
guard.remove("AGENT_BROWSER_AUTO_CONNECT");
let flags = parse_flags(&args("--no-auto-connect open example.com"));
assert!(flags.no_auto_connect);
assert!(!flags.auto_connect);
assert!(!flags.cli_auto_connect);
assert_eq!(
clean_args(&args("--no-auto-connect open example.com")),
args("open example.com")
);
assert_eq!(
clean_args(&args("--no-auto-connect true open example.com")),
args("open example.com")
);
}
#[test]
fn test_no_auto_connect_false_is_not_a_reset() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
guard.remove("AGENT_BROWSER_AUTO_CONNECT");
let flags = parse_flags(&args("--no-auto-connect false open"));
assert!(!flags.no_auto_connect);
}
#[test]
fn test_auto_connect_false_is_the_same_reset() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
guard.remove("AGENT_BROWSER_AUTO_CONNECT");
let flags = parse_flags(&args("--auto-connect false open"));
assert!(flags.no_auto_connect);
assert!(!flags.auto_connect);
assert!(!flags.cli_auto_connect);
let flags = parse_flags(&args("--auto-connect open"));
assert!(flags.auto_connect && flags.cli_auto_connect && !flags.no_auto_connect);
}
#[test]
fn test_no_auto_connect_beats_env_auto_connect() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
guard.set("AGENT_BROWSER_AUTO_CONNECT", "1");
assert!(parse_flags(&args("open")).auto_connect);
let flags = parse_flags(&args("--no-auto-connect open"));
assert!(!flags.auto_connect);
assert!(flags.no_auto_connect);
}
#[test]
fn test_auto_connect_env_is_on_only_for_a_truthy_value() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
for (value, on) in [
("1", true),
("true", true),
("0", false),
("false", false),
("no", false),
("", false),
] {
guard.set("AGENT_BROWSER_AUTO_CONNECT", value);
let patch = crate::session_settings::Patch::from_flags(&parse_flags(&args("open")));
let expected = on.then_some(serde_json::Value::Bool(true));
assert_eq!(
patch.value(crate::session_settings::Key::AutoConnect),
expected.as_ref(),
"AGENT_BROWSER_AUTO_CONNECT={value:?}"
);
}
}
#[test]
fn test_no_auto_connect_beats_config_auto_connect() {
let guard = EnvGuard::new(&["AGENT_BROWSER_AUTO_CONNECT"]);
guard.remove("AGENT_BROWSER_AUTO_CONNECT");
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.json");
std::fs::write(&config, r#"{"autoConnect": true}"#).unwrap();
let config = config.to_string_lossy().into_owned();
let with_config = |extra: &str| {
let mut a = vec!["--config".to_string(), config.clone()];
a.extend(args(extra));
parse_flags(&a)
};
assert!(with_config("open").auto_connect);
let flags = with_config("--no-auto-connect open");
assert!(!flags.auto_connect);
assert!(flags.no_auto_connect);
}
#[test]
fn test_no_webmcp_flag() {
let flags = parse_flags(&args("--no-webmcp open example.com"));
assert!(flags.no_webmcp);
assert!(flags.cli_no_webmcp);
assert_eq!(
clean_args(&args("--no-webmcp open example.com")),
args("open example.com")
);
}
#[test]
fn test_input_mode_is_explicit_and_removed_from_command_args() {
let input = args("--input-mode human open example.com");
let flags = parse_flags(&input);
assert_eq!(flags.input_mode, "human");
assert!(flags.cli_input_mode);
assert_eq!(clean_args(&input), args("open example.com"));
}
#[test]
fn test_input_mode_defaults_without_overwriting_session() {
let flags = parse_flags(&args("click @e1"));
assert_eq!(flags.input_mode, "instant");
assert!(!flags.cli_input_mode);
}
}