Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5970579d7c | ||
|
|
8880aa2f35 |
@@ -52,6 +52,33 @@ agent-browser snapshot -i
|
|||||||
agent-browser click @e2
|
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)
|
### Default: Auto Group Agent Tabs (CDP + Plugin)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -233,6 +260,8 @@ flowchart TD
|
|||||||
|
|
||||||
- Prefer `--headed` for high-friction targets.
|
- Prefer `--headed` for high-friction targets.
|
||||||
- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `default`).
|
- 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.
|
- Keep locale/timezone consistent with target market.
|
||||||
- For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls.
|
- 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.
|
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
|
||||||
|
|||||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.16.3-fork.1"
|
version = "0.16.3-fork.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.16.3-fork.1"
|
version = "0.16.3-fork.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -2060,6 +2060,7 @@ mod tests {
|
|||||||
full: false,
|
full: false,
|
||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
resident: false,
|
||||||
headers: None,
|
headers: None,
|
||||||
executable_path: None,
|
executable_path: None,
|
||||||
extensions: Vec::new(),
|
extensions: Vec::new(),
|
||||||
@@ -2075,6 +2076,7 @@ mod tests {
|
|||||||
device: None,
|
device: None,
|
||||||
auto_connect: false,
|
auto_connect: false,
|
||||||
session_name: None,
|
session_name: None,
|
||||||
|
parallel: None,
|
||||||
cli_executable_path: false,
|
cli_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
cli_state: false,
|
cli_state: false,
|
||||||
@@ -2094,6 +2096,8 @@ mod tests {
|
|||||||
wait_until: None,
|
wait_until: None,
|
||||||
cli_tab_group: false,
|
cli_tab_group: false,
|
||||||
cli_tab_group_plugin_id: false,
|
cli_tab_group_plugin_id: false,
|
||||||
|
cli_session_name: false,
|
||||||
|
cli_resident: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ pub struct DaemonResult {
|
|||||||
pub fn ensure_daemon(
|
pub fn ensure_daemon(
|
||||||
session: &str,
|
session: &str,
|
||||||
headed: bool,
|
headed: bool,
|
||||||
|
// Keep daemon resident and disable idle auto-shutdown.
|
||||||
|
resident: bool,
|
||||||
executable_path: Option<&str>,
|
executable_path: Option<&str>,
|
||||||
extensions: &[String],
|
extensions: &[String],
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
@@ -289,6 +291,11 @@ pub fn ensure_daemon(
|
|||||||
|
|
||||||
let mut cmd = Command::new("node");
|
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_DAEMON", "1")
|
||||||
.env("AGENT_BROWSER_SESSION", session);
|
.env("AGENT_BROWSER_SESSION", session);
|
||||||
|
|
||||||
@@ -384,6 +391,11 @@ pub fn ensure_daemon(
|
|||||||
// and automatically quotes arguments containing spaces.
|
// and automatically quotes arguments containing spaces.
|
||||||
let mut cmd = Command::new("node");
|
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_DAEMON", "1")
|
||||||
.env("AGENT_BROWSER_SESSION", session);
|
.env("AGENT_BROWSER_SESSION", session);
|
||||||
|
|
||||||
|
|||||||
+113
-6
@@ -1,4 +1,5 @@
|
|||||||
use crate::color;
|
use crate::color;
|
||||||
|
use crate::validation::is_valid_session_name;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -40,6 +41,7 @@ pub struct Config {
|
|||||||
pub tab_group_plugin_id: Option<String>,
|
pub tab_group_plugin_id: Option<String>,
|
||||||
pub risk_mode: Option<String>,
|
pub risk_mode: Option<String>,
|
||||||
pub wait_until: Option<String>,
|
pub wait_until: Option<String>,
|
||||||
|
pub parallel: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -78,6 +80,7 @@ impl Config {
|
|||||||
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
|
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
|
||||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||||
wait_until: other.wait_until.or(self.wait_until),
|
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",
|
"--tab-group-plugin-id",
|
||||||
"--risk-mode",
|
"--risk-mode",
|
||||||
"--wait-until",
|
"--wait-until",
|
||||||
|
"--parallel",
|
||||||
];
|
];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@@ -199,6 +203,10 @@ pub struct Flags {
|
|||||||
pub full: bool,
|
pub full: bool,
|
||||||
pub headed: bool,
|
pub headed: bool,
|
||||||
pub debug: 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 session: String,
|
||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
@@ -214,7 +222,9 @@ pub struct Flags {
|
|||||||
pub allow_file_access: bool,
|
pub allow_file_access: bool,
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
pub auto_connect: bool,
|
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 annotate: bool,
|
||||||
pub color_scheme: Option<String>,
|
pub color_scheme: Option<String>,
|
||||||
pub download_path: Option<String>,
|
pub download_path: Option<String>,
|
||||||
@@ -226,6 +236,8 @@ pub struct Flags {
|
|||||||
/// Navigation wait strategy passed to navigate/open commands:
|
/// Navigation wait strategy passed to navigate/open commands:
|
||||||
/// `load`, `domcontentloaded`, or `networkidle`.
|
/// `load`, `domcontentloaded`, or `networkidle`.
|
||||||
pub wait_until: Option<String>,
|
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
|
// Track which launch-time options were explicitly passed via CLI
|
||||||
// (as opposed to being set only via environment variables)
|
// (as opposed to being set only via environment variables)
|
||||||
@@ -241,6 +253,8 @@ pub struct Flags {
|
|||||||
pub cli_download_path: bool,
|
pub cli_download_path: bool,
|
||||||
pub cli_tab_group: bool,
|
pub cli_tab_group: bool,
|
||||||
pub cli_tab_group_plugin_id: bool,
|
pub cli_tab_group_plugin_id: bool,
|
||||||
|
pub cli_session_name: bool,
|
||||||
|
pub cli_resident: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
@@ -273,7 +287,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
Err(_) => config.headed.unwrap_or(true),
|
Err(_) => config.headed.unwrap_or(true),
|
||||||
},
|
},
|
||||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
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(),
|
session: "default".to_string(),
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
||||||
@@ -321,6 +337,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
.or(config.risk_mode)
|
.or(config.risk_mode)
|
||||||
.map(|s| s.to_ascii_lowercase()),
|
.map(|s| s.to_ascii_lowercase()),
|
||||||
wait_until: config.wait_until.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_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
cli_state: false,
|
cli_state: false,
|
||||||
@@ -333,6 +350,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
cli_download_path: false,
|
cli_download_path: false,
|
||||||
cli_tab_group: false,
|
cli_tab_group: false,
|
||||||
cli_tab_group_plugin_id: false,
|
cli_tab_group_plugin_id: false,
|
||||||
|
cli_session_name: false,
|
||||||
|
cli_resident: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -366,6 +385,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--resident" => {
|
||||||
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
|
flags.resident = val;
|
||||||
|
flags.cli_resident = true;
|
||||||
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
"--headers" => {
|
"--headers" => {
|
||||||
if let Some(h) = args.get(i + 1) {
|
if let Some(h) = args.get(i + 1) {
|
||||||
flags.headers = Some(h.clone());
|
flags.headers = Some(h.clone());
|
||||||
@@ -464,6 +491,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
"--session-name" => {
|
"--session-name" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
flags.session_name = Some(s.clone());
|
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;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,9 +557,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep auth/state continuity stable by default: if no explicit --session-name
|
if let Some(parallel_name) = &flags.parallel {
|
||||||
// is provided, derive it from the default session id.
|
// Validate early so session id derivation cannot introduce unsafe paths.
|
||||||
if flags.session_name.is_none() {
|
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());
|
flags.session_name = Some("default".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +591,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--full",
|
"--full",
|
||||||
"--headed",
|
"--headed",
|
||||||
"--debug",
|
"--debug",
|
||||||
|
"--resident",
|
||||||
"--ignore-https-errors",
|
"--ignore-https-errors",
|
||||||
"--allow-file-access",
|
"--allow-file-access",
|
||||||
"--auto-connect",
|
"--auto-connect",
|
||||||
@@ -569,6 +619,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--tab-group-plugin-id",
|
"--tab-group-plugin-id",
|
||||||
"--risk-mode",
|
"--risk-mode",
|
||||||
"--wait-until",
|
"--wait-until",
|
||||||
|
"--parallel",
|
||||||
"--config",
|
"--config",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -770,6 +821,34 @@ mod tests {
|
|||||||
assert_eq!(flags.session_name.as_deref(), Some("default"));
|
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]
|
#[test]
|
||||||
fn test_cli_executable_path_tracking() {
|
fn test_cli_executable_path_tracking() {
|
||||||
// When --executable-path is passed via CLI, cli_executable_path should be true
|
// When --executable-path is passed via CLI, cli_executable_path should be true
|
||||||
@@ -805,6 +884,20 @@ mod tests {
|
|||||||
assert!(!flags.cli_annotate);
|
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]
|
#[test]
|
||||||
fn test_cli_download_path_tracking() {
|
fn test_cli_download_path_tracking() {
|
||||||
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
||||||
@@ -940,6 +1033,18 @@ mod tests {
|
|||||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
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]
|
#[test]
|
||||||
fn test_cli_multiple_flags_tracking() {
|
fn test_cli_multiple_flags_tracking() {
|
||||||
let flags = parse_flags(&args(
|
let flags = parse_flags(&args(
|
||||||
@@ -978,7 +1083,8 @@ mod tests {
|
|||||||
"headers": "{\"Auth\":\"token\"}",
|
"headers": "{\"Auth\":\"token\"}",
|
||||||
"tabGroup": "Agent Browser Stealth",
|
"tabGroup": "Agent Browser Stealth",
|
||||||
"tabGroupPluginId": "tab-group-plugin-id",
|
"tabGroupPluginId": "tab-group-plugin-id",
|
||||||
"riskMode": "block"
|
"riskMode": "block",
|
||||||
|
"parallel": "worker-c"
|
||||||
}"#;
|
}"#;
|
||||||
let config: Config = serde_json::from_str(json).unwrap();
|
let config: Config = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(config.headed, Some(true));
|
assert_eq!(config.headed, Some(true));
|
||||||
@@ -1010,6 +1116,7 @@ mod tests {
|
|||||||
Some("tab-group-plugin-id")
|
Some("tab-group-plugin-id")
|
||||||
);
|
);
|
||||||
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||||
|
assert_eq!(config.parallel.as_deref(), Some("worker-c"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -174,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") {
|
if args.iter().any(|a| a == "--profile") {
|
||||||
let msg =
|
let msg =
|
||||||
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
|
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
|
||||||
@@ -275,6 +293,7 @@ fn main() {
|
|||||||
let daemon_result = match ensure_daemon(
|
let daemon_result = match ensure_daemon(
|
||||||
&flags.session,
|
&flags.session,
|
||||||
flags.headed,
|
flags.headed,
|
||||||
|
flags.resident,
|
||||||
flags.executable_path.as_deref(),
|
flags.executable_path.as_deref(),
|
||||||
&flags.extensions,
|
&flags.extensions,
|
||||||
flags.args.as_deref(),
|
flags.args.as_deref(),
|
||||||
@@ -346,6 +365,7 @@ fn main() {
|
|||||||
flags
|
flags
|
||||||
.cli_tab_group_plugin_id
|
.cli_tab_group_plugin_id
|
||||||
.then_some("--tab-group-plugin-id"),
|
.then_some("--tab-group-plugin-id"),
|
||||||
|
flags.cli_resident.then_some("--resident"),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ use std::env;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||||
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::actions::{execute_command, DaemonState};
|
use super::actions::{execute_command, DaemonState};
|
||||||
use super::state;
|
use super::state;
|
||||||
|
|
||||||
|
const IDLE_SHUTDOWN_SECS: u64 = 600;
|
||||||
|
|
||||||
pub async fn run_daemon(session: &str) {
|
pub async fn run_daemon(session: &str) {
|
||||||
|
let resident_mode = env::args().any(|arg| arg == "--resident");
|
||||||
let socket_dir = get_daemon_socket_dir();
|
let socket_dir = get_daemon_socket_dir();
|
||||||
if !socket_dir.exists() {
|
if !socket_dir.exists() {
|
||||||
let _ = fs::create_dir_all(&socket_dir);
|
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(&socket_path);
|
||||||
let _ = fs::remove_file(&pid_path);
|
let _ = fs::remove_file(&pid_path);
|
||||||
@@ -47,7 +53,11 @@ pub async fn run_daemon(session: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[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;
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
let listener =
|
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>> =
|
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -62,8 +75,10 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
|
|||||||
match accept_result {
|
match accept_result {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
|
let activity_tx = activity_tx.clone();
|
||||||
|
let active_commands = active_commands.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
handle_connection(stream, state).await;
|
handle_connection(stream, state, activity_tx, active_commands).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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() => {
|
_ = shutdown_signal() => {
|
||||||
let mut s = state.lock().await;
|
let mut s = state.lock().await;
|
||||||
if let Some(ref mut mgr) = s.browser {
|
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)]
|
#[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;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
let port = get_port_for_session(session);
|
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>> =
|
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -106,8 +141,10 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
|
|||||||
match accept_result {
|
match accept_result {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
|
let activity_tx = activity_tx.clone();
|
||||||
|
let active_commands = active_commands.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
handle_connection(stream, state).await;
|
handle_connection(stream, state, activity_tx, active_commands).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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() => {
|
_ = shutdown_signal() => {
|
||||||
let mut s = state.lock().await;
|
let mut s = state.lock().await;
|
||||||
if let Some(ref mut mgr) = s.browser {
|
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(())
|
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
|
where
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
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 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 response = {
|
||||||
let mut s = state.lock().await;
|
let mut s = state.lock().await;
|
||||||
@@ -175,8 +233,11 @@ where
|
|||||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||||
resp.push('\n');
|
resp.push('\n');
|
||||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||||
|
active_commands.fetch_sub(1, Ordering::SeqCst);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
active_commands.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
let _ = activity_tx.send(());
|
||||||
|
|
||||||
if is_close {
|
if is_close {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|||||||
+16
-6
@@ -2076,9 +2076,11 @@ Operations:
|
|||||||
|
|
||||||
Automatic State Persistence:
|
Automatic State Persistence:
|
||||||
Use --session-name to auto-save/restore state across restarts.
|
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
|
agent-browser --session-name myapp open https://example.com
|
||||||
Or set AGENT_BROWSER_SESSION_NAME environment variable.
|
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:
|
State Encryption:
|
||||||
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM 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]
|
Usage: agent-browser session [operation]
|
||||||
|
|
||||||
Show the current fixed session and active daemon state.
|
Show the current runtime session and active daemon state.
|
||||||
|
|
||||||
Operations:
|
Operations:
|
||||||
(none) Show current session name
|
(none) Show current session name
|
||||||
@@ -2437,7 +2439,7 @@ Snapshot Options:
|
|||||||
-s, --selector <sel> Scope to CSS selector
|
-s, --selector <sel> Scope to CSS selector
|
||||||
|
|
||||||
Options:
|
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)
|
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
|
||||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
@@ -2467,7 +2469,10 @@ Options:
|
|||||||
Extension side panel supports browser controls + console/network/DOM + workflow scheduling
|
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)
|
--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
|
--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)
|
--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)
|
--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)
|
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
|
||||||
@@ -2482,6 +2487,7 @@ Options:
|
|||||||
Policy:
|
Policy:
|
||||||
--profile / AGENT_BROWSER_PROFILE are forbidden
|
--profile / AGENT_BROWSER_PROFILE are forbidden
|
||||||
--channel / AGENT_BROWSER_CHANNEL 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
|
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
@@ -2497,6 +2503,7 @@ Configuration:
|
|||||||
Boolean flags accept an optional true/false value to override config:
|
Boolean flags accept an optional true/false value to override config:
|
||||||
--headed (same as --headed true)
|
--headed (same as --headed true)
|
||||||
--headed false (disables "headed": true from config)
|
--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).
|
Extensions from user and project configs are merged (not replaced).
|
||||||
|
|
||||||
@@ -2505,7 +2512,8 @@ Configuration:
|
|||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
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_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_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
|
||||||
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
|
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_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_RISK_MODE Verify/captcha handling mode (off, warn, block)
|
||||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
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_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_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
|
||||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
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 --color-scheme dark open example.com # Dark mode
|
||||||
agent-browser --risk-mode block open example.com # Block on verification/captcha pages
|
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 --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:
|
Command Chaining:
|
||||||
Chain commands with && in a single shell call (browser persists via daemon):
|
Chain commands with && in a single shell call (browser persists via daemon):
|
||||||
|
|||||||
@@ -280,6 +280,18 @@ agent-browser state clean --older-than <days> # Delete old states
|
|||||||
```bash
|
```bash
|
||||||
agent-browser session # Show current session name
|
agent-browser session # Show current session name
|
||||||
agent-browser session list # List active sessions
|
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
|
## Navigation
|
||||||
@@ -293,7 +305,7 @@ agent-browser reload # Reload page
|
|||||||
## Global options
|
## Global options
|
||||||
|
|
||||||
```bash
|
```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
|
--state <path> # Load storage state from JSON file
|
||||||
--headers <json> # HTTP headers scoped to URL's origin
|
--headers <json> # HTTP headers scoped to URL's origin
|
||||||
--executable-path <path> # Custom browser executable
|
--executable-path <path> # Custom browser executable
|
||||||
@@ -315,6 +327,8 @@ agent-browser reload # Reload page
|
|||||||
--auto-connect # Auto-discover and connect to running Chrome
|
--auto-connect # Auto-discover and connect to running Chrome
|
||||||
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
|
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
|
||||||
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
|
--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)
|
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
|
||||||
--debug # Debug output (includes stealth connection type + capabilities)
|
--debug # Debug output (includes stealth connection type + capabilities)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
|||||||
|
|
||||||
## All Options
|
## 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>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -128,6 +128,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
|||||||
</td>
|
</td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>parallel</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>--parallel</code>
|
||||||
|
</td>
|
||||||
|
<td>string (isolated runtime channel name)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<code>executablePath</code>
|
<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
|
## Overriding Boolean Options
|
||||||
|
|
||||||
Boolean flags accept an optional `true`/`false` value to override config settings:
|
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
|
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
|
## Extensions Merging
|
||||||
|
|
||||||
@@ -471,6 +499,15 @@ These environment variables configure additional daemon and runtime behavior:
|
|||||||
<code>default</code>
|
<code>default</code>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>AGENT_BROWSER_PARALLEL</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>).
|
||||||
|
</td>
|
||||||
|
<td>(none)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
|
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
|
||||||
|
|||||||
@@ -4,25 +4,49 @@ export const metadata = pageMetadata('sessions');
|
|||||||
|
|
||||||
# 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
|
```bash
|
||||||
# Show current runtime session
|
# Show current runtime session
|
||||||
agent-browser session
|
agent-browser session
|
||||||
# Output: default
|
# Output: default
|
||||||
|
|
||||||
|
# Isolated runtime channel for parallel AI flow
|
||||||
|
agent-browser --parallel worker-a session
|
||||||
|
# Output: parallel-worker-a
|
||||||
|
|
||||||
# Show active daemon sessions
|
# Show active daemon sessions
|
||||||
agent-browser session list
|
agent-browser session list
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session isolation
|
## 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
|
- Cookies and storage snapshots
|
||||||
- Authentication state
|
- Authentication state
|
||||||
- Saved state lifecycle
|
- 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
|
## Session persistence
|
||||||
|
|
||||||
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
|
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`.
|
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.
|
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||||
|
|
||||||
### Session name rules
|
### Session name rules
|
||||||
@@ -165,6 +191,12 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
|
|||||||
</td>
|
</td>
|
||||||
<td>Auto-save/load state persistence name</td>
|
<td>Auto-save/load state persistence name</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>AGENT_BROWSER_PARALLEL</code>
|
||||||
|
</td>
|
||||||
|
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
|
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.16.3-fork.1",
|
"version": "0.16.3-fork.3",
|
||||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
|
|||||||
@@ -208,13 +208,15 @@ agent-browser get text @e1 --json
|
|||||||
### Parallel Workflows
|
### Parallel Workflows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser --session-name site1 open https://site-a.com
|
agent-browser --parallel site1 open https://site-a.com
|
||||||
agent-browser --session-name site2 open https://site-b.com
|
agent-browser --parallel site2 open https://site-b.com
|
||||||
|
|
||||||
agent-browser --session-name site1 snapshot -i
|
agent-browser --parallel site1 snapshot -i
|
||||||
agent-browser --session-name site2 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
|
### Connect to Existing Chrome
|
||||||
|
|
||||||
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
|
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
|
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)
|
### Color Scheme (Dark Mode)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -489,7 +503,7 @@ These behaviors are always active. For sensitive sites, combine with `--headed`
|
|||||||
|
|
||||||
## Session Management and Cleanup
|
## 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:
|
Always close your browser session when done to avoid leaked processes:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2135,7 +2135,7 @@ export class BrowserManager {
|
|||||||
context = await launcher.launchPersistentContext(
|
context = await launcher.launchPersistentContext(
|
||||||
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
|
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
|
||||||
{
|
{
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? false,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
...(chromeChannel && { channel: chromeChannel }),
|
...(chromeChannel && { channel: chromeChannel }),
|
||||||
args: allArgs,
|
args: allArgs,
|
||||||
|
|||||||
+43
-1
@@ -3,7 +3,7 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { EventEmitter } from 'events';
|
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.
|
* 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);
|
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
-298
@@ -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
|
// Platform detection
|
||||||
const isWindows = process.platform === 'win32';
|
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)
|
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
||||||
const DEFAULT_STREAM_PORT = 9223;
|
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.
|
* Save state to file with optional encryption.
|
||||||
@@ -325,6 +344,7 @@ export function getStreamPortFile(session?: string): string {
|
|||||||
export async function startDaemon(options?: {
|
export async function startDaemon(options?: {
|
||||||
streamPort?: number;
|
streamPort?: number;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
resident?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// Ensure socket directory exists with restricted permissions (owner-only access)
|
// Ensure socket directory exists with restricted permissions (owner-only access)
|
||||||
const socketDir = getSocketDir();
|
const socketDir = getSocketDir();
|
||||||
@@ -345,6 +365,10 @@ export async function startDaemon(options?: {
|
|||||||
// Create appropriate manager
|
// Create appropriate manager
|
||||||
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
|
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
|
||||||
let shuttingDown = false;
|
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)
|
// 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
|
// Note: Stream server only works with BrowserManager (desktop), not iOS
|
||||||
@@ -363,6 +387,21 @@ export async function startDaemon(options?: {
|
|||||||
fs.writeFileSync(streamPortFile, streamPort.toString());
|
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) => {
|
const server = net.createServer((socket) => {
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let httpChecked = false;
|
let httpChecked = false;
|
||||||
@@ -379,311 +418,326 @@ export async function startDaemon(options?: {
|
|||||||
|
|
||||||
while (commandQueue.length > 0) {
|
while (commandQueue.length > 0) {
|
||||||
const line = commandQueue.shift()!;
|
const line = commandQueue.shift()!;
|
||||||
|
pendingCommands += 1;
|
||||||
|
cancelIdleTimer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parseResult = parseCommand(line);
|
await runSerialized(async () => {
|
||||||
|
|
||||||
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();
|
|
||||||
try {
|
try {
|
||||||
const devices = await iosManager.listAllDevices();
|
const parseResult = parseCommand(line);
|
||||||
const response = {
|
|
||||||
id: parseResult.command.id,
|
if (!parseResult.success) {
|
||||||
success: true as const,
|
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
||||||
data: { devices },
|
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');
|
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
await safeWrite(
|
await safeWrite(
|
||||||
socket,
|
socket,
|
||||||
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
|
serializeResponse(errorResponse('error', message)) + '\n'
|
||||||
);
|
).catch(() => {}); // Socket may already be destroyed
|
||||||
}
|
}
|
||||||
continue;
|
});
|
||||||
}
|
} finally {
|
||||||
|
pendingCommands = Math.max(0, pendingCommands - 1);
|
||||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
|
scheduleIdleShutdown();
|
||||||
// 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('error', message)) + '\n').catch(
|
|
||||||
() => {}
|
|
||||||
); // Socket may already be destroyed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,14 +811,16 @@ export async function startDaemon(options?: {
|
|||||||
|
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
console.error('Server error:', err);
|
console.error('Server error:', err);
|
||||||
|
cancelIdleTimer();
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle shutdown signals
|
// Handle shutdown signals
|
||||||
const shutdown = async () => {
|
const shutdown = async (_reason?: string) => {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
cancelIdleTimer();
|
||||||
|
|
||||||
// Stop stream server if running
|
// Stop stream server if running
|
||||||
if (streamServer) {
|
if (streamServer) {
|
||||||
@@ -792,28 +848,35 @@ export async function startDaemon(options?: {
|
|||||||
// Handle unexpected errors - always cleanup
|
// Handle unexpected errors - always cleanup
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
console.error('Uncaught exception:', err);
|
console.error('Uncaught exception:', err);
|
||||||
|
cancelIdleTimer();
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
console.error('Unhandled rejection:', reason);
|
console.error('Unhandled rejection:', reason);
|
||||||
|
cancelIdleTimer();
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on normal exit
|
// Cleanup on normal exit
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
|
cancelIdleTimer();
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduleIdleShutdown();
|
||||||
|
|
||||||
// Keep process alive
|
// Keep process alive
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run daemon if this is the entry point
|
// Run daemon if this is the entry point
|
||||||
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
|
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);
|
console.error('Daemon error:', err);
|
||||||
cleanupSocket();
|
cleanupSocket();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user