Compare commits

...
Author SHA1 Message Date
leeguooooo 5970579d7c feat: add parallel mode and idle daemon shutdown 2026-03-05 13:40:34 +09:00
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
22 changed files with 1135 additions and 458 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
+29
View File
@@ -52,6 +52,33 @@ agent-browser snapshot -i
agent-browser click @e2
```
### Parallel AI Runs (Isolated Runtime Channel)
Use `--parallel <name>` to run multiple AI flows concurrently without fighting over the same runtime channel.
```bash
agent-browser --parallel worker-a open https://example.com
agent-browser --parallel worker-b open https://example.org
```
`--parallel` is designed for stateless throughput tasks (navigation, extraction, checks). For authenticated flows, keep using one stable `--session-name`.
| Option | Purpose | Typical Usage |
| --- | --- | --- |
| `--parallel <name>` | Isolate runtime channel for concurrent AI tasks | Stateless/no-login parallel jobs |
| `--session-name <name>` | Persist cookies/localStorage across restarts | Login/auth continuity |
### Daemon Lifecycle
- Daemons auto-shutdown after 10 minutes of inactivity by default.
- Use `--resident` to keep a daemon alive until an explicit `close`.
```bash
agent-browser --resident open https://example.com
# ... long-lived background workflow ...
agent-browser close
```
### Default: Auto Group Agent Tabs (CDP + Plugin)
```bash
@@ -233,6 +260,8 @@ flowchart TD
- Prefer `--headed` for high-friction targets.
- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `default`).
- Use `--parallel <name>` only for stateless parallel workloads where higher throughput matters.
- Use `--resident` only for deliberate long-running workflows, and close when done.
- Keep locale/timezone consistent with target market.
- For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls.
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
+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.3"
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.3"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
+11
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,
@@ -2053,6 +2060,7 @@ mod tests {
full: false,
headed: false,
debug: false,
resident: false,
headers: None,
executable_path: None,
extensions: Vec::new(),
@@ -2068,6 +2076,7 @@ mod tests {
device: None,
auto_connect: false,
session_name: None,
parallel: None,
cli_executable_path: false,
cli_extensions: false,
cli_state: false,
@@ -2087,6 +2096,8 @@ mod tests {
wait_until: None,
cli_tab_group: false,
cli_tab_group_plugin_id: false,
cli_session_name: false,
cli_resident: false,
}
}
+58 -53
View File
@@ -183,6 +183,8 @@ pub struct DaemonResult {
pub fn ensure_daemon(
session: &str,
headed: bool,
// Keep daemon resident and disable idle auto-shutdown.
resident: bool,
executable_path: Option<&str>,
extensions: &[String],
args: Option<&str>,
@@ -278,6 +280,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)]
{
@@ -285,6 +291,11 @@ pub fn ensure_daemon(
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);
@@ -363,11 +374,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)]
@@ -378,6 +391,11 @@ pub fn ensure_daemon(
// 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);
@@ -451,12 +469,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 +485,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 +605,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 +621,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 +633,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 +646,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 +658,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"));
+113 -6
View File
@@ -1,4 +1,5 @@
use crate::color;
use crate::validation::is_valid_session_name;
use serde::Deserialize;
use std::env;
use std::fs;
@@ -40,6 +41,7 @@ pub struct Config {
pub tab_group_plugin_id: Option<String>,
pub risk_mode: Option<String>,
pub wait_until: Option<String>,
pub parallel: Option<String>,
}
impl Config {
@@ -78,6 +80,7 @@ impl Config {
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
risk_mode: other.risk_mode.or(self.risk_mode),
wait_until: other.wait_until.or(self.wait_until),
parallel: other.parallel.or(self.parallel),
}
}
}
@@ -148,6 +151,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--tab-group-plugin-id",
"--risk-mode",
"--wait-until",
"--parallel",
];
let mut i = 0;
while i < args.len() {
@@ -199,6 +203,10 @@ pub struct Flags {
pub full: bool,
pub headed: bool,
pub debug: bool,
/// Keep daemon resident and disable idle auto-shutdown.
pub resident: bool,
/// Runtime daemon session channel.
/// Defaults to `default`; when `--parallel <name>` is provided it becomes `parallel-<name>`.
pub session: String,
pub headers: Option<String>,
pub executable_path: Option<String>,
@@ -214,7 +222,9 @@ pub struct Flags {
pub allow_file_access: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub session_name: Option<String>, // Defaults to "default" when unset
// 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>,
pub annotate: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
@@ -226,6 +236,8 @@ pub struct Flags {
/// Navigation wait strategy passed to navigate/open commands:
/// `load`, `domcontentloaded`, or `networkidle`.
pub wait_until: Option<String>,
/// Parallel execution channel name. When set, commands run in an isolated runtime session.
pub parallel: Option<String>,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -241,6 +253,8 @@ pub struct Flags {
pub cli_download_path: bool,
pub cli_tab_group: bool,
pub cli_tab_group_plugin_id: bool,
pub cli_session_name: bool,
pub cli_resident: bool,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -273,7 +287,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
Err(_) => config.headed.unwrap_or(true),
},
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
// --session is disabled: user-facing CLI always uses one default session.
resident: false,
// --session is disabled for users.
// Runtime session defaults to `default`, and can be isolated with `--parallel`.
session: "default".to_string(),
headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
@@ -321,6 +337,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.risk_mode)
.map(|s| s.to_ascii_lowercase()),
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
parallel: env::var("AGENT_BROWSER_PARALLEL").ok().or(config.parallel),
cli_executable_path: false,
cli_extensions: false,
cli_state: false,
@@ -333,6 +350,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_download_path: false,
cli_tab_group: false,
cli_tab_group_plugin_id: false,
cli_session_name: false,
cli_resident: false,
};
let mut i = 0;
@@ -366,6 +385,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--resident" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.resident = val;
flags.cli_resident = true;
if consumed {
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
@@ -464,6 +491,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--session-name" => {
if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone());
flags.cli_session_name = true;
i += 1;
}
}
"--parallel" => {
if let Some(s) = args.get(i + 1) {
flags.parallel = Some(s.clone());
i += 1;
}
}
@@ -523,9 +557,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
// Keep auth/state continuity stable by default: if no explicit --session-name
// is provided, derive it from the default session id.
if flags.session_name.is_none() {
if let Some(parallel_name) = &flags.parallel {
// Validate early so session id derivation cannot introduce unsafe paths.
if !is_valid_session_name(parallel_name) {
// Keep default session and let main.rs surface a user-facing validation error.
} else {
flags.session = format!("parallel-{}", parallel_name);
}
}
// Parallel mode is for isolated/stateless runs.
// Unless --session-name is explicitly provided on this invocation, disable
// auto save/restore persistence to avoid cross-flow auth leakage.
if flags.parallel.is_some() && !flags.cli_session_name {
flags.session_name = None;
}
// Keep auth/state continuity stable by default for the default runtime session.
if flags.session_name.is_none() && flags.parallel.is_none() {
flags.session_name = Some("default".to_string());
}
@@ -542,6 +591,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--full",
"--headed",
"--debug",
"--resident",
"--ignore-https-errors",
"--allow-file-access",
"--auto-connect",
@@ -569,6 +619,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--tab-group-plugin-id",
"--risk-mode",
"--wait-until",
"--parallel",
"--config",
];
@@ -770,6 +821,34 @@ mod tests {
assert_eq!(flags.session_name.as_deref(), Some("default"));
}
#[test]
fn test_parallel_sets_isolated_runtime_session() {
let flags = parse_flags(&args("--parallel worker_a snapshot"));
assert_eq!(flags.parallel.as_deref(), Some("worker_a"));
assert_eq!(flags.session, "parallel-worker_a");
assert_eq!(flags.session_name, None);
}
#[test]
fn test_parallel_keeps_explicit_session_name() {
let flags = parse_flags(&args(
"--parallel worker_b --session-name keep-state snapshot",
));
assert_eq!(flags.session, "parallel-worker_b");
assert_eq!(flags.session_name.as_deref(), Some("keep-state"));
assert!(flags.cli_session_name);
}
#[test]
fn test_parallel_from_env_sets_runtime_session() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_PARALLEL", "AGENT_BROWSER_SESSION_NAME"]);
env::set_var("AGENT_BROWSER_PARALLEL", "envworker");
env::set_var("AGENT_BROWSER_SESSION_NAME", "persisted");
let flags = parse_flags(&args("snapshot"));
assert_eq!(flags.session, "parallel-envworker");
assert_eq!(flags.session_name, None);
}
#[test]
fn test_cli_executable_path_tracking() {
// When --executable-path is passed via CLI, cli_executable_path should be true
@@ -805,6 +884,20 @@ mod tests {
assert!(!flags.cli_annotate);
}
#[test]
fn test_parse_resident_flag() {
let flags = parse_flags(&args("--resident open example.com"));
assert!(flags.resident);
assert!(flags.cli_resident);
}
#[test]
fn test_parse_resident_false() {
let flags = parse_flags(&args("--resident false open example.com"));
assert!(!flags.resident);
assert!(flags.cli_resident);
}
#[test]
fn test_cli_download_path_tracking() {
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
@@ -940,6 +1033,18 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_parallel() {
let cleaned = clean_args(&args("--parallel worker_x open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_resident_flag() {
let cleaned = clean_args(&args("--resident open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args(
@@ -978,7 +1083,8 @@ mod tests {
"headers": "{\"Auth\":\"token\"}",
"tabGroup": "Agent Browser Stealth",
"tabGroupPluginId": "tab-group-plugin-id",
"riskMode": "block"
"riskMode": "block",
"parallel": "worker-c"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
@@ -1010,6 +1116,7 @@ mod tests {
Some("tab-group-plugin-id")
);
assert_eq!(config.risk_mode.as_deref(), Some("block"));
assert_eq!(config.parallel.as_deref(), Some("worker-c"));
}
#[test]
+22
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;
@@ -172,6 +174,24 @@ fn main() {
}
}
if let Some(ref parallel) = flags.parallel {
if !validation::is_valid_session_name(parallel) {
let msg = format!(
"Invalid --parallel value '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
parallel
);
if flags.json {
println!(
r#"{{"success":false,"error":"{}","type":"invalid_parallel_name"}}"#,
msg.replace('"', "\\\"")
);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
if args.iter().any(|a| a == "--profile") {
let msg =
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
@@ -273,6 +293,7 @@ fn main() {
let daemon_result = match ensure_daemon(
&flags.session,
flags.headed,
flags.resident,
flags.executable_path.as_deref(),
&flags.extensions,
flags.args.as_deref(),
@@ -344,6 +365,7 @@ fn main() {
flags
.cli_tab_group_plugin_id
.then_some("--tab-group-plugin-id"),
flags.cli_resident.then_some("--resident"),
]
.into_iter()
.flatten()
+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");
}
}
+67 -6
View File
@@ -3,14 +3,20 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio::time::{Duration, Instant};
use super::actions::{execute_command, DaemonState};
use super::state;
const IDLE_SHUTDOWN_SECS: u64 = 600;
pub async fn run_daemon(session: &str) {
let resident_mode = env::args().any(|arg| arg == "--resident");
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
@@ -33,7 +39,7 @@ pub async fn run_daemon(session: &str) {
}
}
let result = run_socket_server(&socket_path, session).await;
let result = run_socket_server(&socket_path, session, resident_mode).await;
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
@@ -47,7 +53,11 @@ pub async fn run_daemon(session: &str) {
}
#[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
_session: &str,
resident_mode: bool,
) -> Result<(), String> {
use tokio::net::UnixListener;
let listener =
@@ -55,6 +65,9 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
loop {
tokio::select! {
@@ -62,8 +75,10 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let activity_tx = activity_tx.clone();
let active_commands = active_commands.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, activity_tx, active_commands).await;
});
}
Err(e) => {
@@ -71,6 +86,19 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
}
}
Some(_) = activity_rx.recv() => {
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
}
_ = tokio::time::sleep_until(idle_deadline), if !resident_mode => {
if active_commands.load(Ordering::SeqCst) == 0 {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -85,7 +113,11 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
#[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
resident_mode: bool,
) -> Result<(), String> {
use tokio::net::TcpListener;
let port = get_port_for_session(session);
@@ -99,6 +131,9 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
loop {
tokio::select! {
@@ -106,8 +141,10 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let activity_tx = activity_tx.clone();
let active_commands = active_commands.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, activity_tx, active_commands).await;
});
}
Err(e) => {
@@ -115,6 +152,20 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
}
}
}
Some(_) = activity_rx.recv() => {
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
}
_ = tokio::time::sleep_until(idle_deadline), if !resident_mode => {
if active_commands.load(Ordering::SeqCst) == 0 {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
let _ = fs::remove_file(&port_path);
break;
}
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -129,7 +180,12 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
Ok(())
}
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
activity_tx: UnboundedSender<()>,
active_commands: std::sync::Arc<AtomicUsize>,
)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
@@ -166,6 +222,8 @@ where
};
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let _ = activity_tx.send(());
active_commands.fetch_add(1, Ordering::SeqCst);
let response = {
let mut s = state.lock().await;
@@ -175,8 +233,11 @@ where
let mut resp = serde_json::to_string(&response).unwrap_or_default();
resp.push('\n');
if writer.write_all(resp.as_bytes()).await.is_err() {
active_commands.fetch_sub(1, Ordering::SeqCst);
break;
}
active_commands.fetch_sub(1, Ordering::SeqCst);
let _ = activity_tx.send(());
if is_close {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
+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");
}
}
+17 -7
View File
@@ -2076,9 +2076,11 @@ Operations:
Automatic State Persistence:
Use --session-name to auto-save/restore state across restarts.
If omitted, it defaults to "default":
If omitted in default runtime mode, it defaults to "default":
agent-browser --session-name myapp open https://example.com
Or set AGENT_BROWSER_SESSION_NAME environment variable.
Note: with --parallel <name>, persistence is disabled by default unless
--session-name is explicitly passed on the same command.
State Encryption:
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption.
@@ -2105,7 +2107,7 @@ agent-browser session - Manage sessions
Usage: agent-browser session [operation]
Show the current fixed session and active daemon state.
Show the current runtime session and active daemon state.
Operations:
(none) Show current session name
@@ -2437,7 +2439,7 @@ Snapshot Options:
-s, --selector <sel> Scope to CSS selector
Options:
--session <name> Ignored (single default session only)
--session <name> Ignored (runtime uses default session unless --parallel is set)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
@@ -2456,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)
--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)
@@ -2467,7 +2469,10 @@ Options:
Extension side panel supports browser controls + console/network/DOM + workflow scheduling
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
--session-name <name> Auto-save/restore session state (defaults to "default")
--parallel <name> Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
Default behavior in this mode is stateless (no auto session persistence unless --session-name is explicitly passed)
--resident Keep daemon running; disable 10-minute idle auto-shutdown
--session-name <name> Auto-save/restore session state (defaults to "default" in non-parallel mode)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
@@ -2482,6 +2487,7 @@ Options:
Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden
Daemon auto-shuts down after 10 minutes of inactivity unless --resident is set
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
Configuration:
@@ -2497,6 +2503,7 @@ Configuration:
Boolean flags accept an optional true/false value to override config:
--headed (same as --headed true)
--headed false (disables "headed": true from config)
--resident false (disable resident mode for this invocation)
Extensions from user and project configs are merged (not replaced).
@@ -2505,7 +2512,8 @@ Configuration:
Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config)
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: "default")
AGENT_BROWSER_PARALLEL Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
Best for stateless/no-login tasks where throughput matters
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
@@ -2528,7 +2536,7 @@ Environment:
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name (default: "default" when --parallel is not set)
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
@@ -2564,6 +2572,8 @@ Examples:
agent-browser --color-scheme dark open example.com # Dark mode
agent-browser --risk-mode block open example.com # Block on verification/captcha pages
agent-browser --session-name myapp open example.com # Auto-save/restore state
agent-browser --parallel worker-a open example.com # Isolated runtime for parallel AI task
agent-browser --resident open example.com # Keep daemon resident until explicit close
Command Chaining:
Chain commands with && in a single shell call (browser persists via daemon):
+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),
}
}
}
}
+15 -1
View File
@@ -280,6 +280,18 @@ agent-browser state clean --older-than <days> # Delete old states
```bash
agent-browser session # Show current session name
agent-browser session list # List active sessions
agent-browser --parallel worker-a open https://example.com # Isolated runtime for parallel AI tasks
```
## Daemon lifetime
By default, daemon processes auto-shutdown after 10 minutes of inactivity.
Use `--resident` when you need a long-running daemon:
```bash
agent-browser --resident open https://example.com
agent-browser close
```
## Navigation
@@ -293,7 +305,7 @@ agent-browser reload # Reload page
## Global options
```bash
--session-name <name> # Auto-save/restore session state (defaults to "default" when omitted)
--session-name <name> # Auto-save/restore session state (defaults to "default" in non-parallel mode)
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
@@ -315,6 +327,8 @@ agent-browser reload # Reload page
--auto-connect # Auto-discover and connect to running Chrome
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
--parallel <name> # Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
--resident # Keep daemon running; disable 10-minute idle auto-shutdown
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
--debug # Debug output (includes stealth connection type + capabilities)
```
+39 -2
View File
@@ -72,7 +72,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
## All Options
Every CLI flag can be set in the config file using its camelCase equivalent:
Most CLI flags can be set in the config file using their camelCase equivalents (`--resident` is CLI-only):
<table>
<thead>
@@ -128,6 +128,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>parallel</code>
</td>
<td>
<code>--parallel</code>
</td>
<td>string (isolated runtime channel name)</td>
</tr>
<tr>
<td>
<code>executablePath</code>
@@ -353,6 +362,25 @@ session window isolation controls, activation guard toggles, empty-group cleanup
}
```
### Parallel Stateless Worker
```json
{
"parallel": "worker-a"
}
```
Use this for stateless throughput tasks. For authenticated flows, prefer a stable `sessionName`.
## CLI-only daemon lifecycle flag
`--resident` is a CLI-only flag (not a config/env key). It keeps the daemon alive and disables the default 10-minute idle auto-shutdown.
```bash
agent-browser --resident open example.com
agent-browser close
```
## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings:
@@ -368,7 +396,7 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit
```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--resident`.
## Extensions Merging
@@ -471,6 +499,15 @@ These environment variables configure additional daemon and runtime behavior:
<code>default</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_PARALLEL</code>
</td>
<td>
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-&lt;name&gt;</code>).
</td>
<td>(none)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
+34 -2
View File
@@ -4,25 +4,49 @@ export const metadata = pageMetadata('sessions');
# Sessions
Use one default runtime session and optional named persistence:
Use the default runtime session or an isolated parallel runtime channel, plus optional named persistence:
```bash
# Show current runtime session
agent-browser session
# Output: default
# Isolated runtime channel for parallel AI flow
agent-browser --parallel worker-a session
# Output: parallel-worker-a
# Show active daemon sessions
agent-browser session list
```
## Session isolation
The runtime session is fixed to `default`. Use `--session-name` to isolate persisted state files per workflow.
Runtime session defaults to `default`. Use `--parallel <name>` when you need isolated concurrent runtime channels, and `--session-name` to isolate persisted state files per workflow.
- Cookies and storage snapshots
- Authentication state
- Saved state lifecycle
Daemons auto-shutdown after 10 minutes of inactivity by default. Use `--resident` to keep a daemon alive until explicit `close`.
## Parallel runtime channels
Use `--parallel <name>` to isolate runtime channels for concurrent AI execution:
```bash
agent-browser --parallel worker-a open https://example.com
agent-browser --parallel worker-b open https://example.org
```
`--parallel` is intended for stateless throughput tasks (navigation/extraction/checks). For authenticated flows, use a stable `--session-name`.
For long-running workers, add `--resident` to disable idle auto-shutdown:
```bash
agent-browser --parallel worker-a --resident open https://example.com
agent-browser --parallel worker-a close
```
## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
@@ -41,6 +65,8 @@ agent-browser open twitter.com
If `--session-name` is omitted, it defaults to `default`.
When `--parallel` is enabled, auto persistence is disabled by default unless `--session-name` is explicitly passed on that command.
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
### Session name rules
@@ -165,6 +191,12 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
</td>
<td>Auto-save/load state persistence name</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_PARALLEL</code>
</td>
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-&lt;name&gt;</code>)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.16.1-fork.5",
"version": "0.16.3-fork.3",
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module",
"main": "dist/daemon.js",
+21 -5
View File
@@ -208,13 +208,15 @@ agent-browser get text @e1 --json
### Parallel Workflows
```bash
agent-browser --session-name site1 open https://site-a.com
agent-browser --session-name site2 open https://site-b.com
agent-browser --parallel site1 open https://site-a.com
agent-browser --parallel site2 open https://site-b.com
agent-browser --session-name site1 snapshot -i
agent-browser --session-name site2 snapshot -i
agent-browser --parallel site1 snapshot -i
agent-browser --parallel site2 snapshot -i
```
Use `--parallel <name>` for stateless throughput tasks (navigation, extraction, checks). For login/auth continuity, use `--session-name` instead.
### Connect to Existing Chrome
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
@@ -244,6 +246,18 @@ agent-browser --wait-until domcontentloaded open https://example.com
pnpm run check:turnstile-testkey
```
### Daemon Lifecycle
Daemons auto-shutdown after 10 minutes of inactivity.
Use `--resident` for long-running workflows that should not auto-close:
```bash
agent-browser --resident open https://example.com
# keep running until explicit close
agent-browser close
```
### Color Scheme (Dark Mode)
```bash
@@ -301,6 +315,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
@@ -487,7 +503,7 @@ These behaviors are always active. For sensitive sites, combine with `--headed`
## Session Management and Cleanup
`--session` is ignored in this fork. The runtime always uses one default session. Use `--session-name` to isolate persistence when needed.
`--session` is ignored in this fork. Runtime defaults to `default`; use `--parallel <name>` for isolated concurrent channels, and `--session-name` for persistence isolation.
Always close your browser session when done to avoid leaked processes:
+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,
+43 -1
View File
@@ -3,7 +3,7 @@ import * as os from 'os';
import * as path from 'path';
import * as net from 'net';
import { EventEmitter } from 'events';
import { getSocketDir, safeWrite } from './daemon.js';
import { createSerializedExecutor, getSocketDir, safeWrite } from './daemon.js';
/**
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
@@ -159,3 +159,45 @@ describe('safeWrite', () => {
expect(socket.listenerCount('close')).toBe(0);
});
});
describe('createSerializedExecutor', () => {
it('should execute tasks one-by-one even when started concurrently', async () => {
const runSerialized = createSerializedExecutor();
const order: string[] = [];
const slow = runSerialized(async () => {
order.push('slow-start');
await new Promise((resolve) => setTimeout(resolve, 30));
order.push('slow-end');
return 'slow';
});
const fast = runSerialized(async () => {
order.push('fast-start');
order.push('fast-end');
return 'fast';
});
await expect(Promise.all([slow, fast])).resolves.toEqual(['slow', 'fast']);
expect(order).toEqual(['slow-start', 'slow-end', 'fast-start', 'fast-end']);
});
it('should continue running queued tasks after a task fails', async () => {
const runSerialized = createSerializedExecutor();
const order: string[] = [];
const first = runSerialized(async () => {
order.push('first');
throw new Error('boom');
});
const second = runSerialized(async () => {
order.push('second');
return 'ok';
});
await expect(first).rejects.toThrow('boom');
await expect(second).resolves.toBe('ok');
expect(order).toEqual(['first', 'second']);
});
});
+361 -296
View File
@@ -62,6 +62,23 @@ export function safeWrite(socket: net.Socket, payload: string): Promise<void> {
});
}
/**
* Create an async executor that runs tasks strictly one-by-one.
* Used to serialize daemon commands across all client connections.
*/
export function createSerializedExecutor(): <T>(task: () => Promise<T>) => Promise<T> {
let tail: Promise<void> = Promise.resolve();
return async function runSerialized<T>(task: () => Promise<T>): Promise<T> {
const run = tail.then(task, task);
tail = run.then(
() => undefined,
() => undefined
);
return run;
};
}
// Platform detection
const isWindows = process.platform === 'win32';
@@ -73,6 +90,8 @@ let streamServer: StreamServer | null = null;
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
const DEFAULT_STREAM_PORT = 9223;
// Default idle auto-shutdown timeout: 10 minutes
const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60 * 1000;
/**
* Save state to file with optional encryption.
@@ -325,6 +344,7 @@ export function getStreamPortFile(session?: string): string {
export async function startDaemon(options?: {
streamPort?: number;
provider?: string;
resident?: boolean;
}): Promise<void> {
// Ensure socket directory exists with restricted permissions (owner-only access)
const socketDir = getSocketDir();
@@ -345,6 +365,10 @@ export async function startDaemon(options?: {
// Create appropriate manager
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
let shuttingDown = false;
const runSerialized = createSerializedExecutor();
const residentMode = options?.resident ?? process.argv.includes('--resident');
let idleTimer: NodeJS.Timeout | null = null;
let pendingCommands = 0;
// Start stream server if port is specified (or use default if env var is set)
// Note: Stream server only works with BrowserManager (desktop), not iOS
@@ -363,6 +387,21 @@ export async function startDaemon(options?: {
fs.writeFileSync(streamPortFile, streamPort.toString());
}
const cancelIdleTimer = (): void => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const scheduleIdleShutdown = (): void => {
if (residentMode || shuttingDown || pendingCommands > 0) return;
cancelIdleTimer();
idleTimer = setTimeout(() => {
void shutdown('idle timeout');
}, DEFAULT_IDLE_SHUTDOWN_MS);
};
const server = net.createServer((socket) => {
let buffer = '';
let httpChecked = false;
@@ -379,309 +418,326 @@ export async function startDaemon(options?: {
while (commandQueue.length > 0) {
const line = commandQueue.shift()!;
pendingCommands += 1;
cancelIdleTimer();
try {
const parseResult = parseCommand(line);
if (!parseResult.success) {
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
await safeWrite(socket, serializeResponse(resp) + '\n');
continue;
}
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
await runSerialized(async () => {
try {
const devices = await iosManager.listAllDevices();
const response = {
id: parseResult.command.id,
success: true as const,
data: { devices },
};
const parseResult = parseCommand(line);
if (!parseResult.success) {
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
await safeWrite(socket, serializeResponse(resp) + '\n');
return;
}
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
try {
const devices = await iosManager.listAllDevices();
const response = {
id: parseResult.command.id,
success: true as const,
data: { devices },
};
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(
socket,
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
);
}
return;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: attach to an existing browser only.
const isDoctor = parseResult.command.action === 'doctor';
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load' &&
parseResult.command.action !== 'doctor'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(/[,\n]/)
.map((p) => p.trim())
.filter(Boolean)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
// Stealth is always enabled in agent-browser-stealth
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
const launchOptions = {
id: 'auto',
action: 'launch' as const,
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,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess,
colorScheme,
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
tabGroupPluginId:
tabGroupPluginId && tabGroupPluginId.length > 0
? tabGroupPluginId
: undefined,
autoStateFilePath: getSessionAutoStatePath(),
};
let attachedToExistingBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause false-negative attach failures.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
cdpPort: 9333,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
};
await manager.launch({
...cdpLaunchOptions,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
}
}
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
);
}
}
}
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
// This keeps diagnostics actionable even when CDP is down.
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
try {
await manager.launch({
id: 'doctor-cdp',
action: 'launch',
cdpPort: 9333,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
| 'dark'
| 'light'
| 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
try {
await manager.launch({
id: 'doctor-auto-connect',
action: 'launch',
autoConnect: true,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
| 'dark'
| 'light'
| 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
// Keep running: doctor should report failures instead of exiting early.
}
}
}
// Recover from stale state: browser is launched but all pages were closed
if (
manager instanceof BrowserManager &&
manager.isLaunched() &&
!manager.hasPages() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
await manager.ensurePage();
}
// Handle explicit launch with auto-load state
if (
parseResult.command.action === 'launch' &&
manager instanceof BrowserManager &&
!parseResult.command.autoStateFilePath
) {
const autoStatePath = getSessionAutoStatePath();
if (autoStatePath) {
parseResult.command.autoStateFilePath = autoStatePath;
}
}
// Handle close command specially - shuts down daemon
if (parseResult.command.action === 'close') {
// Auto-save state before closing
if (manager instanceof BrowserManager && manager.isLaunched()) {
const savePath = getSessionSaveStatePath();
if (savePath) {
try {
const { encrypted } = await saveStateToFile(manager, savePath);
fs.chmodSync(savePath, 0o600);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`Failed to auto-save session state:`, err);
}
}
}
}
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
await safeWrite(socket, serializeResponse(response) + '\n');
if (!shuttingDown) {
shuttingDown = true;
setTimeout(() => {
server.close();
cleanupSocket();
process.exit(0);
}, 100);
}
commandQueue.length = 0;
processing = false;
return;
}
// Execute command with appropriate handler
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
// Add any launch warnings to the response
if (manager instanceof BrowserManager) {
const warnings = manager.getAndClearWarnings();
if (warnings.length > 0 && response.success && response.data) {
(response.data as Record<string, unknown>).warnings = warnings;
}
}
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(
socket,
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
);
serializeResponse(errorResponse('error', message)) + '\n'
).catch(() => {}); // Socket may already be destroyed
}
continue;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: attach to an existing browser only.
const isDoctor = parseResult.command.action === 'doctor';
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load' &&
parseResult.command.action !== 'doctor'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
// Stealth is always enabled in agent-browser-stealth
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
const launchOptions = {
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess,
colorScheme,
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
tabGroupPluginId:
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
autoStateFilePath: getSessionAutoStatePath(),
};
let attachedToExistingBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause false-negative attach failures.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
cdpPort: 9333,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
};
await manager.launch({
...cdpLaunchOptions,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
}
}
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
);
}
}
}
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
// This keeps diagnostics actionable even when CDP is down.
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
try {
await manager.launch({
id: 'doctor-cdp',
action: 'launch',
cdpPort: 9333,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as 'dark' | 'light' | 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
try {
await manager.launch({
id: 'doctor-auto-connect',
action: 'launch',
autoConnect: true,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
| 'dark'
| 'light'
| 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
// Keep running: doctor should report failures instead of exiting early.
}
}
}
// Recover from stale state: browser is launched but all pages were closed
if (
manager instanceof BrowserManager &&
manager.isLaunched() &&
!manager.hasPages() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
await manager.ensurePage();
}
// Handle explicit launch with auto-load state
if (
parseResult.command.action === 'launch' &&
manager instanceof BrowserManager &&
!parseResult.command.autoStateFilePath
) {
const autoStatePath = getSessionAutoStatePath();
if (autoStatePath) {
parseResult.command.autoStateFilePath = autoStatePath;
}
}
// Handle close command specially - shuts down daemon
if (parseResult.command.action === 'close') {
// Auto-save state before closing
if (manager instanceof BrowserManager && manager.isLaunched()) {
const savePath = getSessionSaveStatePath();
if (savePath) {
try {
const { encrypted } = await saveStateToFile(manager, savePath);
fs.chmodSync(savePath, 0o600);
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
);
}
} catch (err) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`Failed to auto-save session state:`, err);
}
}
}
}
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
await safeWrite(socket, serializeResponse(response) + '\n');
if (!shuttingDown) {
shuttingDown = true;
setTimeout(() => {
server.close();
cleanupSocket();
process.exit(0);
}, 100);
}
commandQueue.length = 0;
processing = false;
return;
}
// Execute command with appropriate handler
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
// Add any launch warnings to the response
if (manager instanceof BrowserManager) {
const warnings = manager.getAndClearWarnings();
if (warnings.length > 0 && response.success && response.data) {
(response.data as Record<string, unknown>).warnings = warnings;
}
}
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch(
() => {}
); // Socket may already be destroyed
});
} finally {
pendingCommands = Math.max(0, pendingCommands - 1);
scheduleIdleShutdown();
}
}
@@ -755,14 +811,16 @@ export async function startDaemon(options?: {
server.on('error', (err) => {
console.error('Server error:', err);
cancelIdleTimer();
cleanupSocket();
process.exit(1);
});
// Handle shutdown signals
const shutdown = async () => {
const shutdown = async (_reason?: string) => {
if (shuttingDown) return;
shuttingDown = true;
cancelIdleTimer();
// Stop stream server if running
if (streamServer) {
@@ -790,28 +848,35 @@ export async function startDaemon(options?: {
// Handle unexpected errors - always cleanup
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
cancelIdleTimer();
cleanupSocket();
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
cancelIdleTimer();
cleanupSocket();
process.exit(1);
});
// Cleanup on normal exit
process.on('exit', () => {
cancelIdleTimer();
cleanupSocket();
});
scheduleIdleShutdown();
// Keep process alive
process.stdin.resume();
}
// Run daemon if this is the entry point
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
startDaemon().catch((err) => {
startDaemon({
resident: process.argv.includes('--resident'),
}).catch((err) => {
console.error('Daemon error:', err);
cleanupSocket();
process.exit(1);