diff --git a/README.md b/README.md index 4024885..ec4817b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ 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`. +Default session isolation policy: +- Running a default-session command reaps all non-default daemon sessions (`parallel-*` and legacy named channels). +- This avoids stale daemon reuse and keeps stealth behavior consistent on the primary channel. + | Option | Purpose | Typical Usage | | --- | --- | --- | | `--parallel ` | Isolate runtime channel for concurrent AI tasks | Stateless/no-login parallel jobs | @@ -261,6 +265,7 @@ 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 ` only for stateless parallel workloads where higher throughput matters. +- Default-session commands will reap all non-default daemon sessions, so keep parallel workers short-lived. - 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. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index f1c1570..5d6bfa7 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ [[package]] name = "agent-browser-stealth" -version = "0.16.3-fork.3" +version = "0.16.3-fork.4" dependencies = [ "aes-gcm", "async-trait", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8d69253..c30f3f1 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-browser-stealth" -version = "0.16.3-fork.3" +version = "0.16.3-fork.4" edition = "2021" description = "Stealth browser automation CLI for AI agents with anti-bot evasions" license = "Apache-2.0" diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 5142275..16e1c39 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -4,7 +4,7 @@ use std::env; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; @@ -116,6 +116,30 @@ fn get_pid_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.pid", session)) } +fn get_stream_path(session: &str) -> PathBuf { + get_socket_dir().join(format!("{}.stream", session)) +} + +fn get_meta_path(session: &str) -> PathBuf { + get_socket_dir().join(format!("{}.meta.json", session)) +} + +fn remove_session_artifacts(session: &str) { + let _ = fs::remove_file(get_pid_path(session)); + let _ = fs::remove_file(get_stream_path(session)); + let _ = fs::remove_file(get_meta_path(session)); + + #[cfg(unix)] + { + let _ = fs::remove_file(get_socket_path(session)); + } + + #[cfg(windows)] + { + let _ = fs::remove_file(get_port_path(session)); + } +} + /// Clean up stale socket and PID files for a session fn cleanup_stale_files(session: &str) { // Never delete files for a live daemon. A missing PID file can happen in @@ -124,20 +148,194 @@ fn cleanup_stale_files(session: &str) { return; } + remove_session_artifacts(session); +} + +fn should_reap_for_default(session: &str) -> bool { + session != "default" +} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + if pid == 0 { + return false; + } + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +#[cfg(windows)] +fn process_exists(pid: u32) -> bool { + if pid == 0 { + return false; + } + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid)]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + match output { + Ok(out) => { + let text = String::from_utf8_lossy(&out.stdout); + text.contains(&format!(" {}", pid)) + } + Err(_) => false, + } +} + +#[cfg(unix)] +fn terminate_pid(pid: u32) { + if !process_exists(pid) { + return; + } + + let _ = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + for _ in 0..20 { + if !process_exists(pid) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + + let _ = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + for _ in 0..10 { + if !process_exists(pid) { + return; + } + thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(windows)] +fn terminate_pid(pid: u32) { + if !process_exists(pid) { + return; + } + + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +fn terminate_session_daemon(session: &str) { let pid_path = get_pid_path(session); - let _ = fs::remove_file(&pid_path); + if let Ok(pid_str) = fs::read_to_string(&pid_path) { + if let Ok(pid) = pid_str.trim().parse::() { + terminate_pid(pid); + } + } +} - #[cfg(unix)] - { - let socket_path = get_socket_path(session); - let _ = fs::remove_file(&socket_path); +fn reap_sessions_for_default_start() { + let socket_dir = get_socket_dir(); + let entries = match fs::read_dir(&socket_dir) { + Ok(v) => v, + Err(_) => return, + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".pid") { + continue; + } + + let session = name.trim_end_matches(".pid"); + if session.is_empty() || !should_reap_for_default(session) { + continue; + } + + terminate_session_daemon(session); + remove_session_artifacts(session); + } +} + +fn validate_default_daemon_identity(expected_daemon_path: &Path) -> bool { + let meta_path = get_meta_path("default"); + let meta_raw = match fs::read_to_string(&meta_path) { + Ok(v) => v, + Err(_) => return false, + }; + + let meta: Value = match serde_json::from_str(&meta_raw) { + Ok(v) => v, + Err(_) => return false, + }; + + let cli_version = meta + .get("cliVersion") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let daemon_path = meta + .get("daemonPath") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if cli_version != env!("CARGO_PKG_VERSION") || daemon_path.is_empty() { + return false; } - #[cfg(windows)] - { - let port_path = get_port_path(session); - let _ = fs::remove_file(&port_path); + let expected = expected_daemon_path + .canonicalize() + .unwrap_or_else(|_| expected_daemon_path.to_path_buf()); + let observed = PathBuf::from(daemon_path); + let observed = observed.canonicalize().unwrap_or(observed); + observed == expected +} + +fn resolve_daemon_path() -> Result { + let exe_path = env::current_exe().map_err(|e| e.to_string())?; + // Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary) + let exe_path = exe_path.canonicalize().unwrap_or(exe_path); + let exe_dir = exe_path.parent().unwrap(); + + let mut daemon_paths = vec![ + exe_dir.join("daemon.js"), + exe_dir.join("../dist/daemon.js"), + PathBuf::from("dist/daemon.js"), + ]; + + if let Ok(home) = env::var("AGENT_BROWSER_HOME") { + let home_path = PathBuf::from(&home); + daemon_paths.insert(0, home_path.join("dist/daemon.js")); + daemon_paths.insert(1, home_path.join("daemon.js")); } + + let daemon_path = daemon_paths + .into_iter() + .find(|p| p.exists()) + .ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?; + Ok(daemon_path.canonicalize().unwrap_or(daemon_path)) +} + +pub fn list_live_sessions() -> Vec { + let socket_dir = get_socket_dir(); + let mut sessions = Vec::new(); + + if let Ok(entries) = fs::read_dir(&socket_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".pid") { + continue; + } + let session_name = name.trim_end_matches(".pid"); + if session_name.is_empty() { + continue; + } + + if daemon_ready(session_name) { + sessions.push(session_name.to_string()); + } else { + cleanup_stale_files(session_name); + } + } + } + + sessions.sort(); + sessions } #[cfg(windows)] @@ -202,17 +400,35 @@ pub fn ensure_daemon( tab_group: Option<&str>, tab_group_plugin_id: Option<&str>, ) -> Result { + let daemon_path = resolve_daemon_path()?; + + // Project policy: the default runtime channel is a singleton control plane. + // Before touching it, reap all non-default channels to avoid stale daemon reuse. + if session == "default" { + reap_sessions_for_default_start(); + } + // Socket readiness is the source of truth for a usable daemon. // PID files can be missing/stale under concurrent start/stop races. if daemon_ready(session) { - // Double-check it's actually responsive by waiting and checking again - // This handles the race condition where daemon is shutting down - // (daemon has a 100ms shutdown delay, so we wait longer) - thread::sleep(Duration::from_millis(150)); - if daemon_ready(session) { - return Ok(DaemonResult { - already_running: true, - }); + let mut should_reuse = true; + if session == "default" { + should_reuse = validate_default_daemon_identity(&daemon_path); + } + + if should_reuse { + // Double-check it's actually responsive by waiting and checking again + // This handles the race condition where daemon is shutting down + // (daemon has a 100ms shutdown delay, so we wait longer) + thread::sleep(Duration::from_millis(150)); + if daemon_ready(session) { + return Ok(DaemonResult { + already_running: true, + }); + } + } else { + terminate_session_daemon(session); + remove_session_artifacts(session); } } @@ -257,29 +473,6 @@ pub fn ensure_daemon( } } - let exe_path = env::current_exe().map_err(|e| e.to_string())?; - // Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary) - let exe_path = exe_path.canonicalize().unwrap_or(exe_path); - let exe_dir = exe_path.parent().unwrap(); - - let mut daemon_paths = vec![ - exe_dir.join("daemon.js"), - exe_dir.join("../dist/daemon.js"), - PathBuf::from("dist/daemon.js"), - ]; - - // Check AGENT_BROWSER_HOME environment variable - if let Ok(home) = env::var("AGENT_BROWSER_HOME") { - let home_path = PathBuf::from(&home); - daemon_paths.insert(0, home_path.join("dist/daemon.js")); - daemon_paths.insert(1, home_path.join("daemon.js")); - } - - let daemon_path = daemon_paths - .iter() - .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 = None; @@ -290,14 +483,15 @@ pub fn ensure_daemon( use std::os::unix::process::CommandExt; let mut cmd = Command::new("node"); - cmd.arg(daemon_path) + cmd.arg(&daemon_path) .arg(if resident { "--resident" } else { "--idle-auto-shutdown" }) .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); + .env("AGENT_BROWSER_SESSION", session) + .env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION")); if headed { cmd.env("AGENT_BROWSER_HEADED", "1"); @@ -390,14 +584,15 @@ pub fn ensure_daemon( // On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) // and automatically quotes arguments containing spaces. let mut cmd = Command::new("node"); - cmd.arg(daemon_path) + cmd.arg(&daemon_path) .arg(if resident { "--resident" } else { "--idle-auto-shutdown" }) .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); + .env("AGENT_BROWSER_SESSION", session) + .env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION")); if headed { cmd.env("AGENT_BROWSER_HEADED", "1"); @@ -606,6 +801,15 @@ fn send_command_once(cmd: &Value, session: &str) -> Result { mod tests { use super::*; use crate::test_utils::EnvGuard; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_temp_dir(prefix: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), nonce)) + } #[test] fn test_get_socket_dir_explicit_override() { @@ -668,6 +872,167 @@ mod tests { ); } + #[test] + fn test_should_reap_for_default_policy() { + assert!(!should_reap_for_default("default")); + assert!(should_reap_for_default("parallel-worker-a")); + assert!(should_reap_for_default("legacy-session")); + } + + #[test] + fn test_reap_sessions_for_default_start_removes_non_default_artifacts() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-reap"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + fs::write(dir.join("default.pid"), "999999").unwrap(); + fs::write(dir.join("parallel-a.pid"), "999999").unwrap(); + fs::write(dir.join("parallel-a.meta.json"), "{}").unwrap(); + fs::write(dir.join("legacy-x.pid"), "not-a-pid").unwrap(); + fs::write(dir.join("legacy-x.meta.json"), "{}").unwrap(); + #[cfg(unix)] + { + fs::write(dir.join("parallel-a.sock"), "").unwrap(); + fs::write(dir.join("legacy-x.sock"), "").unwrap(); + } + #[cfg(windows)] + { + fs::write(dir.join("parallel-a.port"), "").unwrap(); + fs::write(dir.join("legacy-x.port"), "").unwrap(); + } + + reap_sessions_for_default_start(); + + assert!(dir.join("default.pid").exists()); + assert!(!dir.join("parallel-a.pid").exists()); + assert!(!dir.join("parallel-a.meta.json").exists()); + assert!(!dir.join("legacy-x.pid").exists()); + assert!(!dir.join("legacy-x.meta.json").exists()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_remove_session_artifacts_cleans_all_known_files() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-clean-artifacts"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let session = "legacy-x"; + fs::write(dir.join(format!("{}.pid", session)), "999999").unwrap(); + fs::write(dir.join(format!("{}.stream", session)), "35555").unwrap(); + fs::write(dir.join(format!("{}.meta.json", session)), "{}").unwrap(); + #[cfg(unix)] + fs::write(dir.join(format!("{}.sock", session)), "").unwrap(); + #[cfg(windows)] + fs::write(dir.join(format!("{}.port", session)), "45555").unwrap(); + + remove_session_artifacts(session); + + assert!(!dir.join(format!("{}.pid", session)).exists()); + assert!(!dir.join(format!("{}.stream", session)).exists()); + assert!(!dir.join(format!("{}.meta.json", session)).exists()); + #[cfg(unix)] + assert!(!dir.join(format!("{}.sock", session)).exists()); + #[cfg(windows)] + assert!(!dir.join(format!("{}.port", session)).exists()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_validate_default_daemon_identity_match() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-meta-ok"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let daemon_path = dir.join("daemon.js"); + fs::write(&daemon_path, "// test").unwrap(); + let canonical = daemon_path.canonicalize().unwrap(); + let meta = serde_json::json!({ + "cliVersion": env!("CARGO_PKG_VERSION"), + "daemonPath": canonical.to_string_lossy(), + }); + fs::write(dir.join("default.meta.json"), meta.to_string()).unwrap(); + + assert!(validate_default_daemon_identity(&daemon_path)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_validate_default_daemon_identity_version_mismatch() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-meta-bad"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let daemon_path = dir.join("daemon.js"); + fs::write(&daemon_path, "// test").unwrap(); + let canonical = daemon_path.canonicalize().unwrap(); + let meta = serde_json::json!({ + "cliVersion": "0.0.0-fork.0", + "daemonPath": canonical.to_string_lossy(), + }); + fs::write(dir.join("default.meta.json"), meta.to_string()).unwrap(); + + assert!(!validate_default_daemon_identity(&daemon_path)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_validate_default_daemon_identity_path_mismatch() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-meta-path-mismatch"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let daemon_path = dir.join("daemon.js"); + let other_path = dir.join("daemon-other.js"); + fs::write(&daemon_path, "// test").unwrap(); + fs::write(&other_path, "// other").unwrap(); + let other_canonical = other_path.canonicalize().unwrap(); + let meta = serde_json::json!({ + "cliVersion": env!("CARGO_PKG_VERSION"), + "daemonPath": other_canonical.to_string_lossy(), + }); + fs::write(dir.join("default.meta.json"), meta.to_string()).unwrap(); + + assert!(!validate_default_daemon_identity(&daemon_path)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_validate_default_daemon_identity_missing_meta() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-meta-missing"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let daemon_path = dir.join("daemon.js"); + fs::write(&daemon_path, "// test").unwrap(); + + assert!(!validate_default_daemon_identity(&daemon_path)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_validate_default_daemon_identity_bad_json() { + let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR"]); + let dir = test_temp_dir("agent-browser-meta-bad-json"); + fs::create_dir_all(&dir).unwrap(); + _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_string_lossy().as_ref()); + + let daemon_path = dir.join("daemon.js"); + fs::write(&daemon_path, "// test").unwrap(); + fs::write(dir.join("default.meta.json"), "{invalid-json").unwrap(); + + assert!(!validate_default_daemon_identity(&daemon_path)); + let _ = fs::remove_dir_all(&dir); + } + // === Transient Error Detection Tests === #[test] diff --git a/cli/src/main.rs b/cli/src/main.rs index 0087bed..c32c3c4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -10,16 +10,10 @@ mod validation; use serde_json::json; use std::env; -use std::fs; use std::process::exit; -#[cfg(windows)] -use windows_sys::Win32::Foundation::CloseHandle; -#[cfg(windows)] -use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; - use commands::{gen_id, parse_command, ParseError}; -use connection::{ensure_daemon, get_socket_dir, send_command}; +use connection::{ensure_daemon, list_live_sessions, send_command}; use flags::{clean_args, parse_flags}; use install::run_install; use output::{print_command_help, print_help, print_response, print_version}; @@ -59,46 +53,7 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { match subcommand { Some("list") => { - let socket_dir = get_socket_dir(); - let mut sessions: Vec = Vec::new(); - - if let Ok(entries) = fs::read_dir(&socket_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - // Look for pid files in socket directory - if name.ends_with(".pid") { - let session_name = name.strip_suffix(".pid").unwrap_or(""); - if !session_name.is_empty() { - // Check if session is actually running - let pid_path = socket_dir.join(&name); - if let Ok(pid_str) = fs::read_to_string(&pid_path) { - if let Ok(pid) = pid_str.trim().parse::() { - #[cfg(unix)] - let running = unsafe { - libc::kill(pid as i32, 0) == 0 - || std::io::Error::last_os_error().raw_os_error() - != Some(libc::ESRCH) - }; - #[cfg(windows)] - let running = unsafe { - let handle = - OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle != 0 { - CloseHandle(handle); - true - } else { - false - } - }; - if running { - sessions.push(session_name.to_string()); - } - } - } - } - } - } - } + let sessions = list_live_sessions(); if json_mode { println!( diff --git a/cli/src/output.rs b/cli/src/output.rs index 448f32f..6716d27 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2425,7 +2425,7 @@ Confirmation: Sessions: session Show current session name - session list List active sessions + session list List active sessions (stale entries are auto-cleaned) Setup: install Install browser binaries @@ -2471,6 +2471,7 @@ Options: --wait-until Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle --parallel Isolated runtime channel for parallel AI runs (maps to parallel-) Default behavior in this mode is stateless (no auto session persistence unless --session-name is explicitly passed) + Note: starting default session reaps all non-default daemon sessions --resident Keep daemon running; disable 10-minute idle auto-shutdown --session-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) @@ -2514,6 +2515,7 @@ Environment: AGENT_BROWSER_CONFIG Path to config file (or use --config) AGENT_BROWSER_PARALLEL Isolated runtime channel for parallel AI runs (maps to parallel-) Best for stateless/no-login tasks where throughput matters + Note: any default-session command reaps non-default daemon sessions 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 diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 11091a9..424bbc9 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -279,10 +279,12 @@ agent-browser state clean --older-than # Delete old states ```bash agent-browser session # Show current session name -agent-browser session list # List active sessions +agent-browser session list # List active sessions (auto-cleans stale entries) agent-browser --parallel worker-a open https://example.com # Isolated runtime for parallel AI tasks ``` +When a default-session command starts, all non-default daemon sessions are reaped to avoid stale daemon reuse. + ## Daemon lifetime By default, daemon processes auto-shutdown after 10 minutes of inactivity. @@ -327,7 +329,7 @@ agent-browser reload # Reload page --auto-connect # Auto-discover and connect to running Chrome --tab-group # Base title for agent tab groups (CDP plugin mode) --tab-group-plugin-id # Expected extension ID for tab-group handshake ---parallel # Isolated runtime channel for parallel AI runs (maps to parallel-) +--parallel # Isolated runtime channel for parallel AI runs (maps to parallel-; reaped when default session starts) --resident # Keep daemon running; disable 10-minute idle auto-shutdown --wait-until # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle) --debug # Debug output (includes stealth connection type + capabilities) diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 07a65b3..674c708 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -371,6 +371,7 @@ session window isolation controls, activation guard toggles, empty-group cleanup ``` Use this for stateless throughput tasks. For authenticated flows, prefer a stable `sessionName`. +When a default-session command runs, non-default daemon sessions are reaped. ## CLI-only daemon lifecycle flag @@ -504,7 +505,7 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_PARALLEL - Isolated runtime channel name for parallel AI runs (maps to parallel-<name>). + Isolated runtime channel name for parallel AI runs (maps to parallel-<name>). Non-default daemons are reaped when a default-session command starts. (none) diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index bd3f166..05ac05d 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -17,6 +17,7 @@ agent-browser --parallel worker-a session # Show active daemon sessions agent-browser session list +# stale entries are auto-cleaned during listing ``` ## Session isolation @@ -40,6 +41,11 @@ 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`. +Default session isolation policy: + +- Running a default-session command reaps all non-default daemon sessions (`parallel-*` and legacy named channels). +- This keeps the primary `default` runtime channel free from stale daemon reuse. + For long-running workers, add `--resident` to disable idle auto-shutdown: ```bash @@ -195,7 +201,7 @@ agent-browser set headers '{"X-Custom-Header": "value"}' AGENT_BROWSER_PARALLEL - Isolated runtime channel name for parallel AI runs (maps to parallel-<name>) + Isolated runtime channel name for parallel AI runs (maps to parallel-<name>). Non-default daemons are reaped when a default-session command starts. diff --git a/package.json b/package.json index d9abdfd..078b4da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-browser-stealth", - "version": "0.16.3-fork.3", + "version": "0.16.3-fork.4", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions", "type": "module", "main": "dist/daemon.js", diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 4851ee7..f9809d9 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -216,6 +216,7 @@ agent-browser --parallel site2 snapshot -i ``` Use `--parallel ` for stateless throughput tasks (navigation, extraction, checks). For login/auth continuity, use `--session-name` instead. +Default-session commands intentionally reap all non-default daemon sessions (`parallel-*` and legacy named channels) to prevent stale daemon reuse. ### Connect to Existing Chrome @@ -504,6 +505,7 @@ These behaviors are always active. For sensitive sites, combine with `--headed` ## Session Management and Cleanup `--session` is ignored in this fork. Runtime defaults to `default`; use `--parallel ` for isolated concurrent channels, and `--session-name` for persistence isolation. +When a default-session command runs, non-default daemon sessions are reaped automatically. Always close your browser session when done to avoid leaked processes: diff --git a/src/daemon.ts b/src/daemon.ts index e24a2f5..b0303ae 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -269,6 +269,14 @@ export function getPidFile(session?: string): string { return path.join(getSocketDir(), `${sess}.pid`); } +/** + * Get the daemon metadata file path for a session. + */ +export function getMetaFile(session?: string): string { + const sess = session ?? currentSession; + return path.join(getSocketDir(), `${sess}.meta.json`); +} + /** * Check if daemon is running for the current session */ @@ -313,9 +321,11 @@ export function getConnectionInfo( export function cleanupSocket(session?: string): void { const pidFile = getPidFile(session); const streamPortFile = getStreamPortFile(session); + const metaFile = getMetaFile(session); try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile); + if (fs.existsSync(metaFile)) fs.unlinkSync(metaFile); if (isWindows) { const portFile = getPortFile(session); if (fs.existsSync(portFile)) fs.unlinkSync(portFile); @@ -792,6 +802,28 @@ export async function startDaemon(options?: { // Write PID file before listening fs.writeFileSync(pidFile, process.pid.toString()); + try { + fs.chmodSync(pidFile, 0o600); + } catch { + // Best-effort hardening; skip on platforms that don't support POSIX modes. + } + + const metaFile = getMetaFile(); + // Ownership/version proof consumed by the CLI before reusing default daemon. + const daemonMeta = { + session: currentSession, + pid: process.pid, + startedAt: Date.now(), + daemonPath: process.argv[1] ? path.resolve(process.argv[1]) : '', + cliVersion: process.env.AGENT_BROWSER_CLI_VERSION ?? '', + mode: residentMode ? 'resident' : 'idle', + } as const; + fs.writeFileSync(metaFile, JSON.stringify(daemonMeta, null, 2)); + try { + fs.chmodSync(metaFile, 0o600); + } catch { + // Best-effort hardening; skip on platforms that don't support POSIX modes. + } if (isWindows) { // Windows: use TCP socket on localhost