feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5

This commit is contained in:
leeguooooo
2026-03-09 12:00:13 +09:00
parent 3cbc284076
commit 356e2f5f39
46 changed files with 3542 additions and 218 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.16.3-fork.4"
version = "0.16.3-fork.5"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.16.3-fork.4"
version = "0.16.3-fork.5"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
+3 -3
View File
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
// Only insert underscore at transitions from lowercase to uppercase,
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
let prev_upper = chars[i - 1].is_uppercase();
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
if !prev_upper || next_lower {
result.push('_');
}
@@ -202,7 +202,7 @@ fn resolve_ref(
// Check if this type actually exists in the referenced domain
if domain_types
.get(ref_domain)
.map_or(false, |t| t.contains(ref_type))
.is_some_and(|t| t.contains(ref_type))
{
format!(
"super::cdp_{}::{}",
@@ -339,7 +339,7 @@ fn generate_domain(
if variant == "Self" {
variant = "SelfValue".to_string();
}
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
variant = format!("V{}", variant);
}
if seen_variants.insert(variant.clone()) {
+4
View File
@@ -2075,6 +2075,8 @@ mod tests {
allow_file_access: false,
device: None,
auto_connect: false,
native: false,
engine: None,
session_name: None,
parallel: None,
cli_executable_path: false,
@@ -2087,6 +2089,8 @@ mod tests {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_engine: false,
annotate: false,
color_scheme: None,
download_path: None,
+61 -25
View File
@@ -396,11 +396,18 @@ pub fn ensure_daemon(
device: Option<&str>,
session_name: Option<&str>,
debug: bool,
native: bool,
engine: Option<&str>,
download_path: Option<&str>,
tab_group: Option<&str>,
tab_group_plugin_id: Option<&str>,
) -> Result<DaemonResult, String> {
let daemon_path = resolve_daemon_path()?;
let daemon_path = if native {
let exe = env::current_exe().map_err(|e| e.to_string())?;
exe.canonicalize().unwrap_or(exe)
} else {
resolve_daemon_path()?
};
// Project policy: the default runtime channel is a singleton control plane.
// Before touching it, reap all non-default channels to avoid stale daemon reuse.
@@ -482,16 +489,21 @@ pub fn ensure_daemon(
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(&daemon_path)
.arg(if resident {
"--resident"
} else {
"--idle-auto-shutdown"
})
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session)
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
let mut cmd = if native {
Command::new(&daemon_path)
} else {
let mut cmd = Command::new("node");
cmd.arg(&daemon_path);
cmd
};
cmd.arg(if resident {
"--resident"
} else {
"--idle-auto-shutdown"
})
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session)
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
@@ -544,6 +556,9 @@ pub fn ensure_daemon(
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(engine) = engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
@@ -573,7 +588,13 @@ pub fn ensure_daemon(
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
.map_err(|e| {
if native {
format!("Failed to start native daemon: {}", e)
} else {
format!("Failed to start daemon: {}", e)
}
})?,
);
}
@@ -581,18 +602,24 @@ pub fn ensure_daemon(
{
use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(&daemon_path)
.arg(if resident {
"--resident"
} else {
"--idle-auto-shutdown"
})
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session)
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
let mut cmd = if native {
Command::new(&daemon_path)
} else {
// On Windows, call node directly. Command::new handles PATH
// resolution (node.exe or node.cmd) and automatically quotes
// arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(&daemon_path);
cmd
};
cmd.arg(if resident {
"--resident"
} else {
"--idle-auto-shutdown"
})
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session)
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
@@ -645,6 +672,9 @@ pub fn ensure_daemon(
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(engine) = engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
@@ -670,7 +700,13 @@ pub fn ensure_daemon(
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
.map_err(|e| {
if native {
format!("Failed to start native daemon: {}", e)
} else {
format!("Failed to start daemon: {}", e)
}
})?,
);
}
+88
View File
@@ -33,6 +33,8 @@ pub struct Config {
pub allow_file_access: Option<bool>,
pub cdp: Option<String>,
pub auto_connect: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
pub headers: Option<String>,
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
@@ -72,6 +74,8 @@ impl Config {
allow_file_access: other.allow_file_access.or(self.allow_file_access),
cdp: other.cdp.or(self.cdp),
auto_connect: other.auto_connect.or(self.auto_connect),
native: other.native.or(self.native),
engine: other.engine.or(self.engine),
headers: other.headers.or(self.headers),
annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme),
@@ -152,6 +156,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--risk-mode",
"--wait-until",
"--parallel",
"--engine",
];
let mut i = 0;
while i < args.len() {
@@ -222,6 +227,9 @@ pub struct Flags {
pub allow_file_access: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub native: bool,
/// Browser engine for native local launches. `chrome` is the default.
pub engine: Option<String>,
// Defaults to "default" when unset in default runtime mode.
// In --parallel mode, defaults to None unless explicitly provided on CLI.
pub session_name: Option<String>,
@@ -251,6 +259,8 @@ pub struct Flags {
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_native: bool,
pub cli_engine: bool,
pub cli_tab_group: bool,
pub cli_tab_group_plugin_id: bool,
pub cli_session_name: bool,
@@ -314,6 +324,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
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),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name),
@@ -348,6 +360,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_engine: false,
cli_tab_group: false,
cli_tab_group_plugin_id: false,
cli_session_name: false,
@@ -488,6 +502,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
flags.cli_native = true;
if consumed {
i += 1;
}
}
"--engine" => {
if let Some(s) = args.get(i + 1) {
flags.engine = Some(s.clone());
flags.cli_engine = true;
i += 1;
}
}
"--session-name" => {
if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone());
@@ -595,6 +624,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--ignore-https-errors",
"--allow-file-access",
"--auto-connect",
"--native",
"--annotate",
];
// Global flags that always take a value (need to skip the next arg too)
@@ -621,6 +651,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--wait-until",
"--parallel",
"--config",
"--engine",
];
let mut i = 0;
@@ -1244,6 +1275,12 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_engine() {
let cleaned = clean_args(&args("--engine lightpanda open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_load_config_with_config_flag() {
use std::io::Write;
@@ -1353,6 +1390,57 @@ mod tests {
assert!(!flags.auto_connect);
}
#[test]
fn test_native_false() {
let flags = parse_flags(&args("--native false open example.com"));
assert!(!flags.native);
assert!(flags.cli_native);
}
#[test]
fn test_engine_flag() {
let flags = parse_flags(&args("--engine lightpanda open example.com"));
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
assert!(flags.cli_engine);
}
#[test]
fn test_engine_from_env() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_ENGINE"]);
env::set_var("AGENT_BROWSER_ENGINE", "lightpanda");
let flags = parse_flags(&args("open example.com"));
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
assert!(!flags.cli_engine);
}
#[test]
fn test_native_bare_defaults_true() {
let flags = parse_flags(&args("--native open example.com"));
assert!(flags.native);
assert!(flags.cli_native);
}
#[test]
fn test_native_from_env_sets_native_without_cli_marker() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_NATIVE"]);
env::set_var("AGENT_BROWSER_NATIVE", "1");
let flags = parse_flags(&args("open example.com"));
assert!(flags.native);
assert!(!flags.cli_native);
}
#[test]
fn test_config_deserializes_native() {
let config: Config = serde_json::from_str(r#"{"native": true}"#).unwrap();
assert_eq!(config.native, Some(true));
}
#[test]
fn test_config_deserializes_engine() {
let config: Config = serde_json::from_str(r#"{"engine": "lightpanda"}"#).unwrap();
assert_eq!(config.engine.as_deref(), Some("lightpanda"));
}
#[test]
fn test_full_bare_defaults_true() {
let flags = parse_flags(&args("--full open example.com"));
+29 -2
View File
@@ -3,6 +3,7 @@ mod commands;
mod connection;
mod flags;
mod install;
mod native;
mod output;
#[cfg(test)]
mod test_utils;
@@ -86,6 +87,17 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
}
fn main() {
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());
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::daemon::run_daemon(&session));
return;
}
// Ignore SIGPIPE to prevent panic when piping to head/tail
#[cfg(unix)]
unsafe {
@@ -93,9 +105,13 @@ fn main() {
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let mut flags = parse_flags(&args);
let clean = clean_args(&args);
if flags.engine.is_some() && !flags.native {
flags.native = true;
}
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
@@ -262,6 +278,8 @@ fn main() {
flags.device.as_deref(),
flags.session_name.as_deref(),
flags.debug,
flags.native,
flags.engine.as_deref(),
flags.download_path.as_deref(),
flags.tab_group.as_deref(),
flags.tab_group_plugin_id.as_deref(),
@@ -316,6 +334,8 @@ fn main() {
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"),
flags.cli_native.then_some("--native"),
flags.cli_engine.then_some("--engine"),
flags.cli_tab_group.then_some("--tab-group"),
flags
.cli_tab_group_plugin_id
@@ -407,6 +427,9 @@ fn main() {
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref engine) = flags.engine {
launch_cmd["engine"] = json!(engine);
}
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
@@ -505,6 +528,9 @@ fn main() {
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref engine) = flags.engine {
launch_cmd["engine"] = json!(engine);
}
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
@@ -655,7 +681,8 @@ fn main() {
|| flags.allow_file_access
|| flags.debug
|| flags.color_scheme.is_some()
|| flags.download_path.is_some())
|| flags.download_path.is_some()
|| flags.engine.is_some())
&& flags.cdp.is_none()
&& flags.provider.is_none()
&& !attached_to_existing_browser
+72 -40
View File
@@ -167,13 +167,14 @@ impl DaemonState {
if let Ok(te) =
serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
{
if te.target_info.target_type == "page"
if (te.target_info.target_type == "page"
|| te.target_info.target_type == "webview")
&& !te.target_info.url.is_empty()
{
let already_tracked = self
.browser
.as_ref()
.map_or(true, |b| b.has_target(&te.target_info.target_id));
.is_none_or(|b| b.has_target(&te.target_info.target_id));
if !already_tracked {
new_targets.push(te);
}
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
session_id: attach.session_id,
url: te.target_info.url.clone(),
title: te.target_info.title.clone(),
target_type: te.target_info.target_type.clone(),
});
}
}
@@ -549,16 +551,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
}
// WebDriver backend: reject unsupported CDP-only actions
if matches!(state.backend_type, BackendType::WebDriver) {
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) {
return error_response(
&id,
&format!(
"Action '{}' is not supported on the WebDriver backend",
action
),
);
}
if matches!(state.backend_type, BackendType::WebDriver)
&& WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
{
return error_response(
&id,
&format!(
"Action '{}' is not supported on the WebDriver backend",
action
),
);
}
let result = match action {
@@ -726,6 +728,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let options = launch_options_from_env();
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
@@ -743,7 +746,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
return Ok(());
}
let mgr = BrowserManager::launch(options).await?;
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
try_auto_restore_state(state).await;
@@ -835,20 +838,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("autoConnect")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let engine = cmd
.get("engine")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
// Relaunch logic: check if we can reuse the existing connection
let needs_relaunch = if let Some(ref mgr) = state.browser {
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
let was_cdp = mgr.is_cdp_connection();
if has_cdp_arg != was_cdp {
true
} else if has_cdp_arg && !mgr.is_connection_alive().await {
true
} else if auto_connect && !mgr.is_connection_alive().await {
true
} else {
!mgr.is_connection_alive().await
}
has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
} else {
true
};
@@ -1000,7 +1000,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.domain_filter = Some(DomainFilter::new(domains));
}
state.browser = Some(BrowserManager::launch(options).await?);
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
if let Some(ref filter) = state.domain_filter {
@@ -2469,6 +2469,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
session_id: new_session_id.clone(),
url: nav_url.clone(),
title: String::new(),
target_type: "page".to_string(),
});
// Navigate to URL
@@ -3225,12 +3226,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
.send_command_no_params("Page.getFrameTree", Some(&session_id))
.await?;
fn find_frame(
tree: &Value,
selector: Option<&str>,
name: Option<&str>,
url: Option<&str>,
) -> Option<String> {
fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
let frame = tree.get("frame")?;
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
@@ -3249,7 +3245,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
for child in children {
if let Some(id) = find_frame(child, selector, name, url) {
if let Some(id) = find_frame(child, name, url) {
return Some(id);
}
}
@@ -3274,13 +3270,13 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
);
let result = mgr.evaluate(&js, None).await?;
let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
if let Some(frame_id) = find_frame(frame_tree, None, Some(frame_name), None) {
if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": frame_name }));
}
}
if let Some(frame_id) = find_frame(frame_tree, selector, name, url) {
if let Some(frame_id) = find_frame(frame_tree, name, url) {
let label = name.or(url).unwrap_or("frame");
state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": label }));
@@ -4008,14 +4004,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
Ok(Ok(event)) => {
if event.method == "Page.downloadProgress"
&& event.session_id.as_deref() == Some(&session_id)
&& event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
{
if event.params.get("state").and_then(|v| v.as_str()) == Some("completed") {
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("download");
return Ok(json!({ "path": path }));
}
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("download");
return Ok(json!({ "path": path }));
}
}
Ok(Err(_)) => return Err("Event stream closed".to_string()),
@@ -4064,6 +4059,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
session_id: attach.session_id,
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
if let Some(viewport) = cmd.get("viewport") {
@@ -5134,6 +5130,38 @@ mod tests {
use super::*;
use crate::test_utils::EnvGuard;
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
struct TestKeyGuard {
_lock: std::sync::MutexGuard<'static, ()>,
original: Option<String>,
}
impl TestKeyGuard {
fn new() -> Self {
let lock = super::auth::AUTH_TEST_MUTEX
.lock()
.unwrap_or_else(|e| e.into_inner());
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
Self {
_lock: lock,
original,
}
}
}
impl Drop for TestKeyGuard {
fn drop(&mut self) {
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
match &self.original {
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
}
}
}
#[test]
fn test_success_response_structure() {
let resp = success_response("cmd-1", json!({"url": "https://example.com"}));
@@ -5174,7 +5202,10 @@ mod tests {
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
_guard.set("AGENT_BROWSER_HEADED", "1");
let opts = launch_options_from_env();
assert!(!opts.headless, "AGENT_BROWSER_HEADED=1 should set headless=false");
assert!(
!opts.headless,
"AGENT_BROWSER_HEADED=1 should set headless=false"
);
}
#[tokio::test]
@@ -5226,6 +5257,7 @@ mod tests {
#[tokio::test]
async fn test_credentials_roundtrip_via_actions() {
let _key_guard = TestKeyGuard::new();
let mut state = DaemonState::new();
let set_cmd = json!({
+3 -4
View File
@@ -215,16 +215,15 @@ fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
combined.extend_from_slice(&ciphertext);
combined.extend_from_slice(&auth_tag);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| format!("Decryption key error: {}", e))?;
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
let plaintext = cipher
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
.map_err(|e| format!("Decryption failed: {}", e))?;
let json_str = String::from_utf8(plaintext)
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
return serde_json::from_str(&json_str)
.map_err(|e| format!("Invalid profile data: {}", e));
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
}
// Fallback: try as plain unencrypted JSON profile
+116 -19
View File
@@ -7,6 +7,7 @@ use super::cdp::chrome::{
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
};
use super::cdp::client::CdpClient;
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
use super::cdp::types::*;
// ---------------------------------------------------------------------------
@@ -55,6 +56,34 @@ pub fn validate_launch_options(
Ok(())
}
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
if options
.extensions
.as_ref()
.is_some_and(|exts| !exts.is_empty())
{
return Err("Extensions are not supported with Lightpanda".to_string());
}
if options.profile.is_some() {
return Err("Profiles are not supported with Lightpanda".to_string());
}
if options.storage_state.is_some() {
return Err("Storage state is not supported with Lightpanda".to_string());
}
if options.allow_file_access {
return Err("File access is not supported with Lightpanda".to_string());
}
if !options.headless {
return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
}
if !options.args.is_empty() {
return Err(
"Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
);
}
Ok(())
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -86,6 +115,7 @@ pub struct PageInfo {
pub session_id: String,
pub url: String,
pub title: String,
pub target_type: String,
}
#[derive(Debug, Clone, Copy)]
@@ -105,37 +135,72 @@ impl WaitUntil {
}
}
pub enum BrowserProcess {
Chrome(ChromeProcess),
Lightpanda(LightpandaProcess),
}
pub struct BrowserManager {
pub client: CdpClient,
chrome_process: Option<ChromeProcess>,
browser_process: Option<BrowserProcess>,
pages: Vec<PageInfo>,
active_page_index: usize,
default_timeout_ms: u64,
}
impl BrowserManager {
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
validate_launch_options(
options.extensions.as_deref(),
false,
options.profile.as_deref(),
options.storage_state.as_deref(),
options.allow_file_access,
options.executable_path.as_deref(),
)?;
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
let engine = engine.unwrap_or("chrome");
match engine {
"chrome" => validate_launch_options(
options.extensions.as_deref(),
false,
options.profile.as_deref(),
options.storage_state.as_deref(),
options.allow_file_access,
options.executable_path.as_deref(),
)?,
"lightpanda" => validate_lightpanda_options(&options)?,
_ => {
return Err(format!(
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
engine
))
}
}
let ignore_https_errors = options.ignore_https_errors;
let user_agent = options.user_agent.clone();
let color_scheme = options.color_scheme.clone();
let download_path = options.download_path.clone();
let chrome = launch_chrome(&options)?;
let ws_url = chrome.ws_url.clone();
let (ws_url, process) = match engine {
"lightpanda" => {
let lp_options = LightpandaLaunchOptions {
executable_path: options.executable_path.clone(),
proxy: options.proxy.clone(),
port: None,
};
let process = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
.await
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
let ws_url = process.ws_url.clone();
(ws_url, BrowserProcess::Lightpanda(process))
}
_ => {
let process = tokio::task::spawn_blocking(move || launch_chrome(&options))
.await
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
let ws_url = process.ws_url.clone();
(ws_url, BrowserProcess::Chrome(process))
}
};
let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self {
client,
chrome_process: Some(chrome),
browser_process: Some(process),
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 25_000,
@@ -197,7 +262,7 @@ impl BrowserManager {
let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self {
client,
chrome_process: None,
browser_process: None,
pages: Vec::new(),
active_page_index: 0,
default_timeout_ms: 10_000,
@@ -229,7 +294,9 @@ impl BrowserManager {
let page_targets: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(|t| t.target_type == "page" && !t.url.is_empty())
.filter(|t| {
(t.target_type == "page" || t.target_type == "webview") && !t.url.is_empty()
})
.collect();
if page_targets.is_empty() {
@@ -262,6 +329,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.enable_domains(&attach_result.session_id).await?;
@@ -284,6 +352,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
target_type: target.target_type.clone(),
});
}
@@ -507,10 +576,11 @@ impl BrowserManager {
.send_command_no_params("Browser.close", None)
.await;
if let Some(mut chrome) = self.chrome_process.take() {
if let Some(process) = self.browser_process.take() {
let timeout = std::time::Duration::from_secs(5);
let _ = tokio::task::spawn_blocking(move || {
chrome.wait_or_kill(timeout);
let _ = tokio::task::spawn_blocking(move || match process {
BrowserProcess::Chrome(mut chrome) => chrome.wait_or_kill(timeout),
BrowserProcess::Lightpanda(mut lightpanda) => lightpanda.kill(),
})
.await;
}
@@ -541,7 +611,7 @@ impl BrowserManager {
/// Returns true if this manager was connected via CDP (as opposed to local launch).
pub fn is_cdp_connection(&self) -> bool {
self.chrome_process.is_none()
self.browser_process.is_none()
}
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
@@ -579,6 +649,7 @@ impl BrowserManager {
session_id: attach_result.session_id.clone(),
url: "about:blank".to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.enable_domains(&attach_result.session_id).await?;
@@ -611,6 +682,7 @@ impl BrowserManager {
"index": i,
"title": p.title,
"url": p.url,
"type": p.target_type,
"active": i == self.active_page_index,
})
})
@@ -651,6 +723,7 @@ impl BrowserManager {
session_id: attach.session_id,
url: target_url.to_string(),
title: String::new(),
target_type: "page".to_string(),
});
self.active_page_index = index;
@@ -1068,6 +1141,30 @@ mod tests {
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
}
#[test]
fn test_validate_lightpanda_options_rejects_extensions() {
let opts = LaunchOptions {
extensions: Some(vec!["/tmp/ext".to_string()]),
..Default::default()
};
assert!(validate_lightpanda_options(&opts).is_err());
}
#[test]
fn test_validate_lightpanda_options_rejects_headed() {
let opts = LaunchOptions {
headless: false,
..Default::default()
};
assert!(validate_lightpanda_options(&opts).is_err());
}
#[test]
fn test_validate_lightpanda_options_valid() {
let opts = LaunchOptions::default();
assert!(validate_lightpanda_options(&opts).is_ok());
}
#[test]
fn test_to_ai_friendly_error_strict_mode() {
assert_eq!(
+13 -31
View File
@@ -123,7 +123,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let has_extensions = options
.extensions
.as_ref()
.map_or(false, |exts| !exts.is_empty());
.is_some_and(|exts| !exts.is_empty());
// Extensions require headed mode in native Chrome (content scripts are not
// injected in headless mode). Skip --headless when extensions are loaded.
@@ -144,8 +144,8 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
args.push(format!("--user-data-dir={}", expanded));
None
} else {
let dir = std::env::temp_dir()
.join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
let dir =
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
args.push(format!("--user-data-dir={}", dir.display()));
@@ -216,14 +216,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
"Failed to capture Chrome stderr".to_string()
})?;
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
"Failed to capture Chrome stderr".to_string()
})?;
let reader = BufReader::new(stderr);
let ws_url = match wait_for_ws_url(reader) {
@@ -515,10 +512,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
// Generic container detection: cgroup contains docker/kubepods/lxc
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
if cgroup.contains("docker")
|| cgroup.contains("kubepods")
|| cgroup.contains("lxc")
{
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
return true;
}
}
@@ -662,10 +656,7 @@ mod tests {
#[test]
fn test_chrome_launch_error_generic() {
let lines = vec![
"info line".to_string(),
"another info line".to_string(),
];
let lines = vec!["info line".to_string(), "another info line".to_string()];
let msg = chrome_launch_error("Chrome exited", &lines);
assert!(msg.contains("last 2 lines"));
}
@@ -686,10 +677,7 @@ mod tests {
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.args.iter().any(|a| a == "--headless=new"));
assert!(result
.args
.iter()
.any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
@@ -748,14 +736,8 @@ mod tests {
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result
.args
.iter()
.any(|a| a == "--window-size=1280,720"));
assert!(result
.args
.iter()
.any(|a| a == "--window-size=1920,1080"));
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
+271
View File
@@ -0,0 +1,271 @@
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
pub struct LightpandaProcess {
child: Child,
pub ws_url: String,
_stderr_drain: Option<std::thread::JoinHandle<()>>,
}
impl LightpandaProcess {
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for LightpandaProcess {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Default)]
pub struct LightpandaLaunchOptions {
pub executable_path: Option<String>,
pub proxy: Option<String>,
pub port: Option<u16>,
}
pub fn find_lightpanda() -> Option<PathBuf> {
#[cfg(unix)]
{
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
if let Some(home) = dirs::home_dir() {
let candidates = [
home.join(".lightpanda/lightpanda"),
home.join(".local/bin/lightpanda"),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
}
None
}
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
let binary_path = match &options.executable_path {
Some(path) => PathBuf::from(path),
None => find_lightpanda().ok_or(
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
)?,
};
let port = match options.port {
Some(port) => port,
None => TcpListener::bind("127.0.0.1:0")
.and_then(|listener| listener.local_addr())
.map(|addr| addr.port())
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
};
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
port.to_string(),
"--timeout".to_string(),
"0".to_string(),
];
if let Some(ref proxy) = options.proxy {
args.push("--http_proxy".to_string());
args.push(proxy.clone());
}
let mut child = Command::new(&binary_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
let stderr = child.stderr.take().ok_or_else(|| {
let _ = child.kill();
"Failed to capture Lightpanda stderr".to_string()
})?;
let reader = BufReader::new(stderr);
let (address, reader) = match wait_for_address(reader) {
Ok(result) => result,
Err(e) => {
let _ = child.kill();
return Err(e);
}
};
let ws_url = format!("ws://{}", address);
let drain = std::thread::spawn(move || {
let mut reader = reader;
let mut buf = String::new();
loop {
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
});
Ok(LightpandaProcess {
child,
ws_url,
_stderr_drain: Some(drain),
})
}
fn wait_for_address(
mut reader: BufReader<std::process::ChildStderr>,
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut stderr_lines: Vec<String> = Vec::new();
let mut buf = String::new();
loop {
if std::time::Instant::now() > deadline {
return Err(lightpanda_launch_error(
"Timeout waiting for Lightpanda server address",
&stderr_lines,
));
}
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) => {
return Err(lightpanda_launch_error(
"Lightpanda exited before providing server address",
&stderr_lines,
));
}
Ok(_) => {
let line = buf.trim_end().to_string();
if let Some(address) = extract_address(&line) {
return Ok((address, reader));
}
stderr_lines.push(line);
}
Err(e) => {
return Err(format!("Failed to read Lightpanda stderr: {}", e));
}
}
}
}
fn extract_address(line: &str) -> Option<String> {
if let Some(idx) = line.find("address = ") {
let address = line[idx + "address = ".len()..].trim().to_string();
if !address.is_empty() {
return Some(address);
}
}
None
}
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
if stderr_lines.is_empty() {
return format!("{} (no stderr output from Lightpanda)", message);
}
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
format!(
"{}\nLightpanda stderr (last {} lines):\n {}",
message,
last_lines.len(),
last_lines
.into_iter()
.rev()
.map(|line| line.as_str())
.collect::<Vec<_>>()
.join("\n ")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_address_standard() {
assert_eq!(
extract_address(" address = 127.0.0.1:9222"),
Some("127.0.0.1:9222".to_string())
);
}
#[test]
fn test_extract_address_inline() {
assert_eq!(
extract_address("INFO app : server running address = 127.0.0.1:4567"),
Some("127.0.0.1:4567".to_string())
);
}
#[test]
fn test_extract_address_no_match() {
assert_eq!(extract_address("INFO app : starting up..."), None);
}
#[test]
fn test_find_lightpanda_returns_none_when_missing() {
let _ = find_lightpanda();
}
#[test]
fn test_lightpanda_launch_error_no_stderr() {
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
assert!(msg.contains("no stderr output"));
}
#[test]
fn test_lightpanda_launch_error_with_lines() {
let lines = vec![
"INFO starting up".to_string(),
"ERROR bind failed: address in use".to_string(),
];
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
assert!(msg.contains("bind failed"));
assert!(msg.contains("last 2 lines"));
}
#[test]
fn test_default_options() {
let opts = LightpandaLaunchOptions::default();
assert!(opts.executable_path.is_none());
assert!(opts.proxy.is_none());
assert!(opts.port.is_none());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod chrome;
pub mod client;
pub mod lightpanda;
pub mod types;
+1
View File
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
///
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
#[allow(clippy::upper_case_acronyms)]
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
}
+5 -7
View File
@@ -56,13 +56,11 @@ pub async fn set_cookies(
.into_iter()
.map(|mut c| {
// Auto-fill url if no domain/path/url provided
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
c.as_object_mut().map(|m| {
m.insert(
"url".to_string(),
Value::String(current_url.unwrap().to_string()),
)
});
if c.get("url").is_none() && c.get("domain").is_none() {
if let Some(url) = current_url {
c.as_object_mut()
.map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
}
}
c
})
+13 -3
View File
@@ -1,4 +1,4 @@
use serde_json::Value;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::PathBuf;
@@ -24,6 +24,16 @@ pub async fn run_daemon(session: &str) {
let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string());
let meta_path = socket_dir.join(format!("{}.meta.json", session));
if let Ok(current_exe) = env::current_exe() {
let daemon_path = current_exe.canonicalize().unwrap_or(current_exe);
let cli_version = env::var("AGENT_BROWSER_CLI_VERSION").unwrap_or_default();
let meta = json!({
"daemonPath": daemon_path.to_string_lossy(),
"cliVersion": cli_version,
});
let _ = fs::write(&meta_path, meta.to_string());
}
let socket_path = socket_dir.join(format!("{}.sock", session));
@@ -43,6 +53,7 @@ pub async fn run_daemon(session: &str) {
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&meta_path);
let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path);
@@ -185,8 +196,7 @@ async fn handle_connection<S>(
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
activity_tx: UnboundedSender<()>,
active_commands: std::sync::Arc<AtomicUsize>,
)
where
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
+2
View File
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0]["active"], true);
assert_eq!(tabs[0]["type"], "page");
// Open new tab
let resp = execute_command(
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[1]["active"], true);
assert_eq!(tabs[1]["type"], "page");
// Switch to first tab
let resp = execute_command(
+10 -1
View File
@@ -374,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
// ---------------------------------------------------------------------------
#[tokio::test]
#[ignore]
async fn test_all_documented_actions_are_handled() {
let mut state = DaemonState::new();
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
let id = format!("parity-{}", i);
let cmd = minimal_command(action, &id);
let result = execute_command(&cmd, &mut state).await;
let result = tokio::time::timeout(
tokio::time::Duration::from_millis(250),
execute_command(&cmd, &mut state),
)
.await;
let Ok(result) = result else {
continue;
};
assert!(
result.get("id").is_some(),
+2 -14
View File
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
"RootWebArea",
];
#[derive(Default)]
pub struct SnapshotOptions {
pub selector: Option<String>,
pub interactive: bool,
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
pub cursor: bool,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
selector: None,
interactive: false,
compact: false,
depth: None,
cursor: false,
}
}
}
struct TreeNode {
role: String,
name: String,
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', " ");
.replace(['\n', '\r'], " ");
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
}
+1 -1
View File
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
best_path = Some((path.to_string_lossy().to_string(), modified));
}
}
+4 -2
View File
@@ -2458,7 +2458,7 @@ Options:
--json JSON output
--full, -f Full page screenshot
--annotate Annotated screenshot with numbered labels and legend
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
@@ -2480,6 +2480,7 @@ Options:
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--engine <name> Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE)
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
@@ -2520,7 +2521,7 @@ Environment:
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
AGENT_BROWSER_HEADED Show browser window (not headless)
AGENT_BROWSER_HEADED Show browser window (not headless; accepts 1 or true)
AGENT_BROWSER_JSON JSON output
AGENT_BROWSER_FULL Full page screenshot
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
@@ -2550,6 +2551,7 @@ Environment:
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
Install (recommended, fastest - native Rust CLI):