Compare commits

...
Author SHA1 Message Date
leeguooooo 8880aa2f35 chore(release): 0.16.3-fork.2
- restore extension mode default to headed when headless is unspecified

- keep explicit headless override behavior
2026-03-05 11:44:47 +09:00
leeguooooo f051e72f85 chore(release): bump to 0.16.3-fork.1 2026-03-05 11:20:06 +09:00
leeguooooo 6ae703565c fix(connection): surface daemon startup stderr during launch 2026-03-05 11:18:21 +09:00
layla d56442cf91 Fix dialog dismiss command parsing (#605) 2026-03-05 11:16:14 +09:00
Li Yang dad7be8c77 fix: use reqwest for CDP port discovery instead of broken hand-rolled HTTP client (#619)
reqwest_get_string() was hand-rolling HTTP/1.1 over raw TCP despite reqwest
being an existing dependency. The hand-rolled implementation had two bugs:

1. URL path parsing: url.find('/') matched the first '/' in 'http://',
   producing path '//127.0.0.1:9222/json/version' instead of '/json/version'

2. read_to_end() hangs: Chrome's DevTools HTTP server ignores Connection: close
   and keeps the socket open, so read_to_end() waits for EOF that never comes

This caused 'agent-browser --cdp <port>' to always timeout when AGENT_BROWSER_NATIVE=1.

Fix: replace 49 lines of broken TCP code with reqwest::get(), which was
already in Cargo.toml.
2026-03-05 11:16:14 +09:00
Chris Tate 7921928ec4 headed mode (#607)
* headed mode

* fixes

* fixes

* docs

* fixes

* fixes

* fixes
2026-03-05 11:15:17 +09:00
15 changed files with 367 additions and 135 deletions
+11
View File
@@ -1,5 +1,16 @@
# agent-browser
## 0.16.3-fork.1
### Patch Changes
- Sync upstream `v0.16.2` / `v0.16.3` core fixes into the fork baseline.
- Import headed-mode behavior updates from upstream.
- Improve CDP debug-port discovery by switching to `reqwest` in native Chrome probing.
- Fix dialog dismiss command parsing consistency.
- Surface daemon startup stderr on launch failure to avoid opaque timeout-only errors.
- Keep fork stealth hardening for anti-debug self-destruct flows (`disable-devtool-auto` bootstrap neutralization).
## 0.16.1-fork.5
### Patch Changes
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.16.1-fork.4"
version = "0.16.3-fork.2"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.16.1-fork.5"
version = "0.16.3-fork.2"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
+7
View File
@@ -939,6 +939,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
Ok(cmd)
}
Some("dismiss") => {
let mut cmd = json!({ "id": id, "action": "dialog", "response": "dismiss" });
if let Some(prompt_text) = rest.get(1) {
cmd["promptText"] = json!(prompt_text);
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
+46 -53
View File
@@ -278,6 +278,10 @@ pub fn ensure_daemon(
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
// Keep handle to detect early daemon exit and surface startup errors.
#[allow(unused_assignments)]
let mut daemon_child: Option<std::process::Child> = None;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
{
@@ -363,11 +367,13 @@ pub fn ensure_daemon(
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
#[cfg(windows)]
@@ -451,12 +457,14 @@ pub fn ensure_daemon(
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
for _ in 0..50 {
@@ -465,6 +473,22 @@ pub fn ensure_daemon(
already_running: false,
});
}
// Surface daemon startup stderr instead of returning an opaque timeout.
if let Some(ref mut child) = daemon_child {
if let Ok(Some(_)) = child.try_wait() {
let mut stderr_output = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_output);
}
let stderr_trimmed = stderr_output.trim();
if !stderr_trimmed.is_empty() {
return Err(format!("Daemon failed to start: {}", stderr_trimmed));
}
return Err("Daemon failed to start: process exited during startup".to_string());
}
}
thread::sleep(Duration::from_millis(100));
}
@@ -569,45 +593,14 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
// Mutex to prevent parallel tests from interfering with env vars
static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// RAII guard that locks env mutex and restores env vars on drop
struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => env::set_var(name, v),
None => env::remove_var(name),
}
}
}
}
use crate::test_utils::EnvGuard;
#[test]
fn test_get_socket_dir_explicit_override() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
env::remove_var("XDG_RUNTIME_DIR");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
_guard.remove("XDG_RUNTIME_DIR");
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
}
@@ -616,8 +609,8 @@ mod tests {
fn test_get_socket_dir_ignores_empty_socket_dir() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::remove_var("XDG_RUNTIME_DIR");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.remove("XDG_RUNTIME_DIR");
assert!(get_socket_dir()
.to_string_lossy()
@@ -628,8 +621,8 @@ mod tests {
fn test_get_socket_dir_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
assert_eq!(
get_socket_dir(),
@@ -641,8 +634,8 @@ mod tests {
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
env::set_var("XDG_RUNTIME_DIR", "");
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.set("XDG_RUNTIME_DIR", "");
assert!(get_socket_dir()
.to_string_lossy()
@@ -653,8 +646,8 @@ mod tests {
fn test_get_socket_dir_home_fallback() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
env::remove_var("XDG_RUNTIME_DIR");
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
_guard.remove("XDG_RUNTIME_DIR");
let result = get_socket_dir();
assert!(result.to_string_lossy().ends_with(".agent-browser"));
+2
View File
@@ -4,6 +4,8 @@ mod connection;
mod flags;
mod install;
mod output;
#[cfg(test)]
mod test_utils;
mod validation;
use serde_json::json;
+24 -7
View File
@@ -755,6 +755,13 @@ fn launch_options_from_env() -> LaunchOptions {
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
let extensions: Option<Vec<String>> = env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| {
v.split([',', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
});
LaunchOptions {
headless: !headed,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
@@ -772,12 +779,7 @@ fn launch_options_from_env() -> LaunchOptions {
.collect()
})
.unwrap_or_default(),
extensions: env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| {
v.split([',', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}),
extensions,
storage_state: env::var("AGENT_BROWSER_STATE").ok(),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
@@ -866,6 +868,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
let profile = cmd.get("profile").and_then(|v| v.as_str());
let storage_state = cmd.get("storageState").and_then(|v| v.as_str());
let allow_file_access = cmd
@@ -2877,7 +2880,12 @@ async fn handle_permissions(cmd: &Value, state: &DaemonState) -> Result<Value, S
async fn handle_dialog(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let accept = cmd.get("accept").and_then(|v| v.as_bool()).unwrap_or(true);
let accept = cmd
.get("response")
.and_then(|v| v.as_str())
.map(|r| r == "accept")
.or_else(|| cmd.get("accept").and_then(|v| v.as_bool()))
.unwrap_or(true);
let prompt_text = cmd.get("promptText").and_then(|v| v.as_str());
mgr.handle_dialog(accept, prompt_text).await?;
@@ -5124,6 +5132,7 @@ fn error_response(id: &str, error: &str) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn test_success_response_structure() {
@@ -5160,6 +5169,14 @@ mod tests {
assert!(!opts.allow_file_access);
}
#[test]
fn test_launch_options_from_env_headed_flag() {
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");
}
#[tokio::test]
async fn test_execute_unknown_command() {
let mut state = DaemonState::new();
+214 -66
View File
@@ -8,6 +8,7 @@ use super::types::BrowserVersionInfo;
pub struct ChromeProcess {
child: Child,
pub ws_url: String,
temp_user_data_dir: Option<PathBuf>,
}
impl ChromeProcess {
@@ -20,6 +21,23 @@ impl ChromeProcess {
impl Drop for ChromeProcess {
fn drop(&mut self) {
self.kill();
if let Some(ref dir) = self.temp_user_data_dir {
for attempt in 0..3 {
match std::fs::remove_dir_all(dir) {
Ok(()) => break,
Err(_) if attempt < 2 => {
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
eprintln!(
"Warning: failed to clean up temp profile {}: {}",
dir.display(),
e
);
}
}
}
}
}
}
@@ -59,14 +77,12 @@ impl Default for LaunchOptions {
}
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => {
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
}
};
struct ChromeArgs {
args: Vec<String>,
temp_user_data_dir: Option<PathBuf>,
}
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let mut args = vec![
"--remote-debugging-port=0".to_string(),
"--no-first-run".to_string(),
@@ -97,10 +113,18 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
args.push(format!("--proxy-bypass-list={}", bypass));
}
if let Some(ref profile) = options.profile {
let temp_user_data_dir = if let Some(ref profile) = options.profile {
let expanded = expand_tilde(profile);
args.push(format!("--user-data-dir={}", expanded));
}
None
} else {
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()));
Some(dir)
};
if options.allow_file_access {
args.push("--allow-file-access-from-files".to_string());
@@ -115,7 +139,6 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
}
}
// Check if user args set window size (skip viewport override)
let has_window_size = options
.args
.iter()
@@ -131,23 +154,66 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
args.push("--no-sandbox".to_string());
}
Ok(ChromeArgs {
args,
temp_user_data_dir,
})
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
None => {
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
}
};
let ChromeArgs {
args,
temp_user_data_dir,
} = build_chrome_args(options)?;
let cleanup_temp_dir = |dir: &Option<PathBuf>| {
if let Some(ref d) = dir {
let _ = std::fs::remove_dir_all(d);
}
};
let mut child = Command::new(&chrome_path)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
.map_err(|e| {
cleanup_temp_dir(&temp_user_data_dir);
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
})?;
let stderr = child
.stderr
.take()
.ok_or("Failed to capture Chrome stderr")?;
.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 = wait_for_ws_url(reader)?;
let ws_url = match wait_for_ws_url(reader) {
Ok(url) => url,
Err(e) => {
let _ = child.kill();
cleanup_temp_dir(&temp_user_data_dir);
return Err(e);
}
};
Ok(ChromeProcess { child, ws_url })
Ok(ChromeProcess {
child,
ws_url,
temp_user_data_dir,
})
}
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
@@ -315,55 +381,8 @@ pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
}
async fn reqwest_get_string(url: &str) -> Result<String, String> {
let client = tokio::net::TcpStream::connect(
url.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1:9222"),
)
.await
.map_err(|e| e.to_string())?;
let path = url
.find('/')
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
.unwrap_or("/json/version");
let host = url
.strip_prefix("http://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or("127.0.0.1");
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, host
);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut client = client;
client
.write_all(request.as_bytes())
.await
.map_err(|e| e.to_string())?;
let mut response = Vec::new();
client
.read_to_end(&mut response)
.await
.map_err(|e| e.to_string())?;
let response_str = String::from_utf8_lossy(&response);
let body = response_str
.split("\r\n\r\n")
.nth(1)
.unwrap_or("")
.to_string();
Ok(body)
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
resp.text().await.map_err(|e| e.to_string())
}
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
@@ -559,6 +578,7 @@ fn expand_tilde(path: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn test_find_chrome_returns_some_on_host() {
@@ -626,10 +646,138 @@ mod tests {
#[test]
fn test_find_playwright_chromium_nonexistent() {
// With no Playwright cache, should return None
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let _guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
_guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let result = find_playwright_chromium();
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
assert!(result.is_none());
}
#[test]
fn test_build_args_headless_includes_headless_flag() {
let opts = LaunchOptions {
headless: true,
..Default::default()
};
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"));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
assert!(dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_args_headed_no_headless_flag() {
let opts = LaunchOptions {
headless: false,
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(!result.args.iter().any(|a| a.contains("--headless")));
assert!(!result.args.iter().any(|a| a.starts_with("--window-size=")));
// Temp dir created when no profile
assert!(result.temp_user_data_dir.is_some());
let dir = result.temp_user_data_dir.unwrap();
assert!(dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_args_temp_user_data_dir_created() {
let opts = LaunchOptions::default();
let result = build_chrome_args(&opts).unwrap();
let dir = result.temp_user_data_dir.as_ref().unwrap();
assert!(dir.exists());
assert!(result
.args
.iter()
.any(|a| a.starts_with("--user-data-dir=")));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn test_build_args_profile_no_temp_dir() {
let opts = LaunchOptions {
profile: Some("/tmp/my-profile".to_string()),
..Default::default()
};
let result = build_chrome_args(&opts).unwrap();
assert!(result.temp_user_data_dir.is_none());
assert!(result
.args
.iter()
.any(|a| a == "--user-data-dir=/tmp/my-profile"));
}
#[test]
fn test_build_args_custom_window_size_not_overridden() {
let opts = LaunchOptions {
headless: true,
args: vec!["--window-size=1920,1080".to_string()],
..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"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_build_args_start_maximized_suppresses_default_window_size() {
let opts = LaunchOptions {
headless: true,
args: vec!["--start-maximized".to_string()],
..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 == "--start-maximized"));
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn test_chrome_process_drop_cleans_temp_dir() {
let dir = std::env::temp_dir().join(format!(
"agent-browser-chrome-drop-test-{}",
uuid::Uuid::new_v4()
));
let _ = std::fs::create_dir_all(&dir);
assert!(dir.exists());
{
// Simulate a ChromeProcess with a temp dir but a dummy child.
// We can't actually spawn Chrome here, but we can verify the Drop
// logic by creating a small helper process.
let child = Command::new("echo")
.arg("test")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let _process = ChromeProcess {
child,
ws_url: String::new(),
temp_user_data_dir: Some(dir.clone()),
};
// _process dropped here
}
assert!(!dir.exists(), "Temp dir should be cleaned up on drop");
}
}
+3 -2
View File
@@ -135,6 +135,7 @@ impl ActionPolicy {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
#[test]
fn test_policy_allow_whitelist() {
@@ -205,12 +206,12 @@ mod tests {
#[test]
fn test_confirm_actions_from_env() {
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
let ca = ConfirmActions::from_env().unwrap();
assert!(ca.requires_confirmation("navigate"));
assert!(ca.requires_confirmation("click"));
assert!(ca.requires_confirmation("fill"));
assert!(!ca.requires_confirmation("screenshot"));
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
}
}
+1 -1
View File
@@ -2456,7 +2456,7 @@ Options:
--json JSON output
--full, -f Full page screenshot
--annotate Annotated screenshot with numbered labels and legend
--headed Show browser window (not headless)
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
--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)
+49
View File
@@ -0,0 +1,49 @@
use std::sync::{Mutex, MutexGuard};
/// Global mutex shared across all test modules to prevent parallel tests from
/// interfering with each other when mutating environment variables.
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
pub struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
pub fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), std::env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
pub fn set(&self, name: &str, value: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::set called with unregistered var: {name}"
);
std::env::set_var(name, value);
}
pub fn remove(&self, name: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::remove called with unregistered var: {name}"
);
std::env::remove_var(name);
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.16.1-fork.5",
"version": "0.16.3-fork.2",
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module",
"main": "dist/daemon.js",
+2
View File
@@ -301,6 +301,8 @@ agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop trace.json # Stop and save profile (path optional)
```
Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode.
### Local Files (PDFs, HTML)
```bash
+1 -1
View File
@@ -2135,7 +2135,7 @@ export class BrowserManager {
context = await launcher.launchPersistentContext(
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{
headless: false,
headless: options.headless ?? false,
executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }),
args: allArgs,
+4 -2
View File
@@ -432,7 +432,7 @@ export async function startDaemon(options?: {
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
.map((p) => p.trim())
.filter(Boolean)
: undefined;
@@ -471,7 +471,9 @@ export async function startDaemon(options?: {
const launchOptions = {
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
headless:
process.env.AGENT_BROWSER_HEADED !== '1' &&
process.env.AGENT_BROWSER_HEADED !== 'true',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
storageState: process.env.AGENT_BROWSER_STATE,