use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; #[cfg(unix)] use std::os::unix::net::UnixStream; #[cfg(windows)] use windows_sys::Win32::Foundation::CloseHandle; #[cfg(windows)] use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; #[derive(Serialize)] #[allow(dead_code)] pub struct Request { pub id: String, pub action: String, #[serde(flatten)] pub extra: Value, } #[derive(Deserialize, Serialize, Default)] pub struct Response { pub success: bool, pub data: Option, pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub warning: Option, } #[allow(dead_code)] pub enum Connection { #[cfg(unix)] Unix(UnixStream), Tcp(TcpStream), } impl Read for Connection { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { match self { #[cfg(unix)] Connection::Unix(s) => s.read(buf), Connection::Tcp(s) => s.read(buf), } } } impl Write for Connection { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self { #[cfg(unix)] Connection::Unix(s) => s.write(buf), Connection::Tcp(s) => s.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { #[cfg(unix)] Connection::Unix(s) => s.flush(), Connection::Tcp(s) => s.flush(), } } } impl Connection { pub fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { match self { #[cfg(unix)] Connection::Unix(s) => s.set_read_timeout(dur), Connection::Tcp(s) => s.set_read_timeout(dur), } } pub fn set_write_timeout(&self, dur: Option) -> std::io::Result<()> { match self { #[cfg(unix)] Connection::Unix(s) => s.set_write_timeout(dur), Connection::Tcp(s) => s.set_write_timeout(dur), } } } /// Get the base directory for socket/pid files. /// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir pub fn get_socket_dir() -> PathBuf { // 1. Explicit override (ignore empty string) if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") { if !dir.is_empty() { return PathBuf::from(dir); } } // 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string) if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") { if !runtime_dir.is_empty() { return PathBuf::from(runtime_dir).join("agent-browser"); } } // 3. Home directory fallback (like Docker Desktop's ~/.docker/run/) if let Some(home) = dirs::home_dir() { return home.join(".agent-browser"); } // 4. Last resort: temp dir env::temp_dir().join("agent-browser") } #[cfg(unix)] fn get_socket_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.sock", session)) } fn get_pid_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.pid", session)) } fn get_version_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.version", session)) } /// Path to the sidecar file that records the URL the previous daemon was on, /// used to restore navigation after a version-mismatch restart. Only written /// when the version-mismatch branch fires; cleared after the new daemon /// reads it. Manual `close` does not write this file, so a clean shutdown /// won't trigger surprise navigation. pub fn get_restore_url_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.restore-url", session)) } /// Clean up stale socket and PID files for a session pub fn cleanup_stale_files(session: &str) { let pid_path = get_pid_path(session); let _ = fs::remove_file(&pid_path); let version_path = get_version_path(session); let _ = fs::remove_file(&version_path); let stream_path = get_socket_dir().join(format!("{}.stream", session)); let _ = fs::remove_file(&stream_path); // Note: the .restore-url sidecar is intentionally NOT removed here — // it lives across the brief window between killing the old daemon // and the new daemon reading it back. The new daemon deletes it after // restoring (see actions::auto_launch). #[cfg(unix)] { let socket_path = get_socket_path(session); let _ = fs::remove_file(&socket_path); } #[cfg(windows)] { let port_path = get_port_path(session); let _ = fs::remove_file(&port_path); } } /// Returns whether a process with the given PID is currently alive. /// /// On unix, EPERM (process exists but we can't signal it) counts as alive /// so we don't mis-clean a live daemon owned by a different uid. Only ESRCH /// ("no such process") is treated as dead. pub fn is_pid_alive(pid: u32) -> bool { #[cfg(unix)] unsafe { if libc::kill(pid as i32, 0) == 0 { return true; } std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) } #[cfg(windows)] unsafe { let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); if handle != 0 { CloseHandle(handle); true } else { false } } } /// A currently-running daemon session discovered by [`walk_daemons`]. #[derive(Debug, Clone)] pub struct ActiveSession { pub name: String, pub pid: u32, /// Contents of the session's `.version` file if present and non-empty. pub version: Option, } /// Why a session's sidecar files were cleaned up during a walk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CleanReason { /// The `.pid` file referenced a process that no longer exists. ProcessGone, /// The `.pid` file could not be parsed as a PID. UnreadablePidFile, /// A `.sock` file had no corresponding `.pid` file (unix only). OrphanedSocket, /// The `dashboard.pid` referenced a process that no longer exists. DashboardGone, } /// A session whose sidecar files were removed as a side effect of a walk. #[derive(Debug, Clone)] pub struct CleanedSession { pub name: String, pub reason: CleanReason, } /// Information about the standalone dashboard process, if any. #[derive(Debug, Clone, Copy)] pub struct DashboardInfo { pub pid: u32, pub alive: bool, } /// Snapshot of daemon state under [`get_socket_dir()`] after a walk. Stale /// sidecar files are cleaned up as a side effect and recorded in `cleaned`. #[derive(Debug, Default)] pub struct DaemonInventory { pub sessions: Vec, pub cleaned: Vec, pub dashboard: Option, } /// Read the session's `.version` sidecar if present and non-empty. pub fn read_session_version(session: &str) -> Option { let path = get_socket_dir().join(format!("{}.version", session)); fs::read_to_string(&path) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } /// Walk the socket directory and classify each `.pid` / `.sock` entry. /// /// - Live daemons go into `sessions` with their `.version` file contents. /// - Stale entries (process gone, unreadable pid, orphaned `.sock`) are /// cleaned via [`cleanup_stale_files`] and recorded in `cleaned`. /// - `dashboard.pid` lands in `dashboard` with liveness info; if the /// process is gone, the pid file is removed and a `DashboardGone` entry /// is added to `cleaned`. /// /// If the socket directory doesn't exist, returns an empty inventory with /// no side effects. pub fn walk_daemons() -> DaemonInventory { let socket_dir = get_socket_dir(); let mut inventory = DaemonInventory::default(); let entries = match fs::read_dir(&socket_dir) { Ok(e) => e, Err(_) => return inventory, }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name == "dashboard.pid" { if let Ok(s) = fs::read_to_string(entry.path()) { if let Ok(pid) = s.trim().parse::() { let alive = is_pid_alive(pid); inventory.dashboard = Some(DashboardInfo { pid, alive }); if !alive { let _ = fs::remove_file(entry.path()); inventory.cleaned.push(CleanedSession { name: "dashboard".to_string(), reason: CleanReason::DashboardGone, }); } } } continue; } let session_name = match name.strip_suffix(".pid") { Some(s) if !s.is_empty() => s.to_string(), _ => continue, }; let pid = match fs::read_to_string(entry.path()) .ok() .and_then(|s| s.trim().parse::().ok()) { Some(p) => p, None => { cleanup_stale_files(&session_name); inventory.cleaned.push(CleanedSession { name: session_name, reason: CleanReason::UnreadablePidFile, }); continue; } }; if !is_pid_alive(pid) { cleanup_stale_files(&session_name); inventory.cleaned.push(CleanedSession { name: session_name, reason: CleanReason::ProcessGone, }); continue; } let version = read_session_version(&session_name); inventory.sessions.push(ActiveSession { name: session_name, pid, version, }); } // Orphaned .sock files without a corresponding .pid (unix only). #[cfg(unix)] 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 let Some(session_name) = name.strip_suffix(".sock") { if session_name.is_empty() { continue; } let pid_path = socket_dir.join(format!("{}.pid", session_name)); if !pid_path.exists() { cleanup_stale_files(session_name); inventory.cleaned.push(CleanedSession { name: session_name.to_string(), reason: CleanReason::OrphanedSocket, }); } } } } inventory } #[cfg(windows)] fn get_port_path(session: &str) -> PathBuf { get_socket_dir().join(format!("{}.port", session)) } #[cfg(windows)] pub fn get_port_for_session(session: &str) -> u16 { let mut hash: i32 = 0; for c in session.chars() { hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); } // Correct logic: first take absolute modulo, then cast to u16 // Using unsigned_abs() to safely handle i32::MIN 49152 + ((hash.unsigned_abs() as u32 % 16383) as u16) } /// Read the actual daemon port from the `.port` file written by the daemon. /// Falls back to the hash-derived port if the file does not exist or is /// unreadable (e.g. daemon has not started yet). #[cfg(windows)] pub fn resolve_port(session: &str) -> u16 { let port_path = get_port_path(session); fs::read_to_string(&port_path) .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or_else(|| get_port_for_session(session)) } pub fn daemon_ready(session: &str) -> bool { #[cfg(unix)] { let socket_path = get_socket_path(session); UnixStream::connect(&socket_path).is_ok() } #[cfg(windows)] { let port = resolve_port(session); TcpStream::connect_timeout( &format!("127.0.0.1:{}", port).parse().unwrap(), Duration::from_millis(50), ) .is_ok() } } /// Result of ensure_daemon indicating whether a new daemon was started pub struct DaemonResult { /// True if we connected to an existing daemon, false if we started a new one pub already_running: bool, } /// Options forwarded to the daemon process as environment variables. /// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side /// UX concern (prompting the user on stdin) and not a daemon configuration. /// The daemon only needs `confirm_actions` to gate action categories. pub struct DaemonOptions<'a> { pub headed: bool, pub debug: bool, pub executable_path: Option<&'a str>, pub extensions: &'a [String], pub init_scripts: &'a [String], pub enable: &'a [String], pub args: Option<&'a str>, pub user_agent: Option<&'a str>, pub proxy: Option<&'a str>, pub proxy_bypass: Option<&'a str>, pub proxy_username: Option<&'a str>, pub proxy_password: Option<&'a str>, pub ignore_https_errors: bool, pub allow_file_access: bool, pub hide_scrollbars: bool, pub profile: Option<&'a str>, pub state: Option<&'a str>, pub provider: Option<&'a str>, pub device: Option<&'a str>, pub session_name: Option<&'a str>, pub download_path: Option<&'a str>, pub allowed_domains: Option<&'a [String]>, pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, pub auto_connect: bool, pub force_launch: bool, pub idle_timeout: Option<&'a str>, pub default_timeout: Option, pub cdp: Option<&'a str>, pub no_auto_dialog: bool, } fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { cmd.env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_SESSION", session); if opts.headed { cmd.env("AGENT_BROWSER_HEADED", "1"); } if opts.debug { cmd.env("AGENT_BROWSER_DEBUG", "1"); } if let Some(path) = opts.executable_path { cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); } if !opts.extensions.is_empty() { cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(",")); } if !opts.init_scripts.is_empty() { cmd.env("AGENT_BROWSER_INIT_SCRIPTS", opts.init_scripts.join(",")); } if !opts.enable.is_empty() { cmd.env("AGENT_BROWSER_ENABLE", opts.enable.join(",")); } if let Some(a) = opts.args { cmd.env("AGENT_BROWSER_ARGS", a); } if let Some(ua) = opts.user_agent { cmd.env("AGENT_BROWSER_USER_AGENT", ua); } if let Some(p) = opts.proxy { cmd.env("AGENT_BROWSER_PROXY", p); } if let Some(pb) = opts.proxy_bypass { cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); } if let Some(pu) = opts.proxy_username { cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu); } if let Some(pp) = opts.proxy_password { cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp); } if opts.ignore_https_errors { cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); } if opts.allow_file_access { cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); } cmd.env( "AGENT_BROWSER_HIDE_SCROLLBARS", if opts.hide_scrollbars { "1" } else { "0" }, ); if let Some(prof) = opts.profile { cmd.env("AGENT_BROWSER_PROFILE", prof); } if let Some(st) = opts.state { cmd.env("AGENT_BROWSER_STATE", st); } if let Some(p) = opts.provider { cmd.env("AGENT_BROWSER_PROVIDER", p); } if let Some(d) = opts.device { cmd.env("AGENT_BROWSER_IOS_DEVICE", d); } if let Some(sn) = opts.session_name { cmd.env("AGENT_BROWSER_SESSION_NAME", sn); } if let Some(dp) = opts.download_path { cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); } if let Some(ad) = opts.allowed_domains { cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(",")); } if let Some(ap) = opts.action_policy { cmd.env("AGENT_BROWSER_ACTION_POLICY", ap); } if let Some(ca) = opts.confirm_actions { cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca); } if let Some(engine) = opts.engine { cmd.env("AGENT_BROWSER_ENGINE", engine); } if opts.auto_connect { cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1"); } if opts.force_launch { cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "1"); } if let Some(idle) = opts.idle_timeout { cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle); } if let Some(timeout) = opts.default_timeout { cmd.env("AGENT_BROWSER_DEFAULT_TIMEOUT", timeout.to_string()); } if let Some(cdp) = opts.cdp { cmd.env("AGENT_BROWSER_CDP", cdp); } if opts.no_auto_dialog { cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1"); } } /// Check if the running daemon's version matches this CLI binary. /// Returns false when the version file is missing — an unversioned daemon /// is most likely a stale leftover from before version tracking was added /// (or from the Node.js era), and silently reusing it is the exact bug /// this check exists to prevent. The one-time cost of an unnecessary /// restart on the first upgrade is preferable to silent failures. fn daemon_version_matches(session: &str) -> bool { let version_path = get_version_path(session); match fs::read_to_string(&version_path) { Ok(v) => v.trim() == env!("CARGO_PKG_VERSION"), Err(_) => false, } } /// One-shot socket query for the running daemon's current URL. /// Returns None on any kind of failure — caller must treat as best-effort. fn query_current_url(session: &str) -> Option { let cmd = serde_json::json!({ "id": format!("restore-url-probe-{}", std::process::id()), "action": "url", }); let resp = send_command_once(&cmd, session).ok()?; if !resp.success { return None; } resp.data .as_ref() .and_then(|d| d.get("url")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) } /// Kill a running daemon by reading its PID file and sending a kill signal. fn kill_stale_daemon(session: &str) { // Remove the socket first so no new connections reach the old daemon #[cfg(unix)] { let socket_path = get_socket_path(session); let _ = fs::remove_file(&socket_path); } let pid_path = get_pid_path(session); if let Ok(pid_str) = fs::read_to_string(&pid_path) { if let Ok(pid) = pid_str.trim().parse::() { #[cfg(unix)] { unsafe { libc::kill(pid as i32, libc::SIGTERM); } // Wait up to 1s for graceful shutdown, then force-kill for _ in 0..10 { thread::sleep(Duration::from_millis(100)); if unsafe { libc::kill(pid as i32, 0) } != 0 { break; } } // Force-kill if still alive if unsafe { libc::kill(pid as i32, 0) } == 0 { unsafe { libc::kill(pid as i32, libc::SIGKILL); } thread::sleep(Duration::from_millis(100)); } } #[cfg(windows)] { let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); thread::sleep(Duration::from_millis(500)); } } } // Clean up leftover files regardless cleanup_stale_files(session); } pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result { // Socket connectivity is the sole liveness check — no PID check — so // callers in a different PID namespace (e.g. unshare) can still reuse // an existing daemon they can reach over the socket. if daemon_ready(session) { // Double-check it's actually responsive by waiting and checking again // This handles the race condition where daemon is shutting down // (daemon has a 100ms shutdown delay, so we wait longer) thread::sleep(Duration::from_millis(150)); if daemon_ready(session) { // Check version: if the running daemon is from a different CLI // version (e.g. after an upgrade), kill it and start a fresh one. if !daemon_version_matches(session) { eprintln!( "{} Daemon version mismatch detected, restarting...", crate::color::warning_indicator() ); // Best-effort: ask the old daemon for its current URL so the // new daemon can restore navigation after auto-connect. If the // query fails (already shutting down, no browser, etc.) we // silently skip — the user just sees about:blank as before. if let Some(url) = query_current_url(session) { if !url.is_empty() && url != "about:blank" { let path = get_restore_url_path(session); let _ = fs::write(&path, &url); } } kill_stale_daemon(session); // Fall through to spawn a new daemon below } else { return Ok(DaemonResult { already_running: true, }); } } } // Clean up any stale socket/pid files before starting fresh cleanup_stale_files(session); // Ensure socket directory exists let socket_dir = get_socket_dir(); if !socket_dir.exists() { fs::create_dir_all(&socket_dir) .map_err(|e| format!("Failed to create socket directory: {}", e))?; } // Pre-flight check: Validate socket path length (Unix limit is 104 bytes including null terminator) #[cfg(unix)] { let socket_path = get_socket_path(session); let path_len = socket_path.as_os_str().len(); if path_len > 103 { return Err(format!( "Session name '{}' is too long. Socket path would be {} bytes (max 103).\n\ Use a shorter session name or set AGENT_BROWSER_SOCKET_DIR to a shorter path.", session, path_len )); } } // Pre-flight check: Verify socket directory is writable { let test_file = socket_dir.join(".write_test"); match fs::write(&test_file, b"") { Ok(_) => { let _ = fs::remove_file(&test_file); } Err(e) => { return Err(format!( "Socket directory '{}' is not writable: {}", socket_dir.display(), e )); } } } let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_path = exe_path.canonicalize().unwrap_or(exe_path); #[allow(unused_assignments)] let mut daemon_child: Option = None; #[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); 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; let mut cmd = Command::new(&exe_path); cmd.env("AGENT_BROWSER_DAEMON", "1"); 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))?, ); } for _ in 0..50 { if daemon_ready(session) { return Ok(DaemonResult { already_running: false, }); } // Detect early daemon exit and surface the real error from stderr 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 the daemon failed because another instance won the bind // race ("Address already in use"), check whether that winner is // now accepting connections and piggyback on it. if stderr_trimmed.contains("Address already in use") || stderr_trimmed.contains("Failed to bind") { thread::sleep(Duration::from_millis(200)); if daemon_ready(session) { return Ok(DaemonResult { already_running: true, }); } } if !stderr_trimmed.is_empty() { let msg = if stderr_trimmed.len() > 500 { let mut end = 500; while !stderr_trimmed.is_char_boundary(end) { end -= 1; } &stderr_trimmed[..end] } else { stderr_trimmed }; return Err(format!("Daemon process exited during startup:\n{}", msg)); } return Err( "Daemon process exited during startup with no error output. \ Re-run with --debug for more details." .to_string(), ); } } thread::sleep(Duration::from_millis(100)); } #[cfg(unix)] let endpoint_info = format!( "socket: {}", get_socket_dir().join(format!("{}.sock", session)).display() ); #[cfg(windows)] let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session)); Err(format!("Daemon failed to start ({})", endpoint_info)) } fn connect(session: &str) -> Result { #[cfg(unix)] { let socket_path = get_socket_path(session); UnixStream::connect(&socket_path) .map(Connection::Unix) .map_err(|e| format!("Failed to connect: {}", e)) } #[cfg(windows)] { let port = resolve_port(session); TcpStream::connect(format!("127.0.0.1:{}", port)) .map(Connection::Tcp) .map_err(|e| format!("Failed to connect: {}", e)) } } pub fn send_command(cmd: Value, session: &str) -> Result { // Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues) const MAX_RETRIES: u32 = 5; const RETRY_DELAY_MS: u64 = 200; let mut last_error = String::new(); for attempt in 0..MAX_RETRIES { if attempt > 0 { thread::sleep(Duration::from_millis(RETRY_DELAY_MS * (attempt as u64))); } match send_command_once(&cmd, session) { Ok(response) => return Ok(response), Err(e) => { if is_transient_error(&e) { last_error = e; continue; } // Non-transient error, fail immediately return Err(e); } } } Err(format!( "{} (after {} retries - daemon may be busy or unresponsive)", last_error, MAX_RETRIES )) } /// Check if an error is transient and worth retrying. /// Transient errors include: /// - EAGAIN/EWOULDBLOCK (os error 35 on macOS, 11 on Linux) /// - EOF errors (daemon closed connection before responding) /// - Connection reset/broken pipe (daemon crashed or restarting) /// - Connection refused/socket not found (daemon still starting) fn is_transient_error(error: &str) -> bool { error.contains("os error 35") // EAGAIN on macOS || error.contains("os error 11") // EAGAIN on Linux || error.contains("WouldBlock") || error.contains("Resource temporarily unavailable") || error.contains("EOF") || error.contains("line 1 column 0") // Empty JSON response || error.contains("Connection reset") || error.contains("Broken pipe") || error.contains("os error 54") // Connection reset by peer (macOS) || error.contains("os error 104") // Connection reset by peer (Linux) || error.contains("os error 2") // No such file or directory (socket gone) || error.contains("os error 61") // Connection refused (macOS) || error.contains("os error 111") // Connection refused (Linux) || error.contains("os error 10061") // Connection refused (Windows) || error.contains("os error 10054") // Connection reset by peer (Windows) } fn send_command_once(cmd: &Value, session: &str) -> Result { let mut stream = connect(session)?; stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); let mut json_str = serde_json::to_string(cmd).map_err(|e| e.to_string())?; json_str.push('\n'); stream .write_all(json_str.as_bytes()) .map_err(|e| format!("Failed to send: {}", e))?; let mut reader = BufReader::new(stream); let mut response_line = String::new(); reader .read_line(&mut response_line) .map_err(|e| format!("Failed to read: {}", e))?; serde_json::from_str(&response_line).map_err(|e| format!("Invalid response: {}", e)) } #[cfg(test)] mod tests { use super::*; use crate::test_utils::EnvGuard; #[test] fn test_get_socket_dir_explicit_override() { let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path"); _guard.remove("XDG_RUNTIME_DIR"); assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path")); } #[test] fn test_get_socket_dir_ignores_empty_socket_dir() { let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", ""); _guard.remove("XDG_RUNTIME_DIR"); assert!(get_socket_dir() .to_string_lossy() .ends_with(".agent-browser")); } #[test] fn test_get_socket_dir_xdg_runtime() { let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.remove("AGENT_BROWSER_SOCKET_DIR"); _guard.set("XDG_RUNTIME_DIR", "/run/user/1000"); assert_eq!( get_socket_dir(), PathBuf::from("/run/user/1000/agent-browser") ); } #[test] fn test_get_socket_dir_ignores_empty_xdg_runtime() { let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", ""); _guard.set("XDG_RUNTIME_DIR", ""); assert!(get_socket_dir() .to_string_lossy() .ends_with(".agent-browser")); } #[test] fn test_get_socket_dir_home_fallback() { let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.remove("AGENT_BROWSER_SOCKET_DIR"); _guard.remove("XDG_RUNTIME_DIR"); let result = get_socket_dir(); assert!(result.to_string_lossy().ends_with(".agent-browser")); assert!( result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users") ); } // === Transient Error Detection Tests === #[test] fn test_is_transient_error_eagain_macos() { assert!(is_transient_error( "Failed to read: Resource temporarily unavailable (os error 35)" )); } #[test] fn test_is_transient_error_eagain_linux() { assert!(is_transient_error( "Failed to read: Resource temporarily unavailable (os error 11)" )); } #[test] fn test_is_transient_error_would_block() { assert!(is_transient_error("operation WouldBlock")); } #[test] fn test_is_transient_error_resource_unavailable() { assert!(is_transient_error("Resource temporarily unavailable")); } #[test] fn test_is_transient_error_eof() { assert!(is_transient_error( "Invalid response: EOF while parsing a value at line 1 column 0" )); } #[test] fn test_is_transient_error_empty_json() { assert!(is_transient_error( "Invalid response: expected value at line 1 column 0" )); } #[test] fn test_is_transient_error_connection_reset() { assert!(is_transient_error("Connection reset by peer")); } #[test] fn test_is_transient_error_broken_pipe() { assert!(is_transient_error("Broken pipe")); } #[test] fn test_is_transient_error_connection_reset_macos() { assert!(is_transient_error( "Failed to send: Connection reset by peer (os error 54)" )); } #[test] fn test_is_transient_error_connection_reset_linux() { assert!(is_transient_error( "Failed to send: Connection reset by peer (os error 104)" )); } #[test] fn test_is_transient_error_socket_not_found() { assert!(is_transient_error( "Failed to connect: No such file or directory (os error 2)" )); } #[test] fn test_is_transient_error_connection_refused_macos() { assert!(is_transient_error( "Failed to connect: Connection refused (os error 61)" )); } #[test] fn test_is_transient_error_connection_refused_linux() { assert!(is_transient_error( "Failed to connect: Connection refused (os error 111)" )); } #[test] fn test_is_transient_error_connection_refused_windows() { assert!(is_transient_error( "Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)" )); } #[test] fn test_is_transient_error_connection_reset_windows() { assert!(is_transient_error( "Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)" )); } #[test] fn test_is_transient_error_non_transient() { // These should NOT be considered transient assert!(!is_transient_error("Unknown command: foo")); assert!(!is_transient_error("Invalid JSON syntax")); assert!(!is_transient_error("Permission denied")); assert!(!is_transient_error("Daemon not found")); } #[test] #[cfg(windows)] fn test_get_port_for_session() { assert_eq!(get_port_for_session("default"), 50838); assert_eq!(get_port_for_session("my-session"), 63105); assert_eq!(get_port_for_session("work"), 51184); assert_eq!(get_port_for_session(""), 49152); } // === Daemon Version Mismatch Detection Tests === #[test] fn test_daemon_version_matches_same_version() { let dir = std::env::temp_dir().join("ab-test-version-match"); let _ = fs::create_dir_all(&dir); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); let version_path = dir.join("test-session.version"); let _ = fs::write(&version_path, env!("CARGO_PKG_VERSION")); assert!(daemon_version_matches("test-session")); let _ = fs::remove_file(&version_path); let _ = fs::remove_dir(&dir); } #[test] fn test_daemon_version_matches_different_version() { let dir = std::env::temp_dir().join("ab-test-version-mismatch"); let _ = fs::create_dir_all(&dir); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); let version_path = dir.join("test-session.version"); let _ = fs::write(&version_path, "0.0.0-old"); assert!(!daemon_version_matches("test-session")); let _ = fs::remove_file(&version_path); let _ = fs::remove_dir(&dir); } #[test] fn test_daemon_version_matches_no_file() { let dir = std::env::temp_dir().join("ab-test-version-nofile"); let _ = fs::create_dir_all(&dir); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); // No version file: treated as mismatch so stale pre-version-tracking // daemons (including Node.js era) are always restarted. assert!(!daemon_version_matches("test-session")); let _ = fs::remove_dir(&dir); } #[test] fn test_cleanup_stale_files_removes_version() { let dir = std::env::temp_dir().join("ab-test-cleanup-version"); let _ = fs::create_dir_all(&dir); let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]); _guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap()); let version_path = dir.join("test-session.version"); let _ = fs::write(&version_path, "0.1.0"); assert!(version_path.exists()); cleanup_stale_files("test-session"); assert!(!version_path.exists()); let _ = fs::remove_dir(&dir); } }