Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa52d7faa | ||
|
|
fcd891e8a9 | ||
|
|
8eee9310a4 | ||
|
|
b3ed4b63be | ||
|
|
5d149afef1 | ||
|
|
356e2f5f39 | ||
|
|
3cbc284076 | ||
|
|
bb92e08fdc | ||
|
|
5c142b02b5 | ||
|
|
b100870a02 | ||
|
|
b505d00c97 | ||
|
|
5e0582cede | ||
|
|
5970579d7c | ||
|
|
8880aa2f35 | ||
|
|
f051e72f85 | ||
|
|
6ae703565c | ||
|
|
d56442cf91 | ||
|
|
dad7be8c77 | ||
|
|
7921928ec4 |
@@ -64,12 +64,20 @@ jobs:
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# agent-browser
|
||||
|
||||
## 0.16.3-fork.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Sync upstream `v0.16.2` / `v0.16.3` core fixes into the fork baseline.
|
||||
- Import headed-mode behavior updates from upstream.
|
||||
- Improve CDP debug-port discovery by switching to `reqwest` in native Chrome probing.
|
||||
- Fix dialog dismiss command parsing consistency.
|
||||
- Surface daemon startup stderr on launch failure to avoid opaque timeout-only errors.
|
||||
- Keep fork stealth hardening for anti-debug self-destruct flows (`disable-devtool-auto` bootstrap neutralization).
|
||||
|
||||
## 0.16.1-fork.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -47,11 +47,70 @@ abs install
|
||||
### Minimal Usage
|
||||
|
||||
```bash
|
||||
agent-browser start
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser click @e2
|
||||
```
|
||||
|
||||
### Parallel AI Runs (Isolated Runtime Channel)
|
||||
|
||||
Use `--parallel <name>` to run multiple AI flows concurrently without fighting over the same runtime channel.
|
||||
|
||||
```bash
|
||||
agent-browser --parallel worker-a open https://example.com
|
||||
agent-browser --parallel worker-b open https://example.org
|
||||
```
|
||||
|
||||
`--parallel` is designed for stateless throughput tasks (navigation, extraction, checks). For authenticated flows, keep using one stable `--session-name`.
|
||||
|
||||
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 |
|
||||
| `--session-name <name>` | Persist cookies/localStorage across restarts | Login/auth continuity |
|
||||
| `--engine <name>` | Choose local browser engine (`chrome`, `lightpanda`) | Native-only engine experiments |
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Browser Engine Selection
|
||||
|
||||
`chrome` remains the default engine. If you want to try [Lightpanda](https://lightpanda.io/docs/open-source/installation), use `--engine lightpanda`; this automatically routes through the native daemon.
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open https://example.com
|
||||
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
Lightpanda is headless-only and does not support `--extension`, `--state`, `--profile`, or `--allow-file-access`.
|
||||
|
||||
### Headed Mode
|
||||
|
||||
Use `--headed` when you want a visible browser window:
|
||||
|
||||
```bash
|
||||
agent-browser --headed open https://example.com
|
||||
|
||||
AGENT_BROWSER_HEADED=1 agent-browser open https://example.com
|
||||
AGENT_BROWSER_HEADED=true agent-browser open https://example.com
|
||||
```
|
||||
|
||||
In this fork, local launches default to headed mode unless headless is explicitly requested. Extension launches also stay headed by default so the stealth/runtime policy remains stable.
|
||||
|
||||
### Default: Auto Group Agent Tabs (CDP + Plugin)
|
||||
|
||||
```bash
|
||||
@@ -117,7 +176,8 @@ flowchart TD
|
||||
- Project policy forbids:
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE`
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL`
|
||||
- Default CLI policy auto-attaches an existing browser: try CDP `localhost:9333` first, then auto-discovery unless explicit connection options are provided.
|
||||
- Default CLI policy uses a dedicated automation browser on CDP `localhost:9333`. If `:9333` is unavailable, agent-browser auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile`.
|
||||
- Use `agent-browser start` (or `abs start`) when you want to pre-start that managed `:9333` browser before unattended work begins.
|
||||
|
||||
## Principle 2: Multi-Layer Fingerprint Hardening
|
||||
|
||||
@@ -233,6 +293,9 @@ 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.
|
||||
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
|
||||
|
||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.16.1-fork.4"
|
||||
version = "0.17.0-fork.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.16.1-fork.5"
|
||||
version = "0.17.0-fork.2"
|
||||
edition = "2021"
|
||||
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+3
-3
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).map_or(false, |n| n.is_lowercase());
|
||||
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
@@ -202,7 +202,7 @@ fn resolve_ref(
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.map_or(false, |t| t.contains(ref_type))
|
||||
.is_some_and(|t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
@@ -339,7 +339,7 @@ fn generate_domain(
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) {
|
||||
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
|
||||
@@ -215,6 +215,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
Ok(nav_cmd)
|
||||
}
|
||||
// Prepare the managed automation browser on localhost:9333 without navigating.
|
||||
// This reuses the existing launch action so both Node and native daemons stay aligned.
|
||||
"start" => Ok(json!({
|
||||
"id": id,
|
||||
"action": "launch",
|
||||
"cdpPort": 9333,
|
||||
"headless": !flags.headed
|
||||
})),
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||
"reload" => Ok(json!({ "id": id, "action": "reload" })),
|
||||
@@ -939,6 +947,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("dismiss") => {
|
||||
let mut cmd = json!({ "id": id, "action": "dialog", "response": "dismiss" });
|
||||
if let Some(prompt_text) = rest.get(1) {
|
||||
cmd["promptText"] = json!(prompt_text);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: VALID,
|
||||
@@ -2053,6 +2068,7 @@ mod tests {
|
||||
full: false,
|
||||
headed: false,
|
||||
debug: false,
|
||||
resident: false,
|
||||
headers: None,
|
||||
executable_path: None,
|
||||
extensions: Vec::new(),
|
||||
@@ -2067,7 +2083,10 @@ mod tests {
|
||||
allow_file_access: false,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
native: false,
|
||||
engine: None,
|
||||
session_name: None,
|
||||
parallel: None,
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_state: false,
|
||||
@@ -2078,6 +2097,8 @@ mod tests {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
@@ -2087,6 +2108,8 @@ mod tests {
|
||||
wait_until: None,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
cli_session_name: false,
|
||||
cli_resident: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2348,6 +2371,22 @@ mod tests {
|
||||
assert_eq!(cmd["url"], "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_command_uses_managed_cdp() {
|
||||
let cmd = parse_command(&args("start"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "launch");
|
||||
assert_eq!(cmd["cdpPort"], 9333);
|
||||
assert_eq!(cmd["headless"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_command_respects_headed_flag() {
|
||||
let mut flags = default_flags();
|
||||
flags.headed = true;
|
||||
let cmd = parse_command(&args("start"), &flags).unwrap();
|
||||
assert_eq!(cmd["headless"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_headers() {
|
||||
let mut flags = default_flags();
|
||||
|
||||
+507
-101
@@ -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)]
|
||||
@@ -183,6 +381,8 @@ pub struct DaemonResult {
|
||||
pub fn ensure_daemon(
|
||||
session: &str,
|
||||
headed: bool,
|
||||
// Keep daemon resident and disable idle auto-shutdown.
|
||||
resident: bool,
|
||||
executable_path: Option<&str>,
|
||||
extensions: &[String],
|
||||
args: Option<&str>,
|
||||
@@ -196,21 +396,46 @@ pub fn ensure_daemon(
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
native: bool,
|
||||
engine: Option<&str>,
|
||||
download_path: Option<&str>,
|
||||
tab_group: Option<&str>,
|
||||
tab_group_plugin_id: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
let daemon_path = if native {
|
||||
let exe = env::current_exe().map_err(|e| e.to_string())?;
|
||||
exe.canonicalize().unwrap_or(exe)
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,38 +480,30 @@ 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;
|
||||
|
||||
// Spawn daemon as a fully detached background process
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(&daemon_path);
|
||||
cmd
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -339,6 +556,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -363,23 +583,43 @@ pub fn ensure_daemon(
|
||||
});
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// 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)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
let mut cmd = if native {
|
||||
Command::new(&daemon_path)
|
||||
} else {
|
||||
// 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
|
||||
};
|
||||
cmd.arg(if resident {
|
||||
"--resident"
|
||||
} else {
|
||||
"--idle-auto-shutdown"
|
||||
})
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session)
|
||||
.env("AGENT_BROWSER_CLI_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
@@ -432,6 +672,9 @@ pub fn ensure_daemon(
|
||||
if let Some(sn) = session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(engine) = engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
|
||||
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||
if debug {
|
||||
@@ -451,12 +694,20 @@ pub fn ensure_daemon(
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
daemon_child = Some(
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
if native {
|
||||
format!("Failed to start native daemon: {}", e)
|
||||
} else {
|
||||
format!("Failed to start daemon: {}", e)
|
||||
}
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
@@ -465,6 +716,22 @@ pub fn ensure_daemon(
|
||||
already_running: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Surface daemon startup stderr instead of returning an opaque timeout.
|
||||
if let Some(ref mut child) = daemon_child {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
let mut stderr_output = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
if !stderr_trimmed.is_empty() {
|
||||
return Err(format!("Daemon failed to start: {}", stderr_trimmed));
|
||||
}
|
||||
return Err("Daemon failed to start: process exited during startup".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
@@ -569,45 +836,23 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use crate::test_utils::EnvGuard;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Mutex to prevent parallel tests from interfering with env vars
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks env mutex and restores env vars on drop
|
||||
struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => env::set_var(name, v),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
|
||||
}
|
||||
@@ -616,8 +861,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_socket_dir() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -628,8 +873,8 @@ mod tests {
|
||||
fn test_get_socket_dir_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
|
||||
assert_eq!(
|
||||
get_socket_dir(),
|
||||
@@ -641,8 +886,8 @@ mod tests {
|
||||
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::set_var("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
env::set_var("XDG_RUNTIME_DIR", "");
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
@@ -653,8 +898,8 @@ mod tests {
|
||||
fn test_get_socket_dir_home_fallback() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
env::remove_var("AGENT_BROWSER_SOCKET_DIR");
|
||||
env::remove_var("XDG_RUNTIME_DIR");
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
@@ -663,6 +908,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]
|
||||
|
||||
+201
-6
@@ -1,4 +1,5 @@
|
||||
use crate::color;
|
||||
use crate::validation::is_valid_session_name;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -32,6 +33,8 @@ pub struct Config {
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
pub auto_connect: Option<bool>,
|
||||
pub native: Option<bool>,
|
||||
pub engine: Option<String>,
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
@@ -40,6 +43,7 @@ pub struct Config {
|
||||
pub tab_group_plugin_id: Option<String>,
|
||||
pub risk_mode: Option<String>,
|
||||
pub wait_until: Option<String>,
|
||||
pub parallel: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -70,6 +74,8 @@ impl Config {
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
auto_connect: other.auto_connect.or(self.auto_connect),
|
||||
native: other.native.or(self.native),
|
||||
engine: other.engine.or(self.engine),
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
@@ -78,6 +84,7 @@ impl Config {
|
||||
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
|
||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||
wait_until: other.wait_until.or(self.wait_until),
|
||||
parallel: other.parallel.or(self.parallel),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +155,8 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--engine",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -199,6 +208,10 @@ pub struct Flags {
|
||||
pub full: bool,
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
/// Keep daemon resident and disable idle auto-shutdown.
|
||||
pub resident: bool,
|
||||
/// Runtime daemon session channel.
|
||||
/// Defaults to `default`; when `--parallel <name>` is provided it becomes `parallel-<name>`.
|
||||
pub session: String,
|
||||
pub headers: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
@@ -214,7 +227,12 @@ pub struct Flags {
|
||||
pub allow_file_access: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub session_name: Option<String>, // Defaults to "default" when unset
|
||||
pub native: bool,
|
||||
/// Browser engine for native local launches. `chrome` is the default.
|
||||
pub engine: Option<String>,
|
||||
// Defaults to "default" when unset in default runtime mode.
|
||||
// In --parallel mode, defaults to None unless explicitly provided on CLI.
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
@@ -226,6 +244,8 @@ pub struct Flags {
|
||||
/// Navigation wait strategy passed to navigate/open commands:
|
||||
/// `load`, `domcontentloaded`, or `networkidle`.
|
||||
pub wait_until: Option<String>,
|
||||
/// Parallel execution channel name. When set, commands run in an isolated runtime session.
|
||||
pub parallel: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
@@ -239,8 +259,12 @@ pub struct Flags {
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_native: bool,
|
||||
pub cli_engine: bool,
|
||||
pub cli_tab_group: bool,
|
||||
pub cli_tab_group_plugin_id: bool,
|
||||
pub cli_session_name: bool,
|
||||
pub cli_resident: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -273,7 +297,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
Err(_) => config.headed.unwrap_or(true),
|
||||
},
|
||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
||||
// --session is disabled: user-facing CLI always uses one default session.
|
||||
resident: false,
|
||||
// --session is disabled for users.
|
||||
// Runtime session defaults to `default`, and can be isolated with `--parallel`.
|
||||
session: "default".to_string(),
|
||||
headers: config.headers,
|
||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
||||
@@ -298,6 +324,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
|
||||
engine: env::var("AGENT_BROWSER_ENGINE").ok().or(config.engine),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -321,6 +349,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
.or(config.risk_mode)
|
||||
.map(|s| s.to_ascii_lowercase()),
|
||||
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
|
||||
parallel: env::var("AGENT_BROWSER_PARALLEL").ok().or(config.parallel),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_state: false,
|
||||
@@ -331,8 +360,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_native: false,
|
||||
cli_engine: false,
|
||||
cli_tab_group: false,
|
||||
cli_tab_group_plugin_id: false,
|
||||
cli_session_name: false,
|
||||
cli_resident: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -366,6 +399,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--resident" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.resident = val;
|
||||
flags.cli_resident = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--headers" => {
|
||||
if let Some(h) = args.get(i + 1) {
|
||||
flags.headers = Some(h.clone());
|
||||
@@ -461,9 +502,31 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--native" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.native = val;
|
||||
flags.cli_native = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--engine" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.engine = Some(s.clone());
|
||||
flags.cli_engine = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
flags.cli_session_name = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--parallel" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.parallel = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
@@ -523,9 +586,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Keep auth/state continuity stable by default: if no explicit --session-name
|
||||
// is provided, derive it from the default session id.
|
||||
if flags.session_name.is_none() {
|
||||
if let Some(parallel_name) = &flags.parallel {
|
||||
// Validate early so session id derivation cannot introduce unsafe paths.
|
||||
if !is_valid_session_name(parallel_name) {
|
||||
// Keep default session and let main.rs surface a user-facing validation error.
|
||||
} else {
|
||||
flags.session = format!("parallel-{}", parallel_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel mode is for isolated/stateless runs.
|
||||
// Unless --session-name is explicitly provided on this invocation, disable
|
||||
// auto save/restore persistence to avoid cross-flow auth leakage.
|
||||
if flags.parallel.is_some() && !flags.cli_session_name {
|
||||
flags.session_name = None;
|
||||
}
|
||||
|
||||
// Keep auth/state continuity stable by default for the default runtime session.
|
||||
if flags.session_name.is_none() && flags.parallel.is_none() {
|
||||
flags.session_name = Some("default".to_string());
|
||||
}
|
||||
|
||||
@@ -542,9 +620,11 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--full",
|
||||
"--headed",
|
||||
"--debug",
|
||||
"--resident",
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--auto-connect",
|
||||
"--native",
|
||||
"--annotate",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
@@ -569,7 +649,9 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--tab-group-plugin-id",
|
||||
"--risk-mode",
|
||||
"--wait-until",
|
||||
"--parallel",
|
||||
"--config",
|
||||
"--engine",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -770,6 +852,34 @@ mod tests {
|
||||
assert_eq!(flags.session_name.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parallel_sets_isolated_runtime_session() {
|
||||
let flags = parse_flags(&args("--parallel worker_a snapshot"));
|
||||
assert_eq!(flags.parallel.as_deref(), Some("worker_a"));
|
||||
assert_eq!(flags.session, "parallel-worker_a");
|
||||
assert_eq!(flags.session_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parallel_keeps_explicit_session_name() {
|
||||
let flags = parse_flags(&args(
|
||||
"--parallel worker_b --session-name keep-state snapshot",
|
||||
));
|
||||
assert_eq!(flags.session, "parallel-worker_b");
|
||||
assert_eq!(flags.session_name.as_deref(), Some("keep-state"));
|
||||
assert!(flags.cli_session_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parallel_from_env_sets_runtime_session() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_PARALLEL", "AGENT_BROWSER_SESSION_NAME"]);
|
||||
env::set_var("AGENT_BROWSER_PARALLEL", "envworker");
|
||||
env::set_var("AGENT_BROWSER_SESSION_NAME", "persisted");
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert_eq!(flags.session, "parallel-envworker");
|
||||
assert_eq!(flags.session_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_executable_path_tracking() {
|
||||
// When --executable-path is passed via CLI, cli_executable_path should be true
|
||||
@@ -805,6 +915,20 @@ mod tests {
|
||||
assert!(!flags.cli_annotate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_resident_flag() {
|
||||
let flags = parse_flags(&args("--resident open example.com"));
|
||||
assert!(flags.resident);
|
||||
assert!(flags.cli_resident);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_resident_false() {
|
||||
let flags = parse_flags(&args("--resident false open example.com"));
|
||||
assert!(!flags.resident);
|
||||
assert!(flags.cli_resident);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_tracking() {
|
||||
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
||||
@@ -940,6 +1064,18 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_parallel() {
|
||||
let cleaned = clean_args(&args("--parallel worker_x open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_resident_flag() {
|
||||
let cleaned = clean_args(&args("--resident open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_multiple_flags_tracking() {
|
||||
let flags = parse_flags(&args(
|
||||
@@ -978,7 +1114,8 @@ mod tests {
|
||||
"headers": "{\"Auth\":\"token\"}",
|
||||
"tabGroup": "Agent Browser Stealth",
|
||||
"tabGroupPluginId": "tab-group-plugin-id",
|
||||
"riskMode": "block"
|
||||
"riskMode": "block",
|
||||
"parallel": "worker-c"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
@@ -1010,6 +1147,7 @@ mod tests {
|
||||
Some("tab-group-plugin-id")
|
||||
);
|
||||
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||
assert_eq!(config.parallel.as_deref(), Some("worker-c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1137,6 +1275,12 @@ mod tests {
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_engine() {
|
||||
let cleaned = clean_args(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_with_config_flag() {
|
||||
use std::io::Write;
|
||||
@@ -1246,6 +1390,57 @@ mod tests {
|
||||
assert!(!flags.auto_connect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_false() {
|
||||
let flags = parse_flags(&args("--native false open example.com"));
|
||||
assert!(!flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_flag() {
|
||||
let flags = parse_flags(&args("--engine lightpanda open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_from_env() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_ENGINE"]);
|
||||
env::set_var("AGENT_BROWSER_ENGINE", "lightpanda");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert_eq!(flags.engine.as_deref(), Some("lightpanda"));
|
||||
assert!(!flags.cli_engine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--native open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_from_env_sets_native_without_cli_marker() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_NATIVE"]);
|
||||
env::set_var("AGENT_BROWSER_NATIVE", "1");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.native);
|
||||
assert!(!flags.cli_native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_native() {
|
||||
let config: Config = serde_json::from_str(r#"{"native": true}"#).unwrap();
|
||||
assert_eq!(config.native, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_deserializes_engine() {
|
||||
let config: Config = serde_json::from_str(r#"{"engine": "lightpanda"}"#).unwrap();
|
||||
assert_eq!(config.engine.as_deref(), Some("lightpanda"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_bare_defaults_true() {
|
||||
let flags = parse_flags(&args("--full open example.com"));
|
||||
|
||||
+114
-118
@@ -3,22 +3,21 @@ mod commands;
|
||||
mod connection;
|
||||
mod flags;
|
||||
mod install;
|
||||
mod native;
|
||||
mod output;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
mod validation;
|
||||
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
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 std::time::Duration;
|
||||
|
||||
use commands::{gen_id, parse_command, ParseError};
|
||||
use connection::{ensure_daemon, get_socket_dir, send_command};
|
||||
use flags::{clean_args, parse_flags};
|
||||
use connection::{ensure_daemon, list_live_sessions, send_command, Response};
|
||||
use flags::{clean_args, parse_flags, Flags};
|
||||
use install::run_install;
|
||||
use output::{print_command_help, print_help, print_response, print_version};
|
||||
|
||||
@@ -52,51 +51,35 @@ fn parse_proxy(proxy_str: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn should_try_default_cdp(flags: &Flags, command_name: Option<&str>) -> bool {
|
||||
!matches!(command_name, Some("close"))
|
||||
&& flags.cdp.is_none()
|
||||
&& !flags.auto_connect
|
||||
&& flags.provider.is_none()
|
||||
&& flags.executable_path.is_none()
|
||||
&& flags.state.is_none()
|
||||
&& flags.proxy.is_none()
|
||||
&& flags.args.is_none()
|
||||
&& flags.user_agent.is_none()
|
||||
&& !flags.ignore_https_errors
|
||||
&& !flags.allow_file_access
|
||||
&& flags.extensions.is_empty()
|
||||
}
|
||||
|
||||
fn managed_cdp_port_ready() -> bool {
|
||||
let addr: SocketAddr = match "127.0.0.1:9333".parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(_) => return false,
|
||||
};
|
||||
TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok()
|
||||
}
|
||||
|
||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
|
||||
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!(
|
||||
@@ -129,6 +112,17 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
|
||||
}
|
||||
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(native::daemon::run_daemon(&session));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore SIGPIPE to prevent panic when piping to head/tail
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
@@ -136,8 +130,22 @@ fn main() {
|
||||
}
|
||||
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let flags = parse_flags(&args);
|
||||
let mut flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
let command_name = clean.first().map(|s| s.as_str());
|
||||
|
||||
if flags.engine.is_some() && !flags.native {
|
||||
flags.native = true;
|
||||
}
|
||||
|
||||
let can_try_default_cdp = should_try_default_cdp(&flags, command_name);
|
||||
let can_force_native_for_cdp = !matches!(command_name, Some("close"));
|
||||
if !flags.native
|
||||
&& can_force_native_for_cdp
|
||||
&& (flags.cdp.is_some() || flags.auto_connect || can_try_default_cdp)
|
||||
{
|
||||
flags.native = true;
|
||||
}
|
||||
|
||||
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
|
||||
@@ -172,6 +180,24 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref parallel) = flags.parallel {
|
||||
if !validation::is_valid_session_name(parallel) {
|
||||
let msg = format!(
|
||||
"Invalid --parallel value '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
|
||||
parallel
|
||||
);
|
||||
if flags.json {
|
||||
println!(
|
||||
r#"{{"success":false,"error":"{}","type":"invalid_parallel_name"}}"#,
|
||||
msg.replace('"', "\\\"")
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--profile") {
|
||||
let msg =
|
||||
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
|
||||
@@ -273,6 +299,7 @@ fn main() {
|
||||
let daemon_result = match ensure_daemon(
|
||||
&flags.session,
|
||||
flags.headed,
|
||||
flags.resident,
|
||||
flags.executable_path.as_deref(),
|
||||
&flags.extensions,
|
||||
flags.args.as_deref(),
|
||||
@@ -286,6 +313,8 @@ fn main() {
|
||||
flags.device.as_deref(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.native,
|
||||
flags.engine.as_deref(),
|
||||
flags.download_path.as_deref(),
|
||||
flags.tab_group.as_deref(),
|
||||
flags.tab_group_plugin_id.as_deref(),
|
||||
@@ -340,10 +369,13 @@ fn main() {
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_native.then_some("--native"),
|
||||
flags.cli_engine.then_some("--engine"),
|
||||
flags.cli_tab_group.then_some("--tab-group"),
|
||||
flags
|
||||
.cli_tab_group_plugin_id
|
||||
.then_some("--tab-group-plugin-id"),
|
||||
flags.cli_resident.then_some("--resident"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -430,6 +462,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -528,6 +563,9 @@ fn main() {
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
if let Some(ref engine) = flags.engine {
|
||||
launch_cmd["engine"] = json!(engine);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
@@ -599,74 +637,6 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Project policy: when no explicit connection mode is provided,
|
||||
// commands should attach to an existing browser.
|
||||
// Try CDP :9333 first, then fall back to auto-connect discovery.
|
||||
let can_try_default_cdp = flags.cdp.is_none()
|
||||
&& !flags.auto_connect
|
||||
&& flags.provider.is_none()
|
||||
&& flags.executable_path.is_none()
|
||||
&& flags.state.is_none()
|
||||
&& flags.proxy.is_none()
|
||||
&& flags.args.is_none()
|
||||
&& flags.user_agent.is_none()
|
||||
&& !flags.ignore_https_errors
|
||||
&& !flags.allow_file_access
|
||||
&& flags.extensions.is_empty();
|
||||
|
||||
if can_try_default_cdp {
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
"cdpPort": 9333
|
||||
});
|
||||
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
launch_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
|
||||
attached_to_existing_browser = resp.success;
|
||||
}
|
||||
|
||||
if !attached_to_existing_browser {
|
||||
let mut auto_connect_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
"autoConnect": true
|
||||
});
|
||||
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
auto_connect_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
if let Some(ref tg) = flags.tab_group {
|
||||
auto_connect_cmd["tabGroup"] = json!(tg);
|
||||
}
|
||||
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
|
||||
auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id);
|
||||
}
|
||||
|
||||
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
|
||||
attached_to_existing_browser = resp.success;
|
||||
}
|
||||
}
|
||||
}
|
||||
if can_try_default_cdp && !attached_to_existing_browser {
|
||||
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp <port|url>.";
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Launch headed browser or configure browser options (without CDP or provider)
|
||||
if (flags.headed
|
||||
|| flags.executable_path.is_some()
|
||||
@@ -678,10 +648,12 @@ fn main() {
|
||||
|| flags.allow_file_access
|
||||
|| flags.debug
|
||||
|| flags.color_scheme.is_some()
|
||||
|| flags.download_path.is_some())
|
||||
|| flags.download_path.is_some()
|
||||
|| flags.engine.is_some())
|
||||
&& flags.cdp.is_none()
|
||||
&& flags.provider.is_none()
|
||||
&& !attached_to_existing_browser
|
||||
&& !can_try_default_cdp
|
||||
{
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
@@ -791,6 +763,18 @@ fn main() {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let is_start = command_name == Some("start")
|
||||
&& cmd.get("action").and_then(|v| v.as_str()) == Some("launch")
|
||||
&& cmd.get("cdpPort").and_then(|v| v.as_u64()) == Some(9333);
|
||||
if is_start && managed_cdp_port_ready() {
|
||||
let resp = Response {
|
||||
success: true,
|
||||
data: Some(json!({ "launched": true })),
|
||||
error: None,
|
||||
};
|
||||
print_response(&resp, flags.json, Some("launch"));
|
||||
return;
|
||||
}
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
} else {
|
||||
@@ -858,4 +842,16 @@ mod tests {
|
||||
assert_eq!(result["username"], "user");
|
||||
assert_eq!(result["password"], "p@ss:w0rd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_try_default_cdp_for_open() {
|
||||
let flags = parse_flags(&[]);
|
||||
assert!(should_try_default_cdp(&flags, Some("open")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_try_default_cdp_for_close() {
|
||||
let flags = parse_flags(&[]);
|
||||
assert!(!should_try_default_cdp(&flags, Some("close")));
|
||||
}
|
||||
}
|
||||
|
||||
+157
-48
@@ -4,7 +4,7 @@ use tokio::sync::broadcast;
|
||||
|
||||
use super::auth;
|
||||
use super::browser::{BrowserManager, WaitUntil};
|
||||
use super::cdp::chrome::LaunchOptions;
|
||||
use super::cdp::chrome::{LaunchOptions, MANAGED_CDP_PORT};
|
||||
use super::cdp::types::{
|
||||
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
|
||||
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
|
||||
@@ -167,13 +167,14 @@ impl DaemonState {
|
||||
if let Ok(te) =
|
||||
serde_json::from_value::<TargetCreatedEvent>(event.params.clone())
|
||||
{
|
||||
if te.target_info.target_type == "page"
|
||||
if (te.target_info.target_type == "page"
|
||||
|| te.target_info.target_type == "webview")
|
||||
&& !te.target_info.url.is_empty()
|
||||
{
|
||||
let already_tracked = self
|
||||
.browser
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.has_target(&te.target_info.target_id));
|
||||
.is_none_or(|b| b.has_target(&te.target_info.target_id));
|
||||
if !already_tracked {
|
||||
new_targets.push(te);
|
||||
}
|
||||
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
session_id: attach.session_id,
|
||||
url: te.target_info.url.clone(),
|
||||
title: te.target_info.title.clone(),
|
||||
target_type: te.target_info.target_type.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,16 +551,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
}
|
||||
|
||||
// WebDriver backend: reject unsupported CDP-only actions
|
||||
if matches!(state.backend_type, BackendType::WebDriver) {
|
||||
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) {
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
if matches!(state.backend_type, BackendType::WebDriver)
|
||||
&& WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
|
||||
{
|
||||
return error_response(
|
||||
&id,
|
||||
&format!(
|
||||
"Action '{}' is not supported on the WebDriver backend",
|
||||
action
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let result = match action {
|
||||
@@ -726,8 +728,13 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
|
||||
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
let options = launch_options_from_env();
|
||||
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
|
||||
let debug_enabled = env::var("AGENT_BROWSER_DEBUG").as_deref() == Ok("1");
|
||||
|
||||
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
|
||||
if debug_enabled {
|
||||
eprintln!("[DEBUG] auto_launch: connecting via AGENT_BROWSER_CDP={}", cdp);
|
||||
}
|
||||
let mgr = BrowserManager::connect_cdp(&cdp).await?;
|
||||
state.browser = Some(mgr);
|
||||
state.subscribe_to_browser_events();
|
||||
@@ -736,6 +743,9 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok() {
|
||||
if debug_enabled {
|
||||
eprintln!("[DEBUG] auto_launch: connecting via AGENT_BROWSER_AUTO_CONNECT");
|
||||
}
|
||||
let mgr = BrowserManager::connect_auto().await?;
|
||||
state.browser = Some(mgr);
|
||||
state.subscribe_to_browser_events();
|
||||
@@ -743,18 +753,48 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mgr = BrowserManager::launch(options).await?;
|
||||
let mgr = if should_auto_launch_managed_cdp(&options) {
|
||||
if debug_enabled {
|
||||
eprintln!("[DEBUG] auto_launch: launching managed Chrome on localhost:9333");
|
||||
}
|
||||
BrowserManager::launch_managed_cdp(options.executable_path.clone(), !options.headless)
|
||||
.await?
|
||||
} else {
|
||||
if debug_enabled {
|
||||
eprintln!("[DEBUG] auto_launch: launching local browser from env options");
|
||||
}
|
||||
BrowserManager::launch(options, engine.as_deref()).await?
|
||||
};
|
||||
state.browser = Some(mgr);
|
||||
state.subscribe_to_browser_events();
|
||||
try_auto_restore_state(state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_auto_launch_managed_cdp(options: &LaunchOptions) -> bool {
|
||||
options.proxy.is_none()
|
||||
&& options.proxy_bypass.is_none()
|
||||
&& options.profile.is_none()
|
||||
&& !options.allow_file_access
|
||||
&& options.args.is_empty()
|
||||
&& options.extensions.as_ref().is_none_or(|ext| ext.is_empty())
|
||||
&& options.storage_state.is_none()
|
||||
&& options.user_agent.is_none()
|
||||
&& !options.ignore_https_errors
|
||||
}
|
||||
|
||||
fn launch_options_from_env() -> LaunchOptions {
|
||||
let headed = env::var("AGENT_BROWSER_HEADED")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
let extensions: Option<Vec<String>> = env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| {
|
||||
v.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
});
|
||||
|
||||
LaunchOptions {
|
||||
headless: !headed,
|
||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
||||
@@ -772,12 +812,7 @@ fn launch_options_from_env() -> LaunchOptions {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
extensions: env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| {
|
||||
v.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}),
|
||||
extensions,
|
||||
storage_state: env::var("AGENT_BROWSER_STATE").ok(),
|
||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
|
||||
ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|
||||
@@ -785,6 +820,7 @@ fn launch_options_from_env() -> LaunchOptions {
|
||||
.unwrap_or(false),
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
|
||||
remote_debugging_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,20 +869,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("autoConnect")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let engine = cmd
|
||||
.get("engine")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| env::var("AGENT_BROWSER_ENGINE").ok());
|
||||
|
||||
// Relaunch logic: check if we can reuse the existing connection
|
||||
let needs_relaunch = if let Some(ref mgr) = state.browser {
|
||||
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
|
||||
let was_cdp = mgr.is_cdp_connection();
|
||||
if has_cdp_arg != was_cdp {
|
||||
true
|
||||
} else if has_cdp_arg && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else if auto_connect && !mgr.is_connection_alive().await {
|
||||
true
|
||||
} else {
|
||||
!mgr.is_connection_alive().await
|
||||
}
|
||||
has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
|
||||
} else {
|
||||
true
|
||||
};
|
||||
@@ -866,6 +899,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let profile = cmd.get("profile").and_then(|v| v.as_str());
|
||||
let storage_state = cmd.get("storageState").and_then(|v| v.as_str());
|
||||
let allow_file_access = cmd
|
||||
@@ -895,7 +929,22 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
}
|
||||
|
||||
if let Some(port) = cdp_port {
|
||||
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
|
||||
let headed = !headless;
|
||||
let port_u16 = u16::try_from(port).map_err(|_| format!("Invalid CDP port: {}", port))?;
|
||||
let browser = match BrowserManager::connect_cdp(&port.to_string()).await {
|
||||
Ok(browser) => browser,
|
||||
Err(err) if port_u16 == MANAGED_CDP_PORT => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").as_deref() == Ok("1") {
|
||||
eprintln!(
|
||||
"[DEBUG] Preferred CDP port {} unavailable ({}), launching managed Chrome profile",
|
||||
MANAGED_CDP_PORT, err
|
||||
);
|
||||
}
|
||||
BrowserManager::launch_managed_cdp(executable_path.clone(), headed).await?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
state.browser = Some(browser);
|
||||
state.subscribe_to_browser_events();
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
@@ -987,6 +1036,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("downloadPath")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
remote_debugging_port: None,
|
||||
};
|
||||
|
||||
if let Some(ref domains) = cmd
|
||||
@@ -997,7 +1047,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.domain_filter = Some(DomainFilter::new(domains));
|
||||
}
|
||||
|
||||
state.browser = Some(BrowserManager::launch(options).await?);
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
|
||||
if let Some(ref filter) = state.domain_filter {
|
||||
@@ -2466,6 +2516,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
|
||||
session_id: new_session_id.clone(),
|
||||
url: nav_url.clone(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
// Navigate to URL
|
||||
@@ -2877,7 +2928,12 @@ async fn handle_permissions(cmd: &Value, state: &DaemonState) -> Result<Value, S
|
||||
|
||||
async fn handle_dialog(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let accept = cmd.get("accept").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let accept = cmd
|
||||
.get("response")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|r| r == "accept")
|
||||
.or_else(|| cmd.get("accept").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(true);
|
||||
let prompt_text = cmd.get("promptText").and_then(|v| v.as_str());
|
||||
|
||||
mgr.handle_dialog(accept, prompt_text).await?;
|
||||
@@ -3217,12 +3273,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
.send_command_no_params("Page.getFrameTree", Some(&session_id))
|
||||
.await?;
|
||||
|
||||
fn find_frame(
|
||||
tree: &Value,
|
||||
selector: Option<&str>,
|
||||
name: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Option<String> {
|
||||
fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
|
||||
let frame = tree.get("frame")?;
|
||||
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let frame_url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -3241,7 +3292,7 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
if let Some(id) = find_frame(child, selector, name, url) {
|
||||
if let Some(id) = find_frame(child, name, url) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
@@ -3266,13 +3317,13 @@ async fn handle_frame(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
);
|
||||
let result = mgr.evaluate(&js, None).await?;
|
||||
let frame_name = result.as_str().ok_or("Could not find frame for selector")?;
|
||||
if let Some(frame_id) = find_frame(frame_tree, None, Some(frame_name), None) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, Some(frame_name), None) {
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": frame_name }));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame_id) = find_frame(frame_tree, selector, name, url) {
|
||||
if let Some(frame_id) = find_frame(frame_tree, name, url) {
|
||||
let label = name.or(url).unwrap_or("frame");
|
||||
state.active_frame_id = Some(frame_id);
|
||||
return Ok(json!({ "frame": label }));
|
||||
@@ -4000,14 +4051,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
|
||||
Ok(Ok(event)) => {
|
||||
if event.method == "Page.downloadProgress"
|
||||
&& event.session_id.as_deref() == Some(&session_id)
|
||||
&& event.params.get("state").and_then(|v| v.as_str()) == Some("completed")
|
||||
{
|
||||
if event.params.get("state").and_then(|v| v.as_str()) == Some("completed") {
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("download");
|
||||
return Ok(json!({ "path": path }));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Err("Event stream closed".to_string()),
|
||||
@@ -4056,6 +4106,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
session_id: attach.session_id,
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
|
||||
if let Some(viewport) = cmd.get("viewport") {
|
||||
@@ -5124,6 +5175,39 @@ fn error_response(id: &str, error: &str) -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_response_structure() {
|
||||
@@ -5160,6 +5244,30 @@ mod tests {
|
||||
assert!(!opts.allow_file_access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_options_from_env_headed_flag() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
||||
_guard.set("AGENT_BROWSER_HEADED", "1");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(
|
||||
!opts.headless,
|
||||
"AGENT_BROWSER_HEADED=1 should set headless=false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_auto_launch_managed_cdp_for_bare_defaults() {
|
||||
let opts = launch_options_from_env();
|
||||
assert!(should_auto_launch_managed_cdp(&opts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_auto_launch_managed_cdp_when_proxy_is_set() {
|
||||
let mut opts = launch_options_from_env();
|
||||
opts.proxy = Some("http://127.0.0.1:8080".to_string());
|
||||
assert!(!should_auto_launch_managed_cdp(&opts));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_unknown_command() {
|
||||
let mut state = DaemonState::new();
|
||||
@@ -5209,6 +5317,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_roundtrip_via_actions() {
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let set_cmd = json!({
|
||||
|
||||
+310
-71
@@ -1,11 +1,12 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
@@ -17,6 +18,10 @@ pub struct AuthProfile {
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
@@ -48,79 +53,202 @@ fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
fn derive_encryption_key() -> Vec<u8> {
|
||||
let hostname = std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut buf = [0u8; 256];
|
||||
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
if len == 0 {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).to_string()
|
||||
} else {
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"unknown-host".to_string()
|
||||
}
|
||||
});
|
||||
let username = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-user".to_string());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("agent-browser:{}:{}", hostname, username).as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> {
|
||||
let key = derive_encryption_key();
|
||||
fn get_key_file_path() -> PathBuf {
|
||||
get_agent_browser_dir().join(KEY_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
let hex_str = hex_str.trim();
|
||||
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..32)
|
||||
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
|
||||
fn get_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
|
||||
return parse_key_hex(&key_hex).ok_or_else(|| {
|
||||
format!(
|
||||
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
|
||||
ENCRYPTION_KEY_ENV
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
if key_file.exists() {
|
||||
let hex = fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
|
||||
return parse_key_hex(&hex).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid encryption key in {}. Expected 64-character hex string.",
|
||||
key_file.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption key required. Set {} or ensure {} exists.",
|
||||
ENCRYPTION_KEY_ENV,
|
||||
key_file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure an encryption key exists, auto-generating one if needed.
|
||||
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key) = get_encryption_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
|
||||
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
let dir = get_agent_browser_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
fs::write(&key_file, format!("{}\n", key_hex))
|
||||
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
Ok(key.to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
|
||||
let key = ensure_encryption_key()?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes())
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
|
||||
|
||||
// aes_gcm appends the 16-byte auth tag to the ciphertext
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = json!({
|
||||
"version": 1,
|
||||
"encrypted": true,
|
||||
"iv": STANDARD.encode(iv),
|
||||
"authTag": STANDARD.encode(auth_tag),
|
||||
"data": STANDARD.encode(ciphertext),
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))
|
||||
}
|
||||
|
||||
/// JSON envelope written by Node.js encryption (src/encryption.ts).
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedPayload {
|
||||
#[allow(dead_code)]
|
||||
version: u32,
|
||||
#[allow(dead_code)]
|
||||
encrypted: bool,
|
||||
iv: String,
|
||||
#[serde(rename = "authTag")]
|
||||
auth_tag: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
let text = std::str::from_utf8(data).map_err(|_| {
|
||||
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
|
||||
})?;
|
||||
|
||||
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
|
||||
let key = get_encryption_key()?;
|
||||
|
||||
let iv = STANDARD
|
||||
.decode(&payload.iv)
|
||||
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
|
||||
let auth_tag = STANDARD
|
||||
.decode(&payload.auth_tag)
|
||||
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&payload.data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
// aes_gcm expects ciphertext || auth_tag as input to decrypt
|
||||
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let key = derive_encryption_key();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e))
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
serde_json::from_str::<AuthProfile>(text)
|
||||
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let encrypted = encrypt_profile(profile)?;
|
||||
let encrypted_json = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted).map_err(|e| format!("Failed to write profile: {}", e))
|
||||
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
@@ -147,6 +275,8 @@ pub fn credentials_set(
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
@@ -170,6 +300,8 @@ pub fn auth_save(
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
@@ -252,10 +384,27 @@ pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_test_key<F: FnOnce()>(f: F) {
|
||||
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
let test_key = "a".repeat(64);
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
|
||||
f();
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
@@ -277,6 +426,8 @@ mod tests {
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
@@ -290,26 +441,114 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(&encrypted).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted_json = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_encryption_key_is_stable() {
|
||||
let k1 = derive_encryption_key();
|
||||
let k2 = derive_encryption_key();
|
||||
assert_eq!(k1, k2);
|
||||
assert_eq!(k1.len(), 32);
|
||||
fn test_get_encryption_key_from_env() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xaa));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_valid() {
|
||||
let hex = "ab".repeat(32);
|
||||
let key = parse_key_hex(&hex).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_invalid() {
|
||||
assert!(parse_key_hex("too_short").is_none());
|
||||
assert!(parse_key_hex(&"g".repeat(64)).is_none());
|
||||
assert!(parse_key_hex("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_json_payload_format() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
let profile = AuthProfile {
|
||||
name: "json-test".to_string(),
|
||||
url: "https://example.com/login".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
username_selector: Some("#email".to_string()),
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
|
||||
// Encrypt with aes_gcm, then manually build the JSON payload
|
||||
// to simulate what Node.js would produce
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).unwrap();
|
||||
let plaintext = serde_json::to_string(&profile).unwrap();
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = format!(
|
||||
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
|
||||
STANDARD.encode(iv),
|
||||
STANDARD.encode(auth_tag),
|
||||
STANDARD.encode(ciphertext),
|
||||
);
|
||||
|
||||
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "json-test");
|
||||
assert_eq!(decrypted.password, "hunter2");
|
||||
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_is_json_format() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "format-check".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
|
||||
assert_eq!(parsed["version"], 1);
|
||||
assert_eq!(parsed["encrypted"], true);
|
||||
assert!(parsed["iv"].is_string());
|
||||
assert!(parsed["authTag"].is_string());
|
||||
assert!(parsed["data"].is_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+258
-63
@@ -2,11 +2,14 @@ use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::cdp::chrome::{
|
||||
auto_connect_cdp, discover_cdp_url, launch_chrome, ChromeProcess, LaunchOptions,
|
||||
auto_connect_cdp, discover_cdp_url, launch_chrome, launch_managed_chrome, ChromeProcess,
|
||||
LaunchOptions,
|
||||
};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
|
||||
use super::cdp::types::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +58,34 @@ pub fn validate_launch_options(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lightpanda_options(options: &LaunchOptions) -> Result<(), String> {
|
||||
if options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty())
|
||||
{
|
||||
return Err("Extensions are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.profile.is_some() {
|
||||
return Err("Profiles are not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.storage_state.is_some() {
|
||||
return Err("Storage state is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if options.allow_file_access {
|
||||
return Err("File access is not supported with Lightpanda".to_string());
|
||||
}
|
||||
if !options.headless {
|
||||
return Err("Headed mode is not supported with Lightpanda (headless only)".to_string());
|
||||
}
|
||||
if !options.args.is_empty() {
|
||||
return Err(
|
||||
"Custom Chrome arguments (--args) are not supported with Lightpanda".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
let lower = error.to_lowercase();
|
||||
@@ -80,12 +111,18 @@ pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
fn is_startup_internal_page(url: &str) -> bool {
|
||||
let lower = url.to_lowercase();
|
||||
lower.starts_with("chrome://profile-picker")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PageInfo {
|
||||
pub target_id: String,
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub target_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -105,37 +142,74 @@ impl WaitUntil {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BrowserProcess {
|
||||
Chrome(ChromeProcess),
|
||||
Lightpanda(LightpandaProcess),
|
||||
}
|
||||
|
||||
pub struct BrowserManager {
|
||||
pub client: CdpClient,
|
||||
chrome_process: Option<ChromeProcess>,
|
||||
browser_process: Option<BrowserProcess>,
|
||||
cdp_connection: bool,
|
||||
pages: Vec<PageInfo>,
|
||||
active_page_index: usize,
|
||||
default_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl BrowserManager {
|
||||
pub async fn launch(options: LaunchOptions) -> Result<Self, String> {
|
||||
validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?;
|
||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||
let engine = engine.unwrap_or("chrome");
|
||||
|
||||
match engine {
|
||||
"chrome" => validate_launch_options(
|
||||
options.extensions.as_deref(),
|
||||
false,
|
||||
options.profile.as_deref(),
|
||||
options.storage_state.as_deref(),
|
||||
options.allow_file_access,
|
||||
options.executable_path.as_deref(),
|
||||
)?,
|
||||
"lightpanda" => validate_lightpanda_options(&options)?,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown engine '{}'. Supported engines: chrome, lightpanda",
|
||||
engine
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let ignore_https_errors = options.ignore_https_errors;
|
||||
let user_agent = options.user_agent.clone();
|
||||
let color_scheme = options.color_scheme.clone();
|
||||
let download_path = options.download_path.clone();
|
||||
|
||||
let chrome = launch_chrome(&options)?;
|
||||
let ws_url = chrome.ws_url.clone();
|
||||
let (ws_url, process) = match engine {
|
||||
"lightpanda" => {
|
||||
let lp_options = LightpandaLaunchOptions {
|
||||
executable_path: options.executable_path.clone(),
|
||||
proxy: options.proxy.clone(),
|
||||
port: None,
|
||||
};
|
||||
let process = tokio::task::spawn_blocking(move || launch_lightpanda(&lp_options))
|
||||
.await
|
||||
.map_err(|e| format!("Lightpanda launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Lightpanda(process))
|
||||
}
|
||||
_ => {
|
||||
let process = tokio::task::spawn_blocking(move || launch_chrome(&options))
|
||||
.await
|
||||
.map_err(|e| format!("Chrome launch task failed: {}", e))??;
|
||||
let ws_url = process.ws_url.clone();
|
||||
(ws_url, BrowserProcess::Chrome(process))
|
||||
}
|
||||
};
|
||||
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: Some(chrome),
|
||||
browser_process: Some(process),
|
||||
cdp_connection: false,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 25_000,
|
||||
@@ -197,7 +271,32 @@ impl BrowserManager {
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
chrome_process: None,
|
||||
browser_process: None,
|
||||
cdp_connection: true,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
};
|
||||
|
||||
manager.discover_and_attach_targets().await?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub async fn launch_managed_cdp(
|
||||
executable_path: Option<String>,
|
||||
headed: bool,
|
||||
) -> Result<Self, String> {
|
||||
let process =
|
||||
tokio::task::spawn_blocking(move || launch_managed_chrome(executable_path, headed))
|
||||
.await
|
||||
.map_err(|e| format!("Managed Chrome launch task failed: {}", e))??;
|
||||
|
||||
let ws_url = process.ws_url.clone();
|
||||
let client = CdpClient::connect(&ws_url).await?;
|
||||
let mut manager = Self {
|
||||
client,
|
||||
browser_process: Some(BrowserProcess::Chrome(process)),
|
||||
cdp_connection: true,
|
||||
pages: Vec::new(),
|
||||
active_page_index: 0,
|
||||
default_timeout_ms: 10_000,
|
||||
@@ -212,6 +311,50 @@ impl BrowserManager {
|
||||
Self::connect_cdp(&ws_url).await
|
||||
}
|
||||
|
||||
async fn create_and_attach_blank_page(&mut self) -> Result<(), String> {
|
||||
let result: CreateTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let attach_result: AttachToTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: result.target_id.clone(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.enable_domains_with_timeout(&attach_result.session_id)
|
||||
.await?;
|
||||
|
||||
self.pages = vec![PageInfo {
|
||||
target_id: result.target_id,
|
||||
session_id: attach_result.session_id,
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
}];
|
||||
self.active_page_index = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_domains_with_timeout(&self, session_id: &str) -> Result<(), String> {
|
||||
timeout(Duration::from_secs(3), self.enable_domains(session_id))
|
||||
.await
|
||||
.map_err(|_| format!("Timed out enabling CDP domains for session {}", session_id))?
|
||||
}
|
||||
|
||||
async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
|
||||
self.client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -229,43 +372,15 @@ impl BrowserManager {
|
||||
let page_targets: Vec<TargetInfo> = result
|
||||
.target_infos
|
||||
.into_iter()
|
||||
.filter(|t| t.target_type == "page" && !t.url.is_empty())
|
||||
.filter(|t| {
|
||||
(t.target_type == "page" || t.target_type == "webview") && !t.url.is_empty()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if page_targets.is_empty() {
|
||||
// Create a new tab
|
||||
let result: CreateTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let attach_result: AttachToTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: result.target_id.clone(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.pages.push(PageInfo {
|
||||
target_id: result.target_id,
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
self.create_and_attach_blank_page().await?;
|
||||
} else {
|
||||
let mut attached_pages = Vec::new();
|
||||
for target in &page_targets {
|
||||
let attach_result: AttachToTargetResult = self
|
||||
.client
|
||||
@@ -279,17 +394,50 @@ impl BrowserManager {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.pages.push(PageInfo {
|
||||
let page_info = PageInfo {
|
||||
target_id: target.target_id.clone(),
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: target.url.clone(),
|
||||
title: target.title.clone(),
|
||||
});
|
||||
target_type: target.target_type.clone(),
|
||||
};
|
||||
|
||||
match self
|
||||
.enable_domains_with_timeout(&attach_result.session_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => attached_pages.push(page_info),
|
||||
Err(err) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").as_deref() == Ok("1") {
|
||||
eprintln!(
|
||||
"[DEBUG] Skipping CDP target '{}' ({}): {}",
|
||||
target.title, target.url, err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.active_page_index = 0;
|
||||
let session_id = self.pages[0].session_id.clone();
|
||||
self.enable_domains(&session_id).await?;
|
||||
if attached_pages.is_empty() {
|
||||
self.create_and_attach_blank_page().await?;
|
||||
} else {
|
||||
let preferred_index = attached_pages
|
||||
.iter()
|
||||
.position(|page| !is_startup_internal_page(&page.url));
|
||||
|
||||
self.pages = attached_pages;
|
||||
|
||||
if let Some(index) = preferred_index {
|
||||
self.active_page_index = index;
|
||||
} else {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").as_deref() == Ok("1") {
|
||||
eprintln!(
|
||||
"[DEBUG] All discovered pages were Chrome startup pages; creating a fresh about:blank target"
|
||||
);
|
||||
}
|
||||
self.create_and_attach_blank_page().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -321,6 +469,7 @@ impl BrowserManager {
|
||||
|
||||
pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
|
||||
let session_id = self.active_session_id()?.to_string();
|
||||
let rx = self.client.subscribe();
|
||||
|
||||
let nav_result: PageNavigateResult = self
|
||||
.client
|
||||
@@ -338,7 +487,7 @@ impl BrowserManager {
|
||||
return Err(format!("Navigation failed: {}", error_text));
|
||||
}
|
||||
|
||||
self.wait_for_lifecycle(wait_until, &session_id).await?;
|
||||
self.wait_for_lifecycle(wait_until, &session_id, rx).await?;
|
||||
|
||||
let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
|
||||
let title = self.get_title().await.unwrap_or_default();
|
||||
@@ -355,14 +504,14 @@ impl BrowserManager {
|
||||
&self,
|
||||
wait_until: WaitUntil,
|
||||
session_id: &str,
|
||||
mut rx: tokio::sync::broadcast::Receiver<CdpEvent>,
|
||||
) -> Result<(), String> {
|
||||
let event_name = match wait_until {
|
||||
WaitUntil::Load => "Page.loadEventFired",
|
||||
WaitUntil::DomContentLoaded => "Page.domContentEventFired",
|
||||
WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id).await,
|
||||
WaitUntil::NetworkIdle => return self.wait_for_network_idle(session_id, rx).await,
|
||||
};
|
||||
|
||||
let mut rx = self.client.subscribe();
|
||||
let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
@@ -377,8 +526,11 @@ impl BrowserManager {
|
||||
.map_err(|_| format!("Timeout waiting for {}", event_name))?
|
||||
}
|
||||
|
||||
async fn wait_for_network_idle(&self, session_id: &str) -> Result<(), String> {
|
||||
let mut rx = self.client.subscribe();
|
||||
async fn wait_for_network_idle(
|
||||
&self,
|
||||
session_id: &str,
|
||||
mut rx: tokio::sync::broadcast::Receiver<CdpEvent>,
|
||||
) -> Result<(), String> {
|
||||
let pending = Arc::new(Mutex::new(HashSet::<String>::new()));
|
||||
let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
|
||||
|
||||
@@ -497,7 +649,8 @@ impl BrowserManager {
|
||||
wait_until: WaitUntil,
|
||||
session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
self.wait_for_lifecycle(wait_until, session_id).await
|
||||
self.wait_for_lifecycle(wait_until, session_id, self.client.subscribe())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn close(&mut self) -> Result<(), String> {
|
||||
@@ -507,9 +660,13 @@ impl BrowserManager {
|
||||
.send_command_no_params("Browser.close", None)
|
||||
.await;
|
||||
|
||||
// Kill Chrome process if we own it
|
||||
if let Some(ref mut chrome) = self.chrome_process {
|
||||
chrome.kill();
|
||||
if let Some(process) = self.browser_process.take() {
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
let _ = tokio::task::spawn_blocking(move || match process {
|
||||
BrowserProcess::Chrome(mut chrome) => chrome.wait_or_kill(timeout),
|
||||
BrowserProcess::Lightpanda(mut lightpanda) => lightpanda.kill(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -538,7 +695,7 @@ impl BrowserManager {
|
||||
|
||||
/// Returns true if this manager was connected via CDP (as opposed to local launch).
|
||||
pub fn is_cdp_connection(&self) -> bool {
|
||||
self.chrome_process.is_none()
|
||||
self.cdp_connection
|
||||
}
|
||||
|
||||
/// Ensures the browser has at least one page. If `pages` is empty, creates a new
|
||||
@@ -576,6 +733,7 @@ impl BrowserManager {
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
@@ -608,6 +766,7 @@ impl BrowserManager {
|
||||
"index": i,
|
||||
"title": p.title,
|
||||
"url": p.url,
|
||||
"type": p.target_type,
|
||||
"active": i == self.active_page_index,
|
||||
})
|
||||
})
|
||||
@@ -648,6 +807,7 @@ impl BrowserManager {
|
||||
session_id: attach.session_id,
|
||||
url: target_url.to_string(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = index;
|
||||
|
||||
@@ -1065,6 +1225,30 @@ mod tests {
|
||||
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_extensions() {
|
||||
let opts = LaunchOptions {
|
||||
extensions: Some(vec!["/tmp/ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_rejects_headed() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_lightpanda_options(&opts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_lightpanda_options_valid() {
|
||||
let opts = LaunchOptions::default();
|
||||
assert!(validate_lightpanda_options(&opts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_ai_friendly_error_strict_mode() {
|
||||
assert_eq!(
|
||||
@@ -1110,4 +1294,15 @@ mod tests {
|
||||
let msg = "Some custom error message";
|
||||
assert_eq!(to_ai_friendly_error(msg), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_startup_internal_page_detects_profile_picker() {
|
||||
assert!(is_startup_internal_page("chrome://profile-picker/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_startup_internal_page_ignores_normal_pages() {
|
||||
assert!(!is_startup_internal_page("https://example.com/"));
|
||||
assert!(!is_startup_internal_page("about:blank"));
|
||||
}
|
||||
}
|
||||
|
||||
+340
-80
@@ -8,6 +8,7 @@ use super::types::BrowserVersionInfo;
|
||||
pub struct ChromeProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ChromeProcess {
|
||||
@@ -15,11 +16,46 @@ impl ChromeProcess {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
|
||||
/// Wait for Chrome to exit on its own (after Browser.close CDP command),
|
||||
/// falling back to kill() if it doesn't exit within the timeout.
|
||||
/// This allows Chrome to flush cookies and other state to the user-data-dir.
|
||||
pub fn wait_or_kill(&mut self, timeout: Duration) {
|
||||
let start = std::time::Instant::now();
|
||||
let poll_interval = Duration::from_millis(50);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => std::thread::sleep(poll_interval),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromeProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
if let Some(ref dir) = self.temp_user_data_dir {
|
||||
for attempt in 0..3 {
|
||||
match std::fs::remove_dir_all(dir) {
|
||||
Ok(()) => break,
|
||||
Err(_) if attempt < 2 => {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: failed to clean up temp profile {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +73,7 @@ pub struct LaunchOptions {
|
||||
pub ignore_https_errors: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub remote_debugging_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Default for LaunchOptions {
|
||||
@@ -55,20 +92,21 @@ impl Default for LaunchOptions {
|
||||
ignore_https_errors: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
remote_debugging_port: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
let chrome_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
|
||||
}
|
||||
};
|
||||
struct ChromeArgs {
|
||||
args: Vec<String>,
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let remote_debugging_port = options.remote_debugging_port.unwrap_or(0);
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
format!("--remote-debugging-port={}", remote_debugging_port),
|
||||
"--remote-debugging-address=127.0.0.1".to_string(),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
"--disable-background-networking".to_string(),
|
||||
@@ -79,13 +117,21 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
"--disable-popup-blocking".to_string(),
|
||||
"--disable-prompt-on-repost".to_string(),
|
||||
"--disable-sync".to_string(),
|
||||
"--disable-features=Translate".to_string(),
|
||||
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
|
||||
"--metrics-recording-only".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
];
|
||||
|
||||
if options.headless {
|
||||
let has_extensions = options
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty());
|
||||
|
||||
// Extensions require headed mode in native Chrome (content scripts are not
|
||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||
if options.headless && !has_extensions {
|
||||
args.push("--headless=new".to_string());
|
||||
}
|
||||
|
||||
@@ -97,10 +143,18 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
if let Some(ref profile) = options.profile {
|
||||
let temp_user_data_dir = if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
args.push(format!("--user-data-dir={}", expanded));
|
||||
}
|
||||
None
|
||||
} else {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
|
||||
args.push(format!("--user-data-dir={}", dir.display()));
|
||||
Some(dir)
|
||||
};
|
||||
|
||||
if options.allow_file_access {
|
||||
args.push("--allow-file-access-from-files".to_string());
|
||||
@@ -115,13 +169,12 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user args set window size (skip viewport override)
|
||||
let has_window_size = options
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless {
|
||||
if !has_window_size && options.headless && !has_extensions {
|
||||
args.push("--window-size=1280,720".to_string());
|
||||
}
|
||||
|
||||
@@ -131,23 +184,104 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
args.push("--no-sandbox".to_string());
|
||||
}
|
||||
|
||||
Ok(ChromeArgs {
|
||||
args,
|
||||
temp_user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub const MANAGED_CDP_PORT: u16 = 9333;
|
||||
|
||||
pub fn managed_cdp_profile_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join(".agent-browser")
|
||||
.join("chrome-bot-profile")
|
||||
}
|
||||
|
||||
fn cleanup_managed_profile_locks(profile_dir: &Path) {
|
||||
let _ = std::fs::remove_file(profile_dir.join("DevToolsActivePort"));
|
||||
if let Ok(entries) = std::fs::read_dir(profile_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with("Singleton") {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_managed_chrome(
|
||||
executable_path: Option<String>,
|
||||
headed: bool,
|
||||
) -> Result<ChromeProcess, String> {
|
||||
let profile_dir = managed_cdp_profile_dir();
|
||||
std::fs::create_dir_all(&profile_dir)
|
||||
.map_err(|e| format!("Failed to create managed Chrome profile dir: {}", e))?;
|
||||
cleanup_managed_profile_locks(&profile_dir);
|
||||
|
||||
let options = LaunchOptions {
|
||||
headless: !headed,
|
||||
executable_path,
|
||||
profile: Some(profile_dir.to_string_lossy().to_string()),
|
||||
remote_debugging_port: Some(MANAGED_CDP_PORT),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
launch_chrome(&options)
|
||||
}
|
||||
|
||||
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
let chrome_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
|
||||
}
|
||||
};
|
||||
|
||||
let ChromeArgs {
|
||||
args,
|
||||
temp_user_data_dir,
|
||||
} = build_chrome_args(options)?;
|
||||
|
||||
let cleanup_temp_dir = |dir: &Option<PathBuf>| {
|
||||
if let Some(ref d) = dir {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
};
|
||||
|
||||
let mut child = Command::new(&chrome_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Chrome at {:?}: {}", chrome_path, e))?;
|
||||
.map_err(|e| {
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
format!("Failed to launch Chrome at {:?}: {}", chrome_path, e)
|
||||
})?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("Failed to capture Chrome stderr")?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
"Failed to capture Chrome stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let ws_url = wait_for_ws_url(reader)?;
|
||||
let ws_url = match wait_for_ws_url(reader) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
cleanup_temp_dir(&temp_user_data_dir);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ChromeProcess { child, ws_url })
|
||||
Ok(ChromeProcess {
|
||||
child,
|
||||
ws_url,
|
||||
temp_user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_ws_url(reader: BufReader<std::process::ChildStderr>) -> Result<String, String> {
|
||||
@@ -315,55 +449,8 @@ pub async fn discover_cdp_url(port: u16) -> Result<String, String> {
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let client = tokio::net::TcpStream::connect(
|
||||
url.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1:9222"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let path = url
|
||||
.find('/')
|
||||
.and_then(|i| url[i..].find('/').map(|j| &url[i + j..]))
|
||||
.unwrap_or("/json/version");
|
||||
|
||||
let host = url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1");
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut client = client;
|
||||
client
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
client
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body = response_str
|
||||
.split("\r\n\r\n")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(body)
|
||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)> {
|
||||
@@ -470,10 +557,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
|
||||
|
||||
// Generic container detection: cgroup contains docker/kubepods/lxc
|
||||
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
|
||||
if cgroup.contains("docker")
|
||||
|| cgroup.contains("kubepods")
|
||||
|| cgroup.contains("lxc")
|
||||
{
|
||||
if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -559,6 +643,7 @@ fn expand_tilde(path: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_find_chrome_returns_some_on_host() {
|
||||
@@ -616,20 +701,195 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chrome_launch_error_generic() {
|
||||
let lines = vec![
|
||||
"info line".to_string(),
|
||||
"another info line".to_string(),
|
||||
];
|
||||
let lines = vec!["info line".to_string(), "another info line".to_string()];
|
||||
let msg = chrome_launch_error("Chrome exited", &lines);
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_playwright_chromium_nonexistent() {
|
||||
// With no Playwright cache, should return None
|
||||
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
let _guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
|
||||
_guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
|
||||
let result = find_playwright_chromium();
|
||||
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headless_includes_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
assert!(dir.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headed_no_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a.contains("--headless")));
|
||||
assert!(!result.args.iter().any(|a| a.starts_with("--window-size=")));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
assert!(dir.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_temp_user_data_dir_created() {
|
||||
let opts = LaunchOptions::default();
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
let dir = result.temp_user_data_dir.as_ref().unwrap();
|
||||
assert!(dir.exists());
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--user-data-dir=")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_profile_no_temp_dir() {
|
||||
let opts = LaunchOptions {
|
||||
profile: Some("/tmp/my-profile".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.temp_user_data_dir.is_none());
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--user-data-dir=/tmp/my-profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_custom_window_size_not_overridden() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
args: vec!["--window-size=1920,1080".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_start_maximized_suppresses_default_window_size() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
args: vec!["--start-maximized".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
assert!(result.args.iter().any(|a| a == "--start-maximized"));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_disables_translate() {
|
||||
let opts = LaunchOptions::default();
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.contains("--disable-features") && a.contains("Translate")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headless_with_extensions_skips_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
extensions: Some(vec!["/tmp/my-ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless flag should be omitted when extensions are present"
|
||||
);
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--window-size")),
|
||||
"window-size should be omitted when extensions force headed mode"
|
||||
);
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--load-extension=")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headed_with_extensions_no_headless_flag() {
|
||||
let opts = LaunchOptions {
|
||||
headless: false,
|
||||
extensions: Some(vec!["/tmp/my-ext".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless flag should not be present in headed mode"
|
||||
);
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--load-extension=")));
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_process_drop_cleans_temp_dir() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"agent-browser-chrome-drop-test-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
assert!(dir.exists());
|
||||
|
||||
{
|
||||
// Simulate a ChromeProcess with a temp dir but a dummy child.
|
||||
// We can't actually spawn Chrome here, but we can verify the Drop
|
||||
// logic by creating a small helper process.
|
||||
let child = Command::new("echo")
|
||||
.arg("test")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _process = ChromeProcess {
|
||||
child,
|
||||
ws_url: String::new(),
|
||||
temp_user_data_dir: Some(dir.clone()),
|
||||
};
|
||||
// _process dropped here
|
||||
}
|
||||
|
||||
assert!(!dir.exists(), "Temp dir should be cleaned up on drop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_stderr_drain: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_lightpanda(options: &LightpandaLaunchOptions) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(port) => port,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|listener| listener.local_addr())
|
||||
.map(|addr| addr.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
|
||||
if let Some(ref proxy) = options.proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.clone());
|
||||
}
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
let reader = BufReader::new(stderr);
|
||||
|
||||
let (address, reader) = match wait_for_address(reader) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let ws_url = format!("ws://{}", address);
|
||||
let drain = std::thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_stderr_drain: Some(drain),
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_address(
|
||||
mut reader: BufReader<std::process::ChildStderr>,
|
||||
) -> Result<(String, BufReader<std::process::ChildStderr>), String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
let mut stderr_lines: Vec<String> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Timeout waiting for Lightpanda server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
match reader.read_line(&mut buf) {
|
||||
Ok(0) => {
|
||||
return Err(lightpanda_launch_error(
|
||||
"Lightpanda exited before providing server address",
|
||||
&stderr_lines,
|
||||
));
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = buf.trim_end().to_string();
|
||||
if let Some(address) = extract_address(&line) {
|
||||
return Ok((address, reader));
|
||||
}
|
||||
stderr_lines.push(line);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to read Lightpanda stderr: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_address(line: &str) -> Option<String> {
|
||||
if let Some(idx) = line.find("address = ") {
|
||||
let address = line[idx + "address = ".len()..].trim().to_string();
|
||||
if !address.is_empty() {
|
||||
return Some(address);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(message: &str, stderr_lines: &[String]) -> String {
|
||||
if stderr_lines.is_empty() {
|
||||
return format!("{} (no stderr output from Lightpanda)", message);
|
||||
}
|
||||
|
||||
let last_lines: Vec<&String> = stderr_lines.iter().rev().take(5).collect();
|
||||
format!(
|
||||
"{}\nLightpanda stderr (last {} lines):\n {}",
|
||||
message,
|
||||
last_lines.len(),
|
||||
last_lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|line| line.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_standard() {
|
||||
assert_eq!(
|
||||
extract_address(" address = 127.0.0.1:9222"),
|
||||
Some("127.0.0.1:9222".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_inline() {
|
||||
assert_eq!(
|
||||
extract_address("INFO app : server running address = 127.0.0.1:4567"),
|
||||
Some("127.0.0.1:4567".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_address_no_match() {
|
||||
assert_eq!(extract_address("INFO app : starting up..."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_stderr() {
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &[]);
|
||||
assert!(msg.contains("no stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let lines = vec![
|
||||
"INFO starting up".to_string(),
|
||||
"ERROR bind failed: address in use".to_string(),
|
||||
];
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &lines);
|
||||
assert!(msg.contains("bind failed"));
|
||||
assert!(msg.contains("last 2 lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
|
||||
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
|
||||
@@ -56,13 +56,11 @@ pub async fn set_cookies(
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
if c.get("url").is_none() && c.get("domain").is_none() {
|
||||
if let Some(url) = current_url {
|
||||
c.as_object_mut()
|
||||
.map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
|
||||
}
|
||||
}
|
||||
c
|
||||
})
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::state;
|
||||
|
||||
const IDLE_SHUTDOWN_SECS: u64 = 600;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
let resident_mode = env::args().any(|arg| arg == "--resident");
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
@@ -18,6 +24,16 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
let meta_path = socket_dir.join(format!("{}.meta.json", session));
|
||||
if let Ok(current_exe) = env::current_exe() {
|
||||
let daemon_path = current_exe.canonicalize().unwrap_or(current_exe);
|
||||
let cli_version = env::var("AGENT_BROWSER_CLI_VERSION").unwrap_or_default();
|
||||
let meta = json!({
|
||||
"daemonPath": daemon_path.to_string_lossy(),
|
||||
"cliVersion": cli_version,
|
||||
});
|
||||
let _ = fs::write(&meta_path, meta.to_string());
|
||||
}
|
||||
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
|
||||
@@ -33,10 +49,11 @@ pub async fn run_daemon(session: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
let result = run_socket_server(&socket_path, session).await;
|
||||
let result = run_socket_server(&socket_path, session, resident_mode).await;
|
||||
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&meta_path);
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
@@ -47,7 +64,11 @@ pub async fn run_daemon(session: &str) {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
_session: &str,
|
||||
resident_mode: bool,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
@@ -55,6 +76,9 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
|
||||
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -62,8 +86,10 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let activity_tx = activity_tx.clone();
|
||||
let active_commands = active_commands.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
handle_connection(stream, state, activity_tx, active_commands).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -71,6 +97,19 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) = activity_rx.recv() => {
|
||||
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
}
|
||||
_ = tokio::time::sleep_until(idle_deadline), if !resident_mode => {
|
||||
if active_commands.load(Ordering::SeqCst) == 0 {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
@@ -85,7 +124,11 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
resident_mode: bool,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let port = get_port_for_session(session);
|
||||
@@ -99,6 +142,9 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
|
||||
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
|
||||
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -106,8 +152,10 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let activity_tx = activity_tx.clone();
|
||||
let active_commands = active_commands.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state).await;
|
||||
handle_connection(stream, state, activity_tx, active_commands).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -115,6 +163,20 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) = activity_rx.recv() => {
|
||||
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
}
|
||||
_ = tokio::time::sleep_until(idle_deadline), if !resident_mode => {
|
||||
if active_commands.load(Ordering::SeqCst) == 0 {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
@@ -129,8 +191,12 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
|
||||
where
|
||||
async fn handle_connection<S>(
|
||||
stream: S,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
activity_tx: UnboundedSender<()>,
|
||||
active_commands: std::sync::Arc<AtomicUsize>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
@@ -166,6 +232,8 @@ where
|
||||
};
|
||||
|
||||
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
|
||||
let _ = activity_tx.send(());
|
||||
active_commands.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let response = {
|
||||
let mut s = state.lock().await;
|
||||
@@ -175,8 +243,11 @@ where
|
||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||
active_commands.fetch_sub(1, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
active_commands.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = activity_tx.send(());
|
||||
|
||||
if is_close {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 1);
|
||||
assert_eq!(tabs[0]["active"], true);
|
||||
assert_eq!(tabs[0]["type"], "page");
|
||||
|
||||
// Open new tab
|
||||
let resp = execute_command(
|
||||
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
|
||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||
assert_eq!(tabs.len(), 2);
|
||||
assert_eq!(tabs[1]["active"], true);
|
||||
assert_eq!(tabs[1]["type"], "page");
|
||||
|
||||
// Switch to first tab
|
||||
let resp = execute_command(
|
||||
@@ -1293,3 +1295,115 @@ async fn e2e_error_handling() {
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile cookie persistence across restarts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_profile_cookie_persistence() {
|
||||
let profile_dir = std::env::temp_dir().join(format!(
|
||||
"agent-browser-e2e-profile-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
|
||||
// Session 1: launch with profile, set a cookie, close
|
||||
{
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "1",
|
||||
"action": "launch",
|
||||
"headless": true,
|
||||
"profile": profile_dir.to_str().unwrap()
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "3",
|
||||
"action": "cookies_set",
|
||||
"name": "persist_test",
|
||||
"value": "should_survive_restart",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": 2000000000
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Verify cookie is set
|
||||
let resp =
|
||||
execute_command(&json!({ "id": "4", "action": "cookies_get" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let cookies = get_data(&resp)["cookies"].as_array().unwrap();
|
||||
let found = cookies
|
||||
.iter()
|
||||
.any(|c| c["name"] == "persist_test" && c["value"] == "should_survive_restart");
|
||||
assert!(found, "Cookie should exist before close");
|
||||
|
||||
let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
// Session 2: reopen with the same profile, verify cookie persisted
|
||||
{
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "10",
|
||||
"action": "launch",
|
||||
"headless": true,
|
||||
"profile": profile_dir.to_str().unwrap()
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "11", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp =
|
||||
execute_command(&json!({ "id": "12", "action": "cookies_get" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let cookies = get_data(&resp)["cookies"].as_array().unwrap();
|
||||
let found = cookies
|
||||
.iter()
|
||||
.any(|c| c["name"] == "persist_test" && c["value"] == "should_survive_restart");
|
||||
assert!(
|
||||
found,
|
||||
"Cookie should persist across restart with --profile. Cookies found: {:?}",
|
||||
cookies
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap_or("?"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&profile_dir);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,38 @@ use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
@@ -342,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let result = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(250),
|
||||
execute_command(&cmd, &mut state),
|
||||
)
|
||||
.await;
|
||||
|
||||
let Ok(result) = result else {
|
||||
continue;
|
||||
};
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
@@ -424,6 +465,7 @@ async fn test_credentials_list_without_browser() {
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
@@ -439,6 +481,7 @@ async fn test_auth_profile_name_validation() {
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
|
||||
@@ -135,6 +135,7 @@ impl ActionPolicy {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
@@ -205,12 +206,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
|
||||
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
|
||||
"RootWebArea",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SnapshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub interactive: bool,
|
||||
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
|
||||
pub cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
interactive: false,
|
||||
compact: false,
|
||||
depth: None,
|
||||
cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
role: String,
|
||||
name: String,
|
||||
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
|
||||
let escaped = text
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', " ")
|
||||
.replace('\r', " ");
|
||||
.replace(['\n', '\r'], " ");
|
||||
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().map_or(true, |(_, t)| modified > *t) {
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
|
||||
+51
-11
@@ -435,6 +435,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Launch/start
|
||||
if data.get("launched").is_some() {
|
||||
let label = "Browser ready";
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
return;
|
||||
}
|
||||
// Closed (browser or tab)
|
||||
if data.get("closed").is_some() {
|
||||
let label = match action {
|
||||
@@ -884,6 +890,24 @@ Examples:
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
# ^ Headers only sent to api.example.com, not other domains
|
||||
"##
|
||||
}
|
||||
"start" => {
|
||||
r##"
|
||||
agent-browser start - Start the managed automation browser on localhost:9333
|
||||
|
||||
Usage: agent-browser start
|
||||
|
||||
Starts or reuses the dedicated automation Chrome profile on localhost:9333
|
||||
without navigating to a page. Use this to pre-warm the managed browser for
|
||||
unattended runs.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
|
||||
Examples:
|
||||
agent-browser start
|
||||
abs start
|
||||
"##
|
||||
}
|
||||
"back" => {
|
||||
@@ -2076,9 +2100,11 @@ Operations:
|
||||
|
||||
Automatic State Persistence:
|
||||
Use --session-name to auto-save/restore state across restarts.
|
||||
If omitted, it defaults to "default":
|
||||
If omitted in default runtime mode, it defaults to "default":
|
||||
agent-browser --session-name myapp open https://example.com
|
||||
Or set AGENT_BROWSER_SESSION_NAME environment variable.
|
||||
Note: with --parallel <name>, persistence is disabled by default unless
|
||||
--session-name is explicitly passed on the same command.
|
||||
|
||||
State Encryption:
|
||||
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption.
|
||||
@@ -2105,7 +2131,7 @@ agent-browser session - Manage sessions
|
||||
|
||||
Usage: agent-browser session [operation]
|
||||
|
||||
Show the current fixed session and active daemon state.
|
||||
Show the current runtime session and active daemon state.
|
||||
|
||||
Operations:
|
||||
(none) Show current session name
|
||||
@@ -2337,6 +2363,7 @@ Aliases: agent-browser, agent-browser-stealth, abs
|
||||
|
||||
Core Commands:
|
||||
open <url> Navigate to URL
|
||||
start Start managed browser on localhost:9333
|
||||
click <sel> Click element (or @ref)
|
||||
dblclick <sel> Double-click element
|
||||
type <sel> <text> [--delay <ms>] Type into element
|
||||
@@ -2423,7 +2450,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
|
||||
@@ -2437,7 +2464,7 @@ Snapshot Options:
|
||||
-s, --selector <sel> Scope to CSS selector
|
||||
|
||||
Options:
|
||||
--session <name> Ignored (single default session only)
|
||||
--session <name> Ignored (runtime uses default session unless --parallel is set)
|
||||
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
|
||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||
@@ -2456,10 +2483,10 @@ Options:
|
||||
--json JSON output
|
||||
--full, -f Full page screenshot
|
||||
--annotate Annotated screenshot with numbered labels and legend
|
||||
--headed Show browser window (not headless)
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED=1/true)
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--auto-connect Auto-discover and connect to running Chrome
|
||||
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
|
||||
Explicit existing-browser mode; may trigger Chrome permission prompts
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
|
||||
@@ -2467,13 +2494,18 @@ Options:
|
||||
Extension side panel supports browser controls + console/network/DOM + workflow scheduling
|
||||
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
||||
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
|
||||
--session-name <name> Auto-save/restore session state (defaults to "default")
|
||||
--parallel <name> Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
|
||||
Default behavior in this mode is stateless (no auto session persistence unless --session-name is explicitly passed)
|
||||
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)
|
||||
--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)
|
||||
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
|
||||
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
|
||||
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
|
||||
--engine <name> Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE)
|
||||
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
|
||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||
--debug Debug output
|
||||
@@ -2482,7 +2514,9 @@ Options:
|
||||
Policy:
|
||||
--profile / AGENT_BROWSER_PROFILE are forbidden
|
||||
--channel / AGENT_BROWSER_CHANNEL are forbidden
|
||||
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
|
||||
Daemon auto-shuts down after 10 minutes of inactivity unless --resident is set
|
||||
Default mode uses localhost:9333. If 9333 is unavailable, agent-browser auto-starts a dedicated Chrome profile at ~/.agent-browser/chrome-bot-profile
|
||||
Use --auto-connect only when you explicitly want to attach to an existing manual browser session
|
||||
|
||||
Configuration:
|
||||
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
||||
@@ -2497,6 +2531,7 @@ Configuration:
|
||||
Boolean flags accept an optional true/false value to override config:
|
||||
--headed (same as --headed true)
|
||||
--headed false (disables "headed": true from config)
|
||||
--resident false (disable resident mode for this invocation)
|
||||
|
||||
Extensions from user and project configs are merged (not replaced).
|
||||
|
||||
@@ -2505,12 +2540,14 @@ Configuration:
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: "default")
|
||||
AGENT_BROWSER_PARALLEL Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
|
||||
Best for stateless/no-login tasks where throughput matters
|
||||
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
|
||||
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless)
|
||||
AGENT_BROWSER_HEADED Show browser window (not headless; accepts 1 or true)
|
||||
AGENT_BROWSER_JSON JSON output
|
||||
AGENT_BROWSER_FULL Full page screenshot
|
||||
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend
|
||||
@@ -2528,7 +2565,7 @@ Environment:
|
||||
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name (default: "default" when --parallel is not set)
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
|
||||
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
|
||||
@@ -2540,6 +2577,7 @@ Environment:
|
||||
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
|
||||
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
|
||||
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
|
||||
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
|
||||
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
|
||||
|
||||
Install (recommended, fastest - native Rust CLI):
|
||||
@@ -2564,6 +2602,8 @@ Examples:
|
||||
agent-browser --color-scheme dark open example.com # Dark mode
|
||||
agent-browser --risk-mode block open example.com # Block on verification/captcha pages
|
||||
agent-browser --session-name myapp open example.com # Auto-save/restore state
|
||||
agent-browser --parallel worker-a open example.com # Isolated runtime for parallel AI task
|
||||
agent-browser --resident open example.com # Keep daemon resident until explicit close
|
||||
|
||||
Command Chaining:
|
||||
Chain commands with && in a single shell call (browser persists via daemon):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Global mutex shared across all test modules to prevent parallel tests from
|
||||
/// interfering with each other when mutating environment variables.
|
||||
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
|
||||
pub struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
pub fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), std::env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
|
||||
pub fn set(&self, name: &str, value: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::set called with unregistered var: {name}"
|
||||
);
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
|
||||
pub fn remove(&self, name: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::remove called with unregistered var: {name}"
|
||||
);
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(name, v),
|
||||
None => std::env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ services:
|
||||
|
||||
# Build both targets in parallel
|
||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
||||
PID1=$!
|
||||
PID1=$$!
|
||||
|
||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||
PID2=$!
|
||||
PID2=$$!
|
||||
|
||||
# Wait for both to complete
|
||||
wait $PID1 $PID2
|
||||
wait $$PID1 $$PID2
|
||||
|
||||
echo ""
|
||||
echo "✓ Linux platforms built successfully!"
|
||||
@@ -67,8 +67,8 @@ services:
|
||||
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
||||
command: |
|
||||
-c '
|
||||
cargo zigbuild --release --target $TARGET
|
||||
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
|
||||
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $OUTPUT_NAME"
|
||||
cargo zigbuild --release --target $$TARGET
|
||||
cp /build/target/$$TARGET/release/agent-browser* /output/$$OUTPUT_NAME
|
||||
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $$OUTPUT_NAME"
|
||||
'
|
||||
|
||||
@@ -6,7 +6,14 @@ export const metadata = pageMetadata('cdp-mode');
|
||||
|
||||
Connect to an existing browser via Chrome DevTools Protocol:
|
||||
|
||||
Default behavior in this fork: when `--cdp` is omitted, agent-browser auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed local-launch fallback).
|
||||
Default behavior in this fork: when `--cdp` is omitted, agent-browser targets the managed automation browser on `localhost:9333`. If `:9333` is unavailable, it auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile` and retries the CDP connection.
|
||||
|
||||
If you want to pre-start that managed browser explicitly, run:
|
||||
|
||||
```bash
|
||||
agent-browser start
|
||||
abs start
|
||||
```
|
||||
|
||||
Project policy:
|
||||
|
||||
@@ -67,6 +74,8 @@ This is useful when:
|
||||
- You want a zero-configuration connection to your existing browser
|
||||
- You don't want to track which port Chrome is using
|
||||
|
||||
Use this mode only when you intentionally want to attach to an existing manual browser session. Recent Chrome builds may display a permission prompt before allowing remote debugging access to that session.
|
||||
|
||||
## Color scheme
|
||||
|
||||
Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
|
||||
|
||||
@@ -9,6 +9,7 @@ Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
|
||||
## Core
|
||||
|
||||
```bash
|
||||
agent-browser start # Start/reuse managed browser on localhost:9333
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected
|
||||
agent-browser click <sel> # Click element (--new-tab to open in new tab)
|
||||
@@ -279,7 +280,21 @@ 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.
|
||||
|
||||
Use `--resident` when you need a long-running daemon:
|
||||
|
||||
```bash
|
||||
agent-browser --resident open https://example.com
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Navigation
|
||||
@@ -293,7 +308,7 @@ agent-browser reload # Reload page
|
||||
## Global options
|
||||
|
||||
```bash
|
||||
--session-name <name> # Auto-save/restore session state (defaults to "default" when omitted)
|
||||
--session-name <name> # Auto-save/restore session state (defaults to "default" in non-parallel mode)
|
||||
--state <path> # Load storage state from JSON file
|
||||
--headers <json> # HTTP headers scoped to URL's origin
|
||||
--executable-path <path> # Custom browser executable
|
||||
@@ -315,6 +330,8 @@ agent-browser reload # Reload page
|
||||
--auto-connect # Auto-discover and connect to running Chrome
|
||||
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
|
||||
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
|
||||
--parallel <name> # Isolated runtime channel for parallel AI runs (maps to parallel-<name>; 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)
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ export const metadata = pageMetadata('configuration');
|
||||
|
||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
||||
|
||||
In this fork, default launch behavior auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
|
||||
In this fork, default launch behavior uses a dedicated automation browser on `localhost:9333` (CDP). If `:9333` is unavailable, agent-browser auto-starts Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile` and retries the connection.
|
||||
|
||||
## Config File Locations
|
||||
|
||||
@@ -72,7 +72,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
||||
|
||||
## All Options
|
||||
|
||||
Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
Most CLI flags can be set in the config file using their camelCase equivalents (`--resident` is CLI-only):
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
@@ -128,6 +128,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>parallel</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--parallel</code>
|
||||
</td>
|
||||
<td>string (isolated runtime channel name)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>executablePath</code>
|
||||
@@ -245,6 +254,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>engine</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--engine</code>
|
||||
</td>
|
||||
<td>string (<code>chrome</code>, <code>lightpanda</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>colorScheme</code>
|
||||
@@ -308,6 +326,8 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
`riskMode` defaults to `warn` when unset.
|
||||
|
||||
`engine` defaults to `chrome`. `lightpanda` implies native mode and is headless-only.
|
||||
|
||||
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
|
||||
extension available => grouped by session; extension missing/unavailable => silent no-op.
|
||||
|
||||
@@ -353,6 +373,26 @@ 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`.
|
||||
When a default-session command runs, non-default daemon sessions are reaped.
|
||||
|
||||
## CLI-only daemon lifecycle flag
|
||||
|
||||
`--resident` is a CLI-only flag (not a config/env key). It keeps the daemon alive and disables the default 10-minute idle auto-shutdown.
|
||||
|
||||
```bash
|
||||
agent-browser --resident open example.com
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
## Overriding Boolean Options
|
||||
|
||||
Boolean flags accept an optional `true`/`false` value to override config settings:
|
||||
@@ -368,7 +408,9 @@ agent-browser --headed open example.com # same as --headed true
|
||||
agent-browser --headed true open example.com # explicit
|
||||
```
|
||||
|
||||
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
|
||||
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--resident`.
|
||||
|
||||
For environment variables, headed mode accepts either `AGENT_BROWSER_HEADED=1` or `AGENT_BROWSER_HEADED=true`.
|
||||
|
||||
## Extensions Merging
|
||||
|
||||
@@ -376,6 +418,8 @@ Extensions from user-level and project-level configs are **concatenated**, not r
|
||||
|
||||
The `AGENT_BROWSER_EXTENSIONS` environment variable and CLI `--extension` flags follow the standard priority rules (env replaces config, CLI appends).
|
||||
|
||||
In this fork, local launches and extension launches remain headed by default unless headless is explicitly requested.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These environment variables configure additional daemon and runtime behavior:
|
||||
@@ -421,6 +465,17 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<td>Default directory for browser downloads.</td>
|
||||
<td>(temp directory)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ENGINE</code>
|
||||
</td>
|
||||
<td>
|
||||
Browser engine to use: <code>chrome</code> (default), <code>lightpanda</code>. Implies native mode.
|
||||
</td>
|
||||
<td>
|
||||
<code>chrome</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||
@@ -471,6 +526,15 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<code>default</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_PARALLEL</code>
|
||||
</td>
|
||||
<td>
|
||||
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>). Non-default daemons are reaped when a default-session command starts.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("engines/chrome")
|
||||
|
||||
# Chrome
|
||||
|
||||
Chrome (and Chromium) is the default browser engine. agent-browser discovers, launches, and manages the Chrome process automatically via the Chrome DevTools Protocol (CDP).
|
||||
|
||||
## Binary Discovery
|
||||
|
||||
When no `--executable-path` is provided, agent-browser searches for Chrome in this order:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>Locations checked</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td>
|
||||
<code>/Applications/Google Chrome.app</code>,
|
||||
<code>/Applications/Google Chrome Canary.app</code>,
|
||||
<code>/Applications/Chromium.app</code>,
|
||||
Playwright Chromium cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td>
|
||||
<code>google-chrome</code>,
|
||||
<code>google-chrome-stable</code>,
|
||||
<code>chromium-browser</code>,
|
||||
<code>chromium</code> in PATH,
|
||||
Playwright Chromium cache
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td>
|
||||
<code>%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe</code>,
|
||||
<code>C:\Program Files\Google\Chrome\Application\chrome.exe</code>,
|
||||
<code>C:\Program Files (x86)\...\chrome.exe</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
If Chrome is not found, run `agent-browser install` to download Chromium via Playwright.
|
||||
|
||||
## Usage
|
||||
|
||||
Chrome is the default engine. No `--engine` flag is needed:
|
||||
|
||||
```bash
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
To be explicit:
|
||||
|
||||
```bash
|
||||
agent-browser --engine chrome open example.com
|
||||
```
|
||||
|
||||
## Custom Binary
|
||||
|
||||
Point to any Chromium-based browser with `--executable-path`:
|
||||
|
||||
```bash
|
||||
agent-browser --executable-path /path/to/chromium open example.com
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
## Chrome-Specific Features
|
||||
|
||||
These features are available only with Chrome:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Flag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Browser extensions</td><td><code>--extension <path></code></td></tr>
|
||||
<tr><td>Persistent profiles</td><td><code>--profile <path></code></td></tr>
|
||||
<tr><td>Storage state</td><td><code>--state <path></code></td></tr>
|
||||
<tr><td>File URL access</td><td><code>--allow-file-access</code></td></tr>
|
||||
<tr><td>Headed mode</td><td><code>--headed</code></td></tr>
|
||||
<tr><td>Custom launch args</td><td><code>--args <args></code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Containers and CI
|
||||
|
||||
In Docker, CI runners, or other sandboxed environments, Chrome's user namespace sandbox may need to be disabled:
|
||||
|
||||
```bash
|
||||
agent-browser --args "--no-sandbox" open example.com
|
||||
```
|
||||
|
||||
agent-browser automatically adds `--no-sandbox` when it detects a container environment (Docker, Podman, or root execution).
|
||||
@@ -0,0 +1,97 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
|
||||
export const metadata = pageMetadata("engines/lightpanda")
|
||||
|
||||
# Lightpanda
|
||||
|
||||
[Lightpanda](https://lightpanda.io/) is a headless browser engine built from scratch in Zig. It is intended for machine-driven workloads where fast startup and low memory use matter more than full Chrome compatibility.
|
||||
|
||||
agent-browser manages Lightpanda the same way it manages Chrome: spawn the process, connect via CDP, and drive the same downstream commands (`snapshot`, `click`, `fill`, `screenshot`, and so on).
|
||||
|
||||
## Installation
|
||||
|
||||
Install the Lightpanda binary before using it with agent-browser:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>Command</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>macOS (Apple Silicon)</td>
|
||||
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && chmod a+x ./lightpanda</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux (x86_64)</td>
|
||||
<td><code>curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && chmod a+x ./lightpanda</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Move the binary somewhere in your `PATH` such as `/usr/local/bin/lightpanda` or `~/.local/bin/lightpanda`.
|
||||
|
||||
See the [Lightpanda installation docs](https://lightpanda.io/docs/open-source/installation) for more options.
|
||||
|
||||
## Usage
|
||||
|
||||
Use `--engine` to select Lightpanda:
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open example.com
|
||||
agent-browser --engine lightpanda snapshot
|
||||
agent-browser --engine lightpanda screenshot
|
||||
```
|
||||
|
||||
Or set it as the default via environment variable:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open example.com
|
||||
```
|
||||
|
||||
Or in `agent-browser.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"engine": "lightpanda"
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Binary Path
|
||||
|
||||
If the `lightpanda` binary is not in your `PATH`, use `--executable-path`:
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
|
||||
```
|
||||
|
||||
## Differences From Chrome
|
||||
|
||||
Lightpanda is headless-only and does not support several Chrome-specific features:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Extensions (<code>--extension</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Persistent profiles (<code>--profile</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Storage state (<code>--state</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>File access (<code>--allow-file-access</code>)</td><td>Not supported</td></tr>
|
||||
<tr><td>Headed mode (<code>--headed</code>)</td><td>Not applicable</td></tr>
|
||||
<tr><td>Screenshots</td><td>Depends on Lightpanda CDP support</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
agent-browser returns a clear error if you combine `--engine lightpanda` with unsupported flags.
|
||||
|
||||
## When To Use Lightpanda
|
||||
|
||||
Lightpanda is a good fit for:
|
||||
|
||||
- Fast scraping and extraction jobs
|
||||
- AI agent workflows where speed and low memory matter
|
||||
- CI environments with constrained resources
|
||||
- High-volume parallel automation
|
||||
|
||||
Use Chrome when you need full browser fidelity, extensions, or persistent profiles.
|
||||
@@ -4,25 +4,55 @@ export const metadata = pageMetadata('sessions');
|
||||
|
||||
# Sessions
|
||||
|
||||
Use one default runtime session and optional named persistence:
|
||||
Use the default runtime session or an isolated parallel runtime channel, plus optional named persistence:
|
||||
|
||||
```bash
|
||||
# Show current runtime session
|
||||
agent-browser session
|
||||
# Output: default
|
||||
|
||||
# Isolated runtime channel for parallel AI flow
|
||||
agent-browser --parallel worker-a session
|
||||
# Output: parallel-worker-a
|
||||
|
||||
# Show active daemon sessions
|
||||
agent-browser session list
|
||||
# stale entries are auto-cleaned during listing
|
||||
```
|
||||
|
||||
## Session isolation
|
||||
|
||||
The runtime session is fixed to `default`. Use `--session-name` to isolate persisted state files per workflow.
|
||||
Runtime session defaults to `default`. Use `--parallel <name>` when you need isolated concurrent runtime channels, and `--session-name` to isolate persisted state files per workflow.
|
||||
|
||||
- Cookies and storage snapshots
|
||||
- Authentication state
|
||||
- Saved state lifecycle
|
||||
|
||||
Daemons auto-shutdown after 10 minutes of inactivity by default. Use `--resident` to keep a daemon alive until explicit `close`.
|
||||
|
||||
## Parallel runtime channels
|
||||
|
||||
Use `--parallel <name>` to isolate runtime channels for concurrent AI execution:
|
||||
|
||||
```bash
|
||||
agent-browser --parallel worker-a open https://example.com
|
||||
agent-browser --parallel worker-b open https://example.org
|
||||
```
|
||||
|
||||
`--parallel` is intended for stateless throughput tasks (navigation/extraction/checks). For authenticated flows, use a stable `--session-name`.
|
||||
|
||||
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
|
||||
agent-browser --parallel worker-a --resident open https://example.com
|
||||
agent-browser --parallel worker-a close
|
||||
```
|
||||
|
||||
## Session persistence
|
||||
|
||||
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
|
||||
@@ -41,6 +71,8 @@ agent-browser open twitter.com
|
||||
|
||||
If `--session-name` is omitted, it defaults to `default`.
|
||||
|
||||
When `--parallel` is enabled, auto persistence is disabled by default unless `--session-name` is explicitly passed on that command.
|
||||
|
||||
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
|
||||
|
||||
### Session name rules
|
||||
@@ -165,6 +197,12 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
|
||||
</td>
|
||||
<td>Auto-save/load state persistence name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_PARALLEL</code>
|
||||
</td>
|
||||
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-<name></code>). Non-default daemons are reaped when a default-session command starts.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
|
||||
|
||||
@@ -40,6 +40,13 @@ export const navigation: NavSection[] = [
|
||||
{ name: "Native Mode (Experimental)", href: "/native-mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Engines",
|
||||
items: [
|
||||
{ name: "Chrome", href: "/engines/chrome" },
|
||||
{ name: "Lightpanda", href: "/engines/lightpanda" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: null,
|
||||
items: [{ name: "Changelog", href: "/changelog" }],
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
profiler: "Profiler",
|
||||
ios: "iOS Simulator",
|
||||
security: "Security",
|
||||
"engines/chrome": "Chrome",
|
||||
"engines/lightpanda": "Lightpanda",
|
||||
"native-mode": "Native Mode (Experimental)",
|
||||
changelog: "Changelog",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Upstream Sync Audit (2026-03-09)
|
||||
|
||||
Scope: compare current `main` plus the local in-progress sync worktree with `upstream/main`.
|
||||
|
||||
## Already Synced
|
||||
|
||||
- `de5ea1d` `fix: use reqwest for CDP port discovery instead of broken hand-rolled HTTP client (#619)`
|
||||
- `8f6ad81` `Fix dialog dismiss command parsing (#605)`
|
||||
- `7acde7e` `fix: native auth login fails due to incompatible encryption format (#648)`
|
||||
- `492830a` `Fix: Suppress Google Translate bar in native headless mode (#649)`
|
||||
- `68cebe5` `Fix Chrome extensions not loading by forcing headed mode when extensions present (#652)`
|
||||
- `b7e7a25` `fix: persist auth cookies on close in native mode (#650)`
|
||||
|
||||
## Absorbed Locally (Not Exact Cherry-Picks)
|
||||
|
||||
- `eaa968e` `fix: suppress spurious --native warning when set via env var (#611)`
|
||||
- Covered by the local native CLI restoration in:
|
||||
- [cli/src/flags.rs](/Users/leo/github.com/agent-browser/cli/src/flags.rs)
|
||||
- [cli/src/main.rs](/Users/leo/github.com/agent-browser/cli/src/main.rs)
|
||||
- [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs)
|
||||
- [cli/src/native/daemon.rs](/Users/leo/github.com/agent-browser/cli/src/native/daemon.rs)
|
||||
- `788ad0e` `chore: add cargo fmt check to Rust CI and fix existing violations (#620)`
|
||||
- The Rust CI `fmt` check is already present in [.github/workflows/ci.yml](/Users/leo/github.com/agent-browser/.github/workflows/ci.yml).
|
||||
- `aba2353` `Fix clippy warnings across CLI codebase (#654)`
|
||||
- The current worktree already carries the relevant CLI cleanup needed for `cargo clippy -- -D warnings` to pass.
|
||||
- `d9387aa` `ci: add clippy check to Rust CI workflow (#675)`
|
||||
- The Rust CI `clippy` check is already present in [.github/workflows/ci.yml](/Users/leo/github.com/agent-browser/.github/workflows/ci.yml).
|
||||
- `f262ff1` `docs: improve snapshot usage guidance and add reproducibility check (#630)`
|
||||
- Safe docs-only sync. Applied locally in [skills/dogfood/SKILL.md](/Users/leo/github.com/agent-browser/skills/dogfood/SKILL.md).
|
||||
- `a0bd0c2` `Add webview support for Electron apps in native mode (#671)`
|
||||
- Applied locally in:
|
||||
- [cli/src/native/actions.rs](/Users/leo/github.com/agent-browser/cli/src/native/actions.rs)
|
||||
- [cli/src/native/browser.rs](/Users/leo/github.com/agent-browser/cli/src/native/browser.rs)
|
||||
- Broadens native target discovery from `page` to `page | webview` and adds `type` to native `tab_list` output.
|
||||
- Does not alter the fork's Node.js stealth launch defaults.
|
||||
- `36c2e06` `add benchmarks (#637)`
|
||||
- Applied locally in:
|
||||
- [package.json](/Users/leo/github.com/agent-browser/package.json)
|
||||
- [test/benchmarks/run.ts](/Users/leo/github.com/agent-browser/test/benchmarks/run.ts)
|
||||
- [test/benchmarks/scenarios.ts](/Users/leo/github.com/agent-browser/test/benchmarks/scenarios.ts)
|
||||
- Adds developer benchmark scripts only. No runtime or stealth launch behavior changes.
|
||||
- `0da54c7` `lightpanda (#646)` core feature set
|
||||
- Applied locally in:
|
||||
- [cli/src/flags.rs](/Users/leo/github.com/agent-browser/cli/src/flags.rs)
|
||||
- [cli/src/main.rs](/Users/leo/github.com/agent-browser/cli/src/main.rs)
|
||||
- [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs)
|
||||
- [cli/src/native/actions.rs](/Users/leo/github.com/agent-browser/cli/src/native/actions.rs)
|
||||
- [cli/src/native/browser.rs](/Users/leo/github.com/agent-browser/cli/src/native/browser.rs)
|
||||
- [cli/src/native/cdp/lightpanda.rs](/Users/leo/github.com/agent-browser/cli/src/native/cdp/lightpanda.rs)
|
||||
- [src/protocol.ts](/Users/leo/github.com/agent-browser/src/protocol.ts)
|
||||
- [src/types.ts](/Users/leo/github.com/agent-browser/src/types.ts)
|
||||
- [src/actions.ts](/Users/leo/github.com/agent-browser/src/actions.ts)
|
||||
- [docs/src/app/engines/chrome/page.mdx](/Users/leo/github.com/agent-browser/docs/src/app/engines/chrome/page.mdx)
|
||||
- [docs/src/app/engines/lightpanda/page.mdx](/Users/leo/github.com/agent-browser/docs/src/app/engines/lightpanda/page.mdx)
|
||||
- [docs/src/lib/docs-navigation.ts](/Users/leo/github.com/agent-browser/docs/src/lib/docs-navigation.ts)
|
||||
- [docs/src/lib/page-titles.ts](/Users/leo/github.com/agent-browser/docs/src/lib/page-titles.ts)
|
||||
- [test/benchmarks/run.ts](/Users/leo/github.com/agent-browser/test/benchmarks/run.ts)
|
||||
- [test/benchmarks/engine-scenarios.ts](/Users/leo/github.com/agent-browser/test/benchmarks/engine-scenarios.ts)
|
||||
- [test/benchmarks/pages/article.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/article.html)
|
||||
- [test/benchmarks/pages/dashboard.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/dashboard.html)
|
||||
- [test/benchmarks/pages/ecommerce.html](/Users/leo/github.com/agent-browser/test/benchmarks/pages/ecommerce.html)
|
||||
- Shared launch protocol now accepts `engine`. The Node path still rejects `engine=lightpanda` with a clear `--native` requirement, while the native path can launch either `chrome` or `lightpanda`.
|
||||
- This preserves the current Node.js/Chrome stealth path while adding the native-only alternative engine surface and its supporting docs/benchmarks.
|
||||
|
||||
## Remaining Upstream Commits
|
||||
|
||||
Current status: there are no remaining upstream feature commits that are both codeful and safe to port directly into this fork. What remains is either release metadata or the stealth-sensitive `#607` launch-policy batch.
|
||||
|
||||
### Low Risk / Independent Of Stealth
|
||||
|
||||
- `94521e7` `chore: add minor changeset for release (#683)`
|
||||
- Release metadata only.
|
||||
- `2bab729` `chore: version packages (#684)`
|
||||
- Release/version bump only.
|
||||
- `01ac557` `chore: add patch changeset for release (#609)`
|
||||
- Release metadata only.
|
||||
- `7d2c895` `chore: add patch changeset for release (#612)`
|
||||
- Release metadata only.
|
||||
- `7edc5d5` `chore: version packages (#610)`
|
||||
- Release/version bump only.
|
||||
- `794a77e` `chore: version packages (#613)`
|
||||
- Release/version bump only.
|
||||
|
||||
### Needs Manual Review Because It Touches Stealth-Sensitive Launch Behavior
|
||||
|
||||
- `e5fd26e` `headed mode (#607)`
|
||||
- Overlaps with our fork-modified launch path:
|
||||
- `src/browser.ts`
|
||||
- `src/daemon.ts`
|
||||
- `cli/src/native/cdp/chrome.rs`
|
||||
- `cli/src/connection.rs`
|
||||
- Upstream intent:
|
||||
- honor `AGENT_BROWSER_HEADED`
|
||||
- support headed launch in more places
|
||||
- add temp profile cleanup and tests
|
||||
- Fork-specific risk:
|
||||
- upstream changes persistent extension launch from `headless: false` to `headless: options.headless ?? true` in `src/browser.ts`
|
||||
- our fork intentionally keeps extension launches headed by default via [src/browser.ts](/Users/leo/github.com/agent-browser/src/browser.ts#L2131)
|
||||
- our daemon auto-launch path already honors `AGENT_BROWSER_HEADED=1` and `AGENT_BROWSER_HEADED=true` in [src/daemon.ts](/Users/leo/github.com/agent-browser/src/daemon.ts#L523)
|
||||
- the native temp-profile cleanup and extension-headed logic from upstream are already present in [cli/src/native/cdp/chrome.rs](/Users/leo/github.com/agent-browser/cli/src/native/cdp/chrome.rs)
|
||||
- blindly reapplying the upstream Node hunk would move extension launch defaults back toward upstream headless behavior and would change current stealth assumptions
|
||||
- Recommendation:
|
||||
- do not cherry-pick this commit directly
|
||||
- keep fork ownership of headed/headless defaults in the Node.js path
|
||||
- extract only test-only utilities or assertions that do not alter launch policy
|
||||
- local regression tests now lock the fork policy in [src/browser.test.ts](/Users/leo/github.com/agent-browser/src/browser.test.ts), including default local headed launch and extension launches remaining headed by default
|
||||
- Node daemon env parsing is also locked in [src/daemon.test.ts](/Users/leo/github.com/agent-browser/src/daemon.test.ts), including `AGENT_BROWSER_HEADED=true` and comma/newline parsing for extensions and args
|
||||
- treat headless/headed defaults as a fork-owned policy decision
|
||||
|
||||
### Already Partly Reimplemented In Fork
|
||||
|
||||
- `139dd0e` `fix: surface daemon startup errors instead of opaque timeout message (#614)`
|
||||
- Current fork already captures daemon stderr with `Stdio::piped()` and checks `try_wait()` during startup polling in [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L478) and [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L685).
|
||||
- `AGENT_BROWSER_DEBUG` forwarding is already present in [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L550) and [cli/src/connection.rs](/Users/leo/github.com/agent-browser/cli/src/connection.rs#L651).
|
||||
- Re-review on 2026-03-09 confirms the local implementation is functionally equivalent or stronger than upstream, with the same stderr surfacing and early-exit detection but fork-specific daemon spawn logic.
|
||||
- Recommendation: treat `#614` as absorbed locally and do not cherry-pick it.
|
||||
|
||||
## Fork-Specific Blockers Found During Audit
|
||||
|
||||
- Native CLI wiring was missing during the initial audit, but has since been restored locally.
|
||||
- Remaining blocker is no longer the `--native` switch itself.
|
||||
- The real decision point is whether this fork wants to expose new native features (`--engine`, Lightpanda, Electron webview) that do not help stealth directly but do expand the maintained surface area.
|
||||
- That decision has now been made in favor of exposing them locally, so the blocker section is effectively closed for the current sync round.
|
||||
|
||||
## Current Verification
|
||||
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo fmt -- --check`
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo clippy -- -D warnings`
|
||||
- `cd /Users/leo/github.com/agent-browser/cli && cargo test`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm build`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm exec tsx test/benchmarks/run.ts --node-only --iterations 1 --warmup 0`
|
||||
- `cd /Users/leo/github.com/agent-browser && pnpm exec vitest run src/actions.test.ts test/keyboard.test.ts test/launch-options.test.ts`
|
||||
|
||||
All checks pass against the current local sync worktree.
|
||||
|
||||
## Recommended Migration Order
|
||||
|
||||
1. CI hygiene batch
|
||||
- Already absorbed locally via the current worktree.
|
||||
- No stealth behavior change.
|
||||
2. Docs-only batch
|
||||
- Safe to keep following `#630`-style guidance updates.
|
||||
- No runtime behavior change.
|
||||
3. Headed-mode audit
|
||||
- Reconcile upstream `#607` against fork-owned stealth launch defaults instead of cherry-picking it.
|
||||
4. Release metadata
|
||||
- Keep fork-owned release/versioning flow.
|
||||
- Do not mirror upstream changesets or version bumps unless this fork explicitly decides to realign its release train.
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.16.1-fork.5",
|
||||
"version": "0.17.0-fork.2",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -23,11 +23,11 @@
|
||||
"build": "tsc",
|
||||
"build:native": "npm run version:sync && npm run native:clean && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && npm run native:clean && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:macos": "npm run version:sync && npm run native:clean && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && node scripts/copy-native.js cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && node scripts/copy-native.js cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm run verify:bundled-binaries && npm run verify:native-version && npm publish",
|
||||
"release": "pnpm run version:sync && pnpm run build && pnpm run build:all-platforms && pnpm run verify:bundled-binaries && pnpm run verify:native-version && pnpm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -36,6 +36,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"bench": "pnpm build && tsx test/benchmarks/run.ts",
|
||||
"bench:node": "pnpm build && tsx test/benchmarks/run.ts --node-only",
|
||||
"bench:native": "pnpm build && tsx test/benchmarks/run.ts --native-only",
|
||||
"bench:engine": "pnpm build && tsx test/benchmarks/run.ts --engine",
|
||||
"check:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
|
||||
"check:stealth-regression": "node scripts/check-stealth-regression.js",
|
||||
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
|
||||
@@ -68,6 +72,9 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/leeguooooo/agent-browser.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"tag": "fork"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leeguooooo/agent-browser/issues"
|
||||
},
|
||||
|
||||
+50
-8
@@ -1,26 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Copies the compiled Rust binary to bin/ with platform-specific naming
|
||||
* Copies the compiled Rust binary to bin/ with platform-specific naming.
|
||||
* On macOS, re-apply an ad-hoc signature after copying so the binary remains
|
||||
* executable from the packaged bin/ path.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { platform, arch } from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, '..');
|
||||
|
||||
const sourceExt = platform() === 'win32' ? '.exe' : '';
|
||||
const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
|
||||
const binDir = join(projectRoot, 'bin');
|
||||
|
||||
// Determine platform suffix
|
||||
const platformKey = `${platform()}-${arch()}`;
|
||||
const ext = platform() === 'win32' ? '.exe' : '';
|
||||
const targetName = `agent-browser-${platformKey}${ext}`;
|
||||
const targetPath = join(binDir, targetName);
|
||||
function defaultPaths() {
|
||||
const sourceExt = platform() === 'win32' ? '.exe' : '';
|
||||
const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
|
||||
|
||||
const platformKey = `${platform()}-${arch()}`;
|
||||
const ext = platform() === 'win32' ? '.exe' : '';
|
||||
const targetName = `agent-browser-${platformKey}${ext}`;
|
||||
const targetPath = join(binDir, targetName);
|
||||
|
||||
return { sourcePath, targetPath };
|
||||
}
|
||||
|
||||
function resolvePaths() {
|
||||
const [sourceArg, targetArg] = process.argv.slice(2);
|
||||
if (!sourceArg && !targetArg) {
|
||||
return defaultPaths();
|
||||
}
|
||||
if (!sourceArg || !targetArg) {
|
||||
console.error('Usage: node scripts/copy-native.js [source-binary target-binary]');
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
sourcePath: join(projectRoot, sourceArg),
|
||||
targetPath: join(projectRoot, targetArg),
|
||||
};
|
||||
}
|
||||
|
||||
function adHocSignIfNeeded(targetPath) {
|
||||
if (platform() !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('codesign', ['--force', '--sign', '-', targetPath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const message = result.stderr?.trim() || result.stdout?.trim() || 'unknown codesign error';
|
||||
console.error(`Error: Failed to codesign ${targetPath}: ${message}`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
const { sourcePath, targetPath } = resolvePaths();
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
console.error(`Error: Native binary not found at ${sourcePath}`);
|
||||
@@ -33,4 +74,5 @@ if (!existsSync(binDir)) {
|
||||
}
|
||||
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
adHocSignIfNeeded(targetPath);
|
||||
console.log(`✓ Copied native binary to ${targetPath}`);
|
||||
|
||||
@@ -8,6 +8,8 @@ allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bas
|
||||
|
||||
Install package: `pnpm add -g agent-browser-stealth` (CLI commands: `agent-browser`, `agent-browser-stealth`, and short alias `abs`). If global install is unavailable in your environment, use `pnpm dlx agent-browser-stealth <command>` for one-off runs.
|
||||
|
||||
Use `agent-browser start` when you want to pre-warm the managed automation browser on `localhost:9333` before the actual navigation or task commands run.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
@@ -50,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser start # Start/reuse managed browser on localhost:9333
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
|
||||
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
|
||||
@@ -208,23 +211,26 @@ agent-browser get text @e1 --json
|
||||
### Parallel Workflows
|
||||
|
||||
```bash
|
||||
agent-browser --session-name site1 open https://site-a.com
|
||||
agent-browser --session-name site2 open https://site-b.com
|
||||
agent-browser --parallel site1 open https://site-a.com
|
||||
agent-browser --parallel site2 open https://site-b.com
|
||||
|
||||
agent-browser --session-name site1 snapshot -i
|
||||
agent-browser --session-name site2 snapshot -i
|
||||
agent-browser --parallel site1 snapshot -i
|
||||
agent-browser --parallel site2 snapshot -i
|
||||
```
|
||||
|
||||
Use `--parallel <name>` for stateless throughput tasks (navigation, extraction, checks). For login/auth continuity, use `--session-name` instead.
|
||||
Default-session commands intentionally reap all non-default daemon sessions (`parallel-*` and legacy named channels) to prevent stale daemon reuse.
|
||||
|
||||
### 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` use a dedicated automation browser with this order:
|
||||
|
||||
1. Try CDP at `localhost:9333`
|
||||
2. If unavailable, fall back to `--auto-connect`-style discovery
|
||||
3. If both fail, exit with guidance (no automatic managed local browser launch on this path)
|
||||
2. If unavailable, auto-start Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile`
|
||||
3. If managed `:9333` startup fails, exit with guidance
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging enabled
|
||||
# Explicitly attach to an existing manual Chrome session
|
||||
agent-browser --auto-connect open https://example.com
|
||||
agent-browser --auto-connect snapshot
|
||||
|
||||
@@ -244,6 +250,18 @@ agent-browser --wait-until domcontentloaded open https://example.com
|
||||
pnpm run check:turnstile-testkey
|
||||
```
|
||||
|
||||
### Daemon Lifecycle
|
||||
|
||||
Daemons auto-shutdown after 10 minutes of inactivity.
|
||||
|
||||
Use `--resident` for long-running workflows that should not auto-close:
|
||||
|
||||
```bash
|
||||
agent-browser --resident open https://example.com
|
||||
# keep running until explicit close
|
||||
agent-browser close
|
||||
```
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
|
||||
```bash
|
||||
@@ -301,6 +319,8 @@ agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile (path optional)
|
||||
```
|
||||
|
||||
Use `AGENT_BROWSER_HEADED=1` or `AGENT_BROWSER_HEADED=true` to enable headed mode via environment variable. In this fork, local launches and extension launches stay headed by default unless headless is explicitly requested.
|
||||
|
||||
### Local Files (PDFs, HTML)
|
||||
|
||||
```bash
|
||||
@@ -314,7 +334,7 @@ agent-browser screenshot output.png
|
||||
|
||||
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
|
||||
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
|
||||
- Use existing browser sessions (default attach path: CDP `localhost:9333` then auto-discovery) or pass `--cdp` explicitly
|
||||
- Default mode uses the managed CDP browser on `localhost:9333`; use `--auto-connect` only for explicit existing-browser attachment
|
||||
|
||||
### Stealth Mode (Always On)
|
||||
|
||||
@@ -487,7 +507,8 @@ These behaviors are always active. For sensitive sites, combine with `--headed`
|
||||
|
||||
## Session Management and Cleanup
|
||||
|
||||
`--session` is ignored in this fork. The runtime always uses one default session. Use `--session-name` to isolate persistence when needed.
|
||||
`--session` is ignored in this fork. Runtime defaults to `default`; use `--parallel <name>` for isolated concurrent channels, and `--session-name` for persistence isolation.
|
||||
When a default-session command runs, non-default daemon sessions are reaped automatically.
|
||||
|
||||
Always close your browser session when done to avoid leaked processes:
|
||||
|
||||
@@ -613,6 +634,29 @@ agent-browser open example.com
|
||||
|
||||
The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
Use `--engine` to choose a local browser engine. The default is `chrome`.
|
||||
|
||||
```bash
|
||||
# Use Lightpanda (fast headless browser, requires separate install)
|
||||
agent-browser --engine lightpanda open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open example.com
|
||||
|
||||
# With a custom binary path
|
||||
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
|
||||
```
|
||||
|
||||
Supported engines:
|
||||
|
||||
- `chrome` (default) -- Chrome/Chromium via CDP
|
||||
- `lightpanda` -- Lightpanda headless browser via CDP
|
||||
|
||||
Lightpanda is headless-only and does not support `--extension`, `--state`, `--profile`, or `--allow-file-access`. Install it from https://lightpanda.io/docs/open-source/installation.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
|
||||
@@ -190,9 +190,11 @@ agent-browser --session {SESSION} close
|
||||
## Guidance
|
||||
|
||||
- **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot.
|
||||
- **Verify reproducibility before collecting evidence.** Before recording video or taking screenshots, verify the issue is reproducible with at least one retry. If it cannot be reproduced consistently, do not report it as a confirmed issue.
|
||||
- **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes.
|
||||
- **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence.
|
||||
- **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser.
|
||||
- **Use the right snapshot command.** Use `snapshot -i` to find clickable or fillable elements. Use plain `snapshot` when you need readable page content such as text, headings, or data lists.
|
||||
- **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate.
|
||||
- **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end.
|
||||
- **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward.
|
||||
|
||||
@@ -117,6 +117,26 @@ describe('tab grouping fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('launch engine guard', () => {
|
||||
it('should reject lightpanda on the Node.js path', async () => {
|
||||
const browser = {
|
||||
launch: vi.fn(),
|
||||
getStealthStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 'lp1', action: 'launch', engine: 'lightpanda' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
if (!response.success) {
|
||||
expect(response.error).toContain('requires --native mode');
|
||||
}
|
||||
expect(browser.launch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk interstitial recovery', () => {
|
||||
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
|
||||
const challengeClearMs = 10_000;
|
||||
|
||||
@@ -520,6 +520,10 @@ async function handleLaunch(
|
||||
command: Command & { action: 'launch' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
if (command.engine === 'lightpanda') {
|
||||
return errorResponse(command.id, 'Lightpanda engine requires --native mode');
|
||||
}
|
||||
|
||||
await browser.launch(command);
|
||||
return successResponse(command.id, {
|
||||
launched: true,
|
||||
|
||||
@@ -260,6 +260,77 @@ describe('BrowserManager', () => {
|
||||
await cdpBrowser.close();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should keep local Chrome launch headed by default under fork policy', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
const mockPage = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
emulateMedia: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ loose: true, strict: true }),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
isClosed: () => false,
|
||||
on: vi.fn(),
|
||||
url: () => 'about:blank',
|
||||
};
|
||||
const mockContext = {
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
on: vi.fn(),
|
||||
pages: () => [mockPage],
|
||||
setDefaultTimeout: vi.fn(),
|
||||
};
|
||||
const mockBrowser = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
version: vi.fn().mockReturnValue('123.0.6312.0'),
|
||||
};
|
||||
const launchSpy = vi.spyOn(chromium, 'launch').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
await testBrowser.launch({ id: 'default-headed', action: 'launch' });
|
||||
|
||||
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(launchSpy.mock.calls[0]?.[0]).toMatchObject({ headless: false });
|
||||
|
||||
await testBrowser.close();
|
||||
launchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should keep extension launches headed by default under fork policy', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
const mockPage = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
emulateMedia: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue({ loose: true, strict: true }),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
isClosed: () => false,
|
||||
on: vi.fn(),
|
||||
url: () => 'about:blank',
|
||||
};
|
||||
const mockContext = {
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
on: vi.fn(),
|
||||
pages: () => [mockPage],
|
||||
setDefaultTimeout: vi.fn(),
|
||||
};
|
||||
const launchPersistentContextSpy = vi
|
||||
.spyOn(chromium, 'launchPersistentContext')
|
||||
.mockResolvedValue(mockContext as any);
|
||||
|
||||
await testBrowser.launch({
|
||||
action: 'launch',
|
||||
extensions: ['/tmp/ext-a', '/tmp/ext-b'],
|
||||
id: 'ext-headed',
|
||||
});
|
||||
|
||||
expect(launchPersistentContextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(launchPersistentContextSpy.mock.calls[0]?.[1]).toMatchObject({ headless: false });
|
||||
|
||||
await testBrowser.close();
|
||||
launchPersistentContextSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab-group plugin handshake', () => {
|
||||
|
||||
+130
-4
@@ -14,9 +14,10 @@ import {
|
||||
type CDPSession,
|
||||
type Video,
|
||||
} from 'playwright-core';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type {
|
||||
DoctorCheck,
|
||||
@@ -134,6 +135,8 @@ interface StealthContextDefaults {
|
||||
}
|
||||
|
||||
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||
const MANAGED_CDP_PORT = 9333;
|
||||
const MANAGED_CDP_START_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
|
||||
const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
@@ -149,6 +152,65 @@ interface TabGroupIntent {
|
||||
allowedDomains: string[];
|
||||
}
|
||||
|
||||
function getManagedCdpProfileDir(): string {
|
||||
return path.join(os.homedir(), '.agent-browser', 'chrome-bot-profile');
|
||||
}
|
||||
|
||||
function cleanupManagedCdpProfileLocks(profileDir: string): void {
|
||||
rmSync(path.join(profileDir, 'DevToolsActivePort'), { force: true });
|
||||
try {
|
||||
for (const entry of readdirSync(profileDir)) {
|
||||
if (entry.startsWith('Singleton')) {
|
||||
rmSync(path.join(profileDir, entry), { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup for stale lock files.
|
||||
}
|
||||
}
|
||||
|
||||
function findManagedChromeExecutable(): string {
|
||||
const configured = process.env.AGENT_BROWSER_EXECUTABLE_PATH;
|
||||
if (configured && existsSync(configured)) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
const candidates =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
]
|
||||
: platform === 'win32'
|
||||
? [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
]
|
||||
: [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (platform !== 'win32') {
|
||||
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
|
||||
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
||||
if (result.status === 0) {
|
||||
const resolved = result.stdout.trim();
|
||||
if (resolved.length > 0) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Chrome not found. Install Chrome or set AGENT_BROWSER_EXECUTABLE_PATH.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
*/
|
||||
@@ -189,6 +251,61 @@ export class BrowserManager {
|
||||
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
|
||||
private tabGroupInFlight: WeakSet<Page> = new WeakSet();
|
||||
|
||||
private isManagedCdpEndpoint(cdpEndpoint: string): boolean {
|
||||
return (
|
||||
cdpEndpoint === String(MANAGED_CDP_PORT) ||
|
||||
cdpEndpoint === `http://localhost:${MANAGED_CDP_PORT}` ||
|
||||
cdpEndpoint === `http://127.0.0.1:${MANAGED_CDP_PORT}` ||
|
||||
cdpEndpoint === `ws://127.0.0.1:${MANAGED_CDP_PORT}` ||
|
||||
cdpEndpoint.includes(`127.0.0.1:${MANAGED_CDP_PORT}/devtools/browser/`) ||
|
||||
cdpEndpoint.includes(`localhost:${MANAGED_CDP_PORT}/devtools/browser/`)
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureManagedCdpBrowser(): Promise<void> {
|
||||
if (await this.probeDebugPort(MANAGED_CDP_PORT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileDir = getManagedCdpProfileDir();
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
cleanupManagedCdpProfileLocks(profileDir);
|
||||
|
||||
const executablePath = findManagedChromeExecutable();
|
||||
const headed =
|
||||
process.env.AGENT_BROWSER_HEADED === '1' || process.env.AGENT_BROWSER_HEADED === 'true';
|
||||
const args = [
|
||||
'--remote-debugging-address=127.0.0.1',
|
||||
`--remote-debugging-port=${MANAGED_CDP_PORT}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
];
|
||||
if (!headed) {
|
||||
args.push('--headless=new', '--window-size=1280,720');
|
||||
}
|
||||
|
||||
const child = spawn(executablePath, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + MANAGED_CDP_START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const wsUrl = await this.probeDebugPort(MANAGED_CDP_PORT);
|
||||
if (wsUrl) {
|
||||
return;
|
||||
}
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Managed Chrome exited before opening CDP port ${MANAGED_CDP_PORT}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for managed Chrome on localhost:${MANAGED_CDP_PORT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
* Applied automatically to all new pages and contexts.
|
||||
@@ -2009,7 +2126,15 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
if (cdpEndpoint) {
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
try {
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
} catch (error) {
|
||||
if (!this.isManagedCdpEndpoint(cdpEndpoint)) {
|
||||
throw error;
|
||||
}
|
||||
await this.ensureManagedCdpBrowser();
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2126,7 +2251,8 @@ export class BrowserManager {
|
||||
|
||||
let context: BrowserContext;
|
||||
if (hasExtensions) {
|
||||
// Extensions require persistent context in a temp directory
|
||||
// Extensions require persistent context in a temp directory. In this fork,
|
||||
// extension launches stay headed by default unless headless is explicitly requested.
|
||||
const extPaths = configuredExtensions.join(',');
|
||||
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
// Combine extension args with custom args and file access args
|
||||
@@ -2135,7 +2261,7 @@ export class BrowserManager {
|
||||
context = await launcher.launchPersistentContext(
|
||||
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
|
||||
{
|
||||
headless: false,
|
||||
headless: options.headless ?? false,
|
||||
executablePath: options.executablePath,
|
||||
...(chromeChannel && { channel: chromeChannel }),
|
||||
args: allArgs,
|
||||
|
||||
+116
-1
@@ -3,7 +3,12 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
import { getSocketDir, safeWrite } from './daemon.js';
|
||||
import {
|
||||
buildAutoLaunchOptionsFromEnv,
|
||||
createSerializedExecutor,
|
||||
getSocketDir,
|
||||
safeWrite,
|
||||
} from './daemon.js';
|
||||
|
||||
/**
|
||||
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
|
||||
@@ -97,6 +102,74 @@ describe('getSocketDir', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAutoLaunchOptionsFromEnv', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AGENT_BROWSER_HEADED;
|
||||
delete process.env.AGENT_BROWSER_EXTENSIONS;
|
||||
delete process.env.AGENT_BROWSER_ARGS;
|
||||
delete process.env.AGENT_BROWSER_PROXY;
|
||||
delete process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
delete process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
delete process.env.AGENT_BROWSER_TAB_GROUP;
|
||||
delete process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID;
|
||||
delete process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS;
|
||||
delete process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should treat AGENT_BROWSER_HEADED=true as headed mode', () => {
|
||||
process.env.AGENT_BROWSER_HEADED = 'true';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.headless).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep default auto-launch headed behavior unchanged when env is unset', () => {
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.headless).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse extensions and args from comma or newline separated env vars', () => {
|
||||
process.env.AGENT_BROWSER_EXTENSIONS = ' /tmp/ext-a,\n/tmp/ext-b ,, \n /tmp/ext-c ';
|
||||
process.env.AGENT_BROWSER_ARGS = '--start-maximized,\n--disable-gpu';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
expect(options.extensions).toEqual(['/tmp/ext-a', '/tmp/ext-b', '/tmp/ext-c']);
|
||||
expect(options.args).toEqual(['--start-maximized', '--disable-gpu']);
|
||||
});
|
||||
|
||||
it('should preserve proxy and optional launch fields from env', () => {
|
||||
process.env.AGENT_BROWSER_PROXY = 'http://127.0.0.1:8080';
|
||||
process.env.AGENT_BROWSER_PROXY_BYPASS = 'localhost,*.internal';
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME = 'dark';
|
||||
process.env.AGENT_BROWSER_TAB_GROUP = ' Agent Browser ';
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID = ' plugin-123 ';
|
||||
process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS = '1';
|
||||
process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS = '1';
|
||||
|
||||
const options = buildAutoLaunchOptionsFromEnv('/tmp/auto-state.json');
|
||||
|
||||
expect(options.proxy).toEqual({
|
||||
server: 'http://127.0.0.1:8080',
|
||||
bypass: 'localhost,*.internal',
|
||||
});
|
||||
expect(options.colorScheme).toBe('dark');
|
||||
expect(options.tabGroup).toBe('Agent Browser');
|
||||
expect(options.tabGroupPluginId).toBe('plugin-123');
|
||||
expect(options.ignoreHTTPSErrors).toBe(true);
|
||||
expect(options.allowFileAccess).toBe(true);
|
||||
expect(options.autoStateFilePath).toBe('/tmp/auto-state.json');
|
||||
});
|
||||
});
|
||||
|
||||
function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const socket = Object.assign(emitter, {
|
||||
@@ -159,3 +232,45 @@ describe('safeWrite', () => {
|
||||
expect(socket.listenerCount('close')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSerializedExecutor', () => {
|
||||
it('should execute tasks one-by-one even when started concurrently', async () => {
|
||||
const runSerialized = createSerializedExecutor();
|
||||
const order: string[] = [];
|
||||
|
||||
const slow = runSerialized(async () => {
|
||||
order.push('slow-start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
order.push('slow-end');
|
||||
return 'slow';
|
||||
});
|
||||
|
||||
const fast = runSerialized(async () => {
|
||||
order.push('fast-start');
|
||||
order.push('fast-end');
|
||||
return 'fast';
|
||||
});
|
||||
|
||||
await expect(Promise.all([slow, fast])).resolves.toEqual(['slow', 'fast']);
|
||||
expect(order).toEqual(['slow-start', 'slow-end', 'fast-start', 'fast-end']);
|
||||
});
|
||||
|
||||
it('should continue running queued tasks after a task fails', async () => {
|
||||
const runSerialized = createSerializedExecutor();
|
||||
const order: string[] = [];
|
||||
|
||||
const first = runSerialized(async () => {
|
||||
order.push('first');
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const second = runSerialized(async () => {
|
||||
order.push('second');
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
await expect(first).rejects.toThrow('boom');
|
||||
await expect(second).resolves.toBe('ok');
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
});
|
||||
|
||||
+336
-296
@@ -8,6 +8,7 @@ import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand } from './actions.js';
|
||||
import { executeIOSCommand } from './ios-actions.js';
|
||||
import { StreamServer } from './stream-server.js';
|
||||
import type { LaunchCommand } from './types.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
ensureSessionsDir,
|
||||
@@ -62,6 +63,23 @@ export function safeWrite(socket: net.Socket, payload: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an async executor that runs tasks strictly one-by-one.
|
||||
* Used to serialize daemon commands across all client connections.
|
||||
*/
|
||||
export function createSerializedExecutor(): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
let tail: Promise<void> = Promise.resolve();
|
||||
|
||||
return async function runSerialized<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = tail.then(task, task);
|
||||
tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
};
|
||||
}
|
||||
|
||||
// Platform detection
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
@@ -73,6 +91,8 @@ let streamServer: StreamServer | null = null;
|
||||
|
||||
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
||||
const DEFAULT_STREAM_PORT = 9223;
|
||||
// Default idle auto-shutdown timeout: 10 minutes
|
||||
const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Save state to file with optional encryption.
|
||||
@@ -181,6 +201,55 @@ export function getSession(): string {
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
function parseEnvList(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
export function buildAutoLaunchOptionsFromEnv(
|
||||
autoStateFilePath: string | undefined = getSessionAutoStatePath()
|
||||
): LaunchCommand {
|
||||
const proxyServer = process.env.AGENT_BROWSER_PROXY;
|
||||
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
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();
|
||||
|
||||
return {
|
||||
id: 'auto',
|
||||
action: 'launch',
|
||||
// Accept both AGENT_BROWSER_HEADED=1 and =true for daemon auto-launch.
|
||||
headless:
|
||||
process.env.AGENT_BROWSER_HEADED !== '1' && process.env.AGENT_BROWSER_HEADED !== 'true',
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
extensions: parseEnvList(process.env.AGENT_BROWSER_EXTENSIONS),
|
||||
storageState: process.env.AGENT_BROWSER_STATE,
|
||||
args: parseEnvList(process.env.AGENT_BROWSER_ARGS),
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
proxy: proxyServer
|
||||
? {
|
||||
server: proxyServer,
|
||||
...(proxyBypass && { bypass: proxyBypass }),
|
||||
}
|
||||
: undefined,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
allowFileAccess: process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1',
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
|
||||
autoStateFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get port number for TCP mode (Windows)
|
||||
* Uses a hash of the session name to get a consistent port
|
||||
@@ -250,6 +319,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
|
||||
*/
|
||||
@@ -294,9 +371,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);
|
||||
@@ -325,6 +404,7 @@ export function getStreamPortFile(session?: string): string {
|
||||
export async function startDaemon(options?: {
|
||||
streamPort?: number;
|
||||
provider?: string;
|
||||
resident?: boolean;
|
||||
}): Promise<void> {
|
||||
// Ensure socket directory exists with restricted permissions (owner-only access)
|
||||
const socketDir = getSocketDir();
|
||||
@@ -345,6 +425,10 @@ export async function startDaemon(options?: {
|
||||
// Create appropriate manager
|
||||
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
|
||||
let shuttingDown = false;
|
||||
const runSerialized = createSerializedExecutor();
|
||||
const residentMode = options?.resident ?? process.argv.includes('--resident');
|
||||
let idleTimer: NodeJS.Timeout | null = null;
|
||||
let pendingCommands = 0;
|
||||
|
||||
// Start stream server if port is specified (or use default if env var is set)
|
||||
// Note: Stream server only works with BrowserManager (desktop), not iOS
|
||||
@@ -363,6 +447,21 @@ export async function startDaemon(options?: {
|
||||
fs.writeFileSync(streamPortFile, streamPort.toString());
|
||||
}
|
||||
|
||||
const cancelIdleTimer = (): void => {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleIdleShutdown = (): void => {
|
||||
if (residentMode || shuttingDown || pendingCommands > 0) return;
|
||||
cancelIdleTimer();
|
||||
idleTimer = setTimeout(() => {
|
||||
void shutdown('idle timeout');
|
||||
}, DEFAULT_IDLE_SHUTDOWN_MS);
|
||||
};
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
let httpChecked = false;
|
||||
@@ -379,309 +478,219 @@ export async function startDaemon(options?: {
|
||||
|
||||
while (commandQueue.length > 0) {
|
||||
const line = commandQueue.shift()!;
|
||||
pendingCommands += 1;
|
||||
cancelIdleTimer();
|
||||
|
||||
try {
|
||||
const parseResult = parseCommand(line);
|
||||
|
||||
if (!parseResult.success) {
|
||||
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
||||
await safeWrite(socket, serializeResponse(resp) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle device_list specially - it works without a session and always uses IOSManager
|
||||
if (parseResult.command.action === 'device_list') {
|
||||
const iosManager = new IOSManager();
|
||||
await runSerialized(async () => {
|
||||
try {
|
||||
const devices = await iosManager.listAllDevices();
|
||||
const response = {
|
||||
id: parseResult.command.id,
|
||||
success: true as const,
|
||||
data: { devices },
|
||||
};
|
||||
const parseResult = parseCommand(line);
|
||||
|
||||
if (!parseResult.success) {
|
||||
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
||||
await safeWrite(socket, serializeResponse(resp) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle device_list specially - it works without a session and always uses IOSManager
|
||||
if (parseResult.command.action === 'device_list') {
|
||||
const iosManager = new IOSManager();
|
||||
try {
|
||||
const devices = await iosManager.listAllDevices();
|
||||
const response = {
|
||||
id: parseResult.command.id,
|
||||
success: true as const,
|
||||
data: { devices },
|
||||
};
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await safeWrite(
|
||||
socket,
|
||||
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
|
||||
// Default behavior for this fork: attach to an existing browser only.
|
||||
const isDoctor = parseResult.command.action === 'doctor';
|
||||
if (
|
||||
!manager.isLaunched() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close' &&
|
||||
parseResult.command.action !== 'state_load' &&
|
||||
parseResult.command.action !== 'doctor'
|
||||
) {
|
||||
if (isIOS && manager instanceof IOSManager) {
|
||||
// Auto-launch iOS Safari
|
||||
// Check for device in command first (for reused daemons), then fall back to env vars
|
||||
const cmd = parseResult.command as { iosDevice?: string };
|
||||
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
|
||||
await manager.launch({
|
||||
device: iosDevice,
|
||||
udid: process.env.AGENT_BROWSER_IOS_UDID,
|
||||
});
|
||||
} else if (manager instanceof BrowserManager) {
|
||||
// Auto-launch desktop browser
|
||||
const launchOptions = buildAutoLaunchOptionsFromEnv();
|
||||
|
||||
let attachedToManagedBrowser = false;
|
||||
try {
|
||||
// Keep the preferred localhost:9333 path minimal so the daemon can
|
||||
// connect to or auto-start the dedicated automation Chrome profile.
|
||||
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,
|
||||
});
|
||||
attachedToManagedBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via managed CDP port 9333');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[DEBUG] Managed CDP port 9333 unavailable: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachedToManagedBrowser) {
|
||||
throw new Error(
|
||||
'Project policy requires using the dedicated automation browser on localhost:9333. Could not connect to or auto-start the managed Chrome profile.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For doctor, attempt the same managed localhost:9333 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 {
|
||||
// Keep running: doctor should report failures instead of exiting early.
|
||||
}
|
||||
}
|
||||
|
||||
// Recover from stale state: browser is launched but all pages were closed
|
||||
if (
|
||||
manager instanceof BrowserManager &&
|
||||
manager.isLaunched() &&
|
||||
!manager.hasPages() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close'
|
||||
) {
|
||||
await manager.ensurePage();
|
||||
}
|
||||
|
||||
// Handle explicit launch with auto-load state
|
||||
if (
|
||||
parseResult.command.action === 'launch' &&
|
||||
manager instanceof BrowserManager &&
|
||||
!parseResult.command.autoStateFilePath
|
||||
) {
|
||||
const autoStatePath = getSessionAutoStatePath();
|
||||
if (autoStatePath) {
|
||||
parseResult.command.autoStateFilePath = autoStatePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close command specially - shuts down daemon
|
||||
if (parseResult.command.action === 'close') {
|
||||
// Auto-save state before closing
|
||||
if (manager instanceof BrowserManager && manager.isLaunched()) {
|
||||
const savePath = getSessionSaveStatePath();
|
||||
if (savePath) {
|
||||
try {
|
||||
const { encrypted } = await saveStateToFile(manager, savePath);
|
||||
fs.chmodSync(savePath, 0o600);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`Failed to auto-save session state:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
commandQueue.length = 0;
|
||||
processing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute command with appropriate handler
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
|
||||
// Add any launch warnings to the response
|
||||
if (manager instanceof BrowserManager) {
|
||||
const warnings = manager.getAndClearWarnings();
|
||||
if (warnings.length > 0 && response.success && response.data) {
|
||||
(response.data as Record<string, unknown>).warnings = warnings;
|
||||
}
|
||||
}
|
||||
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await safeWrite(
|
||||
socket,
|
||||
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
|
||||
);
|
||||
serializeResponse(errorResponse('error', message)) + '\n'
|
||||
).catch(() => {}); // Socket may already be destroyed
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
|
||||
// Default behavior for this fork: attach to an existing browser only.
|
||||
const isDoctor = parseResult.command.action === 'doctor';
|
||||
if (
|
||||
!manager.isLaunched() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close' &&
|
||||
parseResult.command.action !== 'state_load' &&
|
||||
parseResult.command.action !== 'doctor'
|
||||
) {
|
||||
if (isIOS && manager instanceof IOSManager) {
|
||||
// Auto-launch iOS Safari
|
||||
// Check for device in command first (for reused daemons), then fall back to env vars
|
||||
const cmd = parseResult.command as { iosDevice?: string };
|
||||
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
|
||||
await manager.launch({
|
||||
device: iosDevice,
|
||||
udid: process.env.AGENT_BROWSER_IOS_UDID,
|
||||
});
|
||||
} else if (manager instanceof BrowserManager) {
|
||||
// Auto-launch desktop browser
|
||||
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
|
||||
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
// Parse args from env (comma or newline separated)
|
||||
const argsEnv = process.env.AGENT_BROWSER_ARGS;
|
||||
const args = argsEnv
|
||||
? argsEnv
|
||||
.split(/[,\n]/)
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0)
|
||||
: undefined;
|
||||
|
||||
// Parse proxy from env
|
||||
const proxyServer = process.env.AGENT_BROWSER_PROXY;
|
||||
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
|
||||
const proxy = proxyServer
|
||||
? {
|
||||
server: proxyServer,
|
||||
...(proxyBypass && { bypass: proxyBypass }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
|
||||
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
||||
// Stealth is always enabled in agent-browser-stealth
|
||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
|
||||
colorSchemeEnv === 'dark' ||
|
||||
colorSchemeEnv === 'light' ||
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
headless: process.env.AGENT_BROWSER_HEADED !== '1',
|
||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||
extensions: extensions,
|
||||
storageState: process.env.AGENT_BROWSER_STATE,
|
||||
args,
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
proxy,
|
||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||
allowFileAccess: allowFileAccess,
|
||||
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
};
|
||||
|
||||
let attachedToExistingBrowser = false;
|
||||
try {
|
||||
// Keep default CDP attempt minimal. Launch-only options like extensions
|
||||
// are incompatible with CDP and can cause false-negative attach failures.
|
||||
const cdpLaunchOptions = {
|
||||
id: launchOptions.id,
|
||||
action: launchOptions.action,
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachedToExistingBrowser) {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: launchOptions.id,
|
||||
action: launchOptions.action,
|
||||
autoConnect: true,
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachedToExistingBrowser) {
|
||||
throw new Error(
|
||||
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
|
||||
// This keeps diagnostics actionable even when CDP is down.
|
||||
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: 'doctor-cdp',
|
||||
action: 'launch',
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
colorScheme:
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
|
||||
? (process.env.AGENT_BROWSER_COLOR_SCHEME as 'dark' | 'light' | 'no-preference')
|
||||
: undefined,
|
||||
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
|
||||
tabGroupPluginId:
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: 'doctor-auto-connect',
|
||||
action: 'launch',
|
||||
autoConnect: true,
|
||||
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
|
||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||
colorScheme:
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
|
||||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
|
||||
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
|
||||
| 'dark'
|
||||
| 'light'
|
||||
| 'no-preference')
|
||||
: undefined,
|
||||
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
|
||||
tabGroupPluginId:
|
||||
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
|
||||
});
|
||||
} catch {
|
||||
// Keep running: doctor should report failures instead of exiting early.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recover from stale state: browser is launched but all pages were closed
|
||||
if (
|
||||
manager instanceof BrowserManager &&
|
||||
manager.isLaunched() &&
|
||||
!manager.hasPages() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
parseResult.command.action !== 'close'
|
||||
) {
|
||||
await manager.ensurePage();
|
||||
}
|
||||
|
||||
// Handle explicit launch with auto-load state
|
||||
if (
|
||||
parseResult.command.action === 'launch' &&
|
||||
manager instanceof BrowserManager &&
|
||||
!parseResult.command.autoStateFilePath
|
||||
) {
|
||||
const autoStatePath = getSessionAutoStatePath();
|
||||
if (autoStatePath) {
|
||||
parseResult.command.autoStateFilePath = autoStatePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close command specially - shuts down daemon
|
||||
if (parseResult.command.action === 'close') {
|
||||
// Auto-save state before closing
|
||||
if (manager instanceof BrowserManager && manager.isLaunched()) {
|
||||
const savePath = getSessionSaveStatePath();
|
||||
if (savePath) {
|
||||
try {
|
||||
const { encrypted } = await saveStateToFile(manager, savePath);
|
||||
fs.chmodSync(savePath, 0o600);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`Failed to auto-save session state:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
commandQueue.length = 0;
|
||||
processing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute command with appropriate handler
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
|
||||
// Add any launch warnings to the response
|
||||
if (manager instanceof BrowserManager) {
|
||||
const warnings = manager.getAndClearWarnings();
|
||||
if (warnings.length > 0 && response.success && response.data) {
|
||||
(response.data as Record<string, unknown>).warnings = warnings;
|
||||
}
|
||||
}
|
||||
|
||||
await safeWrite(socket, serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await safeWrite(socket, serializeResponse(errorResponse('error', message)) + '\n').catch(
|
||||
() => {}
|
||||
); // Socket may already be destroyed
|
||||
});
|
||||
} finally {
|
||||
pendingCommands = Math.max(0, pendingCommands - 1);
|
||||
scheduleIdleShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,6 +745,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
|
||||
@@ -755,14 +786,16 @@ export async function startDaemon(options?: {
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
cancelIdleTimer();
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle shutdown signals
|
||||
const shutdown = async () => {
|
||||
const shutdown = async (_reason?: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
cancelIdleTimer();
|
||||
|
||||
// Stop stream server if running
|
||||
if (streamServer) {
|
||||
@@ -790,28 +823,35 @@ export async function startDaemon(options?: {
|
||||
// Handle unexpected errors - always cleanup
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('Uncaught exception:', err);
|
||||
cancelIdleTimer();
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled rejection:', reason);
|
||||
cancelIdleTimer();
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Cleanup on normal exit
|
||||
process.on('exit', () => {
|
||||
cancelIdleTimer();
|
||||
cleanupSocket();
|
||||
});
|
||||
|
||||
scheduleIdleShutdown();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// Run daemon if this is the entry point
|
||||
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
|
||||
startDaemon().catch((err) => {
|
||||
startDaemon({
|
||||
resident: process.argv.includes('--resident'),
|
||||
}).catch((err) => {
|
||||
console.error('Daemon error:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
|
||||
@@ -57,6 +57,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
actionPolicy: z.string().optional(),
|
||||
confirmActions: z.array(z.string()).optional(),
|
||||
engine: z.enum(['chrome', 'lightpanda']).optional(),
|
||||
});
|
||||
|
||||
const navigateSchema = baseCommandSchema.extend({
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
engine?: 'chrome' | 'lightpanda'; // Browser engine selection; lightpanda requires native mode
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { BenchmarkCommand, Scenario } from "./scenarios.js";
|
||||
|
||||
function generateArticlePage(): string {
|
||||
const paragraphs = Array.from({ length: 30 }, (_, index) => {
|
||||
const words = Array.from(
|
||||
{ length: 40 + (index % 5) * 10 },
|
||||
(_, wordIndex) =>
|
||||
[
|
||||
"the",
|
||||
"quick",
|
||||
"browser",
|
||||
"engine",
|
||||
"renders",
|
||||
"content",
|
||||
"across",
|
||||
"multiple",
|
||||
"layout",
|
||||
"passes",
|
||||
"while",
|
||||
"handling",
|
||||
"style",
|
||||
"recalculations",
|
||||
"and",
|
||||
"DOM",
|
||||
"mutations",
|
||||
][wordIndex % 17],
|
||||
).join(" ");
|
||||
return `<p class="article-p">${words}</p>`;
|
||||
});
|
||||
|
||||
const comments = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) =>
|
||||
`<div class="comment" data-id="${index}">` +
|
||||
`<div class="comment-header"><span class="author">User ${index}</span><time>2025-01-${String((index % 28) + 1).padStart(2, "0")}</time></div>` +
|
||||
`<div class="comment-body"><p>This is comment number ${index + 1} with some discussion text.</p></div>` +
|
||||
'<div class="comment-actions"><button class="reply-btn">Reply</button><button class="like-btn">Like</button></div>' +
|
||||
"</div>",
|
||||
);
|
||||
|
||||
const sidebar = Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) =>
|
||||
`<li class="sidebar-item"><a href="#section-${index}">Related Article ${index + 1}: A Longer Title Here</a></li>`,
|
||||
);
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Article</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;padding:0;display:grid;grid-template-columns:1fr 300px;gap:20px;max-width:1200px;margin:0 auto}",
|
||||
".article{padding:20px}.sidebar{padding:20px;border-left:1px solid #ddd}",
|
||||
".comment{border:1px solid #eee;padding:12px;margin:8px 0;border-radius:4px}",
|
||||
".comment-header{display:flex;justify-content:space-between;font-size:14px;color:#666}",
|
||||
".nav{display:flex;gap:16px;padding:12px 20px;background:#f5f5f5;grid-column:1/-1}",
|
||||
".tag{display:inline-block;padding:2px 8px;background:#e0e7ff;border-radius:12px;font-size:12px;margin:2px}",
|
||||
"</style></head><body>",
|
||||
`<nav class="nav">${Array.from({ length: 8 }, (_, index) => `<a href="#nav-${index}">Section ${index + 1}</a>`).join("")}</nav>`,
|
||||
'<div class="article">',
|
||||
"<h1>Understanding Modern Browser Engine Architecture</h1>",
|
||||
'<div class="meta"><span class="author">Dr. Smith</span> | <time>2025-03-15</time> | <span>15 min read</span></div>',
|
||||
`<div class="tags">${Array.from({ length: 6 }, (_, index) => `<span class="tag">tag-${index + 1}</span>`).join("")}</div>`,
|
||||
"<h2>Introduction</h2>",
|
||||
...paragraphs.slice(0, 5),
|
||||
"<h2>Core Concepts</h2>",
|
||||
...paragraphs.slice(5, 12),
|
||||
'<blockquote>"Performance is not just about speed, it is about efficiency." - Anonymous</blockquote>',
|
||||
"<h2>Implementation Details</h2>",
|
||||
...paragraphs.slice(12, 20),
|
||||
"<h3>Subsection A</h3>",
|
||||
...paragraphs.slice(20, 25),
|
||||
"<h3>Subsection B</h3>",
|
||||
...paragraphs.slice(25),
|
||||
"<h2>Comments</h2>",
|
||||
'<div class="comments">',
|
||||
...comments,
|
||||
"</div></div>",
|
||||
'<div class="sidebar">',
|
||||
"<h3>Related Articles</h3>",
|
||||
`<ul>${sidebar.join("")}</ul>`,
|
||||
"<h3>Archives</h3>",
|
||||
`<ul>${Array.from({ length: 12 }, (_, index) => `<li><a href="#month-${index}">Month ${index + 1}, 2025</a></li>`).join("")}</ul>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDataTablePage(): string {
|
||||
const headerCells = ["ID", "Name", "Email", "Department", "Role", "Status", "Joined", "Last Active"];
|
||||
const header = `<tr>${headerCells.map((cell) => `<th>${cell}</th>`).join("")}</tr>`;
|
||||
|
||||
const rows = Array.from({ length: 200 }, (_, index) => {
|
||||
const department = ["Engineering", "Design", "Marketing", "Sales", "Support"][index % 5];
|
||||
const role = ["Admin", "Manager", "Member", "Viewer"][index % 4];
|
||||
const status = ["Active", "Inactive", "Pending"][index % 3];
|
||||
return (
|
||||
`<tr data-row="${index}">` +
|
||||
`<td>${index + 1}</td>` +
|
||||
`<td><a href="#user-${index}">User ${index + 1}</a></td>` +
|
||||
`<td>user${index + 1}@example.com</td>` +
|
||||
`<td>${department}</td>` +
|
||||
`<td><span class="badge badge-${role.toLowerCase()}">${role}</span></td>` +
|
||||
`<td><span class="status status-${status.toLowerCase()}">${status}</span></td>` +
|
||||
`<td>2024-${String((index % 12) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}</td>` +
|
||||
`<td>${index % 3 === 0 ? "Today" : index % 3 === 1 ? "Yesterday" : "Last week"}</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Table</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
"table{width:100%;border-collapse:collapse}",
|
||||
"th,td{padding:8px 12px;border:1px solid #ddd;text-align:left}",
|
||||
"th{background:#f5f5f5;font-weight:600;position:sticky;top:0}",
|
||||
"tr:nth-child(even){background:#fafafa}",
|
||||
".badge{padding:2px 8px;border-radius:4px;font-size:12px}",
|
||||
".toolbar{display:flex;gap:12px;margin-bottom:16px;align-items:center}",
|
||||
"input,select,button{padding:6px 12px;border:1px solid #ccc;border-radius:4px}",
|
||||
"</style></head><body>",
|
||||
"<h1>User Management Dashboard</h1>",
|
||||
'<div class="toolbar">',
|
||||
'<input id="search" type="text" placeholder="Search users...">',
|
||||
'<select id="dept-filter"><option value="">All Departments</option><option value="eng">Engineering</option><option value="des">Design</option></select>',
|
||||
'<select id="status-filter"><option value="">All Statuses</option><option value="active">Active</option><option value="inactive">Inactive</option></select>',
|
||||
'<button id="add-user">Add User</button>',
|
||||
'<span id="count">Showing 200 users</span>',
|
||||
"</div>",
|
||||
`<table><thead>${header}</thead><tbody>${rows.join("")}</tbody></table>`,
|
||||
'<div class="pagination">',
|
||||
...Array.from({ length: 10 }, (_, index) => `<button class="page-btn" data-page="${index + 1}">${index + 1}</button>`),
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateNestedPage(): string {
|
||||
function nest(depth: number, breadth: number, prefix: string): string {
|
||||
if (depth === 0) {
|
||||
return `<span class="leaf" data-path="${prefix}">Leaf node at ${prefix}</span>`;
|
||||
}
|
||||
const children = Array.from(
|
||||
{ length: breadth },
|
||||
(_, index) =>
|
||||
`<div class="node depth-${depth}" data-depth="${depth}" data-idx="${index}">` +
|
||||
`<div class="node-header"><strong>Section ${prefix}.${index + 1}</strong> <em>(depth ${depth})</em></div>` +
|
||||
`<div class="node-content">${nest(depth - 1, Math.max(2, breadth - 1), `${prefix}.${index + 1}`)}</div>` +
|
||||
"</div>",
|
||||
);
|
||||
return children.join("");
|
||||
}
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Nested</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:20px}",
|
||||
".node{border-left:2px solid #ddd;padding-left:16px;margin:4px 0}",
|
||||
".node-header{padding:4px 0;cursor:pointer}",
|
||||
".leaf{display:block;padding:2px 8px;background:#f0f9ff;margin:2px 0;border-radius:2px}",
|
||||
"</style></head><body>",
|
||||
"<h1>Deeply Nested Document Structure</h1>",
|
||||
nest(7, 3, "root"),
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function generateDashboardPage(): string {
|
||||
const cards = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
`<div class="card" data-card="${index}">` +
|
||||
`<div class="card-title">Metric ${index + 1}</div>` +
|
||||
`<div class="card-value">${Math.floor(Math.random() * 10000)}</div>` +
|
||||
`<div class="card-trend ${index % 2 === 0 ? "up" : "down"}">${index % 2 === 0 ? "+" : "-"}${(Math.random() * 20).toFixed(1)}%</div>` +
|
||||
"</div>",
|
||||
);
|
||||
|
||||
const chartBars = Array.from({ length: 24 }, (_, index) => {
|
||||
const height = 20 + ((index * 7 + 13) % 80);
|
||||
return `<div class="bar" style="height:${height}%" data-hour="${index}"><span class="bar-label">${String(index).padStart(2, "0")}:00</span></div>`;
|
||||
});
|
||||
|
||||
const logRows = Array.from({ length: 100 }, (_, index) => {
|
||||
const level = ["INFO", "WARN", "ERROR", "DEBUG"][index % 4];
|
||||
return (
|
||||
`<tr class="log-${level.toLowerCase()}" data-log="${index}">` +
|
||||
`<td>${new Date(2025, 0, 1, index % 24, index % 60).toISOString()}</td>` +
|
||||
`<td><span class="level level-${level.toLowerCase()}">${level}</span></td>` +
|
||||
`<td>Service ${["auth", "api", "worker", "cache", "db"][index % 5]}</td>` +
|
||||
`<td>Log message number ${index + 1}: operation completed in ${(Math.random() * 1000).toFixed(0)}ms</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
"<html><head><title>Benchmark Dashboard</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui;margin:0;background:#f5f5f5}",
|
||||
".header{background:#1a1a2e;color:white;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}",
|
||||
".grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;padding:24px}",
|
||||
".card{background:white;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".card-value{font-size:28px;font-weight:700;margin:8px 0}",
|
||||
".card-trend.up{color:#16a34a}.card-trend.down{color:#dc2626}",
|
||||
".chart-area{background:white;margin:0 24px;padding:20px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)}",
|
||||
".bars{display:flex;align-items:flex-end;gap:4px;height:200px}",
|
||||
".bar{background:#3b82f6;flex:1;border-radius:2px 2px 0 0;position:relative;min-width:8px}",
|
||||
".log-table{margin:24px;background:white;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1);overflow:hidden}",
|
||||
"table{width:100%;border-collapse:collapse;font-size:13px}",
|
||||
"th,td{padding:6px 12px;border-bottom:1px solid #eee;text-align:left}",
|
||||
"th{background:#f9fafb;font-weight:600}",
|
||||
".tabs{display:flex;gap:0;margin:24px 24px 0}",
|
||||
".tab{padding:8px 20px;background:#e5e7eb;cursor:pointer;border-radius:6px 6px 0 0}",
|
||||
".tab.active{background:white}",
|
||||
"</style></head><body>",
|
||||
'<div class="header"><h1>Operations Dashboard</h1><div><input id="dash-search" placeholder="Search..." type="text"><button id="refresh">Refresh</button></div></div>',
|
||||
`<div class="grid">${cards.join("")}</div>`,
|
||||
'<div class="tabs"><div class="tab active">Hourly</div><div class="tab">Daily</div><div class="tab">Weekly</div></div>',
|
||||
`<div class="chart-area"><h3>Request Volume</h3><div class="bars">${chartBars.join("")}</div></div>`,
|
||||
'<div class="log-table">',
|
||||
"<h3 style='padding:16px 12px 0'>Recent Logs</h3>",
|
||||
`<table><thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th></tr></thead><tbody>${logRows.join("")}</tbody></table>`,
|
||||
"</div>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
const ARTICLE_HTML = generateArticlePage();
|
||||
const TABLE_HTML = generateDataTablePage();
|
||||
const NESTED_HTML = generateNestedPage();
|
||||
const DASHBOARD_HTML = generateDashboardPage();
|
||||
|
||||
function injectCmd(id: string, html: string): BenchmarkCommand {
|
||||
return {
|
||||
action: "evaluate",
|
||||
id,
|
||||
script: `document.open(); document.write(${JSON.stringify(html)}); document.close(); 'ok'`,
|
||||
};
|
||||
}
|
||||
|
||||
function setupPage(html: string, tag: string): BenchmarkCommand[] {
|
||||
return [
|
||||
{ action: "navigate", id: `${tag}-nav`, url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
injectCmd(`${tag}-inject`, html),
|
||||
];
|
||||
}
|
||||
|
||||
export const engineScenarios: Scenario[] = [
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a realistic article page (~800 DOM nodes, 30 paragraphs, 40 comments)",
|
||||
name: "article-snapshot",
|
||||
setup: setupPage(ARTICLE_HTML, "art"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a data table with 200 rows and 8 columns",
|
||||
name: "table-snapshot",
|
||||
setup: setupPage(TABLE_HTML, "tbl"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot a deeply nested DOM tree (7 levels, ~3000 nodes)",
|
||||
name: "nested-snapshot",
|
||||
setup: setupPage(NESTED_HTML, "nest"),
|
||||
},
|
||||
{
|
||||
commands: [{ action: "snapshot", id: "snap" }],
|
||||
description: "Snapshot an operations dashboard with cards, chart, and 100 log rows",
|
||||
name: "dashboard-snap",
|
||||
setup: setupPage(DASHBOARD_HTML, "dash"),
|
||||
},
|
||||
{
|
||||
commands: [injectCmd("ai-write", ARTICLE_HTML)],
|
||||
description: "Write a full article page into the DOM (measures parse + layout)",
|
||||
name: "article-inject",
|
||||
setup: [{ action: "navigate", id: "ai-nav", url: "about:blank", waitUntil: "domcontentloaded" }],
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{
|
||||
action: "evaluate",
|
||||
id: "query",
|
||||
script: "document.querySelectorAll('tr[data-row]').length + ' rows, ' + document.querySelectorAll('td').length + ' cells'",
|
||||
},
|
||||
],
|
||||
description: "Evaluate a querySelectorAll across a large table",
|
||||
name: "table-query",
|
||||
setup: setupPage(TABLE_HTML, "tq"),
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{ action: "snapshot", id: "dw-snap" },
|
||||
{ action: "fill", id: "dw-fill", selector: "#dash-search", value: "error logs" },
|
||||
{ action: "click", id: "dw-click", selector: "#refresh" },
|
||||
{ action: "evaluate", id: "dw-eval", script: "document.querySelectorAll('.card').length + ' cards'" },
|
||||
{ action: "screenshot", id: "dw-ss" },
|
||||
],
|
||||
description: "Full agent workflow on complex dashboard: snapshot, click, fill, eval, screenshot",
|
||||
name: "dashboard-workflow",
|
||||
setup: setupPage(DASHBOARD_HTML, "dw"),
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
{
|
||||
action: "evaluate",
|
||||
id: "walk",
|
||||
script: "(function(){let c=0;const w=n=>{c++;for(const ch of n.children)w(ch);};w(document.body);return c+' nodes';})()",
|
||||
},
|
||||
],
|
||||
description: "Recursive DOM traversal via evaluate on deeply nested tree",
|
||||
name: "nested-eval",
|
||||
setup: setupPage(NESTED_HTML, "ne"),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Understanding Modern Browser Engine Architecture</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a2e; background: #fff; }
|
||||
.nav { display: flex; align-items: center; gap: 24px; padding: 12px 24px; background: #1a1a2e; color: #fff; position: sticky; top: 0; z-index: 100; }
|
||||
.nav a { color: #94a3b8; text-decoration: none; font-size: 14px; transition: color 0.2s; }
|
||||
.nav a:hover { color: #fff; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 40px; max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
|
||||
.article { min-width: 0; }
|
||||
.article h1 { font-size: 2.2rem; line-height: 1.2; margin-bottom: 16px; }
|
||||
.meta { display: flex; gap: 16px; color: #64748b; font-size: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e2e8f0; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 32px; }
|
||||
.tag { display: inline-block; padding: 4px 12px; background: #e0e7ff; color: #3730a3; border-radius: 16px; font-size: 12px; font-weight: 500; }
|
||||
.article h2 { font-size: 1.5rem; margin: 32px 0 16px; padding-top: 24px; border-top: 1px solid #f1f5f9; }
|
||||
.article h3 { font-size: 1.2rem; margin: 24px 0 12px; }
|
||||
.article p { margin-bottom: 16px; color: #374151; }
|
||||
.article blockquote { margin: 24px 0; padding: 16px 24px; border-left: 4px solid #6366f1; background: #f8fafc; font-style: italic; color: #475569; border-radius: 0 8px 8px 0; }
|
||||
.article pre { background: #1e293b; color: #e2e8f0; padding: 16px 20px; border-radius: 8px; overflow-x: auto; margin: 16px 0; font-size: 14px; line-height: 1.5; }
|
||||
.article code { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
.article img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
|
||||
.figure { margin: 24px 0; text-align: center; }
|
||||
.figure figcaption { font-size: 13px; color: #64748b; margin-top: 8px; }
|
||||
.comments { margin-top: 40px; }
|
||||
.comments h2 { border-top: 2px solid #e2e8f0; }
|
||||
.comment { padding: 16px; margin: 12px 0; border: 1px solid #e2e8f0; border-radius: 8px; transition: box-shadow 0.2s; }
|
||||
.comment:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.comment-author { font-weight: 600; font-size: 14px; }
|
||||
.comment-date { font-size: 12px; color: #94a3b8; }
|
||||
.comment-body { font-size: 14px; color: #475569; }
|
||||
.comment-actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||
.comment-actions button { background: none; border: none; color: #6366f1; font-size: 13px; cursor: pointer; padding: 2px 0; }
|
||||
.sidebar { position: sticky; top: 72px; align-self: start; }
|
||||
.sidebar section { margin-bottom: 32px; }
|
||||
.sidebar h3 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 12px; }
|
||||
.sidebar ul { list-style: none; }
|
||||
.sidebar li { margin-bottom: 8px; }
|
||||
.sidebar a { color: #3730a3; text-decoration: none; font-size: 14px; }
|
||||
.sidebar a:hover { text-decoration: underline; }
|
||||
.toc a { display: block; padding: 4px 0; border-left: 2px solid transparent; padding-left: 12px; }
|
||||
.toc a:hover { border-left-color: #6366f1; }
|
||||
.sidebar .widget { background: #f8fafc; padding: 16px; border-radius: 8px; }
|
||||
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<strong style="font-size:18px;color:#fff">TechBlog</strong>
|
||||
<a href="#">Home</a><a href="#">Articles</a><a href="#">Tutorials</a><a href="#">About</a>
|
||||
<a href="#">Open Source</a><a href="#">Newsletter</a><a href="#">Contact</a>
|
||||
<div style="flex:1"></div>
|
||||
<a href="#">Sign In</a>
|
||||
</nav>
|
||||
|
||||
<div class="layout">
|
||||
<main class="article">
|
||||
<h1>Understanding Modern Browser Engine Architecture</h1>
|
||||
<div class="meta">
|
||||
<span>By <strong>Dr. Alexandra Chen</strong></span>
|
||||
<span>March 15, 2025</span>
|
||||
<span>18 min read</span>
|
||||
<span>2,847 views</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="tag">Browser Engines</span><span class="tag">Performance</span>
|
||||
<span class="tag">Web Standards</span><span class="tag">Rendering</span>
|
||||
<span class="tag">Architecture</span><span class="tag">Open Source</span>
|
||||
</div>
|
||||
|
||||
<p>Modern browser engines are among the most complex pieces of software ever created. They must parse HTML, CSS, and JavaScript, construct a DOM tree, compute styles, perform layout calculations, paint pixels, and composite layers, all within milliseconds to maintain smooth rendering.</p>
|
||||
|
||||
<p>This article explores the architecture of modern browser engines, examining how they process web content from raw bytes to rendered pixels on screen. We trace the critical rendering path, review optimization strategies, and explain why certain patterns lead to better performance.</p>
|
||||
|
||||
<h2>The Critical Rendering Path</h2>
|
||||
|
||||
<p>When a browser receives an HTML document, it begins a multi-stage pipeline known as the critical rendering path. Each stage transforms the document into progressively more structured representations until pixels are painted on screen.</p>
|
||||
|
||||
<p>The first stage involves parsing the HTML into a Document Object Model (DOM). The parser processes tokens sequentially, building a tree structure that represents the document hierarchy. During this phase, the parser may encounter external resources like stylesheets and scripts that can block further processing.</p>
|
||||
|
||||
<p>CSS parsing happens in parallel where possible. The browser constructs the CSS Object Model (CSSOM), which represents all style rules that apply to the document. This includes user-agent styles, author styles, and inline styles.</p>
|
||||
|
||||
<p>Once both the DOM and CSSOM are available, the browser combines them into a render tree. This tree contains only the elements that will be visible on screen. Elements with <code>display: none</code> are excluded, while pseudo-elements like <code>::before</code> and <code>::after</code> are added.</p>
|
||||
|
||||
<p>Layout, also called reflow, calculates the exact position and size of each element in the render tree. This is one of the most computationally expensive operations in the rendering pipeline because changes to one element can cascade through the rest of the tree.</p>
|
||||
|
||||
<blockquote>"The fastest code is code that does not run. The fastest layout is layout that does not need to happen."</blockquote>
|
||||
|
||||
<h2>DOM Construction and Tree Building</h2>
|
||||
|
||||
<p>The DOM is a tree-structured representation of the HTML document. Each node corresponds to an element, text node, comment, or other construct in the HTML. The tree preserves hierarchical relationships between elements, allowing efficient traversal and manipulation.</p>
|
||||
|
||||
<p>Modern parsers handle malformed HTML gracefully through error recovery algorithms specified in the HTML standard. This includes automatic closing of unclosed tags, adoption of misplaced elements, and reconstruction of formatting element lists.</p>
|
||||
|
||||
<p>Shadow DOM introduces additional complexity by creating encapsulated subtrees that can have their own scoped styles and behavior. Custom elements use shadow roots to attach shadow trees that are rendered in place of the element's regular children.</p>
|
||||
|
||||
<h3>Incremental DOM Updates</h3>
|
||||
|
||||
<p>When JavaScript modifies the DOM, the browser must determine which parts of the rendering pipeline need to be re-executed. Modern engines use fine-grained invalidation to minimize the work required. A change to an element's text content may only require a repaint, while changing its width could trigger a full relayout.</p>
|
||||
|
||||
<p>Mutation observers provide a way for JavaScript to respond to DOM changes without polling. The browser batches mutations and delivers them asynchronously, allowing multiple changes to be processed efficiently in a single callback.</p>
|
||||
|
||||
<h3>Memory Management</h3>
|
||||
|
||||
<p>DOM nodes are garbage collected when no longer reachable. Detached DOM trees, subtrees removed from the document but still referenced by JavaScript, are a common source of memory leaks in web applications.</p>
|
||||
|
||||
<p>Browser engines use string interning for common values, node pools for rapid allocation, and lazy initialization of rarely accessed properties to minimize memory overhead.</p>
|
||||
|
||||
<h2>Style Resolution and Cascade</h2>
|
||||
|
||||
<p>CSS style resolution involves matching each element against all applicable style rules and computing the final value for every CSS property. With thousands of rules and millions of elements on complex pages, this process must be highly optimized.</p>
|
||||
|
||||
<p>Modern engines use fast prefilters to eliminate rules that cannot match an element, reducing the number of full selector matches required. Selector matching proceeds right-to-left, starting from the key selector and working backward through ancestors.</p>
|
||||
|
||||
<p>The cascade algorithm resolves conflicts between competing declarations by considering origin, specificity, and source order. Custom properties add another layer of complexity because they must be resolved during the cascade before they can be used in property values.</p>
|
||||
|
||||
<pre><code>.data-grid tr:nth-child(even) td {
|
||||
background-color: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}</code></pre>
|
||||
|
||||
<h2>Layout Algorithms</h2>
|
||||
|
||||
<p>Layout converts the styled render tree into positioned boxes with concrete pixel dimensions. Different layout modes, including block, inline, flex, grid, and table, each use their own algorithm for determining element sizes and positions.</p>
|
||||
|
||||
<p>Flexbox layout involves multiple passes: computing the flex basis of each item, distributing free space according to flex-grow and flex-shrink factors, and then positioning items along the cross axis. This makes flex layout more expensive than simple block layout.</p>
|
||||
|
||||
<p>Grid layout is even more complex, supporting both explicit and implicit grid definitions, named areas, auto-placement, and spanning. The placement algorithm must resolve conflicts between explicitly placed and auto-placed items while respecting sizing constraints.</p>
|
||||
|
||||
<h2>Paint and Compositing</h2>
|
||||
|
||||
<p>After layout, the browser paints the visual representation of each element. This includes drawing backgrounds, borders, text, images, shadows, and other effects in the correct stacking order defined by the z-index property and stacking context rules.</p>
|
||||
|
||||
<p>Modern browsers use a layered compositing architecture. Elements that change frequently, such as animations, scrolling regions, and video, are promoted to their own compositing layers. These layers can be updated independently and combined on the GPU.</p>
|
||||
|
||||
<p>The compositor thread operates independently from the main thread, allowing smooth scrolling and animations even when JavaScript is executing. Touch events and scroll gestures are often handled directly by the compositor.</p>
|
||||
|
||||
<h2>JavaScript Engine Integration</h2>
|
||||
|
||||
<p>The JavaScript engine is tightly integrated with the browser rendering pipeline. Script execution can trigger style recalculation, layout, and paint through DOM manipulation and CSSOM access. The browser must balance script execution with maintaining smooth rendering.</p>
|
||||
|
||||
<p>Modern engines use just-in-time compilation to achieve near-native performance for hot code paths. The compilation pipeline typically includes an interpreter for initial execution, a baseline compiler for warm functions, and an optimizing compiler for hot functions.</p>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
|
||||
<p>Browser engines represent decades of engineering effort to make the web fast, secure, and compatible. Understanding their architecture helps web developers write code that works with the browser rather than against it.</p>
|
||||
|
||||
<p>As the web platform continues to evolve with new APIs, layout modes, and rendering capabilities, browser engines must adapt while maintaining backwards compatibility with billions of existing web pages.</p>
|
||||
|
||||
<div class="comments">
|
||||
<h2>Comments (50)</h2>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.querySelector('.comments');
|
||||
var names = ['Alex Morgan', 'Jamie Rivera', 'Sam Patel', 'Taylor Kim', 'Jordan Lee',
|
||||
'Casey Wu', 'Riley Chen', 'Morgan Davis', 'Avery Singh', 'Quinn Zhao'];
|
||||
for (var i = 0; i < 50; i++) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
div.dataset.id = i;
|
||||
var d = new Date(2025, 2, 15 - Math.floor(i / 5));
|
||||
div.innerHTML = '<div class="comment-header"><span class="comment-author">' + names[i % 10] +
|
||||
'</span><span class="comment-date">' + d.toLocaleDateString() + '</span></div>' +
|
||||
'<div class="comment-body"><p>' + (i % 3 === 0 ?
|
||||
'Great article. The section on compositing layers was particularly useful.' :
|
||||
i % 3 === 1 ?
|
||||
'Thanks for the detailed breakdown. The invalidation notes were helpful.' :
|
||||
'This clarified several misconceptions I had about GPU acceleration.') +
|
||||
'</p></div><div class="comment-actions"><button>Reply</button><button>Like (' + (Math.floor(Math.random() * 30)) + ')</button></div>';
|
||||
container.appendChild(div);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section>
|
||||
<h3>Table of Contents</h3>
|
||||
<ul class="toc">
|
||||
<li><a href="#crp">The Critical Rendering Path</a></li>
|
||||
<li><a href="#dom">DOM Construction and Tree Building</a></li>
|
||||
<li><a href="#styles">Style Resolution and Cascade</a></li>
|
||||
<li><a href="#layout">Layout Algorithms</a></li>
|
||||
<li><a href="#paint">Paint and Compositing</a></li>
|
||||
<li><a href="#js">JavaScript Engine Integration</a></li>
|
||||
<li><a href="#conclusion">Conclusion</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Related Articles</h3>
|
||||
<ul>
|
||||
<li><a href="#">How V8 Optimizes JavaScript Execution</a></li>
|
||||
<li><a href="#">CSS Grid Layout: A Complete Guide</a></li>
|
||||
<li><a href="#">Web Performance Metrics That Matter</a></li>
|
||||
<li><a href="#">Understanding the Event Loop</a></li>
|
||||
<li><a href="#">Debugging Layout Thrashing</a></li>
|
||||
<li><a href="#">Service Workers and Caching Strategies</a></li>
|
||||
<li><a href="#">WebAssembly Beyond JavaScript</a></li>
|
||||
<li><a href="#">Rendering Performance Case Studies</a></li>
|
||||
<li><a href="#">Accessibility Tree Deep Dive</a></li>
|
||||
<li><a href="#">Cross-Browser Compatibility Patterns</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="widget">
|
||||
<h3>Newsletter</h3>
|
||||
<p style="font-size:13px;color:#475569;margin-bottom:8px">Get weekly browser engineering insights.</p>
|
||||
<input type="email" placeholder="you@example.com" style="width:100%;padding:8px;border:1px solid #d1d5db;border-radius:4px;margin-bottom:8px">
|
||||
<button style="width:100%;padding:8px;background:#6366f1;color:#fff;border:none;border-radius:4px;cursor:pointer">Subscribe</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Operations Dashboard</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f1f5f9; color: #0f172a; }
|
||||
.header { background: #0f172a; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 18px; }
|
||||
.header-actions { display: flex; gap: 12px; align-items: center; }
|
||||
.header input { padding: 6px 12px; border: 1px solid #334155; background: #1e293b; color: #fff; border-radius: 6px; font-size: 13px; width: 200px; }
|
||||
.header button { padding: 6px 16px; background: #6366f1; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 24px; }
|
||||
.card { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.card-label { font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.card-value { font-size: 32px; font-weight: 700; margin: 8px 0 4px; }
|
||||
.card-trend { font-size: 14px; font-weight: 500; }
|
||||
.card-trend.up { color: #16a34a; }
|
||||
.card-trend.down { color: #dc2626; }
|
||||
.card-sparkline { height: 40px; display: flex; align-items: flex-end; gap: 2px; margin-top: 8px; }
|
||||
.card-sparkline .bar { flex: 1; background: #e0e7ff; border-radius: 2px; min-width: 3px; }
|
||||
.card-sparkline .bar:last-child { background: #6366f1; }
|
||||
.section { margin: 0 24px 24px; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
.section-header { padding: 16px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-header h2 { font-size: 16px; }
|
||||
.tabs { display: flex; gap: 0; }
|
||||
.tab { padding: 6px 16px; font-size: 13px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; }
|
||||
.tab:first-child { border-radius: 6px 0 0 6px; }
|
||||
.tab:last-child { border-radius: 0 6px 6px 0; }
|
||||
.tab.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.chart { padding: 20px; height: 240px; display: flex; align-items: flex-end; gap: 4px; }
|
||||
.chart .bar { flex: 1; background: #6366f1; border-radius: 4px 4px 0 0; position: relative; min-width: 6px; }
|
||||
.chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: #94a3b8; white-space: nowrap; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th { background: #f8fafc; padding: 10px 16px; text-align: left; font-weight: 600; color: #475569; border-bottom: 1px solid #e2e8f0; }
|
||||
tbody td { padding: 8px 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-info { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-warn { background: #fef3c7; color: #b45309; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-debug { background: #f1f5f9; color: #475569; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f1f5f9; color: #64748b; }
|
||||
.badge-pending { background: #fef3c7; color: #b45309; }
|
||||
.pagination { display: flex; justify-content: center; gap: 4px; padding: 16px; }
|
||||
.pagination button { width: 32px; height: 32px; border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.pagination button.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 0 24px 24px; }
|
||||
@media (max-width: 1024px) { .grid { grid-template-columns: repeat(2, 1fr); } .two-col { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Operations Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<input id="search" type="text" placeholder="Search...">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button style="background:#334155">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="metrics-grid"></div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Request Volume</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">Hourly</div>
|
||||
<div class="tab">Daily</div>
|
||||
<div class="tab">Weekly</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Top Endpoints</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Endpoint</th><th>Requests</th><th>Avg Latency</th><th>Error Rate</th></tr></thead>
|
||||
<tbody id="endpoints-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section" style="margin:0">
|
||||
<div class="section-header"><h2>Active Alerts</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Alert</th><th>Severity</th><th>Service</th><th>Since</th></tr></thead>
|
||||
<tbody id="alerts-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Logs</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active">All</div>
|
||||
<div class="tab">Errors</div>
|
||||
<div class="tab">Warnings</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Timestamp</th><th>Level</th><th>Service</th><th>Message</th><th>Duration</th></tr></thead>
|
||||
<tbody id="logs-table"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header"><h2>Service Status</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Uptime</th><th>CPU</th><th>Memory</th><th>Requests/min</th><th>Error Rate</th><th>Last Deploy</th></tr></thead>
|
||||
<tbody id="services-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var metrics = [
|
||||
{ label: 'Total Requests', value: '1,284,392', trend: '+12.5%', up: true },
|
||||
{ label: 'Avg Response Time', value: '142ms', trend: '-8.3%', up: true },
|
||||
{ label: 'Error Rate', value: '0.42%', trend: '+0.12%', up: false },
|
||||
{ label: 'Active Users', value: '3,847', trend: '+5.1%', up: true },
|
||||
{ label: 'Throughput', value: '892/s', trend: '+3.7%', up: true },
|
||||
{ label: 'P99 Latency', value: '487ms', trend: '+15ms', up: false },
|
||||
{ label: 'CPU Usage', value: '67%', trend: '-2.4%', up: true },
|
||||
{ label: 'Memory Usage', value: '4.2GB', trend: '+180MB', up: false },
|
||||
{ label: 'Cache Hit Rate', value: '94.7%', trend: '+1.2%', up: true },
|
||||
{ label: 'Queue Depth', value: '234', trend: '-45', up: true },
|
||||
{ label: 'Open Connections', value: '12,483', trend: '+892', up: false },
|
||||
{ label: 'Deployments Today', value: '7', trend: '+2', up: true }
|
||||
];
|
||||
var grid = document.getElementById('metrics-grid');
|
||||
metrics.forEach(function(metric) {
|
||||
var sparkBars = '';
|
||||
for (var index = 0; index < 12; index++) {
|
||||
var height = 20 + Math.floor(Math.random() * 80);
|
||||
sparkBars += '<div class="bar" style="height:' + height + '%"></div>';
|
||||
}
|
||||
grid.innerHTML += '<div class="card"><div class="card-label">' + metric.label +
|
||||
'</div><div class="card-value">' + metric.value +
|
||||
'</div><div class="card-trend ' + (metric.up ? 'up' : 'down') + '">' +
|
||||
(metric.up ? '+' : '') + metric.trend +
|
||||
'</div><div class="card-sparkline">' + sparkBars + '</div></div>';
|
||||
});
|
||||
|
||||
var chart = document.getElementById('chart');
|
||||
for (var hour = 0; hour < 24; hour++) {
|
||||
var height = 15 + ((hour * 17 + 7) % 85);
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
bar.style.height = height + '%';
|
||||
bar.innerHTML = '<span class="bar-label">' + String(hour).padStart(2, '0') + ':00</span>';
|
||||
chart.appendChild(bar);
|
||||
}
|
||||
|
||||
var endpoints = document.getElementById('endpoints-table');
|
||||
var paths = ['/api/users', '/api/auth', '/api/products', '/api/orders', '/api/search',
|
||||
'/api/analytics', '/api/notifications', '/api/payments', '/api/inventory', '/api/reports'];
|
||||
paths.forEach(function(pathName, index) {
|
||||
endpoints.innerHTML += '<tr><td><code>' + pathName + '</code></td><td>' +
|
||||
(50000 - index * 3000) + '</td><td>' + (45 + index * 12) + 'ms</td><td>' +
|
||||
(0.1 + index * 0.08).toFixed(2) + '%</td></tr>';
|
||||
});
|
||||
|
||||
var alerts = document.getElementById('alerts-table');
|
||||
var alertData = [
|
||||
['High error rate on /api/payments', 'error', 'payments'],
|
||||
['Memory usage above 85%', 'warn', 'api-gateway'],
|
||||
['Slow queries detected', 'warn', 'database'],
|
||||
['SSL certificate expiring in 7 days', 'info', 'infrastructure'],
|
||||
['Disk usage above 80%', 'warn', 'storage'],
|
||||
['Connection pool exhaustion', 'error', 'database']
|
||||
];
|
||||
alertData.forEach(function(alert, index) {
|
||||
var badge = alert[1] === 'error' ? 'badge-error' : alert[1] === 'warn' ? 'badge-warn' : 'badge-info';
|
||||
alerts.innerHTML += '<tr><td>' + alert[0] + '</td><td><span class="badge ' + badge + '">' +
|
||||
alert[1].toUpperCase() + '</span></td><td>' + alert[2] + '</td><td>' + (index * 15 + 5) + 'm ago</td></tr>';
|
||||
});
|
||||
|
||||
var logs = document.getElementById('logs-table');
|
||||
var services = ['auth', 'api-gateway', 'worker', 'cache', 'database', 'payments', 'search', 'notifications'];
|
||||
var levels = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
|
||||
var messages = [
|
||||
'Request processed successfully',
|
||||
'Connection pool running low',
|
||||
'Failed to connect to upstream service',
|
||||
'Cache miss for key session:',
|
||||
'Query execution exceeded threshold',
|
||||
'Payment webhook received',
|
||||
'Search index rebuild started',
|
||||
'Rate limit applied to client'
|
||||
];
|
||||
for (var i = 0; i < 160; i++) {
|
||||
var level = levels[i % 4];
|
||||
var badge = level === 'ERROR' ? 'badge-error' : level === 'WARN' ? 'badge-warn' :
|
||||
level === 'DEBUG' ? 'badge-debug' : 'badge-info';
|
||||
var ts = new Date(2025, 2, 15, 23 - Math.floor(i / 8), 59 - (i % 60));
|
||||
logs.innerHTML += '<tr><td style="white-space:nowrap">' + ts.toISOString().replace('T', ' ').substring(0, 19) +
|
||||
'</td><td><span class="badge ' + badge + '">' + level +
|
||||
'</span></td><td>' + services[i % 8] +
|
||||
'</td><td>' + messages[i % 8] + ' #' + (i + 1) +
|
||||
'</td><td>' + (Math.floor(Math.random() * 500) + 10) + 'ms</td></tr>';
|
||||
}
|
||||
|
||||
var pagination = document.getElementById('pagination');
|
||||
for (var page = 1; page <= 10; page++) {
|
||||
pagination.innerHTML += '<button class="' + (page === 1 ? 'active' : '') + '">' + page + '</button>';
|
||||
}
|
||||
|
||||
var servicesTable = document.getElementById('services-table');
|
||||
var serviceNames = ['api-gateway', 'auth-service', 'payment-processor', 'search-engine',
|
||||
'notification-hub', 'analytics-pipeline', 'cache-layer', 'worker-pool'];
|
||||
serviceNames.forEach(function(name, index) {
|
||||
var status = index < 6 ? 'active' : index === 6 ? 'pending' : 'inactive';
|
||||
var badge = status === 'active' ? 'badge-active' : status === 'pending' ? 'badge-pending' : 'badge-inactive';
|
||||
servicesTable.innerHTML += '<tr><td><strong>' + name + '</strong></td>' +
|
||||
'<td><span class="badge ' + badge + '">' + status + '</span></td>' +
|
||||
'<td>' + (99.9 - index * 0.05).toFixed(2) + '%</td>' +
|
||||
'<td>' + (30 + index * 5) + '%</td>' +
|
||||
'<td>' + (512 + index * 128) + 'MB</td>' +
|
||||
'<td>' + (2000 - index * 150) + '</td>' +
|
||||
'<td>' + (0.1 + index * 0.04).toFixed(2) + '%</td>' +
|
||||
'<td>' + (index + 1) + 'h ago</td></tr>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TechStore - Electronics & Gadgets</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #fff; color: #0f172a; }
|
||||
.topbar { background: #0f172a; color: #94a3b8; font-size: 12px; padding: 6px 24px; display: flex; justify-content: space-between; }
|
||||
.navbar { display: flex; align-items: center; gap: 24px; padding: 12px 24px; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; background: #fff; z-index: 100; }
|
||||
.navbar .logo { font-size: 22px; font-weight: 800; color: #6366f1; }
|
||||
.navbar .search { flex: 1; max-width: 500px; position: relative; }
|
||||
.navbar .search input { width: 100%; padding: 10px 16px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 14px; }
|
||||
.nav-actions { display: flex; gap: 16px; align-items: center; }
|
||||
.nav-actions button { background: none; border: none; font-size: 14px; cursor: pointer; color: #475569; }
|
||||
.cart-badge { background: #6366f1; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 4px; }
|
||||
.categories { display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
||||
.categories a { padding: 10px 16px; font-size: 13px; color: #64748b; text-decoration: none; white-space: nowrap; border-bottom: 2px solid transparent; }
|
||||
.categories a.active { color: #6366f1; border-bottom-color: #6366f1; }
|
||||
.hero { background: linear-gradient(135deg, #312e81, #6366f1); color: #fff; padding: 60px 24px; text-align: center; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.hero p { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
|
||||
.hero button { padding: 12px 32px; background: #fff; color: #6366f1; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
|
||||
.section-title { font-size: 1.5rem; font-weight: 700; margin: 32px 0 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-title a { font-size: 14px; color: #6366f1; text-decoration: none; }
|
||||
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 32px; }
|
||||
.product { border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; }
|
||||
.product:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
|
||||
.product-img { height: 200px; display: flex; align-items: center; justify-content: center; font-size: 48px; }
|
||||
.product-info { padding: 16px; }
|
||||
.product-brand { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.product-name { font-size: 15px; font-weight: 600; margin: 4px 0 8px; line-height: 1.3; }
|
||||
.product-price { font-size: 20px; font-weight: 700; color: #0f172a; }
|
||||
.product-original { font-size: 14px; color: #94a3b8; text-decoration: line-through; margin-left: 8px; }
|
||||
.product-rating { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 13px; color: #64748b; }
|
||||
.stars { color: #f59e0b; }
|
||||
.product-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.product-actions button { flex: 1; padding: 8px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||
.btn-primary { background: #6366f1; color: #fff; }
|
||||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.filter { padding: 6px 16px; border: 1px solid #e2e8f0; border-radius: 20px; font-size: 13px; background: #fff; cursor: pointer; }
|
||||
.filter.active { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||
.deals-banner { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 12px; padding: 20px 24px; margin: 24px 0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.deals-banner h3 { color: #b45309; }
|
||||
.reviews { margin: 24px 0; }
|
||||
.review { padding: 16px; border-bottom: 1px solid #f1f5f9; }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.review-author { font-weight: 600; font-size: 14px; }
|
||||
.review-date { font-size: 12px; color: #94a3b8; }
|
||||
.review-body { font-size: 14px; color: #475569; }
|
||||
.footer { background: #0f172a; color: #94a3b8; padding: 48px 24px; margin-top: 48px; }
|
||||
.footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; max-width: 1280px; margin: 0 auto; }
|
||||
.footer h4 { color: #fff; margin-bottom: 16px; font-size: 14px; }
|
||||
.footer a { display: block; color: #94a3b8; text-decoration: none; font-size: 13px; margin-bottom: 8px; }
|
||||
.footer-bottom { border-top: 1px solid #1e293b; padding-top: 24px; margin-top: 32px; text-align: center; font-size: 13px; max-width: 1280px; margin-left: auto; margin-right: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<span>Free shipping on orders over $99</span>
|
||||
<span>Customer Service: 1-800-TECH | Track Order | Help</span>
|
||||
</div>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="logo">TechStore</div>
|
||||
<div class="search"><input type="text" placeholder="Search products, brands, categories..."></div>
|
||||
<div class="nav-actions">
|
||||
<button>Account</button>
|
||||
<button>Wishlist</button>
|
||||
<button>Cart <span class="cart-badge">3</span></button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="categories">
|
||||
<a href="#" class="active">All</a><a href="#">Laptops</a><a href="#">Phones</a>
|
||||
<a href="#">Tablets</a><a href="#">Audio</a><a href="#">Cameras</a>
|
||||
<a href="#">Monitors</a><a href="#">Storage</a><a href="#">Networking</a>
|
||||
<a href="#">Accessories</a><a href="#">Deals</a><a href="#">New Arrivals</a>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h2>Spring Tech Sale</h2>
|
||||
<p>Up to 40% off on selected electronics. Limited time offer.</p>
|
||||
<button>Shop Now</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="deals-banner">
|
||||
<div><h3>Flash Deals - Ends in 04:32:17</h3><p style="font-size:13px;color:#92400e">Extra 15% off with code SPRING15</p></div>
|
||||
<button class="btn-primary" style="padding:10px 24px;border-radius:8px;border:none;cursor:pointer">View All Deals</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title"><span>Featured Products</span><a href="#">View All</a></div>
|
||||
<div class="filters">
|
||||
<span class="filter active">All</span><span class="filter">Under $100</span>
|
||||
<span class="filter">$100 - $500</span><span class="filter">$500+</span>
|
||||
<span class="filter">Top Rated</span><span class="filter">New</span>
|
||||
</div>
|
||||
<div class="product-grid" id="featured-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Best Sellers</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="bestsellers-grid"></div>
|
||||
|
||||
<div class="section-title"><span>New Arrivals</span><a href="#">View All</a></div>
|
||||
<div class="product-grid" id="newarrivals-grid"></div>
|
||||
|
||||
<div class="section-title"><span>Customer Reviews</span></div>
|
||||
<div class="reviews" id="reviews"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-grid">
|
||||
<div><h4>Shop</h4><a href="#">Laptops</a><a href="#">Phones</a><a href="#">Tablets</a><a href="#">Audio</a></div>
|
||||
<div><h4>Support</h4><a href="#">Help Center</a><a href="#">Returns</a><a href="#">Warranty</a><a href="#">Contact Us</a></div>
|
||||
<div><h4>Company</h4><a href="#">About Us</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Blog</a></div>
|
||||
<div><h4>Connect</h4><a href="#">Newsletter</a><a href="#">Social Media</a><a href="#">Affiliate Program</a><a href="#">Developer API</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">2025 TechStore Inc. All rights reserved. | Privacy Policy | Terms of Service | Cookie Settings</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var brands = ['Apple', 'Samsung', 'Sony', 'Bose', 'Dell', 'Lenovo', 'LG', 'ASUS', 'Logitech', 'Canon'];
|
||||
var categories = ['Laptop', 'Phone', 'Tablet', 'Headphones', 'Camera', 'Monitor', 'Speaker', 'Keyboard'];
|
||||
var colors = ['#dbeafe', '#fce7f3', '#d1fae5', '#fef3c7', '#e0e7ff', '#f1f5f9', '#fef2f2', '#f0fdf4'];
|
||||
|
||||
function makeProduct(i) {
|
||||
var brand = brands[i % brands.length];
|
||||
var category = categories[i % categories.length];
|
||||
var price = 49 + (i * 73) % 1500;
|
||||
var original = Math.round(price * 1.25);
|
||||
var rating = (3.5 + (i % 15) * 0.1).toFixed(1);
|
||||
var reviews = 50 + (i * 37) % 2000;
|
||||
var stars = '';
|
||||
for (var s = 0; s < 5; s++) stars += s < Math.round(parseFloat(rating)) ? '*' : ' ';
|
||||
return '<div class="product"><div class="product-img" style="background:' + colors[i % 8] + '">' +
|
||||
category.charAt(0).toUpperCase() + '</div><div class="product-info">' +
|
||||
'<div class="product-brand">' + brand + '</div>' +
|
||||
'<div class="product-name">' + brand + ' ' + category + ' Pro ' + (2024 + (i % 3)) + ' Edition</div>' +
|
||||
'<div><span class="product-price">$' + price + '</span><span class="product-original">$' + original + '</span></div>' +
|
||||
'<div class="product-rating"><span class="stars">' + stars + '</span> ' + rating + ' (' + reviews + ')</div>' +
|
||||
'<div class="product-actions"><button class="btn-primary">Add to Cart</button><button class="btn-secondary">Compare</button></div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
var featured = document.getElementById('featured-grid');
|
||||
for (var i = 0; i < 16; i++) featured.innerHTML += makeProduct(i);
|
||||
|
||||
var bestsellers = document.getElementById('bestsellers-grid');
|
||||
for (var j = 16; j < 32; j++) bestsellers.innerHTML += makeProduct(j);
|
||||
|
||||
var newArrivals = document.getElementById('newarrivals-grid');
|
||||
for (var k = 32; k < 48; k++) newArrivals.innerHTML += makeProduct(k);
|
||||
|
||||
var reviewsEl = document.getElementById('reviews');
|
||||
var names = ['Alice M.', 'Bob K.', 'Carol S.', 'David L.', 'Eva R.', 'Frank W.'];
|
||||
var reviewTexts = [
|
||||
'Excellent product, arrived faster than expected. Build quality is outstanding.',
|
||||
'Good value for money. Reliable device and easy to set up.',
|
||||
'Battery life could be better, but performance is strong.',
|
||||
'Amazing quality. This is my third purchase from this brand.'
|
||||
];
|
||||
for (var idx = 0; idx < 16; idx++) {
|
||||
reviewsEl.innerHTML += '<div class="review"><div class="review-header"><div><span class="review-author">' +
|
||||
names[idx % names.length] + '</span></div><span class="review-date">March ' + (15 - idx % 15) + ', 2025</span></div>' +
|
||||
'<div class="review-body">' + reviewTexts[idx % 4] + '</div></div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
export interface BenchmarkCommand {
|
||||
id: string;
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Commands to run once before measured iterations (e.g. navigate to a page). */
|
||||
setup?: BenchmarkCommand[];
|
||||
/** The commands whose total execution time is measured per iteration. */
|
||||
commands: BenchmarkCommand[];
|
||||
/** Commands to run once after measured iterations (e.g. cleanup). */
|
||||
teardown?: BenchmarkCommand[];
|
||||
}
|
||||
|
||||
const FORM_HTML = [
|
||||
"<html><head><title>Bench</title></head><body>",
|
||||
"<h1>Benchmark Page</h1>",
|
||||
"<input id='name' type='text' placeholder='Name'>",
|
||||
"<input id='email' type='email' placeholder='Email'>",
|
||||
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
|
||||
"<input id='agree' type='checkbox'>",
|
||||
"<textarea id='bio' placeholder='Bio'></textarea>",
|
||||
"<button id='submit'>Submit</button>",
|
||||
"<p id='status'>Ready</p>",
|
||||
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
|
||||
"<ul>",
|
||||
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
|
||||
"</ul>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
|
||||
const INJECT_FORM: BenchmarkCommand = {
|
||||
id: "inject",
|
||||
action: "evaluate",
|
||||
script: `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`,
|
||||
};
|
||||
|
||||
const SETUP_PAGE: BenchmarkCommand[] = [
|
||||
{ id: "setup-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
INJECT_FORM,
|
||||
];
|
||||
|
||||
export const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "navigate",
|
||||
description: "Page navigation (about:blank round-trip)",
|
||||
commands: [
|
||||
{ id: "nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "snapshot",
|
||||
description: "DOM snapshot (accessibility tree)",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "snap", action: "snapshot" }],
|
||||
},
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Screenshot capture",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "ss", action: "screenshot" }],
|
||||
},
|
||||
{
|
||||
name: "evaluate",
|
||||
description: "JavaScript evaluation",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [
|
||||
{
|
||||
id: "eval",
|
||||
action: "evaluate",
|
||||
script: "document.title + ' ' + document.querySelectorAll('li').length",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "click",
|
||||
description: "Element click interaction",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "clk", action: "click", selector: "#link" }],
|
||||
},
|
||||
{
|
||||
name: "fill",
|
||||
description: "Form field fill",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [{ id: "fill", action: "fill", selector: "#name", value: "Benchmark User" }],
|
||||
},
|
||||
{
|
||||
name: "tabs",
|
||||
description: "Tab new + list + switch",
|
||||
commands: [
|
||||
{ id: "tnew", action: "tab_new", url: "about:blank" },
|
||||
{ id: "tlist", action: "tab_list" },
|
||||
{ id: "tswitch", action: "tab_switch", index: 0 },
|
||||
],
|
||||
teardown: [{ id: "tclose", action: "tab_close", index: 1 }],
|
||||
},
|
||||
{
|
||||
name: "full-workflow",
|
||||
description: "Realistic agent workflow: navigate, snapshot, click, fill, evaluate, screenshot",
|
||||
commands: [
|
||||
{ id: "w-nav", action: "navigate", url: "about:blank", waitUntil: "domcontentloaded" },
|
||||
INJECT_FORM,
|
||||
{ id: "w-snap", action: "snapshot" },
|
||||
{ id: "w-click", action: "click", selector: "#link" },
|
||||
{ id: "w-fill", action: "fill", selector: "#name", value: "Agent User" },
|
||||
{ id: "w-eval", action: "evaluate", script: "document.getElementById('name').value" },
|
||||
{ id: "w-ss", action: "screenshot" },
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user