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
+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,
_ => {}