feat: enforce default session daemon isolation and bump 0.16.3-fork.4
This commit is contained in:
@@ -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 <name>` | 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 <name>` 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.
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+410
-45
@@ -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::<u32>() {
|
||||
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<PathBuf, String> {
|
||||
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<String> {
|
||||
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<DaemonResult, String> {
|
||||
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<std::process::Child> = 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<Response, String> {
|
||||
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]
|
||||
|
||||
+2
-47
@@ -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<String> = 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::<u32>() {
|
||||
#[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!(
|
||||
|
||||
+3
-1
@@ -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 <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
|
||||
--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)
|
||||
Note: starting default session reaps all non-default daemon sessions
|
||||
--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)
|
||||
@@ -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-<name>)
|
||||
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
|
||||
|
||||
@@ -279,10 +279,12 @@ 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 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 <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>)
|
||||
--parallel <name> # Isolated runtime channel for parallel AI runs (maps to parallel-<name>; reaped when default session starts)
|
||||
--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)
|
||||
|
||||
@@ -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:
|
||||
<code>AGENT_BROWSER_PARALLEL</code>
|
||||
</td>
|
||||
<td>
|
||||
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>).
|
||||
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>). Non-default daemons are reaped when a default-session command starts.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
|
||||
@@ -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"}'
|
||||
<td>
|
||||
<code>AGENT_BROWSER_PARALLEL</code>
|
||||
</td>
|
||||
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>)</td>
|
||||
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>). Non-default daemons are reaped when a default-session command starts.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -216,6 +216,7 @@ agent-browser --parallel site2 snapshot -i
|
||||
```
|
||||
|
||||
Use `--parallel <name>` 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 <name>` 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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user