full native (#754)

* full native

* fix: apply cargo fmt formatting

* fix: prevent zip path traversal in Chromium installer

Use enclosed_name() to sanitize zip entry paths, preventing malicious
archives from writing outside the extraction directory.

* improvements

* fix: apply cargo fmt formatting

* benchmarks

* bench

* updates

* fixes
This commit is contained in:
Chris Tate
2026-03-13 19:59:21 -05:00
committed by GitHub
parent d4b948c1d4
commit 8e43469c8b
88 changed files with 2511 additions and 22629 deletions
+1 -3
View File
@@ -1061,7 +1061,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
// === Recording (Playwright native video recording) ===
// === Recording (browser video recording) ===
"record" => {
const VALID: &[&str] = &["start", "stop", "restart"];
match rest.first().copied() {
@@ -2167,7 +2167,6 @@ mod tests {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_headed: false,
annotate: false,
color_scheme: None,
@@ -2178,7 +2177,6 @@ mod tests {
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
engine: None,
screenshot_dir: None,
screenshot_quality: None,
+35 -119
View File
@@ -233,7 +233,6 @@ pub struct DaemonOptions<'a> {
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
pub engine: Option<&'a str>,
}
@@ -359,137 +358,54 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
}
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);
// On Windows, canonicalize() returns \\?\ prefixed extended-length paths.
// Node.js cannot handle these, so strip the prefix.
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
#[allow(unused_assignments)]
let mut daemon_child: Option<std::process::Child> = None;
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?,
);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?,
);
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
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
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
daemon_child = Some(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
daemon_child = Some(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?,
);
}
for _ in 0..50 {
-15
View File
@@ -41,7 +41,6 @@ pub struct Config {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
@@ -87,7 +86,6 @@ impl Config {
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
engine: other.engine.or(self.engine),
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
@@ -247,7 +245,6 @@ pub struct Flags {
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
pub engine: Option<String>,
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
@@ -266,7 +263,6 @@ pub struct Flags {
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_native: bool,
pub cli_headed: bool,
}
@@ -358,7 +354,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.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),
screenshot_dir: env::var("AGENT_BROWSER_SCREENSHOT_DIR")
.ok()
@@ -382,7 +377,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_native: false,
cli_headed: false,
};
@@ -604,14 +598,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
flags.cli_native = true;
if consumed {
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
@@ -675,7 +661,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
+470 -157
View File
@@ -1,167 +1,357 @@
use crate::color;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{exit, Command, Stdio};
const LAST_KNOWN_GOOD_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
pub fn get_browsers_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join("browsers")
}
pub fn find_installed_chrome() -> Option<PathBuf> {
let browsers_dir = get_browsers_dir();
if !browsers_dir.exists() {
return None;
}
let mut versions: Vec<_> = fs::read_dir(&browsers_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("chrome-"))
})
.collect();
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for entry in versions {
if let Some(bin) = chrome_binary_in_dir(&entry.path()) {
if bin.exists() {
return Some(bin);
}
}
}
None
}
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let app =
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if app.exists() {
return Some(app);
}
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
if inner.exists() {
return Some(inner);
}
let inner_x64 = dir.join(
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
);
if inner_x64.exists() {
return Some(inner_x64);
}
None
}
#[cfg(target_os = "linux")]
{
let bin = dir.join("chrome");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-linux64/chrome");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(target_os = "windows")]
{
let bin = dir.join("chrome.exe");
if bin.exists() {
return Some(bin);
}
let inner = dir.join("chrome-win64/chrome.exe");
if inner.exists() {
return Some(inner);
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
fn platform_key() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"mac-arm64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"mac-x64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"win64"
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
{
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
// can still be used for other commands like `connect`. The install path
// guards against this at runtime before calling platform_key().
panic!("Unsupported platform for Chrome for Testing download")
}
}
async fn fetch_download_url() -> Result<(String, String), String> {
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
let channel = body
.get("channels")
.and_then(|c| c.get("Stable"))
.ok_or("No Stable channel found in version info")?;
let version = channel
.get("version")
.and_then(|v| v.as_str())
.ok_or("No version string found")?
.to_string();
let platform = platform_key();
let url = channel
.get("downloads")
.and_then(|d| d.get("chrome"))
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter().find_map(|entry| {
if entry.get("platform")?.as_str()? == platform {
Some(entry.get("url")?.as_str()?.to_string())
} else {
None
}
})
})
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
Ok((version, url))
}
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("Download failed: {}", e))?;
let total = resp.content_length();
let mut bytes = Vec::new();
let mut stream = resp;
let mut downloaded: u64 = 0;
let mut last_pct: u64 = 0;
loop {
let chunk = stream
.chunk()
.await
.map_err(|e| format!("Download error: {}", e))?;
match chunk {
Some(data) => {
downloaded += data.len() as u64;
bytes.extend_from_slice(&data);
if let Some(total) = total {
let pct = (downloaded * 100) / total;
if pct >= last_pct + 5 {
last_pct = pct;
let mb = downloaded as f64 / 1_048_576.0;
let total_mb = total as f64 / 1_048_576.0;
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
let _ = io::stderr().flush();
}
}
}
None => break,
}
}
eprintln!();
Ok(bytes)
}
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
let cursor = io::Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
let enclosed = match file.enclosed_name() {
Some(name) => name.to_owned(),
None => continue,
};
let raw_name = enclosed.to_string_lossy().to_string();
let rel_path = raw_name
.strip_prefix("chrome-")
.and_then(|s| s.split_once('/'))
.map(|(_, rest)| rest.to_string())
.unwrap_or(raw_name.clone());
if rel_path.is_empty() {
continue;
}
let out_path = dest.join(&rel_path);
// Defense-in-depth: ensure the resolved path is inside dest
if !out_path.starts_with(dest) {
continue;
}
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create parent dir {}: {}", parent.display(), e)
})?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
}
}
}
}
Ok(())
}
pub fn run_install(with_deps: bool) {
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
eprintln!(
"{} Chrome for Testing does not provide Linux ARM64 builds.",
color::error_indicator()
);
eprintln!(" Install Chromium from your system package manager instead:");
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
eprintln!(" sudo dnf install chromium # Fedora");
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
exit(1);
}
let is_linux = cfg!(target_os = "linux");
if is_linux {
if with_deps {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
"libxcb-shm0",
"libx11-xcb1",
"libx11-6",
"libxcb1",
"libxext6",
"libxrandr2",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxfixes3",
"libxi6",
"libgtk-3-0",
"libpangocairo-1.0-0",
"libpango-1.0-0",
"libatk1.0-0",
"libcairo-gobject2",
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
"libnss3",
"libnspr4",
"libatk-bridge2.0-0",
"libdrm2",
"libxkbcommon0",
"libatspi2.0-0",
"libcups2",
"libxshmfence1",
"libgbm1",
],
)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
let install_cmd = match pkg_mgr {
"apt-get" => {
format!(
"sudo apt-get update && sudo apt-get install -y {}",
deps.join(" ")
)
}
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
};
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
match status {
Ok(s) if s.success() => {
println!("{} System dependencies installed", color::success_indicator())
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e),
}
install_linux_deps();
} else {
println!(
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" or: npx playwright install-deps chromium");
println!();
}
}
println!("{}", color::cyan("Installing Chromium browser..."));
println!("{}", color::cyan("Installing Chrome..."));
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
// and Command::new() doesn't resolve .cmd files the way the shell does.
// Pass the entire command as a single string to /c to handle paths with spaces.
#[cfg(windows)]
let status = Command::new("cmd")
.args(["/c", "npx playwright install chromium"])
.status();
#[cfg(not(windows))]
let status = Command::new("npx")
.args(["playwright", "install", "chromium"])
.status();
match status {
Ok(s) if s.success() => {
println!(
"{} Chromium installed successfully",
color::success_indicator()
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let (version, url) = match rt.block_on(fetch_download_url()) {
Ok(v) => v,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
let dest = get_browsers_dir().join(format!("chrome-{}", version));
if let Some(bin) = chrome_binary_in_dir(&dest) {
if bin.exists() {
println!(
"{} Chrome {} is already installed",
color::success_indicator(),
version
);
return;
}
}
println!(" Downloading Chrome {} for {}", version, platform_key());
println!(" {}", url);
let bytes = match rt.block_on(download_bytes(&url)) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
};
match extract_zip(bytes, &dest) {
Ok(()) => {
println!(
"{} Chrome {} installed successfully",
color::success_indicator(),
version
);
println!(" Location: {}", dest.display());
if is_linux && !with_deps {
println!();
println!(
@@ -171,25 +361,148 @@ pub fn run_install(with_deps: bool) {
println!(" agent-browser install --with-deps");
}
}
Ok(_) => {
eprintln!("{} Failed to install browser", color::error_indicator());
if is_linux {
println!(
"{} Try installing system dependencies first:",
color::yellow("Tip:")
);
println!(" agent-browser install --with-deps");
}
exit(1);
}
Err(e) => {
eprintln!("{} Failed to run npx: {}", color::error_indicator(), e);
eprintln!("Make sure Node.js is installed and npx is in your PATH");
let _ = fs::remove_dir_all(&dest);
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
}
fn install_linux_deps() {
println!("{}", color::cyan("Installing system dependencies..."));
let (pkg_mgr, deps) = if which_exists("apt-get") {
let libasound = if package_exists_apt("libasound2t64") {
"libasound2t64"
} else {
"libasound2"
};
(
"apt-get",
vec![
"libxcb-shm0",
"libx11-xcb1",
"libx11-6",
"libxcb1",
"libxext6",
"libxrandr2",
"libxcomposite1",
"libxcursor1",
"libxdamage1",
"libxfixes3",
"libxi6",
"libgtk-3-0",
"libpangocairo-1.0-0",
"libpango-1.0-0",
"libatk1.0-0",
"libcairo-gobject2",
"libcairo2",
"libgdk-pixbuf-2.0-0",
"libxrender1",
libasound,
"libfreetype6",
"libfontconfig1",
"libdbus-1-3",
"libnss3",
"libnspr4",
"libatk-bridge2.0-0",
"libdrm2",
"libxkbcommon0",
"libatspi2.0-0",
"libcups2",
"libxshmfence1",
"libgbm1",
],
)
} else if which_exists("dnf") {
(
"dnf",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
"libxcb",
"libX11-xcb",
"libX11",
"libXext",
"libXcursor",
"libXfixes",
"libXi",
"gtk3",
"cairo-gobject",
],
)
} else if which_exists("yum") {
(
"yum",
vec![
"nss",
"nspr",
"atk",
"at-spi2-atk",
"cups-libs",
"libdrm",
"libXcomposite",
"libXdamage",
"libXrandr",
"mesa-libgbm",
"pango",
"alsa-lib",
"libxkbcommon",
],
)
} else {
eprintln!(
"{} No supported package manager found (apt-get, dnf, or yum)",
color::error_indicator()
);
exit(1);
};
let install_cmd = match pkg_mgr {
"apt-get" => {
format!(
"sudo apt-get update && sudo apt-get install -y {}",
deps.join(" ")
)
}
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
};
println!("Running: {}", install_cmd);
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
match status {
Ok(s) if s.success() => {
println!(
"{} System dependencies installed",
color::success_indicator()
)
}
Ok(_) => eprintln!(
"{} Failed to install some dependencies. You may need to run manually with sudo.",
color::warning_indicator()
),
Err(e) => eprintln!(
"{} Could not run install command: {}",
color::warning_indicator(),
e
),
}
}
fn which_exists(cmd: &str) -> bool {
#[cfg(unix)]
{
+1 -125
View File
@@ -27,9 +27,6 @@ use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
fn serialize_json_value(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| {
r#"{"success":false,"error":"Failed to serialize JSON response"}"#.to_string()
@@ -55,110 +52,6 @@ fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
}));
}
/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js.
/// These commands don't need a browser, so we handle them directly to avoid
/// sending passwords through the daemon's Unix socket channel.
fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
let exe_path = env::current_exe().unwrap_or_default();
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
let mut script_paths = vec![
exe_dir.join("auth-cli.js"),
exe_dir.join("../dist/auth-cli.js"),
PathBuf::from("dist/auth-cli.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
script_paths.insert(0, home_path.join("dist/auth-cli.js"));
script_paths.insert(1, home_path.join("auth-cli.js"));
}
let script_path = match script_paths.iter().find(|p| p.exists()) {
Some(p) => p.clone(),
None => {
if json_mode {
print_json_error("auth-cli.js not found");
} else {
eprintln!(
"{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.",
color::error_indicator()
);
}
exit(1);
}
};
let cmd_json = serde_json::to_string(cmd).unwrap_or_default();
match ProcessCommand::new("node")
.arg(&script_path)
.arg(&cmd_json)
.output()
{
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprint!("{}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
if json_mode {
print_json_error("No response from auth-cli");
} else {
eprintln!("{} No response from auth-cli", color::error_indicator());
}
exit(1);
}
if json_mode {
println!("{}", stdout);
} else {
// Parse the JSON response and use the standard output formatter
match serde_json::from_str::<connection::Response>(stdout) {
Ok(resp) => {
let action = cmd.get("action").and_then(|v| v.as_str());
let opts = OutputOptions {
json: false,
content_boundaries: false,
max_output: None,
};
print_response_with_opts(&resp, action, &opts);
if !resp.success {
exit(1);
}
}
Err(_) => {
println!("{}", stdout);
}
}
}
exit(output.status.code().unwrap_or(0));
}
Err(e) => {
if json_mode {
print_json_error(format!("Failed to run auth-cli: {}", e));
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
return json!({ "server": proxy_str });
@@ -299,13 +192,9 @@ fn main() {
}
let args: Vec<String> = env::args().skip(1).collect();
let mut flags = parse_flags(&args);
let flags = parse_flags(&args);
let clean = clean_args(&args);
if flags.engine.is_some() && !flags.native {
flags.native = true;
}
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
@@ -392,17 +281,6 @@ fn main() {
}
}
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(
action,
"auth_save" | "auth_list" | "auth_show" | "auth_delete"
) {
run_auth_cli(&cmd, flags.json);
}
}
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
@@ -436,7 +314,6 @@ fn main() {
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
engine: flags.engine.as_deref(),
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
@@ -495,7 +372,6 @@ fn main() {
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"),
flags.cli_native.then_some("--native"),
flags.cli_headed.then_some("--headed"),
]
.into_iter()
+84 -1
View File
@@ -1,10 +1,12 @@
use serde_json::{json, Value};
use std::env;
use tokio::sync::broadcast;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use super::auth;
use super::browser::{BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
@@ -102,6 +104,8 @@ pub struct DaemonState {
pub tracked_requests: Vec<TrackedRequest>,
pub request_tracking: bool,
pub active_frame_id: Option<String>,
/// Shared slot for stream server to receive CDP client when browser launches.
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
}
impl DaemonState {
@@ -134,15 +138,33 @@ impl DaemonState {
tracked_requests: Vec::new(),
request_tracking: false,
active_frame_id: None,
stream_client: None,
}
}
/// Create state with an optional stream client slot (for daemon startup with stream server).
pub fn new_with_stream_client(
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
) -> Self {
let mut s = Self::new();
s.stream_client = stream_client;
s
}
fn subscribe_to_browser_events(&mut self) {
if let Some(ref browser) = self.browser {
self.event_rx = Some(browser.client.subscribe());
}
}
/// Update the stream server's CDP client slot when browser is set or cleared.
pub async fn update_stream_client(&self) {
if let Some(ref slot) = self.stream_client {
let mut guard = slot.write().await;
*guard = self.browser.as_ref().map(|m| Arc::clone(&m.client));
}
}
fn drain_cdp_events(
&mut self,
) -> (
@@ -540,6 +562,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
let _ = mgr.close().await;
}
state.browser = None;
state.update_stream_client().await;
}
if let Err(e) = auto_launch(state).await {
return error_response(&id, &format!("Auto-launch failed: {}", e));
@@ -739,6 +762,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
}
@@ -747,6 +771,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::connect_auto().await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
return Ok(());
}
@@ -754,6 +779,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
try_auto_restore_state(state).await;
Ok(())
}
@@ -857,6 +883,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
if let Some(ref mut b) = state.browser {
b.close().await?;
state.browser = None;
state.update_stream_client().await;
}
} else {
return Ok(json!({ "launched": true, "reused": true }));
@@ -894,18 +921,21 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
if let Some(url) = cdp_url {
state.browser = Some(BrowserManager::connect_cdp(url).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
if let Some(port) = cdp_port {
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
if auto_connect {
state.browser = Some(BrowserManager::connect_auto().await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -923,6 +953,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
Ok(mgr) => {
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
return Ok(json!({ "launched": true, "provider": provider }));
}
Err(e) => {
@@ -1008,6 +1039,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
if let Some(ref filter) = state.domain_filter {
if let Some(ref mgr) = state.browser {
@@ -1287,6 +1319,7 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
mgr.close().await?;
}
state.browser = None;
state.update_stream_client().await;
// Close WebDriver sessions
if let Some(ref mut wb) = state.webdriver_backend {
@@ -1463,6 +1496,44 @@ async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let new_tab = cmd.get("newTab").and_then(|v| v.as_bool()).unwrap_or(false);
if new_tab {
use super::element::resolve_element_object_id;
let object_id =
resolve_element_object_id(&mgr.client, &session_id, &state.ref_map, selector).await?;
let call_params = json!({
"objectId": object_id,
"functionDeclaration": "function() { var h = this.getAttribute('href'); if (!h) return null; try { return new URL(h, document.baseURI).toString(); } catch(e) { return null; } }",
"returnByValue": true
});
let call_result = mgr
.client
.send_command(
"Runtime.callFunctionOn",
Some(call_params),
Some(&session_id),
)
.await?;
let href = call_result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!(
"Element '{}' does not have an href attribute. --new-tab only works on links.",
selector
)
})?
.to_string();
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
state.ref_map.clear();
mgr.tab_new(Some(&href)).await?;
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
}
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
@@ -5334,6 +5405,12 @@ mod tests {
#[tokio::test]
async fn test_credentials_roundtrip_via_actions() {
let _lock = crate::native::auth::AUTH_TEST_MUTEX.lock().unwrap();
let key_var = "AGENT_BROWSER_ENCRYPTION_KEY";
let original = std::env::var(key_var).ok();
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
unsafe { std::env::set_var(key_var, "a".repeat(64)) };
let mut state = DaemonState::new();
let set_cmd = json!({
@@ -5366,6 +5443,12 @@ mod tests {
});
let result = execute_command(&del_cmd, &mut state).await;
assert_eq!(result["success"], true);
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
match original {
Some(val) => unsafe { std::env::set_var(key_var, val) },
None => unsafe { std::env::remove_var(key_var) },
}
}
#[tokio::test]
+4 -4
View File
@@ -160,7 +160,7 @@ impl BrowserProcess {
}
pub struct BrowserManager {
pub client: CdpClient,
pub client: Arc<CdpClient>,
browser_process: Option<BrowserProcess>,
ws_url: String,
pages: Vec<PageInfo>,
@@ -226,7 +226,7 @@ impl BrowserManager {
let manager = if engine == "lightpanda" {
initialize_lightpanda_manager(ws_url, process).await?
} else {
let client = CdpClient::connect(&ws_url).await?;
let client = Arc::new(CdpClient::connect(&ws_url).await?);
let mut manager = Self {
client,
browser_process: Some(process),
@@ -290,7 +290,7 @@ impl BrowserManager {
pub async fn connect_cdp(url: &str) -> Result<Self, String> {
let ws_url = resolve_cdp_url(url).await?;
let client = CdpClient::connect(&ws_url).await?;
let client = Arc::new(CdpClient::connect(&ws_url).await?);
let mut manager = Self {
client,
browser_process: None,
@@ -1173,7 +1173,7 @@ async fn initialize_lightpanda_manager(
};
let mut manager = BrowserManager {
client,
client: Arc::new(client),
browser_process: None,
ws_url: ws_url.clone(),
pages: Vec::new(),
+13 -10
View File
@@ -190,7 +190,7 @@ 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.")?
find_chrome().ok_or("Chrome not found. Run `agent-browser install` to download Chrome, or use --executable-path.")?
}
};
@@ -320,6 +320,12 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
}
pub fn find_chrome() -> Option<PathBuf> {
// 1. Check Chrome downloaded by `agent-browser install`
if let Some(p) = crate::install::find_installed_chrome() {
return Some(p);
}
// 2. Check system-installed Chrome
#[cfg(target_os = "macos")]
{
let candidates = [
@@ -333,10 +339,6 @@ pub fn find_chrome() -> Option<PathBuf> {
return Some(p);
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "linux")]
@@ -357,10 +359,6 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
}
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
}
#[cfg(target_os = "windows")]
@@ -383,6 +381,11 @@ pub fn find_chrome() -> Option<PathBuf> {
}
}
// 3. Fallback: check Playwright's browser cache (for existing installs)
if let Some(p) = find_playwright_chromium() {
return Some(p);
}
None
}
@@ -500,7 +503,7 @@ fn should_disable_sandbox(existing_args: &[String]) -> bool {
}
/// Search Playwright's browser cache for a Chromium binary.
/// This is where `agent-browser install` (via `npx playwright install chromium`) puts it.
/// Legacy fallback for users who previously installed Chromium via Playwright.
fn find_playwright_chromium() -> Option<PathBuf> {
let mut search_dirs = Vec::new();
+3
View File
@@ -342,6 +342,7 @@ mod tests {
socket.write_all(response.as_bytes()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn waits_for_ready_without_logs() {
let port = unused_port();
@@ -369,6 +370,7 @@ mod tests {
let _ = child.wait();
}
#[cfg(unix)]
#[tokio::test]
async fn child_exit_surfaces_logs() {
let port = unused_port();
@@ -389,6 +391,7 @@ mod tests {
assert!(err.contains("boom"));
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_reports_last_probe_error() {
let port = unused_port();
+109 -11
View File
@@ -3,12 +3,17 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::signal;
use tokio::sync::{mpsc, RwLock};
use super::actions::{execute_command, DaemonState};
use super::cdp::client::CdpClient;
use super::state;
use super::stream::StreamServer;
pub async fn run_daemon(session: &str) {
let socket_dir = get_daemon_socket_dir();
@@ -33,7 +38,34 @@ pub async fn run_daemon(session: &str) {
}
}
let result = run_socket_server(&socket_path, session).await;
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") {
if let Ok(port) = port_str.parse::<u16>() {
if port > 0 {
match StreamServer::start_without_client(port, session.to_string()).await {
Ok((stream_server, client_slot)) => {
stream_client = Some(client_slot.clone());
let stream_path = socket_dir.join(format!("{}.stream", session));
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
eprintln!("Failed to write .stream file: {}", e);
}
}
Err(e) => {
eprintln!("Stream server failed to start: {}", e);
}
}
}
}
}
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
// Disabled when unset or 0.
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&ms| ms > 0);
let result = run_socket_server(&socket_path, session, stream_client, idle_timeout_ms).await;
let _ = fs::remove_file(&socket_path);
let _ = fs::remove_file(&pid_path);
@@ -47,23 +79,36 @@ pub async fn run_daemon(session: &str) {
}
#[cfg(unix)]
async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
_session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::UnixListener;
let listener =
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, reset_tx).await;
});
}
Err(e) => {
@@ -71,6 +116,22 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
let _ = mgr.close().await;
}
break;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -85,7 +146,12 @@ async fn run_socket_server(socket_path: &PathBuf, _session: &str) -> Result<(),
}
#[cfg(windows)]
async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), String> {
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
idle_timeout_ms: Option<u64>,
) -> Result<(), String> {
use tokio::net::TcpListener;
let port = get_port_for_session(session);
@@ -97,17 +163,25 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
let port_path = socket_dir.join(format!("{}.port", session));
let _ = fs::write(&port_path, port.to_string());
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
tokio::sync::Mutex::new(DaemonState::new_with_stream_client(stream_client)),
);
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
loop {
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
let mut sleep_pin = sleep_future.map(Box::pin);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let reset_tx = reset_tx.clone();
tokio::spawn(async move {
handle_connection(stream, state).await;
handle_connection(stream, state, reset_tx).await;
});
}
Err(e) => {
@@ -115,6 +189,23 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
}
}
}
_ = async {
if let Some(ref mut s) = sleep_pin {
s.as_mut().await
} else {
std::future::pending::<()>().await
}
}, if idle_timeout_ms.is_some() => {
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;
}
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
continue;
}
_ = shutdown_signal() => {
let mut s = state.lock().await;
if let Some(ref mut mgr) = s.browser {
@@ -129,8 +220,11 @@ async fn run_socket_server(socket_path: &PathBuf, session: &str) -> Result<(), S
Ok(())
}
async fn handle_connection<S>(stream: S, state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>)
where
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
@@ -165,6 +259,10 @@ where
}
};
if let Some(ref tx) = idle_reset_tx {
let _ = tx.try_send(());
}
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
let response = {
+1
View File
@@ -533,6 +533,7 @@ async fn test_daemon_state_new_defaults() {
assert!(state.tracked_requests.is_empty());
assert!(state.active_frame_id.is_none());
assert!(state.webdriver_backend.is_none());
assert!(state.stream_client.is_none());
}
#[tokio::test]
+40 -13
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, Mutex};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio_tungstenite::tungstenite::Message;
use super::cdp::client::CdpClient;
@@ -47,6 +47,27 @@ impl StreamServer {
client: Arc<CdpClient>,
session_id: String,
) -> Result<Self, String> {
let client_slot = Arc::new(RwLock::new(Some(client)));
let (server, _) = Self::start_inner(preferred_port, client_slot, session_id).await?;
Ok(server)
}
/// Start the stream server without a CDP client (e.g. at daemon startup before browser launch).
/// Returns the server and a shared slot to set the client when the browser launches.
/// Input messages are ignored until the client is set.
pub async fn start_without_client(
preferred_port: u16,
session_id: String,
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
let client_slot = Arc::new(RwLock::new(None::<Arc<CdpClient>>));
Self::start_inner(preferred_port, client_slot, session_id).await
}
async fn start_inner(
preferred_port: u16,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
let addr = format!("127.0.0.1:{}", preferred_port);
let listener = TcpListener::bind(&addr)
.await
@@ -62,23 +83,27 @@ impl StreamServer {
let frame_tx_clone = frame_tx.clone();
let client_count_clone = client_count.clone();
let client_slot_clone = client_slot.clone();
tokio::spawn(async move {
accept_loop(
listener,
frame_tx_clone,
client_count_clone,
client,
client_slot_clone,
session_id,
)
.await;
});
Ok(Self {
port,
frame_tx,
client_count,
})
Ok((
Self {
port,
frame_tx,
client_count,
},
client_slot,
))
}
pub fn port(&self) -> u16 {
@@ -140,17 +165,17 @@ async fn accept_loop(
listener: TcpListener,
frame_tx: broadcast::Sender<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) {
while let Ok((stream, addr)) = listener.accept().await {
let frame_rx = frame_tx.subscribe();
let client_count = client_count.clone();
let cdp = cdp_client.clone();
let client_slot = client_slot.clone();
let sid = session_id.clone();
tokio::spawn(async move {
handle_ws_client(stream, addr, frame_rx, client_count, cdp, sid).await;
handle_ws_client(stream, addr, frame_rx, client_count, client_slot, sid).await;
});
}
}
@@ -161,10 +186,9 @@ async fn handle_ws_client(
_addr: SocketAddr,
mut frame_rx: broadcast::Receiver<String>,
client_count: Arc<Mutex<usize>>,
cdp_client: Arc<CdpClient>,
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
session_id: String,
) {
// Origin checking on WebSocket handshake
let callback =
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
@@ -211,7 +235,10 @@ async fn handle_ws_client(
msg = ws_rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
handle_client_message(&text, &cdp_client, &session_id).await;
let guard = client_slot.read().await;
if let Some(ref client) = *guard {
handle_client_message(&text, client.as_ref(), &session_id).await;
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
+9 -14
View File
@@ -1389,8 +1389,7 @@ Options:
Each label [N] corresponds to ref @eN from snapshot.
Prints a legend mapping labels to element roles/names.
With --json, annotations are included in the response.
In native mode, this is currently supported on the
CDP-backed browser path (Chromium/Lightpanda).
Supported on Chromium and Lightpanda.
--screenshot-dir <path> Default output directory for screenshots
(or AGENT_BROWSER_SCREENSHOT_DIR env)
--screenshot-quality <0-100> JPEG quality (0-100, only applies to jpeg format)
@@ -1986,7 +1985,7 @@ agent-browser trace - Record execution trace
Usage: agent-browser trace <operation> [path]
Record a trace for debugging with Playwright Trace Viewer.
Record a Chrome DevTools trace for debugging.
Operations:
start [path] Start recording trace
@@ -2053,7 +2052,7 @@ Usage: agent-browser record start <path.webm> [url]
agent-browser record stop
agent-browser record restart <path.webm> [url]
Record the browser to a WebM video file using Playwright's native recording.
Record the browser to a WebM video file.
Creates a fresh browser context but preserves cookies and localStorage.
If no URL is provided, automatically navigates to your current page.
@@ -2499,7 +2498,7 @@ Diff:
diff url <u1> <u2> Compare two pages
Debug:
trace start|stop [path] Record Playwright trace
trace start|stop [path] Record Chrome DevTools trace
profiler start|stop [path] Record Chrome DevTools profile
record start <path> [url] Start video recording (WebM)
record stop Stop and save video
@@ -2576,8 +2575,7 @@ Options:
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--engine <name> Browser engine: chrome (default), lightpanda; implies --native (or AGENT_BROWSER_ENGINE)
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--engine <name> Browser engine: chrome (default), lightpanda (or AGENT_BROWSER_ENGINE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2620,11 +2618,12 @@ Environment:
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default)
AGENT_BROWSER_IOS_DEVICE Default iOS device name
AGENT_BROWSER_IOS_UDID Default iOS device UDID
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
@@ -2634,17 +2633,13 @@ Environment:
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
AGENT_BROWSER_ENGINE Browser engine: chrome (default), lightpanda
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
AGENT_BROWSER_SCREENSHOT_DIR Default screenshot output directory
AGENT_BROWSER_SCREENSHOT_QUALITY JPEG quality 0-100
AGENT_BROWSER_SCREENSHOT_FORMAT Screenshot format: png, jpeg
Install (recommended, fastest - native Rust CLI):
Install:
npm install -g agent-browser
agent-browser install # Download Chromium (first time)
Try without installing (slower, routes through Node.js):
npx agent-browser open example.com
agent-browser install # Download Chrome (first time)
Examples:
agent-browser open example.com