Compare commits

..
Author SHA1 Message Date
leeguooooo 8aa52d7faa fix(release): 为 fork 预发布设置默认 tag 2026-03-10 13:57:30 +09:00
leeguooooo fcd891e8a9 fix(cli): 增加 abs start 并修复 managed 9333 启动链路 2026-03-10 12:45:50 +09:00
leeguooooo 8eee9310a4 fix(cdp): 自动拉起 9333 专用浏览器 2026-03-09 17:45:13 +09:00
leeguooooo b3ed4b63be fix(native): 修复 CDP 附着卡死问题 2026-03-09 17:10:42 +09:00
leeguooooo 5d149afef1 chore(release): bump version to 0.17.0-fork.1 2026-03-09 12:02:00 +09:00
leeguooooo 356e2f5f39 feat(sync): 同步 upstream 改动并升级到 0.16.3-fork.5 2026-03-09 12:00:13 +09:00
Chris Tate 3cbc284076 fix: persist auth cookies on close in native mode (#650)
(cherry picked from commit b7e7a2548e)
2026-03-09 09:38:10 +09:00
Chris Tate bb92e08fdc Fix Chrome extensions not loading by forcing headed mode when extensions present (#652)
* Fix Chrome extensions not loading by forcing headed mode when extensions present

Fixes #640

* Restore wait_or_kill() and add tests for headless+extensions logic

Restore the ChromeProcess::wait_or_kill() method that was accidentally
removed. It is still referenced by BrowserProcess in browser.rs and is
needed for graceful shutdown / cookie persistence (PR #650).

Add unit tests verifying --headless=new is omitted when extensions are
present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix window-size leak in headed+extensions mode and remove unused channel option

- Skip --window-size=1280,720 when extensions force headed mode (native)
- Remove unexplained channel: 'chromium' from extensions launch path (TS)
- Add window-size assertion to existing extension test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 68cebe5192)
2026-03-09 09:37:36 +09:00
Chris Tate 5c142b02b5 Fix: Suppress Google Translate bar in native headless mode (#649)
Fixes #617

(cherry picked from commit 492830accb)
2026-03-09 09:37:33 +09:00
Chris Tate b100870a02 fix: native auth login fails due to incompatible encryption format (#648)
* fix: native auth login fails due to incompatible encryption format

* fixes

* fixes

(cherry picked from commit 7acde7e29a)
2026-03-09 09:37:30 +09:00
leeguooooo b505d00c97 fix: escape compose shell vars to prevent stale multi-platform binaries 2026-03-05 18:55:22 +09:00
leeguooooo 5e0582cede feat: enforce default session daemon isolation and bump 0.16.3-fork.4 2026-03-05 18:44:52 +09:00
leeguooooo 5970579d7c feat: add parallel mode and idle daemon shutdown 2026-03-05 13:40:34 +09:00
leeguooooo 8880aa2f35 chore(release): 0.16.3-fork.2
- restore extension mode default to headed when headless is unspecified

- keep explicit headless override behavior
2026-03-05 11:44:47 +09:00
leeguooooo f051e72f85 chore(release): bump to 0.16.3-fork.1 2026-03-05 11:20:06 +09:00
leeguooooo 6ae703565c fix(connection): surface daemon startup stderr during launch 2026-03-05 11:18:21 +09:00
layla d56442cf91 Fix dialog dismiss command parsing (#605) 2026-03-05 11:16:14 +09:00
Li Yang dad7be8c77 fix: use reqwest for CDP port discovery instead of broken hand-rolled HTTP client (#619)
reqwest_get_string() was hand-rolling HTTP/1.1 over raw TCP despite reqwest
being an existing dependency. The hand-rolled implementation had two bugs:

1. URL path parsing: url.find('/') matched the first '/' in 'http://',
   producing path '//127.0.0.1:9222/json/version' instead of '/json/version'

2. read_to_end() hangs: Chrome's DevTools HTTP server ignores Connection: close
   and keeps the socket open, so read_to_end() waits for EOF that never comes

This caused 'agent-browser --cdp <port>' to always timeout when AGENT_BROWSER_NATIVE=1.

Fix: replace 49 lines of broken TCP code with reqwest::get(), which was
already in Cargo.toml.
2026-03-05 11:16:14 +09:00
Chris Tate 7921928ec4 headed mode (#607)
* headed mode

* fixes

* fixes

* docs

* fixes

* fixes

* fixes
2026-03-05 11:15:17 +09:00
54 changed files with 6098 additions and 874 deletions
+8
View File
@@ -64,12 +64,20 @@ jobs:
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
workspaces: cli 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 - name: Run Rust tests
run: cargo test --profile ci --manifest-path cli/Cargo.toml run: cargo test --profile ci --manifest-path cli/Cargo.toml
+11
View File
@@ -1,5 +1,16 @@
# agent-browser # 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 ## 0.16.1-fork.5
### Patch Changes ### Patch Changes
+64 -1
View File
@@ -47,11 +47,70 @@ abs install
### Minimal Usage ### Minimal Usage
```bash ```bash
agent-browser start
agent-browser open https://example.com agent-browser open https://example.com
agent-browser snapshot -i agent-browser snapshot -i
agent-browser click @e2 agent-browser click @e2
``` ```
### Parallel AI Runs (Isolated Runtime Channel)
Use `--parallel <name>` to run multiple AI flows concurrently without fighting over the same runtime channel.
```bash
agent-browser --parallel worker-a open https://example.com
agent-browser --parallel worker-b open https://example.org
```
`--parallel` is designed for stateless throughput tasks (navigation, extraction, checks). For authenticated flows, keep using one stable `--session-name`.
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) ### Default: Auto Group Agent Tabs (CDP + Plugin)
```bash ```bash
@@ -117,7 +176,8 @@ flowchart TD
- Project policy forbids: - Project policy forbids:
- `--profile` / `AGENT_BROWSER_PROFILE` - `--profile` / `AGENT_BROWSER_PROFILE`
- `--channel` / `AGENT_BROWSER_CHANNEL` - `--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 ## Principle 2: Multi-Layer Fingerprint Hardening
@@ -233,6 +293,9 @@ flowchart TD
- Prefer `--headed` for high-friction targets. - Prefer `--headed` for high-friction targets.
- Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `default`). - Reuse session state with one stable `--session-name` for continuity (when omitted, it defaults to `default`).
- Use `--parallel <name>` only for stateless parallel workloads where higher throughput matters.
- 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. - Keep locale/timezone consistent with target market.
- For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls. - For challenge-heavy pages, prefer `--wait-until domcontentloaded` on `open`/`navigate` to avoid `load` stalls.
- Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages. - Use `--risk-mode block` in strict pipelines that require explicit operator intervention on verification pages.
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.16.1-fork.4" version = "0.17.0-fork.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.16.1-fork.5" version = "0.17.0-fork.2"
edition = "2021" edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions" description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0" license = "Apache-2.0"
+3 -3
View File
@@ -175,7 +175,7 @@ fn to_snake_case(s: &str) -> String {
// Only insert underscore at transitions from lowercase to uppercase, // Only insert underscore at transitions from lowercase to uppercase,
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m") // or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
let prev_upper = chars[i - 1].is_uppercase(); 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 { if !prev_upper || next_lower {
result.push('_'); result.push('_');
} }
@@ -202,7 +202,7 @@ fn resolve_ref(
// Check if this type actually exists in the referenced domain // Check if this type actually exists in the referenced domain
if domain_types if domain_types
.get(ref_domain) .get(ref_domain)
.map_or(false, |t| t.contains(ref_type)) .is_some_and(|t| t.contains(ref_type))
{ {
format!( format!(
"super::cdp_{}::{}", "super::cdp_{}::{}",
@@ -339,7 +339,7 @@ fn generate_domain(
if variant == "Self" { if variant == "Self" {
variant = "SelfValue".to_string(); 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); variant = format!("V{}", variant);
} }
if seen_variants.insert(variant.clone()) { if seen_variants.insert(variant.clone()) {
+39
View File
@@ -215,6 +215,14 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} }
Ok(nav_cmd) 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" })), "back" => Ok(json!({ "id": id, "action": "back" })),
"forward" => Ok(json!({ "id": id, "action": "forward" })), "forward" => Ok(json!({ "id": id, "action": "forward" })),
"reload" => Ok(json!({ "id": id, "action": "reload" })), "reload" => Ok(json!({ "id": id, "action": "reload" })),
@@ -939,6 +947,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} }
Ok(cmd) 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 { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, valid_options: VALID,
@@ -2053,6 +2068,7 @@ mod tests {
full: false, full: false,
headed: false, headed: false,
debug: false, debug: false,
resident: false,
headers: None, headers: None,
executable_path: None, executable_path: None,
extensions: Vec::new(), extensions: Vec::new(),
@@ -2067,7 +2083,10 @@ mod tests {
allow_file_access: false, allow_file_access: false,
device: None, device: None,
auto_connect: false, auto_connect: false,
native: false,
engine: None,
session_name: None, session_name: None,
parallel: None,
cli_executable_path: false, cli_executable_path: false,
cli_extensions: false, cli_extensions: false,
cli_state: false, cli_state: false,
@@ -2078,6 +2097,8 @@ mod tests {
cli_allow_file_access: false, cli_allow_file_access: false,
cli_annotate: false, cli_annotate: false,
cli_download_path: false, cli_download_path: false,
cli_native: false,
cli_engine: false,
annotate: false, annotate: false,
color_scheme: None, color_scheme: None,
download_path: None, download_path: None,
@@ -2087,6 +2108,8 @@ mod tests {
wait_until: None, wait_until: None,
cli_tab_group: false, cli_tab_group: false,
cli_tab_group_plugin_id: false, cli_tab_group_plugin_id: false,
cli_session_name: false,
cli_resident: false,
} }
} }
@@ -2348,6 +2371,22 @@ mod tests {
assert_eq!(cmd["url"], "https://example.com"); 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] #[test]
fn test_navigate_with_headers() { fn test_navigate_with_headers() {
let mut flags = default_flags(); let mut flags = default_flags();
+507 -101
View File
@@ -4,7 +4,7 @@ use std::env;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
@@ -116,6 +116,30 @@ fn get_pid_path(session: &str) -> PathBuf {
get_socket_dir().join(format!("{}.pid", session)) 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 /// Clean up stale socket and PID files for a session
fn cleanup_stale_files(session: &str) { fn cleanup_stale_files(session: &str) {
// Never delete files for a live daemon. A missing PID file can happen in // 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; 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 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)] fn reap_sessions_for_default_start() {
{ let socket_dir = get_socket_dir();
let socket_path = get_socket_path(session); let entries = match fs::read_dir(&socket_dir) {
let _ = fs::remove_file(&socket_path); 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 expected = expected_daemon_path
{ .canonicalize()
let port_path = get_port_path(session); .unwrap_or_else(|_| expected_daemon_path.to_path_buf());
let _ = fs::remove_file(&port_path); 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)] #[cfg(windows)]
@@ -183,6 +381,8 @@ pub struct DaemonResult {
pub fn ensure_daemon( pub fn ensure_daemon(
session: &str, session: &str,
headed: bool, headed: bool,
// Keep daemon resident and disable idle auto-shutdown.
resident: bool,
executable_path: Option<&str>, executable_path: Option<&str>,
extensions: &[String], extensions: &[String],
args: Option<&str>, args: Option<&str>,
@@ -196,21 +396,46 @@ pub fn ensure_daemon(
device: Option<&str>, device: Option<&str>,
session_name: Option<&str>, session_name: Option<&str>,
debug: bool, debug: bool,
native: bool,
engine: Option<&str>,
download_path: Option<&str>, download_path: Option<&str>,
tab_group: Option<&str>, tab_group: Option<&str>,
tab_group_plugin_id: Option<&str>, tab_group_plugin_id: Option<&str>,
) -> Result<DaemonResult, String> { ) -> 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. // Socket readiness is the source of truth for a usable daemon.
// PID files can be missing/stale under concurrent start/stop races. // PID files can be missing/stale under concurrent start/stop races.
if daemon_ready(session) { if daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again let mut should_reuse = true;
// This handles the race condition where daemon is shutting down if session == "default" {
// (daemon has a 100ms shutdown delay, so we wait longer) should_reuse = validate_default_daemon_identity(&daemon_path);
thread::sleep(Duration::from_millis(150)); }
if daemon_ready(session) {
return Ok(DaemonResult { if should_reuse {
already_running: true, // 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())?; // Keep handle to detect early daemon exit and surface startup errors.
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary) #[allow(unused_assignments)]
let exe_path = exe_path.canonicalize().unwrap_or(exe_path); let mut daemon_child: Option<std::process::Child> = None;
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.")?;
// Spawn daemon as a fully detached background process // Spawn daemon as a fully detached background process
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node"); let mut cmd = if native {
cmd.arg(daemon_path) Command::new(&daemon_path)
.env("AGENT_BROWSER_DAEMON", "1") } else {
.env("AGENT_BROWSER_SESSION", session); 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 { if headed {
cmd.env("AGENT_BROWSER_HEADED", "1"); cmd.env("AGENT_BROWSER_HEADED", "1");
@@ -339,6 +556,9 @@ pub fn ensure_daemon(
if let Some(sn) = session_name { if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn); cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
} }
if let Some(engine) = engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
cmd.env("AGENT_BROWSER_STEALTH", "1"); cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug { if debug {
@@ -363,23 +583,43 @@ pub fn ensure_daemon(
}); });
} }
cmd.stdin(Stdio::null()) daemon_child = Some(
.stdout(Stdio::null()) cmd.stdin(Stdio::null())
.stderr(Stdio::null()); .stdout(Stdio::null())
cmd.spawn() .stderr(Stdio::piped())
.map_err(|e| format!("Failed to start daemon: {}", e))?; .spawn()
.map_err(|e| {
if native {
format!("Failed to start native daemon: {}", e)
} else {
format!("Failed to start daemon: {}", e)
}
})?,
);
} }
#[cfg(windows)] #[cfg(windows)]
{ {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) let mut cmd = if native {
// and automatically quotes arguments containing spaces. Command::new(&daemon_path)
let mut cmd = Command::new("node"); } else {
cmd.arg(daemon_path) // On Windows, call node directly. Command::new handles PATH
.env("AGENT_BROWSER_DAEMON", "1") // resolution (node.exe or node.cmd) and automatically quotes
.env("AGENT_BROWSER_SESSION", session); // 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 { if headed {
cmd.env("AGENT_BROWSER_HEADED", "1"); cmd.env("AGENT_BROWSER_HEADED", "1");
@@ -432,6 +672,9 @@ pub fn ensure_daemon(
if let Some(sn) = session_name { if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn); cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
} }
if let Some(engine) = engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
cmd.env("AGENT_BROWSER_STEALTH", "1"); cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug { if debug {
@@ -451,12 +694,20 @@ pub fn ensure_daemon(
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008; const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) daemon_child = Some(
.stdin(Stdio::null()) cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdout(Stdio::null()) .stdin(Stdio::null())
.stderr(Stdio::null()); .stdout(Stdio::null())
cmd.spawn() .stderr(Stdio::piped())
.map_err(|e| format!("Failed to start daemon: {}", e))?; .spawn()
.map_err(|e| {
if native {
format!("Failed to start native daemon: {}", e)
} else {
format!("Failed to start daemon: {}", e)
}
})?,
);
} }
for _ in 0..50 { for _ in 0..50 {
@@ -465,6 +716,22 @@ pub fn ensure_daemon(
already_running: false, 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)); thread::sleep(Duration::from_millis(100));
} }
@@ -569,45 +836,23 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 fn test_temp_dir(prefix: &str) -> PathBuf {
static ENV_MUTEX: Mutex<()> = Mutex::new(()); let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
/// RAII guard that locks env mutex and restores env vars on drop .unwrap()
struct EnvGuard<'a> { .as_nanos();
_lock: MutexGuard<'a, ()>, env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), nonce))
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),
}
}
}
} }
#[test] #[test]
fn test_get_socket_dir_explicit_override() { fn test_get_socket_dir_explicit_override() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path"); _guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
env::remove_var("XDG_RUNTIME_DIR"); _guard.remove("XDG_RUNTIME_DIR");
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path")); 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() { fn test_get_socket_dir_ignores_empty_socket_dir() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", ""); _guard.set("AGENT_BROWSER_SOCKET_DIR", "");
env::remove_var("XDG_RUNTIME_DIR"); _guard.remove("XDG_RUNTIME_DIR");
assert!(get_socket_dir() assert!(get_socket_dir()
.to_string_lossy() .to_string_lossy()
@@ -628,8 +873,8 @@ mod tests {
fn test_get_socket_dir_xdg_runtime() { fn test_get_socket_dir_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR"); _guard.remove("AGENT_BROWSER_SOCKET_DIR");
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000"); _guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
assert_eq!( assert_eq!(
get_socket_dir(), get_socket_dir(),
@@ -641,8 +886,8 @@ mod tests {
fn test_get_socket_dir_ignores_empty_xdg_runtime() { fn test_get_socket_dir_ignores_empty_xdg_runtime() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::set_var("AGENT_BROWSER_SOCKET_DIR", ""); _guard.set("AGENT_BROWSER_SOCKET_DIR", "");
env::set_var("XDG_RUNTIME_DIR", ""); _guard.set("XDG_RUNTIME_DIR", "");
assert!(get_socket_dir() assert!(get_socket_dir()
.to_string_lossy() .to_string_lossy()
@@ -653,8 +898,8 @@ mod tests {
fn test_get_socket_dir_home_fallback() { fn test_get_socket_dir_home_fallback() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
env::remove_var("AGENT_BROWSER_SOCKET_DIR"); _guard.remove("AGENT_BROWSER_SOCKET_DIR");
env::remove_var("XDG_RUNTIME_DIR"); _guard.remove("XDG_RUNTIME_DIR");
let result = get_socket_dir(); let result = get_socket_dir();
assert!(result.to_string_lossy().ends_with(".agent-browser")); 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 === // === Transient Error Detection Tests ===
#[test] #[test]
+201 -6
View File
@@ -1,4 +1,5 @@
use crate::color; use crate::color;
use crate::validation::is_valid_session_name;
use serde::Deserialize; use serde::Deserialize;
use std::env; use std::env;
use std::fs; use std::fs;
@@ -32,6 +33,8 @@ pub struct Config {
pub allow_file_access: Option<bool>, pub allow_file_access: Option<bool>,
pub cdp: Option<String>, pub cdp: Option<String>,
pub auto_connect: Option<bool>, pub auto_connect: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
pub headers: Option<String>, pub headers: Option<String>,
pub annotate: Option<bool>, pub annotate: Option<bool>,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
@@ -40,6 +43,7 @@ pub struct Config {
pub tab_group_plugin_id: Option<String>, pub tab_group_plugin_id: Option<String>,
pub risk_mode: Option<String>, pub risk_mode: Option<String>,
pub wait_until: Option<String>, pub wait_until: Option<String>,
pub parallel: Option<String>,
} }
impl Config { impl Config {
@@ -70,6 +74,8 @@ impl Config {
allow_file_access: other.allow_file_access.or(self.allow_file_access), allow_file_access: other.allow_file_access.or(self.allow_file_access),
cdp: other.cdp.or(self.cdp), cdp: other.cdp.or(self.cdp),
auto_connect: other.auto_connect.or(self.auto_connect), 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), headers: other.headers.or(self.headers),
annotate: other.annotate.or(self.annotate), annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme), 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), tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
risk_mode: other.risk_mode.or(self.risk_mode), risk_mode: other.risk_mode.or(self.risk_mode),
wait_until: other.wait_until.or(self.wait_until), wait_until: other.wait_until.or(self.wait_until),
parallel: other.parallel.or(self.parallel),
} }
} }
} }
@@ -148,6 +155,8 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--tab-group-plugin-id", "--tab-group-plugin-id",
"--risk-mode", "--risk-mode",
"--wait-until", "--wait-until",
"--parallel",
"--engine",
]; ];
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
@@ -199,6 +208,10 @@ pub struct Flags {
pub full: bool, pub full: bool,
pub headed: bool, pub headed: bool,
pub debug: bool, pub debug: bool,
/// Keep daemon resident and disable idle auto-shutdown.
pub resident: bool,
/// Runtime daemon session channel.
/// Defaults to `default`; when `--parallel <name>` is provided it becomes `parallel-<name>`.
pub session: String, pub session: String,
pub headers: Option<String>, pub headers: Option<String>,
pub executable_path: Option<String>, pub executable_path: Option<String>,
@@ -214,7 +227,12 @@ pub struct Flags {
pub allow_file_access: bool, pub allow_file_access: bool,
pub device: Option<String>, pub device: Option<String>,
pub auto_connect: bool, pub auto_connect: bool,
pub session_name: Option<String>, // Defaults to "default" when unset 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 annotate: bool,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: Option<String>, pub download_path: Option<String>,
@@ -226,6 +244,8 @@ pub struct Flags {
/// Navigation wait strategy passed to navigate/open commands: /// Navigation wait strategy passed to navigate/open commands:
/// `load`, `domcontentloaded`, or `networkidle`. /// `load`, `domcontentloaded`, or `networkidle`.
pub wait_until: Option<String>, pub wait_until: Option<String>,
/// Parallel execution channel name. When set, commands run in an isolated runtime session.
pub parallel: Option<String>,
// Track which launch-time options were explicitly passed via CLI // Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables) // (as opposed to being set only via environment variables)
@@ -239,8 +259,12 @@ pub struct Flags {
pub cli_allow_file_access: bool, pub cli_allow_file_access: bool,
pub cli_annotate: bool, pub cli_annotate: bool,
pub cli_download_path: bool, pub cli_download_path: bool,
pub cli_native: bool,
pub cli_engine: bool,
pub cli_tab_group: bool, pub cli_tab_group: bool,
pub cli_tab_group_plugin_id: bool, pub cli_tab_group_plugin_id: bool,
pub cli_session_name: bool,
pub cli_resident: bool,
} }
pub fn parse_flags(args: &[String]) -> Flags { pub fn parse_flags(args: &[String]) -> Flags {
@@ -273,7 +297,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
Err(_) => config.headed.unwrap_or(true), Err(_) => config.headed.unwrap_or(true),
}, },
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false), debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
// --session is disabled: user-facing CLI always uses one default session. resident: false,
// --session is disabled for users.
// Runtime session defaults to `default`, and can be isolated with `--parallel`.
session: "default".to_string(), session: "default".to_string(),
headers: config.headers, headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH") executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
@@ -298,6 +324,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device), device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT") auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false), || 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") session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok() .ok()
.or(config.session_name), .or(config.session_name),
@@ -321,6 +349,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.risk_mode) .or(config.risk_mode)
.map(|s| s.to_ascii_lowercase()), .map(|s| s.to_ascii_lowercase()),
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()), wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
parallel: env::var("AGENT_BROWSER_PARALLEL").ok().or(config.parallel),
cli_executable_path: false, cli_executable_path: false,
cli_extensions: false, cli_extensions: false,
cli_state: false, cli_state: false,
@@ -331,8 +360,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false, cli_allow_file_access: false,
cli_annotate: false, cli_annotate: false,
cli_download_path: false, cli_download_path: false,
cli_native: false,
cli_engine: false,
cli_tab_group: false, cli_tab_group: false,
cli_tab_group_plugin_id: false, cli_tab_group_plugin_id: false,
cli_session_name: false,
cli_resident: false,
}; };
let mut i = 0; let mut i = 0;
@@ -366,6 +399,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; i += 1;
} }
} }
"--resident" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.resident = val;
flags.cli_resident = true;
if consumed {
i += 1;
}
}
"--headers" => { "--headers" => {
if let Some(h) = args.get(i + 1) { if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone()); flags.headers = Some(h.clone());
@@ -461,9 +502,31 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; 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" => { "--session-name" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
flags.session_name = Some(s.clone()); flags.session_name = Some(s.clone());
flags.cli_session_name = true;
i += 1;
}
}
"--parallel" => {
if let Some(s) = args.get(i + 1) {
flags.parallel = Some(s.clone());
i += 1; i += 1;
} }
} }
@@ -523,9 +586,24 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1; i += 1;
} }
// Keep auth/state continuity stable by default: if no explicit --session-name if let Some(parallel_name) = &flags.parallel {
// is provided, derive it from the default session id. // Validate early so session id derivation cannot introduce unsafe paths.
if flags.session_name.is_none() { if !is_valid_session_name(parallel_name) {
// Keep default session and let main.rs surface a user-facing validation error.
} else {
flags.session = format!("parallel-{}", parallel_name);
}
}
// Parallel mode is for isolated/stateless runs.
// Unless --session-name is explicitly provided on this invocation, disable
// auto save/restore persistence to avoid cross-flow auth leakage.
if flags.parallel.is_some() && !flags.cli_session_name {
flags.session_name = None;
}
// Keep auth/state continuity stable by default for the default runtime session.
if flags.session_name.is_none() && flags.parallel.is_none() {
flags.session_name = Some("default".to_string()); flags.session_name = Some("default".to_string());
} }
@@ -542,9 +620,11 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--full", "--full",
"--headed", "--headed",
"--debug", "--debug",
"--resident",
"--ignore-https-errors", "--ignore-https-errors",
"--allow-file-access", "--allow-file-access",
"--auto-connect", "--auto-connect",
"--native",
"--annotate", "--annotate",
]; ];
// Global flags that always take a value (need to skip the next arg too) // 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", "--tab-group-plugin-id",
"--risk-mode", "--risk-mode",
"--wait-until", "--wait-until",
"--parallel",
"--config", "--config",
"--engine",
]; ];
let mut i = 0; let mut i = 0;
@@ -770,6 +852,34 @@ mod tests {
assert_eq!(flags.session_name.as_deref(), Some("default")); assert_eq!(flags.session_name.as_deref(), Some("default"));
} }
#[test]
fn test_parallel_sets_isolated_runtime_session() {
let flags = parse_flags(&args("--parallel worker_a snapshot"));
assert_eq!(flags.parallel.as_deref(), Some("worker_a"));
assert_eq!(flags.session, "parallel-worker_a");
assert_eq!(flags.session_name, None);
}
#[test]
fn test_parallel_keeps_explicit_session_name() {
let flags = parse_flags(&args(
"--parallel worker_b --session-name keep-state snapshot",
));
assert_eq!(flags.session, "parallel-worker_b");
assert_eq!(flags.session_name.as_deref(), Some("keep-state"));
assert!(flags.cli_session_name);
}
#[test]
fn test_parallel_from_env_sets_runtime_session() {
let _guard = EnvGuard::new(&["AGENT_BROWSER_PARALLEL", "AGENT_BROWSER_SESSION_NAME"]);
env::set_var("AGENT_BROWSER_PARALLEL", "envworker");
env::set_var("AGENT_BROWSER_SESSION_NAME", "persisted");
let flags = parse_flags(&args("snapshot"));
assert_eq!(flags.session, "parallel-envworker");
assert_eq!(flags.session_name, None);
}
#[test] #[test]
fn test_cli_executable_path_tracking() { fn test_cli_executable_path_tracking() {
// When --executable-path is passed via CLI, cli_executable_path should be true // When --executable-path is passed via CLI, cli_executable_path should be true
@@ -805,6 +915,20 @@ mod tests {
assert!(!flags.cli_annotate); assert!(!flags.cli_annotate);
} }
#[test]
fn test_parse_resident_flag() {
let flags = parse_flags(&args("--resident open example.com"));
assert!(flags.resident);
assert!(flags.cli_resident);
}
#[test]
fn test_parse_resident_false() {
let flags = parse_flags(&args("--resident false open example.com"));
assert!(!flags.resident);
assert!(flags.cli_resident);
}
#[test] #[test]
fn test_cli_download_path_tracking() { fn test_cli_download_path_tracking() {
let flags = parse_flags(&args("--download-path /tmp/dl snapshot")); let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
@@ -940,6 +1064,18 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]); assert_eq!(cleaned, vec!["open", "example.com"]);
} }
#[test]
fn test_clean_args_removes_parallel() {
let cleaned = clean_args(&args("--parallel worker_x open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_resident_flag() {
let cleaned = clean_args(&args("--resident open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test] #[test]
fn test_cli_multiple_flags_tracking() { fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args( let flags = parse_flags(&args(
@@ -978,7 +1114,8 @@ mod tests {
"headers": "{\"Auth\":\"token\"}", "headers": "{\"Auth\":\"token\"}",
"tabGroup": "Agent Browser Stealth", "tabGroup": "Agent Browser Stealth",
"tabGroupPluginId": "tab-group-plugin-id", "tabGroupPluginId": "tab-group-plugin-id",
"riskMode": "block" "riskMode": "block",
"parallel": "worker-c"
}"#; }"#;
let config: Config = serde_json::from_str(json).unwrap(); let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true)); assert_eq!(config.headed, Some(true));
@@ -1010,6 +1147,7 @@ mod tests {
Some("tab-group-plugin-id") Some("tab-group-plugin-id")
); );
assert_eq!(config.risk_mode.as_deref(), Some("block")); assert_eq!(config.risk_mode.as_deref(), Some("block"));
assert_eq!(config.parallel.as_deref(), Some("worker-c"));
} }
#[test] #[test]
@@ -1137,6 +1275,12 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]); 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] #[test]
fn test_load_config_with_config_flag() { fn test_load_config_with_config_flag() {
use std::io::Write; use std::io::Write;
@@ -1246,6 +1390,57 @@ mod tests {
assert!(!flags.auto_connect); 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] #[test]
fn test_full_bare_defaults_true() { fn test_full_bare_defaults_true() {
let flags = parse_flags(&args("--full open example.com")); let flags = parse_flags(&args("--full open example.com"));
+114 -118
View File
@@ -3,22 +3,21 @@ mod commands;
mod connection; mod connection;
mod flags; mod flags;
mod install; mod install;
mod native;
mod output; mod output;
#[cfg(test)]
mod test_utils;
mod validation; mod validation;
use serde_json::json; use serde_json::json;
use std::env; use std::env;
use std::fs; use std::net::{SocketAddr, TcpStream};
use std::process::exit; use std::process::exit;
use std::time::Duration;
#[cfg(windows)]
use windows_sys::Win32::Foundation::CloseHandle;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use commands::{gen_id, parse_command, ParseError}; use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command}; use connection::{ensure_daemon, list_live_sessions, send_command, Response};
use flags::{clean_args, parse_flags}; use flags::{clean_args, parse_flags, Flags};
use install::run_install; use install::run_install;
use output::{print_command_help, print_help, print_response, print_version}; 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) { fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str()); let subcommand = args.get(1).map(|s| s.as_str());
match subcommand { match subcommand {
Some("list") => { Some("list") => {
let socket_dir = get_socket_dir(); let sessions = list_live_sessions();
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());
}
}
}
}
}
}
}
if json_mode { if json_mode {
println!( println!(
@@ -129,6 +112,17 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
} }
fn main() { 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 // Ignore SIGPIPE to prevent panic when piping to head/tail
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
@@ -136,8 +130,22 @@ fn main() {
} }
let args: Vec<String> = env::args().skip(1).collect(); 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 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_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V"); 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") { if args.iter().any(|a| a == "--profile") {
let msg = let msg =
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence."; "Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
@@ -273,6 +299,7 @@ fn main() {
let daemon_result = match ensure_daemon( let daemon_result = match ensure_daemon(
&flags.session, &flags.session,
flags.headed, flags.headed,
flags.resident,
flags.executable_path.as_deref(), flags.executable_path.as_deref(),
&flags.extensions, &flags.extensions,
flags.args.as_deref(), flags.args.as_deref(),
@@ -286,6 +313,8 @@ fn main() {
flags.device.as_deref(), flags.device.as_deref(),
flags.session_name.as_deref(), flags.session_name.as_deref(),
flags.debug, flags.debug,
flags.native,
flags.engine.as_deref(),
flags.download_path.as_deref(), flags.download_path.as_deref(),
flags.tab_group.as_deref(), flags.tab_group.as_deref(),
flags.tab_group_plugin_id.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.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"), flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"), 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.then_some("--tab-group"),
flags flags
.cli_tab_group_plugin_id .cli_tab_group_plugin_id
.then_some("--tab-group-plugin-id"), .then_some("--tab-group-plugin-id"),
flags.cli_resident.then_some("--resident"),
] ]
.into_iter() .into_iter()
.flatten() .flatten()
@@ -430,6 +462,9 @@ fn main() {
if let Some(ref dp) = flags.download_path { if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp); 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 { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); launch_cmd["tabGroup"] = json!(tg);
} }
@@ -528,6 +563,9 @@ fn main() {
if let Some(ref dp) = flags.download_path { if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp); 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 { if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg); 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) // Launch headed browser or configure browser options (without CDP or provider)
if (flags.headed if (flags.headed
|| flags.executable_path.is_some() || flags.executable_path.is_some()
@@ -678,10 +648,12 @@ fn main() {
|| flags.allow_file_access || flags.allow_file_access
|| flags.debug || flags.debug
|| flags.color_scheme.is_some() || flags.color_scheme.is_some()
|| flags.download_path.is_some()) || flags.download_path.is_some()
|| flags.engine.is_some())
&& flags.cdp.is_none() && flags.cdp.is_none()
&& flags.provider.is_none() && flags.provider.is_none()
&& !attached_to_existing_browser && !attached_to_existing_browser
&& !can_try_default_cdp
{ {
let mut launch_cmd = json!({ let mut launch_cmd = json!({
"id": gen_id(), "id": gen_id(),
@@ -791,6 +763,18 @@ fn main() {
} }
} }
Err(e) => { 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 { if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e); println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else { } else {
@@ -858,4 +842,16 @@ mod tests {
assert_eq!(result["username"], "user"); assert_eq!(result["username"], "user");
assert_eq!(result["password"], "p@ss:w0rd"); 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
View File
@@ -4,7 +4,7 @@ use tokio::sync::broadcast;
use super::auth; use super::auth;
use super::browser::{BrowserManager, WaitUntil}; use super::browser::{BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions; use super::cdp::chrome::{LaunchOptions, MANAGED_CDP_PORT};
use super::cdp::types::{ use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent, AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent, CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
@@ -167,13 +167,14 @@ impl DaemonState {
if let Ok(te) = if let Ok(te) =
serde_json::from_value::<TargetCreatedEvent>(event.params.clone()) 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() && !te.target_info.url.is_empty()
{ {
let already_tracked = self let already_tracked = self
.browser .browser
.as_ref() .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 { if !already_tracked {
new_targets.push(te); new_targets.push(te);
} }
@@ -443,6 +444,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
session_id: attach.session_id, session_id: attach.session_id,
url: te.target_info.url.clone(), url: te.target_info.url.clone(),
title: te.target_info.title.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 // WebDriver backend: reject unsupported CDP-only actions
if matches!(state.backend_type, BackendType::WebDriver) { if matches!(state.backend_type, BackendType::WebDriver)
if WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action) { && WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&action)
return error_response( {
&id, return error_response(
&format!( &id,
"Action '{}' is not supported on the WebDriver backend", &format!(
action "Action '{}' is not supported on the WebDriver backend",
), action
); ),
} );
} }
let result = match 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> { async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let options = launch_options_from_env(); 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 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?; let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.browser = Some(mgr); state.browser = Some(mgr);
state.subscribe_to_browser_events(); 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 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?; let mgr = BrowserManager::connect_auto().await?;
state.browser = Some(mgr); state.browser = Some(mgr);
state.subscribe_to_browser_events(); state.subscribe_to_browser_events();
@@ -743,18 +753,48 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
return Ok(()); 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.browser = Some(mgr);
state.subscribe_to_browser_events(); state.subscribe_to_browser_events();
try_auto_restore_state(state).await; try_auto_restore_state(state).await;
Ok(()) 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 { fn launch_options_from_env() -> LaunchOptions {
let headed = env::var("AGENT_BROWSER_HEADED") let headed = env::var("AGENT_BROWSER_HEADED")
.map(|v| v == "1" || v == "true") .map(|v| v == "1" || v == "true")
.unwrap_or(false); .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 { LaunchOptions {
headless: !headed, headless: !headed,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
@@ -772,12 +812,7 @@ fn launch_options_from_env() -> LaunchOptions {
.collect() .collect()
}) })
.unwrap_or_default(), .unwrap_or_default(),
extensions: env::var("AGENT_BROWSER_EXTENSIONS").ok().map(|v| { extensions,
v.split([',', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}),
storage_state: env::var("AGENT_BROWSER_STATE").ok(), storage_state: env::var("AGENT_BROWSER_STATE").ok(),
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(), user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok(),
ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS") ignore_https_errors: env::var("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
@@ -785,6 +820,7 @@ fn launch_options_from_env() -> LaunchOptions {
.unwrap_or(false), .unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").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") .get("autoConnect")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .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 // Relaunch logic: check if we can reuse the existing connection
let needs_relaunch = if let Some(ref mgr) = state.browser { let needs_relaunch = if let Some(ref mgr) = state.browser {
let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some(); let has_cdp_arg = cdp_url.is_some() || cdp_port.is_some();
let was_cdp = mgr.is_cdp_connection(); let was_cdp = mgr.is_cdp_connection();
if has_cdp_arg != was_cdp { has_cdp_arg != was_cdp || !mgr.is_connection_alive().await
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
}
} else { } else {
true 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)) .filter_map(|v| v.as_str().map(String::from))
.collect() .collect()
}); });
let profile = cmd.get("profile").and_then(|v| v.as_str()); let profile = cmd.get("profile").and_then(|v| v.as_str());
let storage_state = cmd.get("storageState").and_then(|v| v.as_str()); let storage_state = cmd.get("storageState").and_then(|v| v.as_str());
let allow_file_access = cmd 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 { 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(); state.subscribe_to_browser_events();
return Ok(json!({ "launched": true })); return Ok(json!({ "launched": true }));
} }
@@ -987,6 +1036,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.get("downloadPath") .get("downloadPath")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
remote_debugging_port: None,
}; };
if let Some(ref domains) = cmd 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.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(); state.subscribe_to_browser_events();
if let Some(ref filter) = state.domain_filter { 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(), session_id: new_session_id.clone(),
url: nav_url.clone(), url: nav_url.clone(),
title: String::new(), title: String::new(),
target_type: "page".to_string(),
}); });
// Navigate to URL // 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> { async fn handle_dialog(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; 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()); let prompt_text = cmd.get("promptText").and_then(|v| v.as_str());
mgr.handle_dialog(accept, prompt_text).await?; 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)) .send_command_no_params("Page.getFrameTree", Some(&session_id))
.await?; .await?;
fn find_frame( fn find_frame(tree: &Value, name: Option<&str>, url: Option<&str>) -> Option<String> {
tree: &Value,
selector: Option<&str>,
name: Option<&str>,
url: Option<&str>,
) -> Option<String> {
let frame = tree.get("frame")?; let frame = tree.get("frame")?;
let frame_name = frame.get("name").and_then(|v| v.as_str()).unwrap_or(""); 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(""); 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()) { if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
for child in children { 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); 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 result = mgr.evaluate(&js, None).await?;
let frame_name = result.as_str().ok_or("Could not find frame for selector")?; 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); state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": frame_name })); 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"); let label = name.or(url).unwrap_or("frame");
state.active_frame_id = Some(frame_id); state.active_frame_id = Some(frame_id);
return Ok(json!({ "frame": label })); return Ok(json!({ "frame": label }));
@@ -4000,14 +4051,13 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Valu
Ok(Ok(event)) => { Ok(Ok(event)) => {
if event.method == "Page.downloadProgress" if event.method == "Page.downloadProgress"
&& event.session_id.as_deref() == Some(&session_id) && 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
let path = cmd .get("path")
.get("path") .and_then(|v| v.as_str())
.and_then(|v| v.as_str()) .unwrap_or("download");
.unwrap_or("download"); return Ok(json!({ "path": path }));
return Ok(json!({ "path": path }));
}
} }
} }
Ok(Err(_)) => return Err("Event stream closed".to_string()), 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, session_id: attach.session_id,
url: "about:blank".to_string(), url: "about:blank".to_string(),
title: String::new(), title: String::new(),
target_type: "page".to_string(),
}); });
if let Some(viewport) = cmd.get("viewport") { if let Some(viewport) = cmd.get("viewport") {
@@ -5124,6 +5175,39 @@ fn error_response(id: &str, error: &str) -> Value {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_success_response_structure() { fn test_success_response_structure() {
@@ -5160,6 +5244,30 @@ mod tests {
assert!(!opts.allow_file_access); 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] #[tokio::test]
async fn test_execute_unknown_command() { async fn test_execute_unknown_command() {
let mut state = DaemonState::new(); let mut state = DaemonState::new();
@@ -5209,6 +5317,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_credentials_roundtrip_via_actions() { async fn test_credentials_roundtrip_via_actions() {
let _key_guard = TestKeyGuard::new();
let mut state = DaemonState::new(); let mut state = DaemonState::new();
let set_cmd = json!({ let set_cmd = json!({
+310 -71
View File
@@ -1,11 +1,12 @@
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm}; use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthProfile { pub struct AuthProfile {
pub name: String, pub name: String,
pub url: String, pub url: String,
@@ -17,6 +18,10 @@ pub struct AuthProfile {
pub password_selector: Option<String>, pub password_selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub submit_selector: Option<String>, 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 // 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)) get_auth_dir().join(format!("{}.json", name))
} }
fn derive_encryption_key() -> Vec<u8> { const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
let hostname = std::env::var("HOSTNAME") const KEY_FILE_NAME: &str = ".encryption-key";
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| { fn get_agent_browser_dir() -> PathBuf {
#[cfg(unix)] if let Some(home) = dirs::home_dir() {
{ home.join(".agent-browser")
let mut buf = [0u8; 256]; } else {
let len = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) }; std::env::temp_dir().join("agent-browser")
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()
} }
fn encrypt_profile(profile: &AuthProfile) -> Result<Vec<u8>, String> { fn get_key_file_path() -> PathBuf {
let key = derive_encryption_key(); 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 = let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?; Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
let plaintext = serde_json::to_string(profile) let plaintext = serde_json::to_string(profile)
.map_err(|e| format!("Failed to serialize profile: {}", e))?; .map_err(|e| format!("Failed to serialize profile: {}", e))?;
let mut nonce = [0u8; 12]; let mut iv = [0u8; 12];
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?; getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
let ciphertext = cipher
.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext.as_bytes()) // 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))?; .map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len()); let tag_offset = encrypted.len() - 16;
result.extend_from_slice(&nonce); let ciphertext = &encrypted[..tag_offset];
result.extend_from_slice(&ciphertext); let auth_tag = &encrypted[tag_offset..];
Ok(result)
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> { fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
if data.len() < 13 { let text = std::str::from_utf8(data).map_err(|_| {
return Err("Encrypted data too short".to_string()); "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(); // Fallback: try as plain unencrypted JSON profile
let cipher = serde_json::from_str::<AuthProfile>(text)
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?; .map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
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))
} }
fn save_profile(profile: &AuthProfile) -> Result<(), String> { fn save_profile(profile: &AuthProfile) -> Result<(), String> {
let dir = get_auth_dir(); 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); 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> { fn load_profile(name: &str) -> Result<AuthProfile, String> {
@@ -147,6 +275,8 @@ pub fn credentials_set(
username_selector: None, username_selector: None,
password_selector: None, password_selector: None,
submit_selector: None, submit_selector: None,
created_at: None,
last_login_at: None,
}; };
save_profile(&profile)?; save_profile(&profile)?;
Ok(json!({ "saved": name })) Ok(json!({ "saved": name }))
@@ -170,6 +300,8 @@ pub fn auth_save(
username_selector: username_selector.map(String::from), username_selector: username_selector.map(String::from),
password_selector: password_selector.map(String::from), password_selector: password_selector.map(String::from),
submit_selector: submit_selector.map(String::from), submit_selector: submit_selector.map(String::from),
created_at: None,
last_login_at: None,
}; };
save_profile(&profile)?; save_profile(&profile)?;
Ok(json!({ "saved": name })) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_validate_profile_name() { fn test_validate_profile_name() {
assert!(validate_profile_name("github").is_ok()); assert!(validate_profile_name("github").is_ok());
@@ -277,6 +426,8 @@ mod tests {
username_selector: None, username_selector: None,
password_selector: None, password_selector: None,
submit_selector: Some("button[type=submit]".to_string()), submit_selector: Some("button[type=submit]".to_string()),
created_at: None,
last_login_at: None,
}; };
let json = serde_json::to_string(&profile).unwrap(); let json = serde_json::to_string(&profile).unwrap();
let parsed: AuthProfile = serde_json::from_str(&json).unwrap(); let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
@@ -290,26 +441,114 @@ mod tests {
#[test] #[test]
fn test_encrypt_decrypt_roundtrip() { fn test_encrypt_decrypt_roundtrip() {
let profile = AuthProfile { with_test_key(|| {
name: "roundtrip".to_string(), let profile = AuthProfile {
url: "https://example.com".to_string(), name: "roundtrip".to_string(),
username: "user".to_string(), url: "https://example.com".to_string(),
password: "s3cret!".to_string(), username: "user".to_string(),
username_selector: None, password: "s3cret!".to_string(),
password_selector: None, username_selector: None,
submit_selector: None, password_selector: None,
}; submit_selector: None,
let encrypted = encrypt_profile(&profile).unwrap(); created_at: None,
let decrypted = decrypt_profile(&encrypted).unwrap(); last_login_at: None,
assert_eq!(decrypted.name, "roundtrip"); };
assert_eq!(decrypted.password, "s3cret!"); 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] #[test]
fn test_derive_encryption_key_is_stable() { fn test_get_encryption_key_from_env() {
let k1 = derive_encryption_key(); with_test_key(|| {
let k2 = derive_encryption_key(); let key = get_encryption_key().unwrap();
assert_eq!(k1, k2); assert_eq!(key.len(), 32);
assert_eq!(k1.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
View File
@@ -2,11 +2,14 @@ use serde_json::{json, Value};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use super::cdp::chrome::{ 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::client::CdpClient;
use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, LightpandaProcess};
use super::cdp::types::*; use super::cdp::types::*;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -55,6 +58,34 @@ pub fn validate_launch_options(
Ok(()) 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. /// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String { pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase(); let lower = error.to_lowercase();
@@ -80,12 +111,18 @@ pub fn to_ai_friendly_error(error: &str) -> String {
error.to_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)] #[derive(Debug, Clone)]
pub struct PageInfo { pub struct PageInfo {
pub target_id: String, pub target_id: String,
pub session_id: String, pub session_id: String,
pub url: String, pub url: String,
pub title: String, pub title: String,
pub target_type: String,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -105,37 +142,74 @@ impl WaitUntil {
} }
} }
pub enum BrowserProcess {
Chrome(ChromeProcess),
Lightpanda(LightpandaProcess),
}
pub struct BrowserManager { pub struct BrowserManager {
pub client: CdpClient, pub client: CdpClient,
chrome_process: Option<ChromeProcess>, browser_process: Option<BrowserProcess>,
cdp_connection: bool,
pages: Vec<PageInfo>, pages: Vec<PageInfo>,
active_page_index: usize, active_page_index: usize,
default_timeout_ms: u64, default_timeout_ms: u64,
} }
impl BrowserManager { impl BrowserManager {
pub async fn launch(options: LaunchOptions) -> Result<Self, String> { pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
validate_launch_options( let engine = engine.unwrap_or("chrome");
options.extensions.as_deref(),
false, match engine {
options.profile.as_deref(), "chrome" => validate_launch_options(
options.storage_state.as_deref(), options.extensions.as_deref(),
options.allow_file_access, false,
options.executable_path.as_deref(), 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 ignore_https_errors = options.ignore_https_errors;
let user_agent = options.user_agent.clone(); let user_agent = options.user_agent.clone();
let color_scheme = options.color_scheme.clone(); let color_scheme = options.color_scheme.clone();
let download_path = options.download_path.clone(); let download_path = options.download_path.clone();
let chrome = launch_chrome(&options)?; let (ws_url, process) = match engine {
let ws_url = chrome.ws_url.clone(); "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 client = CdpClient::connect(&ws_url).await?;
let mut manager = Self { let mut manager = Self {
client, client,
chrome_process: Some(chrome), browser_process: Some(process),
cdp_connection: false,
pages: Vec::new(), pages: Vec::new(),
active_page_index: 0, active_page_index: 0,
default_timeout_ms: 25_000, default_timeout_ms: 25_000,
@@ -197,7 +271,32 @@ impl BrowserManager {
let client = CdpClient::connect(&ws_url).await?; let client = CdpClient::connect(&ws_url).await?;
let mut manager = Self { let mut manager = Self {
client, 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(), pages: Vec::new(),
active_page_index: 0, active_page_index: 0,
default_timeout_ms: 10_000, default_timeout_ms: 10_000,
@@ -212,6 +311,50 @@ impl BrowserManager {
Self::connect_cdp(&ws_url).await 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> { async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
self.client self.client
.send_command_typed::<_, Value>( .send_command_typed::<_, Value>(
@@ -229,43 +372,15 @@ impl BrowserManager {
let page_targets: Vec<TargetInfo> = result let page_targets: Vec<TargetInfo> = result
.target_infos .target_infos
.into_iter() .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(); .collect();
if page_targets.is_empty() { if page_targets.is_empty() {
// Create a new tab self.create_and_attach_blank_page().await?;
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?;
} else { } else {
let mut attached_pages = Vec::new();
for target in &page_targets { for target in &page_targets {
let attach_result: AttachToTargetResult = self let attach_result: AttachToTargetResult = self
.client .client
@@ -279,17 +394,50 @@ impl BrowserManager {
) )
.await?; .await?;
self.pages.push(PageInfo { let page_info = PageInfo {
target_id: target.target_id.clone(), target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(), session_id: attach_result.session_id.clone(),
url: target.url.clone(), url: target.url.clone(),
title: target.title.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; if attached_pages.is_empty() {
let session_id = self.pages[0].session_id.clone(); self.create_and_attach_blank_page().await?;
self.enable_domains(&session_id).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(()) Ok(())
@@ -321,6 +469,7 @@ impl BrowserManager {
pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> { pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
let session_id = self.active_session_id()?.to_string(); let session_id = self.active_session_id()?.to_string();
let rx = self.client.subscribe();
let nav_result: PageNavigateResult = self let nav_result: PageNavigateResult = self
.client .client
@@ -338,7 +487,7 @@ impl BrowserManager {
return Err(format!("Navigation failed: {}", error_text)); 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 page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
let title = self.get_title().await.unwrap_or_default(); let title = self.get_title().await.unwrap_or_default();
@@ -355,14 +504,14 @@ impl BrowserManager {
&self, &self,
wait_until: WaitUntil, wait_until: WaitUntil,
session_id: &str, session_id: &str,
mut rx: tokio::sync::broadcast::Receiver<CdpEvent>,
) -> Result<(), String> { ) -> Result<(), String> {
let event_name = match wait_until { let event_name = match wait_until {
WaitUntil::Load => "Page.loadEventFired", WaitUntil::Load => "Page.loadEventFired",
WaitUntil::DomContentLoaded => "Page.domContentEventFired", 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); let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
tokio::time::timeout(timeout, async { tokio::time::timeout(timeout, async {
@@ -377,8 +526,11 @@ impl BrowserManager {
.map_err(|_| format!("Timeout waiting for {}", event_name))? .map_err(|_| format!("Timeout waiting for {}", event_name))?
} }
async fn wait_for_network_idle(&self, session_id: &str) -> Result<(), String> { async fn wait_for_network_idle(
let mut rx = self.client.subscribe(); &self,
session_id: &str,
mut rx: tokio::sync::broadcast::Receiver<CdpEvent>,
) -> Result<(), String> {
let pending = Arc::new(Mutex::new(HashSet::<String>::new())); let pending = Arc::new(Mutex::new(HashSet::<String>::new()));
let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms);
@@ -497,7 +649,8 @@ impl BrowserManager {
wait_until: WaitUntil, wait_until: WaitUntil,
session_id: &str, session_id: &str,
) -> Result<(), String> { ) -> 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> { pub async fn close(&mut self) -> Result<(), String> {
@@ -507,9 +660,13 @@ impl BrowserManager {
.send_command_no_params("Browser.close", None) .send_command_no_params("Browser.close", None)
.await; .await;
// Kill Chrome process if we own it if let Some(process) = self.browser_process.take() {
if let Some(ref mut chrome) = self.chrome_process { let timeout = std::time::Duration::from_secs(5);
chrome.kill(); 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(()) Ok(())
@@ -538,7 +695,7 @@ impl BrowserManager {
/// Returns true if this manager was connected via CDP (as opposed to local launch). /// Returns true if this manager was connected via CDP (as opposed to local launch).
pub fn is_cdp_connection(&self) -> bool { 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 /// 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(), session_id: attach_result.session_id.clone(),
url: "about:blank".to_string(), url: "about:blank".to_string(),
title: String::new(), title: String::new(),
target_type: "page".to_string(),
}); });
self.active_page_index = 0; self.active_page_index = 0;
self.enable_domains(&attach_result.session_id).await?; self.enable_domains(&attach_result.session_id).await?;
@@ -608,6 +766,7 @@ impl BrowserManager {
"index": i, "index": i,
"title": p.title, "title": p.title,
"url": p.url, "url": p.url,
"type": p.target_type,
"active": i == self.active_page_index, "active": i == self.active_page_index,
}) })
}) })
@@ -648,6 +807,7 @@ impl BrowserManager {
session_id: attach.session_id, session_id: attach.session_id,
url: target_url.to_string(), url: target_url.to_string(),
title: String::new(), title: String::new(),
target_type: "page".to_string(),
}); });
self.active_page_index = index; self.active_page_index = index;
@@ -1065,6 +1225,30 @@ mod tests {
assert!(validate_launch_options(None, false, None, None, false, None,).is_ok()); 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] #[test]
fn test_to_ai_friendly_error_strict_mode() { fn test_to_ai_friendly_error_strict_mode() {
assert_eq!( assert_eq!(
@@ -1110,4 +1294,15 @@ mod tests {
let msg = "Some custom error message"; let msg = "Some custom error message";
assert_eq!(to_ai_friendly_error(msg), msg); 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
View File
@@ -8,6 +8,7 @@ use super::types::BrowserVersionInfo;
pub struct ChromeProcess { pub struct ChromeProcess {
child: Child, child: Child,
pub ws_url: String, pub ws_url: String,
temp_user_data_dir: Option<PathBuf>,
} }
impl ChromeProcess { impl ChromeProcess {
@@ -15,11 +16,46 @@ impl ChromeProcess {
let _ = self.child.kill(); let _ = self.child.kill();
let _ = self.child.wait(); 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 { impl Drop for ChromeProcess {
fn drop(&mut self) { fn drop(&mut self) {
self.kill(); 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 ignore_https_errors: bool,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: Option<String>, pub download_path: Option<String>,
pub remote_debugging_port: Option<u16>,
} }
impl Default for LaunchOptions { impl Default for LaunchOptions {
@@ -55,20 +92,21 @@ impl Default for LaunchOptions {
ignore_https_errors: false, ignore_https_errors: false,
color_scheme: None, color_scheme: None,
download_path: None, download_path: None,
remote_debugging_port: None,
} }
} }
} }
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> { struct ChromeArgs {
let chrome_path = match &options.executable_path { args: Vec<String>,
Some(p) => PathBuf::from(p), temp_user_data_dir: Option<PathBuf>,
None => { }
find_chrome().ok_or("Chrome not found. Install Chrome or use --executable-path.")?
}
};
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let remote_debugging_port = options.remote_debugging_port.unwrap_or(0);
let mut args = vec![ 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-first-run".to_string(),
"--no-default-browser-check".to_string(), "--no-default-browser-check".to_string(),
"--disable-background-networking".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-popup-blocking".to_string(),
"--disable-prompt-on-repost".to_string(), "--disable-prompt-on-repost".to_string(),
"--disable-sync".to_string(), "--disable-sync".to_string(),
"--disable-features=Translate".to_string(),
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(), "--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
"--metrics-recording-only".to_string(), "--metrics-recording-only".to_string(),
"--password-store=basic".to_string(), "--password-store=basic".to_string(),
"--use-mock-keychain".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()); 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)); 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); let expanded = expand_tilde(profile);
args.push(format!("--user-data-dir={}", expanded)); 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 { if options.allow_file_access {
args.push("--allow-file-access-from-files".to_string()); 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 let has_window_size = options
.args .args
.iter() .iter()
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size=")); .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()); 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()); 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) let mut child = Command::new(&chrome_path)
.args(&args) .args(&args)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .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 let stderr = child.stderr.take().ok_or_else(|| {
.stderr let _ = child.kill();
.take() cleanup_temp_dir(&temp_user_data_dir);
.ok_or("Failed to capture Chrome stderr")?; "Failed to capture Chrome stderr".to_string()
})?;
let reader = BufReader::new(stderr); 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> { 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> { async fn reqwest_get_string(url: &str) -> Result<String, String> {
let client = tokio::net::TcpStream::connect( let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
url.strip_prefix("http://") resp.text().await.map_err(|e| e.to_string())
.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)
} }
pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, 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 // Generic container detection: cgroup contains docker/kubepods/lxc
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") { if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
if cgroup.contains("docker") if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
|| cgroup.contains("kubepods")
|| cgroup.contains("lxc")
{
return true; return true;
} }
} }
@@ -559,6 +643,7 @@ fn expand_tilde(path: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::test_utils::EnvGuard;
#[test] #[test]
fn test_find_chrome_returns_some_on_host() { fn test_find_chrome_returns_some_on_host() {
@@ -616,20 +701,195 @@ mod tests {
#[test] #[test]
fn test_chrome_launch_error_generic() { fn test_chrome_launch_error_generic() {
let lines = vec![ let lines = vec!["info line".to_string(), "another info line".to_string()];
"info line".to_string(),
"another info line".to_string(),
];
let msg = chrome_launch_error("Chrome exited", &lines); let msg = chrome_launch_error("Chrome exited", &lines);
assert!(msg.contains("last 2 lines")); assert!(msg.contains("last 2 lines"));
} }
#[test] #[test]
fn test_find_playwright_chromium_nonexistent() { fn test_find_playwright_chromium_nonexistent() {
// With no Playwright cache, should return None let _guard = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
std::env::set_var("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path"); _guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let result = find_playwright_chromium(); let result = find_playwright_chromium();
std::env::remove_var("PLAYWRIGHT_BROWSERS_PATH");
assert!(result.is_none()); 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");
}
} }
+271
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod chrome; pub mod chrome;
pub mod client; pub mod client;
pub mod lightpanda;
pub mod types; pub mod types;
+1
View File
@@ -532,6 +532,7 @@ pub struct BrowserVersionInfo {
/// Chromium source) into `cli/cdp-protocol/` and rebuild. /// Chromium source) into `cli/cdp-protocol/` and rebuild.
/// ///
/// Usage: `use super::cdp::types::generated::cdp_page::*;` /// Usage: `use super::cdp::types::generated::cdp_page::*;`
#[allow(clippy::upper_case_acronyms)]
pub mod generated { pub mod generated {
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs")); include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
} }
+5 -7
View File
@@ -56,13 +56,11 @@ pub async fn set_cookies(
.into_iter() .into_iter()
.map(|mut c| { .map(|mut c| {
// Auto-fill url if no domain/path/url provided // Auto-fill url if no domain/path/url provided
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() { if c.get("url").is_none() && c.get("domain").is_none() {
c.as_object_mut().map(|m| { if let Some(url) = current_url {
m.insert( c.as_object_mut()
"url".to_string(), .map(|m| m.insert("url".to_string(), Value::String(url.to_string())));
Value::String(current_url.unwrap().to_string()), }
)
});
} }
c c
}) })
+79 -8
View File
@@ -1,16 +1,22 @@
use serde_json::Value; use serde_json::{json, Value};
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process; use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal; use tokio::signal;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio::time::{Duration, Instant};
use super::actions::{execute_command, DaemonState}; use super::actions::{execute_command, DaemonState};
use super::state; use super::state;
const IDLE_SHUTDOWN_SECS: u64 = 600;
pub async fn run_daemon(session: &str) { pub async fn run_daemon(session: &str) {
let resident_mode = env::args().any(|arg| arg == "--resident");
let socket_dir = get_daemon_socket_dir(); let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() { if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir); let _ = fs::create_dir_all(&socket_dir);
@@ -18,6 +24,16 @@ pub async fn run_daemon(session: &str) {
let pid_path = socket_dir.join(format!("{}.pid", session)); let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string()); 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)); 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(&socket_path);
let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&meta_path);
let stream_path = socket_dir.join(format!("{}.stream", session)); let stream_path = socket_dir.join(format!("{}.stream", session));
let _ = fs::remove_file(&stream_path); let _ = fs::remove_file(&stream_path);
@@ -47,7 +64,11 @@ pub async fn run_daemon(session: &str) {
} }
#[cfg(unix)] #[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> { async fn run_socket_server(
socket_path: &PathBuf,
_session: &str,
resident_mode: bool,
) -> Result<(), String> {
use tokio::net::UnixListener; use tokio::net::UnixListener;
let listener = let listener =
@@ -55,6 +76,9 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
loop { loop {
tokio::select! { tokio::select! {
@@ -62,8 +86,10 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
match accept_result { match accept_result {
Ok((stream, _)) => { Ok((stream, _)) => {
let state = state.clone(); let state = state.clone();
let activity_tx = activity_tx.clone();
let active_commands = active_commands.clone();
tokio::spawn(async move { tokio::spawn(async move {
handle_connection(stream, state).await; handle_connection(stream, state, activity_tx, active_commands).await;
}); });
} }
Err(e) => { Err(e) => {
@@ -71,6 +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() => { _ = shutdown_signal() => {
let mut s = state.lock().await; let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser { if let Some(ref mut mgr) = s.browser {
@@ -85,7 +124,11 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
} }
#[cfg(windows)] #[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> { async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
resident_mode: bool,
) -> Result<(), String> {
use tokio::net::TcpListener; use tokio::net::TcpListener;
let port = get_port_for_session(session); let port = get_port_for_session(session);
@@ -99,6 +142,9 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new())); std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let active_commands = std::sync::Arc::new(AtomicUsize::new(0));
let (activity_tx, mut activity_rx) = unbounded_channel::<()>();
let mut idle_deadline = Instant::now() + Duration::from_secs(IDLE_SHUTDOWN_SECS);
loop { loop {
tokio::select! { tokio::select! {
@@ -106,8 +152,10 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
match accept_result { match accept_result {
Ok((stream, _)) => { Ok((stream, _)) => {
let state = state.clone(); let state = state.clone();
let activity_tx = activity_tx.clone();
let active_commands = active_commands.clone();
tokio::spawn(async move { tokio::spawn(async move {
handle_connection(stream, state).await; handle_connection(stream, state, activity_tx, active_commands).await;
}); });
} }
Err(e) => { Err(e) => {
@@ -115,6 +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() => { _ = shutdown_signal() => {
let mut s = state.lock().await; let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser { if let Some(ref mut mgr) = s.browser {
@@ -129,8 +191,12 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
Ok(()) Ok(())
} }
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>) async fn handle_connection<S>(
where 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, S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{ {
let (reader, mut writer) = tokio::io::split(stream); 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 is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let _ = activity_tx.send(());
active_commands.fetch_add(1, Ordering::SeqCst);
let response = { let response = {
let mut s = state.lock().await; let mut s = state.lock().await;
@@ -175,8 +243,11 @@ where
let mut resp = serde_json::to_string(&response).unwrap_or_default(); let mut resp = serde_json::to_string(&response).unwrap_or_default();
resp.push('\n'); resp.push('\n');
if writer.write_all(resp.as_bytes()).await.is_err() { if writer.write_all(resp.as_bytes()).await.is_err() {
active_commands.fetch_sub(1, Ordering::SeqCst);
break; break;
} }
active_commands.fetch_sub(1, Ordering::SeqCst);
let _ = activity_tx.send(());
if is_close { if is_close {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
+114
View File
@@ -566,6 +566,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap(); let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 1); assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0]["active"], true); assert_eq!(tabs[0]["active"], true);
assert_eq!(tabs[0]["type"], "page");
// Open new tab // Open new tab
let resp = execute_command( let resp = execute_command(
@@ -582,6 +583,7 @@ async fn e2e_tabs() {
let tabs = get_data(&resp)["tabs"].as_array().unwrap(); let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2); assert_eq!(tabs.len(), 2);
assert_eq!(tabs[1]["active"], true); assert_eq!(tabs[1]["active"], true);
assert_eq!(tabs[1]["type"], "page");
// Switch to first tab // Switch to first tab
let resp = execute_command( 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; let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp); 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);
}
+44 -1
View File
@@ -9,6 +9,38 @@ use serde_json::{json, Value};
use super::actions::{execute_command, DaemonState}; 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. /// All documented action names that should be implemented.
const DOCUMENTED_ACTIONS: &[&str] = &[ const DOCUMENTED_ACTIONS: &[&str] = &[
"launch", "launch",
@@ -342,13 +374,22 @@ fn minimal_command(action: &str, id: &str) -> Value {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tokio::test] #[tokio::test]
#[ignore]
async fn test_all_documented_actions_are_handled() { async fn test_all_documented_actions_are_handled() {
let mut state = DaemonState::new(); let mut state = DaemonState::new();
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() { for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
let id = format!("parity-{}", i); let id = format!("parity-{}", i);
let cmd = minimal_command(action, &id); 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!( assert!(
result.get("id").is_some(), result.get("id").is_some(),
@@ -424,6 +465,7 @@ async fn test_credentials_list_without_browser() {
#[tokio::test] #[tokio::test]
async fn test_auth_profile_name_validation() { async fn test_auth_profile_name_validation() {
use super::auth; use super::auth;
let _key_guard = TestKeyGuard::new();
let valid = auth::credentials_set("valid-name_123", "u", "p", None); let valid = auth::credentials_set("valid-name_123", "u", "p", None);
assert!(valid.is_ok()); assert!(valid.is_ok());
let invalid = auth::credentials_set("invalid/name", "u", "p", None); let invalid = auth::credentials_set("invalid/name", "u", "p", None);
@@ -439,6 +481,7 @@ async fn test_auth_profile_name_validation() {
#[tokio::test] #[tokio::test]
async fn test_auth_save_and_show() { async fn test_auth_save_and_show() {
use super::auth; use super::auth;
let _key_guard = TestKeyGuard::new();
let result = auth::auth_save( let result = auth::auth_save(
"parity-roundtrip", "parity-roundtrip",
"https://example.com", "https://example.com",
+3 -2
View File
@@ -135,6 +135,7 @@ impl ActionPolicy {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::test_utils::EnvGuard;
#[test] #[test]
fn test_policy_allow_whitelist() { fn test_policy_allow_whitelist() {
@@ -205,12 +206,12 @@ mod tests {
#[test] #[test]
fn test_confirm_actions_from_env() { 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(); let ca = ConfirmActions::from_env().unwrap();
assert!(ca.requires_confirmation("navigate")); assert!(ca.requires_confirmation("navigate"));
assert!(ca.requires_confirmation("click")); assert!(ca.requires_confirmation("click"));
assert!(ca.requires_confirmation("fill")); assert!(ca.requires_confirmation("fill"));
assert!(!ca.requires_confirmation("screenshot")); assert!(!ca.requires_confirmation("screenshot"));
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
} }
} }
+2 -14
View File
@@ -65,6 +65,7 @@ const STRUCTURAL_ROLES: &[&str] = &[
"RootWebArea", "RootWebArea",
]; ];
#[derive(Default)]
pub struct SnapshotOptions { pub struct SnapshotOptions {
pub selector: Option<String>, pub selector: Option<String>,
pub interactive: bool, pub interactive: bool,
@@ -73,18 +74,6 @@ pub struct SnapshotOptions {
pub cursor: bool, pub cursor: bool,
} }
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
selector: None,
interactive: false,
compact: false,
depth: None,
cursor: false,
}
}
}
struct TreeNode { struct TreeNode {
role: String, role: String,
name: String, name: String,
@@ -364,8 +353,7 @@ async fn find_cursor_interactive_elements(
let escaped = text let escaped = text
.replace('\\', "\\\\") .replace('\\', "\\\\")
.replace('"', "\\\"") .replace('"', "\\\"")
.replace('\n', " ") .replace(['\n', '\r'], " ");
.replace('\r', " ");
lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped)); lines.push(format!("[ref={}] ({}) \"{}\"", ref_id, kind, escaped));
} }
+1 -1
View File
@@ -467,7 +467,7 @@ pub fn find_auto_state_file(session_name: &str) -> Option<String> {
.ok() .ok()
.and_then(|m| m.modified().ok()) .and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH); .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)); best_path = Some((path.to_string_lossy().to_string(), modified));
} }
} }
+51 -11
View File
@@ -435,6 +435,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
} }
return; return;
} }
// Launch/start
if data.get("launched").is_some() {
let label = "Browser ready";
println!("{} {}", color::success_indicator(), label);
return;
}
// Closed (browser or tab) // Closed (browser or tab)
if data.get("closed").is_some() { if data.get("closed").is_some() {
let label = match action { let label = match action {
@@ -884,6 +890,24 @@ Examples:
agent-browser open localhost:3000 agent-browser open localhost:3000
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}' agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
# ^ Headers only sent to api.example.com, not other domains # ^ 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" => { "back" => {
@@ -2076,9 +2100,11 @@ Operations:
Automatic State Persistence: Automatic State Persistence:
Use --session-name to auto-save/restore state across restarts. Use --session-name to auto-save/restore state across restarts.
If omitted, it defaults to "default": If omitted in default runtime mode, it defaults to "default":
agent-browser --session-name myapp open https://example.com agent-browser --session-name myapp open https://example.com
Or set AGENT_BROWSER_SESSION_NAME environment variable. Or set AGENT_BROWSER_SESSION_NAME environment variable.
Note: with --parallel <name>, persistence is disabled by default unless
--session-name is explicitly passed on the same command.
State Encryption: State Encryption:
Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption. Set AGENT_BROWSER_ENCRYPTION_KEY (64-char hex) for AES-256-GCM encryption.
@@ -2105,7 +2131,7 @@ agent-browser session - Manage sessions
Usage: agent-browser session [operation] Usage: agent-browser session [operation]
Show the current fixed session and active daemon state. Show the current runtime session and active daemon state.
Operations: Operations:
(none) Show current session name (none) Show current session name
@@ -2337,6 +2363,7 @@ Aliases: agent-browser, agent-browser-stealth, abs
Core Commands: Core Commands:
open <url> Navigate to URL open <url> Navigate to URL
start Start managed browser on localhost:9333
click <sel> Click element (or @ref) click <sel> Click element (or @ref)
dblclick <sel> Double-click element dblclick <sel> Double-click element
type <sel> <text> [--delay <ms>] Type into element type <sel> <text> [--delay <ms>] Type into element
@@ -2423,7 +2450,7 @@ Confirmation:
Sessions: Sessions:
session Show current session name session Show current session name
session list List active sessions session list List active sessions (stale entries are auto-cleaned)
Setup: Setup:
install Install browser binaries install Install browser binaries
@@ -2437,7 +2464,7 @@ Snapshot Options:
-s, --selector <sel> Scope to CSS selector -s, --selector <sel> Scope to CSS selector
Options: Options:
--session <name> Ignored (single default session only) --session <name> Ignored (runtime uses default session unless --parallel is set)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env) --state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
--headers <json> HTTP headers scoped to URL's origin (for auth) --headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
@@ -2456,10 +2483,10 @@ Options:
--json JSON output --json JSON output
--full, -f Full page screenshot --full, -f Full page screenshot
--annotate Annotated screenshot with numbered labels and legend --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) --cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome --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) --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) --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) --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 Extension side panel supports browser controls + console/network/DOM + workflow scheduling
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE) --risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle --wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
--session-name <name> Auto-save/restore session state (defaults to "default") --parallel <name> Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
Default behavior in this mode is stateless (no auto session persistence unless --session-name is explicitly passed)
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) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) --max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) --allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --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) --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) --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) --config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output --debug Debug output
@@ -2482,7 +2514,9 @@ Options:
Policy: Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden --profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden --channel / AGENT_BROWSER_CHANNEL are forbidden
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: Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority): 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: Boolean flags accept an optional true/false value to override config:
--headed (same as --headed true) --headed (same as --headed true)
--headed false (disables "headed": true from config) --headed false (disables "headed": true from config)
--resident false (disable resident mode for this invocation)
Extensions from user and project configs are merged (not replaced). Extensions from user and project configs are merged (not replaced).
@@ -2505,12 +2540,14 @@ Configuration:
Environment: Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config) AGENT_BROWSER_CONFIG Path to config file (or use --config)
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: "default") AGENT_BROWSER_PARALLEL Isolated runtime channel for parallel AI runs (maps to parallel-<name>)
Best for stateless/no-login tasks where throughput matters
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_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30) AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
AGENT_BROWSER_EXTENSIONS Comma-separated browser extension paths 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_JSON JSON output
AGENT_BROWSER_FULL Full page screenshot AGENT_BROWSER_FULL Full page screenshot
AGENT_BROWSER_ANNOTATE Annotated screenshot with numbered labels and legend 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_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block) AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name (default: "default" when --parallel is not set)
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
@@ -2540,6 +2577,7 @@ Environment:
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts 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) AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
Install (recommended, fastest - native Rust CLI): Install (recommended, fastest - native Rust CLI):
@@ -2564,6 +2602,8 @@ Examples:
agent-browser --color-scheme dark open example.com # Dark mode agent-browser --color-scheme dark open example.com # Dark mode
agent-browser --risk-mode block open example.com # Block on verification/captcha pages agent-browser --risk-mode block open example.com # Block on verification/captcha pages
agent-browser --session-name myapp open example.com # Auto-save/restore state agent-browser --session-name myapp open example.com # Auto-save/restore state
agent-browser --parallel worker-a open example.com # Isolated runtime for parallel AI task
agent-browser --resident open example.com # Keep daemon resident until explicit close
Command Chaining: Command Chaining:
Chain commands with && in a single shell call (browser persists via daemon): Chain commands with && in a single shell call (browser persists via daemon):
+49
View File
@@ -0,0 +1,49 @@
use std::sync::{Mutex, MutexGuard};
/// Global mutex shared across all test modules to prevent parallel tests from
/// interfering with each other when mutating environment variables.
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
pub struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
pub fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), std::env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
pub fn set(&self, name: &str, value: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::set called with unregistered var: {name}"
);
std::env::set_var(name, value);
}
pub fn remove(&self, name: &str) {
debug_assert!(
self.vars.iter().any(|(n, _)| n == name),
"EnvGuard::remove called with unregistered var: {name}"
);
std::env::remove_var(name);
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
+7 -7
View File
@@ -20,13 +20,13 @@ services:
# Build both targets in parallel # 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") & (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") & (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 for both to complete
wait $PID1 $PID2 wait $$PID1 $$PID2
echo "" echo ""
echo "✓ Linux platforms built successfully!" echo "✓ Linux platforms built successfully!"
@@ -67,8 +67,8 @@ services:
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64} - OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
command: | command: |
-c ' -c '
cargo zigbuild --release --target $TARGET cargo zigbuild --release --target $$TARGET
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME cp /build/target/$$TARGET/release/agent-browser* /output/$$OUTPUT_NAME
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
echo "✓ Built $OUTPUT_NAME" echo "✓ Built $$OUTPUT_NAME"
' '
+10 -1
View File
@@ -6,7 +6,14 @@ export const metadata = pageMetadata('cdp-mode');
Connect to an existing browser via Chrome DevTools Protocol: 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: Project policy:
@@ -67,6 +74,8 @@ This is useful when:
- You want a zero-configuration connection to your existing browser - You want a zero-configuration connection to your existing browser
- You don't want to track which port Chrome is using - 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 ## 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: Playwright overrides the browser's color scheme to `light` by default when connecting via CDP. Use `--color-scheme` to set a persistent preference:
+19 -2
View File
@@ -9,6 +9,7 @@ Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
## Core ## Core
```bash ```bash
agent-browser start # Start/reuse managed browser on localhost:9333
agent-browser open <url> # Navigate (aliases: goto, navigate) agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected 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) 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 ```bash
agent-browser session # Show current session name agent-browser session # Show current session name
agent-browser session list # List active sessions agent-browser session list # List active sessions (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 ## Navigation
@@ -293,7 +308,7 @@ agent-browser reload # Reload page
## Global options ## Global options
```bash ```bash
--session-name <name> # Auto-save/restore session state (defaults to "default" when omitted) --session-name <name> # Auto-save/restore session state (defaults to "default" in non-parallel mode)
--state <path> # Load storage state from JSON file --state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin --headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable --executable-path <path> # Custom browser executable
@@ -315,6 +330,8 @@ agent-browser reload # Reload page
--auto-connect # Auto-discover and connect to running Chrome --auto-connect # Auto-discover and connect to running Chrome
--tab-group <name> # Base title for agent tab groups (CDP plugin mode) --tab-group <name> # Base title for agent tab groups (CDP plugin mode)
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake --tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
--parallel <name> # Isolated runtime channel for parallel AI runs (maps to parallel-<name>; 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) --wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
--debug # Debug output (includes stealth connection type + capabilities) --debug # Debug output (includes stealth connection type + capabilities)
``` ```
+67 -3
View File
@@ -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. 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 ## Config File Locations
@@ -72,7 +72,7 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
## All Options ## All Options
Every CLI flag can be set in the config file using its camelCase equivalent: Most CLI flags can be set in the config file using their camelCase equivalents (`--resident` is CLI-only):
<table> <table>
<thead> <thead>
@@ -128,6 +128,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
</td> </td>
<td>string</td> <td>string</td>
</tr> </tr>
<tr>
<td>
<code>parallel</code>
</td>
<td>
<code>--parallel</code>
</td>
<td>string (isolated runtime channel name)</td>
</tr>
<tr> <tr>
<td> <td>
<code>executablePath</code> <code>executablePath</code>
@@ -245,6 +254,15 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
</td> </td>
<td>boolean</td> <td>boolean</td>
</tr> </tr>
<tr>
<td>
<code>engine</code>
</td>
<td>
<code>--engine</code>
</td>
<td>string (<code>chrome</code>, <code>lightpanda</code>)</td>
</tr>
<tr> <tr>
<td> <td>
<code>colorScheme</code> <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. `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: 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. 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 ## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings: Boolean flags accept an optional `true`/`false` value to override config settings:
@@ -368,7 +408,9 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit agent-browser --headed true open example.com # explicit
``` ```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`. This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--resident`.
For environment variables, headed mode accepts either `AGENT_BROWSER_HEADED=1` or `AGENT_BROWSER_HEADED=true`.
## Extensions Merging ## 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). 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 ## Environment Variables
These environment variables configure additional daemon and runtime behavior: 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>Default directory for browser downloads.</td>
<td>(temp directory)</td> <td>(temp directory)</td>
</tr> </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> <tr>
<td> <td>
<code>AGENT_BROWSER_TAB_GROUP</code> <code>AGENT_BROWSER_TAB_GROUP</code>
@@ -471,6 +526,15 @@ These environment variables configure additional daemon and runtime behavior:
<code>default</code> <code>default</code>
</td> </td>
</tr> </tr>
<tr>
<td>
<code>AGENT_BROWSER_PARALLEL</code>
</td>
<td>
Isolated runtime channel name for parallel AI runs (maps to <code>parallel-&lt;name&gt;</code>). Non-default daemons are reaped when a default-session command starts.
</td>
<td>(none)</td>
</tr>
<tr> <tr>
<td> <td>
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code> <code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
+105
View File
@@ -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 &lt;path&gt;</code></td></tr>
<tr><td>Persistent profiles</td><td><code>--profile &lt;path&gt;</code></td></tr>
<tr><td>Storage state</td><td><code>--state &lt;path&gt;</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 &lt;args&gt;</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).
+97
View File
@@ -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 &amp;&amp; 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 &amp;&amp; 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.
+40 -2
View File
@@ -4,25 +4,55 @@ export const metadata = pageMetadata('sessions');
# Sessions # Sessions
Use one default runtime session and optional named persistence: Use the default runtime session or an isolated parallel runtime channel, plus optional named persistence:
```bash ```bash
# Show current runtime session # Show current runtime session
agent-browser session agent-browser session
# Output: default # Output: default
# Isolated runtime channel for parallel AI flow
agent-browser --parallel worker-a session
# Output: parallel-worker-a
# Show active daemon sessions # Show active daemon sessions
agent-browser session list agent-browser session list
# stale entries are auto-cleaned during listing
``` ```
## Session isolation ## Session isolation
The runtime session is fixed to `default`. Use `--session-name` to isolate persisted state files per workflow. Runtime session defaults to `default`. Use `--parallel <name>` when you need isolated concurrent runtime channels, and `--session-name` to isolate persisted state files per workflow.
- Cookies and storage snapshots - Cookies and storage snapshots
- Authentication state - Authentication state
- Saved state lifecycle - Saved state lifecycle
Daemons auto-shutdown after 10 minutes of inactivity by default. Use `--resident` to keep a daemon alive until explicit `close`.
## Parallel runtime channels
Use `--parallel <name>` to isolate runtime channels for concurrent AI execution:
```bash
agent-browser --parallel worker-a open https://example.com
agent-browser --parallel worker-b open https://example.org
```
`--parallel` is intended for stateless throughput tasks (navigation/extraction/checks). For authenticated flows, use a stable `--session-name`.
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 ## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts: Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
@@ -41,6 +71,8 @@ agent-browser open twitter.com
If `--session-name` is omitted, it defaults to `default`. If `--session-name` is omitted, it defaults to `default`.
When `--parallel` is enabled, auto persistence is disabled by default unless `--session-name` is explicitly passed on that command.
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start. State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
### Session name rules ### Session name rules
@@ -165,6 +197,12 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
</td> </td>
<td>Auto-save/load state persistence name</td> <td>Auto-save/load state persistence name</td>
</tr> </tr>
<tr>
<td>
<code>AGENT_BROWSER_PARALLEL</code>
</td>
<td>Isolated runtime channel name for parallel AI runs (maps to <code>parallel-&lt;name&gt;</code>). Non-default daemons are reaped when a default-session command starts.</td>
</tr>
<tr> <tr>
<td> <td>
<code>AGENT_BROWSER_ENCRYPTION_KEY</code> <code>AGENT_BROWSER_ENCRYPTION_KEY</code>
+7
View File
@@ -40,6 +40,13 @@ export const navigation: NavSection[] = [
{ name: "Native Mode (Experimental)", href: "/native-mode" }, { name: "Native Mode (Experimental)", href: "/native-mode" },
], ],
}, },
{
title: "Engines",
items: [
{ name: "Chrome", href: "/engines/chrome" },
{ name: "Lightpanda", href: "/engines/lightpanda" },
],
},
{ {
title: null, title: null,
items: [{ name: "Changelog", href: "/changelog" }], items: [{ name: "Changelog", href: "/changelog" }],
+2
View File
@@ -14,6 +14,8 @@ export const PAGE_TITLES: Record<string, string> = {
profiler: "Profiler", profiler: "Profiler",
ios: "iOS Simulator", ios: "iOS Simulator",
security: "Security", security: "Security",
"engines/chrome": "Chrome",
"engines/lightpanda": "Lightpanda",
"native-mode": "Native Mode (Experimental)", "native-mode": "Native Mode (Experimental)",
changelog: "Changelog", changelog: "Changelog",
}; };
+148
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser-stealth", "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", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module", "type": "module",
"main": "dist/daemon.js", "main": "dist/daemon.js",
@@ -23,11 +23,11 @@
"build": "tsc", "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: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: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: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: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 .", "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", "start": "node dist/daemon.js",
"dev": "tsx src/daemon.ts", "dev": "tsx src/daemon.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -36,6 +36,10 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts", "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:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
"check:stealth-regression": "node scripts/check-stealth-regression.js", "check:stealth-regression": "node scripts/check-stealth-regression.js",
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts", "check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
@@ -68,6 +72,9 @@
"type": "git", "type": "git",
"url": "git+https://github.com/leeguooooo/agent-browser.git" "url": "git+https://github.com/leeguooooo/agent-browser.git"
}, },
"publishConfig": {
"tag": "fork"
},
"bugs": { "bugs": {
"url": "https://github.com/leeguooooo/agent-browser/issues" "url": "https://github.com/leeguooooo/agent-browser/issues"
}, },
+50 -8
View File
@@ -1,26 +1,67 @@
#!/usr/bin/env node #!/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 { copyFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { platform, arch } from 'os'; import { platform, arch } from 'os';
import { spawnSync } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..'); const projectRoot = join(__dirname, '..');
const sourceExt = platform() === 'win32' ? '.exe' : '';
const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
const binDir = join(projectRoot, 'bin'); const binDir = join(projectRoot, 'bin');
// Determine platform suffix function defaultPaths() {
const platformKey = `${platform()}-${arch()}`; const sourceExt = platform() === 'win32' ? '.exe' : '';
const ext = platform() === 'win32' ? '.exe' : ''; const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
const targetName = `agent-browser-${platformKey}${ext}`;
const targetPath = join(binDir, targetName); 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)) { if (!existsSync(sourcePath)) {
console.error(`Error: Native binary not found at ${sourcePath}`); console.error(`Error: Native binary not found at ${sourcePath}`);
@@ -33,4 +74,5 @@ if (!existsSync(binDir)) {
} }
copyFileSync(sourcePath, targetPath); copyFileSync(sourcePath, targetPath);
adHocSignIfNeeded(targetPath);
console.log(`✓ Copied native binary to ${targetPath}`); console.log(`✓ Copied native binary to ${targetPath}`);
+54 -10
View File
@@ -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. 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 ## Core Workflow
Every browser automation follows this pattern: Every browser automation follows this pattern:
@@ -50,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
```bash ```bash
# Navigation # Navigation
agent-browser start # Start/reuse managed browser on localhost:9333
agent-browser open <url> # Navigate (aliases: goto, navigate) agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
@@ -208,23 +211,26 @@ agent-browser get text @e1 --json
### Parallel Workflows ### Parallel Workflows
```bash ```bash
agent-browser --session-name site1 open https://site-a.com agent-browser --parallel site1 open https://site-a.com
agent-browser --session-name site2 open https://site-b.com agent-browser --parallel site2 open https://site-b.com
agent-browser --session-name site1 snapshot -i agent-browser --parallel site1 snapshot -i
agent-browser --session-name site2 snapshot -i agent-browser --parallel site2 snapshot -i
``` ```
Use `--parallel <name>` for stateless throughput tasks (navigation, extraction, checks). For login/auth continuity, use `--session-name` instead.
Default-session commands intentionally reap all non-default daemon sessions (`parallel-*` and legacy named channels) to prevent stale daemon reuse.
### Connect to Existing Chrome ### Connect to Existing Chrome
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order: By default in this fork, commands without `--cdp` use a dedicated automation browser with this order:
1. Try CDP at `localhost:9333` 1. Try CDP at `localhost:9333`
2. If unavailable, fall back to `--auto-connect`-style discovery 2. If unavailable, auto-start Chrome with the persistent profile `~/.agent-browser/chrome-bot-profile`
3. If both fail, exit with guidance (no automatic managed local browser launch on this path) 3. If managed `:9333` startup fails, exit with guidance
```bash ```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 open https://example.com
agent-browser --auto-connect snapshot agent-browser --auto-connect snapshot
@@ -244,6 +250,18 @@ agent-browser --wait-until domcontentloaded open https://example.com
pnpm run check:turnstile-testkey pnpm run check:turnstile-testkey
``` ```
### Daemon Lifecycle
Daemons auto-shutdown after 10 minutes of inactivity.
Use `--resident` for long-running workflows that should not auto-close:
```bash
agent-browser --resident open https://example.com
# keep running until explicit close
agent-browser close
```
### Color Scheme (Dark Mode) ### Color Scheme (Dark Mode)
```bash ```bash
@@ -301,6 +319,8 @@ agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop trace.json # Stop and save profile (path optional) 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) ### Local Files (PDFs, HTML)
```bash ```bash
@@ -314,7 +334,7 @@ agent-browser screenshot output.png
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden - `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` 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) ### Stealth Mode (Always On)
@@ -487,7 +507,8 @@ These behaviors are always active. For sensitive sites, combine with `--headed`
## Session Management and Cleanup ## Session Management and Cleanup
`--session` is ignored in this fork. The runtime always uses one default session. Use `--session-name` to isolate persistence when needed. `--session` is ignored in this fork. Runtime defaults to `default`; use `--parallel <name>` for isolated concurrent channels, and `--session-name` for persistence isolation.
When a default-session command runs, non-default daemon sessions are reaped automatically.
Always close your browser session when done to avoid leaked processes: 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. 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 ## Ready-to-Use Templates
| Template | Description | | Template | Description |
+2
View File
@@ -190,9 +190,11 @@ agent-browser --session {SESSION} close
## Guidance ## 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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.
+20
View File
@@ -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', () => { describe('risk interstitial recovery', () => {
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => { it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
const challengeClearMs = 10_000; const challengeClearMs = 10_000;
+4
View File
@@ -520,6 +520,10 @@ async function handleLaunch(
command: Command & { action: 'launch' }, command: Command & { action: 'launch' },
browser: BrowserManager browser: BrowserManager
): Promise<Response> { ): Promise<Response> {
if (command.engine === 'lightpanda') {
return errorResponse(command.id, 'Lightpanda engine requires --native mode');
}
await browser.launch(command); await browser.launch(command);
return successResponse(command.id, { return successResponse(command.id, {
launched: true, launched: true,
+71
View File
@@ -260,6 +260,77 @@ describe('BrowserManager', () => {
await cdpBrowser.close(); await cdpBrowser.close();
spy.mockRestore(); 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', () => { describe('tab-group plugin handshake', () => {
+130 -4
View File
@@ -14,9 +14,10 @@ import {
type CDPSession, type CDPSession,
type Video, type Video,
} from 'playwright-core'; } from 'playwright-core';
import { spawn, spawnSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; 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 { writeFile, mkdir } from 'node:fs/promises';
import type { import type {
DoctorCheck, DoctorCheck,
@@ -134,6 +135,8 @@ interface StealthContextDefaults {
} }
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/']; 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_NAME = 'Agent Browser Stealth';
const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST'; const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
@@ -149,6 +152,65 @@ interface TabGroupIntent {
allowedDomains: string[]; 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 * Manages the Playwright browser lifecycle with multiple tabs/windows
*/ */
@@ -189,6 +251,61 @@ export class BrowserManager {
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map(); private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
private tabGroupInFlight: WeakSet<Page> = new WeakSet(); 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. * Set the persistent color scheme preference.
* Applied automatically to all new pages and contexts. * Applied automatically to all new pages and contexts.
@@ -2009,7 +2126,15 @@ export class BrowserManager {
} }
if (cdpEndpoint) { 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; return;
} }
@@ -2126,7 +2251,8 @@ export class BrowserManager {
let context: BrowserContext; let context: BrowserContext;
if (hasExtensions) { 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 extPaths = configuredExtensions.join(',');
const session = process.env.AGENT_BROWSER_SESSION || 'default'; const session = process.env.AGENT_BROWSER_SESSION || 'default';
// Combine extension args with custom args and file access args // Combine extension args with custom args and file access args
@@ -2135,7 +2261,7 @@ export class BrowserManager {
context = await launcher.launchPersistentContext( context = await launcher.launchPersistentContext(
path.join(os.tmpdir(), `agent-browser-ext-${session}`), path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{ {
headless: false, headless: options.headless ?? false,
executablePath: options.executablePath, executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }), ...(chromeChannel && { channel: chromeChannel }),
args: allArgs, args: allArgs,
+116 -1
View File
@@ -3,7 +3,12 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as net from 'net'; import * as net from 'net';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { getSocketDir, safeWrite } from './daemon.js'; import {
buildAutoLaunchOptionsFromEnv,
createSerializedExecutor,
getSocketDir,
safeWrite,
} from './daemon.js';
/** /**
* HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks. * HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks.
@@ -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 } = {}) { function createMockSocket(opts: { destroyed?: boolean; writeReturns?: boolean } = {}) {
const emitter = new EventEmitter(); const emitter = new EventEmitter();
const socket = Object.assign(emitter, { const socket = Object.assign(emitter, {
@@ -159,3 +232,45 @@ describe('safeWrite', () => {
expect(socket.listenerCount('close')).toBe(0); expect(socket.listenerCount('close')).toBe(0);
}); });
}); });
describe('createSerializedExecutor', () => {
it('should execute tasks one-by-one even when started concurrently', async () => {
const runSerialized = createSerializedExecutor();
const order: string[] = [];
const slow = runSerialized(async () => {
order.push('slow-start');
await new Promise((resolve) => setTimeout(resolve, 30));
order.push('slow-end');
return 'slow';
});
const fast = runSerialized(async () => {
order.push('fast-start');
order.push('fast-end');
return 'fast';
});
await expect(Promise.all([slow, fast])).resolves.toEqual(['slow', 'fast']);
expect(order).toEqual(['slow-start', 'slow-end', 'fast-start', 'fast-end']);
});
it('should continue running queued tasks after a task fails', async () => {
const runSerialized = createSerializedExecutor();
const order: string[] = [];
const first = runSerialized(async () => {
order.push('first');
throw new Error('boom');
});
const second = runSerialized(async () => {
order.push('second');
return 'ok';
});
await expect(first).rejects.toThrow('boom');
await expect(second).resolves.toBe('ok');
expect(order).toEqual(['first', 'second']);
});
});
+336 -296
View File
@@ -8,6 +8,7 @@ import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js'; import { executeCommand } from './actions.js';
import { executeIOSCommand } from './ios-actions.js'; import { executeIOSCommand } from './ios-actions.js';
import { StreamServer } from './stream-server.js'; import { StreamServer } from './stream-server.js';
import type { LaunchCommand } from './types.js';
import { import {
getSessionsDir, getSessionsDir,
ensureSessionsDir, 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 // Platform detection
const isWindows = process.platform === 'win32'; 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) // Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
const DEFAULT_STREAM_PORT = 9223; const DEFAULT_STREAM_PORT = 9223;
// Default idle auto-shutdown timeout: 10 minutes
const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60 * 1000;
/** /**
* Save state to file with optional encryption. * Save state to file with optional encryption.
@@ -181,6 +201,55 @@ export function getSession(): string {
return currentSession; 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) * Get port number for TCP mode (Windows)
* Uses a hash of the session name to get a consistent port * 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`); 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 * Check if daemon is running for the current session
*/ */
@@ -294,9 +371,11 @@ export function getConnectionInfo(
export function cleanupSocket(session?: string): void { export function cleanupSocket(session?: string): void {
const pidFile = getPidFile(session); const pidFile = getPidFile(session);
const streamPortFile = getStreamPortFile(session); const streamPortFile = getStreamPortFile(session);
const metaFile = getMetaFile(session);
try { try {
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile); if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile);
if (fs.existsSync(metaFile)) fs.unlinkSync(metaFile);
if (isWindows) { if (isWindows) {
const portFile = getPortFile(session); const portFile = getPortFile(session);
if (fs.existsSync(portFile)) fs.unlinkSync(portFile); if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
@@ -325,6 +404,7 @@ export function getStreamPortFile(session?: string): string {
export async function startDaemon(options?: { export async function startDaemon(options?: {
streamPort?: number; streamPort?: number;
provider?: string; provider?: string;
resident?: boolean;
}): Promise<void> { }): Promise<void> {
// Ensure socket directory exists with restricted permissions (owner-only access) // Ensure socket directory exists with restricted permissions (owner-only access)
const socketDir = getSocketDir(); const socketDir = getSocketDir();
@@ -345,6 +425,10 @@ export async function startDaemon(options?: {
// Create appropriate manager // Create appropriate manager
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager(); const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
let shuttingDown = false; let shuttingDown = false;
const runSerialized = createSerializedExecutor();
const residentMode = options?.resident ?? process.argv.includes('--resident');
let idleTimer: NodeJS.Timeout | null = null;
let pendingCommands = 0;
// Start stream server if port is specified (or use default if env var is set) // Start stream server if port is specified (or use default if env var is set)
// Note: Stream server only works with BrowserManager (desktop), not iOS // Note: Stream server only works with BrowserManager (desktop), not iOS
@@ -363,6 +447,21 @@ export async function startDaemon(options?: {
fs.writeFileSync(streamPortFile, streamPort.toString()); fs.writeFileSync(streamPortFile, streamPort.toString());
} }
const cancelIdleTimer = (): void => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const scheduleIdleShutdown = (): void => {
if (residentMode || shuttingDown || pendingCommands > 0) return;
cancelIdleTimer();
idleTimer = setTimeout(() => {
void shutdown('idle timeout');
}, DEFAULT_IDLE_SHUTDOWN_MS);
};
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
let buffer = ''; let buffer = '';
let httpChecked = false; let httpChecked = false;
@@ -379,309 +478,219 @@ export async function startDaemon(options?: {
while (commandQueue.length > 0) { while (commandQueue.length > 0) {
const line = commandQueue.shift()!; const line = commandQueue.shift()!;
pendingCommands += 1;
cancelIdleTimer();
try { try {
const parseResult = parseCommand(line); await runSerialized(async () => {
if (!parseResult.success) {
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
await safeWrite(socket, serializeResponse(resp) + '\n');
continue;
}
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
try { try {
const devices = await iosManager.listAllDevices(); const parseResult = parseCommand(line);
const response = {
id: parseResult.command.id, if (!parseResult.success) {
success: true as const, const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
data: { devices }, await safeWrite(socket, serializeResponse(resp) + '\n');
}; return;
}
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
try {
const devices = await iosManager.listAllDevices();
const response = {
id: parseResult.command.id,
success: true as const,
data: { devices },
};
await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await safeWrite(
socket,
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
);
}
return;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: attach to an existing browser only.
const isDoctor = parseResult.command.action === 'doctor';
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load' &&
parseResult.command.action !== 'doctor'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const 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'); await safeWrite(socket, serializeResponse(response) + '\n');
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
await safeWrite( await safeWrite(
socket, socket,
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n' serializeResponse(errorResponse('error', message)) + '\n'
); ).catch(() => {}); // Socket may already be destroyed
} }
continue; });
} } finally {
pendingCommands = Math.max(0, pendingCommands - 1);
// Auto-launch if not already launched and this isn't a launch/close/state_load command. scheduleIdleShutdown();
// Default behavior for this fork: attach to an existing browser only.
const isDoctor = parseResult.command.action === 'doctor';
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load' &&
parseResult.command.action !== 'doctor'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.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
} }
} }
@@ -736,6 +745,28 @@ export async function startDaemon(options?: {
// Write PID file before listening // Write PID file before listening
fs.writeFileSync(pidFile, process.pid.toString()); 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) { if (isWindows) {
// Windows: use TCP socket on localhost // Windows: use TCP socket on localhost
@@ -755,14 +786,16 @@ export async function startDaemon(options?: {
server.on('error', (err) => { server.on('error', (err) => {
console.error('Server error:', err); console.error('Server error:', err);
cancelIdleTimer();
cleanupSocket(); cleanupSocket();
process.exit(1); process.exit(1);
}); });
// Handle shutdown signals // Handle shutdown signals
const shutdown = async () => { const shutdown = async (_reason?: string) => {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
cancelIdleTimer();
// Stop stream server if running // Stop stream server if running
if (streamServer) { if (streamServer) {
@@ -790,28 +823,35 @@ export async function startDaemon(options?: {
// Handle unexpected errors - always cleanup // Handle unexpected errors - always cleanup
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err); console.error('Uncaught exception:', err);
cancelIdleTimer();
cleanupSocket(); cleanupSocket();
process.exit(1); process.exit(1);
}); });
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason); console.error('Unhandled rejection:', reason);
cancelIdleTimer();
cleanupSocket(); cleanupSocket();
process.exit(1); process.exit(1);
}); });
// Cleanup on normal exit // Cleanup on normal exit
process.on('exit', () => { process.on('exit', () => {
cancelIdleTimer();
cleanupSocket(); cleanupSocket();
}); });
scheduleIdleShutdown();
// Keep process alive // Keep process alive
process.stdin.resume(); process.stdin.resume();
} }
// Run daemon if this is the entry point // Run daemon if this is the entry point
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') { if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
startDaemon().catch((err) => { startDaemon({
resident: process.argv.includes('--resident'),
}).catch((err) => {
console.error('Daemon error:', err); console.error('Daemon error:', err);
cleanupSocket(); cleanupSocket();
process.exit(1); process.exit(1);
+1
View File
@@ -57,6 +57,7 @@ const launchSchema = baseCommandSchema.extend({
allowedDomains: z.array(z.string()).optional(), allowedDomains: z.array(z.string()).optional(),
actionPolicy: z.string().optional(), actionPolicy: z.string().optional(),
confirmActions: z.array(z.string()).optional(), confirmActions: z.array(z.string()).optional(),
engine: z.enum(['chrome', 'lightpanda']).optional(),
}); });
const navigateSchema = baseCommandSchema.extend({ const navigateSchema = baseCommandSchema.extend({
+1
View File
@@ -46,6 +46,7 @@ export interface LaunchCommand extends BaseCommand {
allowedDomains?: string[]; allowedDomains?: string[];
actionPolicy?: string; actionPolicy?: string;
confirmActions?: string[]; confirmActions?: string[];
engine?: 'chrome' | 'lightpanda'; // Browser engine selection; lightpanda requires native mode
// Auto-load state file for session persistence // Auto-load state file for session persistence
autoStateFilePath?: string; autoStateFilePath?: string;
} }
+314
View File
@@ -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"),
},
];
+222
View File
@@ -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>
+234
View File
@@ -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>
+176
View File
@@ -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
+113
View File
@@ -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" },
],
},
];