Skip to main content

agent_first_http/host/
browser.rs

1//! Browser handle. Holds a backend subprocess plus the discovered CDP WS URL.
2//!
3//! Chromium-family backends are spawned directly with an isolated environment
4//! and then connected through chromiumoxide. Lightpanda and Camoufox are also
5//! raw subprocesses. Callers outside this module only ever read `ws_url`; the
6//! engine-specific keepalive lives in a private enum so each variant can clean
7//! up on drop.
8
9use std::collections::VecDeque;
10use std::net::{SocketAddr, TcpListener as StdTcpListener};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use chromiumoxide::Browser;
16use tokio::io::AsyncBufReadExt;
17use tokio::net::TcpStream;
18use tokio::process::{Child, Command};
19use tokio::sync::Mutex;
20use tokio::task::JoinHandle;
21
22use crate::host::bootstrap::{BrowserChoice, HostArgs};
23use crate::shared::error::{Error, ErrorCode};
24
25mod camoufox;
26mod chromium;
27mod lightpanda;
28mod profile_launch;
29
30/// Maximum number of stderr lines buffered per browser process.
31const STDERR_RING_CAP: usize = 200;
32
33/// A running browser. `Drop` cleans up the engine-specific resources:
34/// aborting chromiumoxide event loops and terminating backend subprocesses.
35pub struct BrowserHandle {
36    pub ws_url: String,
37    pub family: String,
38    pub version: String,
39    /// OS process id for the primary backend process, when the host spawned one.
40    pub process_id: Option<u32>,
41    /// Resolved on-disk profile directory. Either the persistent profile
42    /// path under `$XDG_DATA_HOME/afhttp/profiles/<name>/` or the
43    /// ephemeral tempdir backing this host.
44    pub profile_path: PathBuf,
45    /// Browser-initiated downloads are captured inside the active profile.
46    pub download_dir: PathBuf,
47    /// `Some(path)` for the runtime tempdir backing an ephemeral profile.
48    /// Dropping the TempDir removes it from disk.
49    pub _ephemeral_dir: Option<tempfile::TempDir>,
50    /// Held for persistent profiles so lifecycle tooling can detect active use.
51    pub _profile_lock: Option<crate::sdk::profile::lock::Guard>,
52    _keepalive: BackendKeepalive,
53    /// Tail of the browser subprocess's stderr, capped at [`STDERR_RING_CAP`]
54    /// lines.
55    pub stderr_ring: Arc<Mutex<VecDeque<String>>>,
56}
57
58/// Engine-specific resources that must outlive every fetch against the host.
59/// Kept opaque so call sites can't reach into chromiumoxide types when the
60/// backend happens to be Lightpanda (or vice versa).
61enum BackendKeepalive {
62    Chromium {
63        _browser: Arc<Mutex<Browser>>,
64        handler_task: JoinHandle<()>,
65        child: Child,
66    },
67    /// Generic subprocess slot used by CDP-compatible subprocess backends
68    /// (lightpanda's own `serve`, the foxbridge -> camoufox stack, future
69    /// subprocess-driven engines). `kill_on_drop` plus the explicit
70    /// `start_kill` below guarantee the child process tree dies with
71    /// this handle.
72    Subprocess { child: Child },
73    /// No-op keepalive for synthetic handles (tests only).
74    None,
75}
76
77impl Drop for BackendKeepalive {
78    fn drop(&mut self) {
79        match self {
80            BackendKeepalive::Chromium {
81                handler_task,
82                child,
83                ..
84            } => {
85                handler_task.abort();
86                let _ = child.start_kill();
87            }
88            BackendKeepalive::Subprocess { child, .. } => {
89                // start_kill is non-blocking and the only thing we can do
90                // from a synchronous Drop. The subprocess gets SIGKILL'd by
91                // the OS; tempdir cleanup happens after.
92                let _ = child.start_kill();
93            }
94            BackendKeepalive::None => {}
95        }
96    }
97}
98
99impl BrowserHandle {
100    /// Create a synthetic handle that carries only a `profile_path`. Used
101    /// in tests that exercise the HTTP path without a real browser subprocess.
102    #[cfg(any(test, feature = "host"))]
103    pub fn synthetic(profile_path: PathBuf) -> Self {
104        BrowserHandle {
105            ws_url: String::new(),
106            family: "synthetic".to_string(),
107            version: String::new(),
108            process_id: None,
109            profile_path,
110            download_dir: PathBuf::new(),
111            _ephemeral_dir: None,
112            _profile_lock: None,
113            _keepalive: BackendKeepalive::None,
114            stderr_ring: Arc::new(Mutex::new(VecDeque::new())),
115        }
116    }
117
118    #[cfg(test)]
119    pub(crate) fn synthetic_ephemeral(ephemeral_dir: tempfile::TempDir) -> Self {
120        let profile_path = ephemeral_dir.path().to_path_buf();
121        BrowserHandle {
122            ws_url: String::new(),
123            family: "synthetic".to_string(),
124            version: String::new(),
125            process_id: None,
126            profile_path,
127            download_dir: PathBuf::new(),
128            _ephemeral_dir: Some(ephemeral_dir),
129            _profile_lock: None,
130            _keepalive: BackendKeepalive::None,
131            stderr_ring: Arc::new(Mutex::new(VecDeque::new())),
132        }
133    }
134}
135
136pub async fn launch(args: &HostArgs) -> Result<BrowserHandle, Error> {
137    match args.browser {
138        BrowserChoice::Lightpanda => lightpanda::launch(args).await,
139        BrowserChoice::Camoufox => camoufox::launch(args).await,
140        _ => chromium::launch(args).await,
141    }
142}
143
144/// Stable 32-bit FNV-1a hash of the profile path, used to seed
145/// fingerprint-chromium. Persistent profiles repeat across host
146/// restarts (so the spoofed surface stays consistent); ephemeral
147/// tempdir paths are unique per host instance (so each ephemeral host
148/// gets a distinct fingerprint). FNV-1a is portable and stable across
149/// Rust versions, unlike `std::hash::DefaultHasher`.
150fn fingerprint_seed_from_path(path: &std::path::Path) -> u32 {
151    let bytes = path.as_os_str().as_encoded_bytes();
152    let mut h: u32 = 0x811c_9dc5;
153    for b in bytes {
154        h ^= *b as u32;
155        h = h.wrapping_mul(0x0100_0193);
156    }
157    // The upstream tool documents `--fingerprint=<u32>`; we coerce
158    // away the value 0 so an unlikely all-zeros hash doesn't disable
159    // the spoofing pipeline by accident.
160    if h == 0 {
161        1
162    } else {
163        h
164    }
165}
166
167async fn ensure_download_dir(profile_dir: &std::path::Path) -> Result<PathBuf, Error> {
168    let dir = profile_dir.join("downloads");
169    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
170        Error::new(
171            ErrorCode::IoError,
172            format!("create download dir {}: {e}", dir.display()),
173        )
174    })?;
175    Ok(dir)
176}
177
178/// Configure a subprocess `Command` for backend launch with full env
179/// isolation: scrub everything, then re-inject the small allowlist of vars
180/// we actually need plus the explicit `--engine-env` passthroughs.
181///
182/// Allowlist rationale:
183/// - `PATH` — child may shell-exec helper utilities (DNS resolvers, fonts).
184/// - `HOME` — chromium falls back here for some XDG paths even with
185///   `--user-data-dir`; lightpanda uses it for cache dirs.
186/// - `LANG`, `LC_*` — engine locale is an honest engine-level fingerprint
187///   surface; agents that want to override pass `--engine-env`.
188/// - `TZ` — same reasoning for timezone.
189/// - `TMPDIR` — chromium's child processes use this for IPC.
190/// - `DISPLAY` — only meaningful for headful mode, but always cheap to
191///   pass through; the engine ignores it under headless.
192///
193/// Deliberate omissions (these are silent egress / behavior leaks):
194/// - `HTTP_PROXY`, `HTTPS_PROXY`, `SOCKS_PROXY`, `NO_PROXY`,
195///   `ALL_PROXY` — explicit `--proxy-url` (future flag) only.
196/// - `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` — we override
197///   with `--user-data-dir`; honoring these too could escape the profile.
198/// - `BROWSER` — affects xdg-open inside the engine.
199/// - `CHROME_*`, `MOZ_*`, `LIGHTPANDA_*` — engine-specific tunables.
200fn apply_subprocess_env(cmd: &mut Command, engine_envs: &[(String, String)]) {
201    cmd.env_clear();
202    const ALLOWLIST: &[&str] = &[
203        "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "DISPLAY",
204    ];
205    // Windows-essential variables. Without SYSTEMROOT the browser cannot
206    // initialize winsock (`WSALookupServiceBegin` fails) and the CDP/DevTools
207    // HTTP server never binds — so a scrubbed env silently breaks every
208    // browser-backed fetch on Windows. These are OS plumbing, not ambient
209    // browsing config (HTTP_PROXY/XDG/BROWSER stay scrubbed per the isolation
210    // invariant).
211    #[cfg(windows)]
212    const WINDOWS_ALLOWLIST: &[&str] = &[
213        "SYSTEMROOT",
214        "SystemDrive",
215        "windir",
216        "TEMP",
217        "TMP",
218        "APPDATA",
219        "LOCALAPPDATA",
220        "USERPROFILE",
221        "ProgramData",
222        "ProgramFiles",
223        "ProgramFiles(x86)",
224        "ProgramW6432",
225        "PATHEXT",
226        "COMSPEC",
227        "NUMBER_OF_PROCESSORS",
228        "PROCESSOR_ARCHITECTURE",
229    ];
230    for key in ALLOWLIST {
231        if let Ok(value) = std::env::var(key) {
232            cmd.env(key, value);
233        }
234    }
235    #[cfg(windows)]
236    for key in WINDOWS_ALLOWLIST {
237        if let Ok(value) = std::env::var(key) {
238            cmd.env(key, value);
239        }
240    }
241    for (k, v) in engine_envs {
242        cmd.env(k, v);
243    }
244}
245
246/// Spawn a task that reads `stderr` lines into a bounded ring buffer. Returns
247/// an empty ring when stderr is not piped.
248fn new_stderr_ring(stderr: Option<tokio::process::ChildStderr>) -> Arc<Mutex<VecDeque<String>>> {
249    let ring = Arc::new(Mutex::new(VecDeque::<String>::new()));
250    if let Some(stderr) = stderr {
251        let ring_w = ring.clone();
252        tokio::spawn(async move {
253            let mut reader = tokio::io::BufReader::new(stderr).lines();
254            while let Ok(Some(line)) = reader.next_line().await {
255                let mut guard = ring_w.lock().await;
256                if guard.len() >= STDERR_RING_CAP {
257                    guard.pop_front();
258                }
259                guard.push_back(line);
260            }
261        });
262    }
263    ring
264}
265
266/// Look up a specifically named binary on the standard install paths.
267/// `override_bin` lets the host accept an explicit path for the primary
268/// binary (foxbridge); the secondary (camoufox) is always discovered.
269pub(crate) fn resolve_named_bin(
270    name: &str,
271    override_bin: &Option<PathBuf>,
272) -> Result<PathBuf, Error> {
273    if let Some(p) = override_bin {
274        if p.file_name().and_then(|n| n.to_str()) == Some(name) && p.exists() {
275            return Ok(p.clone());
276        }
277    }
278    for dir in [
279        "/usr/local/bin",
280        "/usr/bin",
281        "/opt/camoufox",
282        "/opt/foxbridge",
283    ] {
284        let candidate = PathBuf::from(dir).join(name);
285        if candidate.exists() {
286            return Ok(candidate);
287        }
288    }
289    if let Some(candidate) = find_on_path(name) {
290        return Ok(candidate);
291    }
292    Err(Error::new(
293        ErrorCode::BrowserLaunchFailed,
294        format!("could not find {name} binary on PATH"),
295    ))
296}
297
298/// Walk `$PATH` for an executable named `name`, returning the first existing
299/// match. Uses the platform path separator so it works on Windows too.
300fn find_on_path(name: &str) -> Option<PathBuf> {
301    let path = std::env::var_os("PATH")?;
302    std::env::split_paths(&path)
303        .map(|dir| dir.join(name))
304        .find(|candidate| candidate.exists())
305}
306
307/// Reserve a localhost port by binding a TCP listener, reading its assigned
308/// port, and closing it. There is a small window before lightpanda binds
309/// the same port in which a third process could steal it — extremely
310/// unlikely in practice and the launch will fail loudly if it happens.
311pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
312    let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
313    let port = listener.local_addr()?.port();
314    drop(listener);
315    Ok(port)
316}
317
318/// Poll the given (host, port) with TCP connects until the target accepts
319/// a connection or `timeout` elapses.
320pub(crate) async fn wait_for_tcp_ready(
321    target: (&str, u16),
322    timeout: Duration,
323) -> Result<(), String> {
324    let deadline = tokio::time::Instant::now() + timeout;
325    let addr: SocketAddr = format!("{}:{}", target.0, target.1)
326        .parse()
327        .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
328    loop {
329        if tokio::time::Instant::now() >= deadline {
330            return Err(format!("timed out after {timeout:?}"));
331        }
332        match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
333            Ok(Ok(_)) => return Ok(()),
334            _ => tokio::time::sleep(Duration::from_millis(50)).await,
335        }
336    }
337}
338
339fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
340    if let Some(p) = &args.browser_bin {
341        if !p.exists() {
342            return Err(Error::new(
343                ErrorCode::BrowserLaunchFailed,
344                format!("--browser-bin {} does not exist", p.display()),
345            ));
346        }
347        return Ok(resolve_chromium_wrapper_target(p));
348    }
349    let candidates: Vec<&str> = match args.browser {
350        BrowserChoice::Lightpanda => vec!["lightpanda"],
351        BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
352        BrowserChoice::ChromeShell => vec!["chrome-headless-shell"],
353        BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
354        BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
355        BrowserChoice::Brave => vec!["brave-browser", "brave"],
356        BrowserChoice::Chromium | BrowserChoice::Auto => vec![
357            "chromium",
358            "chromium-browser",
359            "google-chrome",
360            "google-chrome-stable",
361        ],
362        // Camoufox is launched via launch_camoufox(), not this helper —
363        // resolve_browser_bin is only reachable from chromium-family + the
364        // lightpanda path. Return an unambiguous error if the dispatcher
365        // somehow regressed.
366        BrowserChoice::Camoufox => {
367            return Err(Error::new(
368                ErrorCode::InternalError,
369                "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
370            ));
371        }
372    };
373    for name in candidates {
374        for dir in [
375            "/usr/bin",
376            "/usr/local/bin",
377            "/opt/google/chrome",
378            "/Applications/Google Chrome.app/Contents/MacOS",
379        ] {
380            let p = PathBuf::from(dir).join(name);
381            if p.exists() {
382                return Ok(resolve_chromium_wrapper_target(&p));
383            }
384        }
385        if let Some(p) = find_on_path(name) {
386            return Ok(resolve_chromium_wrapper_target(&p));
387        }
388    }
389
390    // Standard app-bundle / Program Files locations the name×dir loop can't
391    // express: macOS binaries contain a space ("Google Chrome") and Windows
392    // installs live outside $PATH. Only meaningful for the chromium/chrome
393    // family; on Linux this block is compiled out entirely.
394    #[cfg(any(target_os = "macos", target_os = "windows"))]
395    if matches!(
396        args.browser,
397        BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
398    ) {
399        let mut app_candidates: Vec<PathBuf> = Vec::new();
400        #[cfg(target_os = "macos")]
401        {
402            let mut roots = vec![PathBuf::from("/Applications")];
403            if let Ok(home) = std::env::var("HOME") {
404                roots.push(PathBuf::from(home).join("Applications"));
405            }
406            for root in roots {
407                app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
408                app_candidates.push(root.join(
409                    "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
410                ));
411                app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
412            }
413        }
414        #[cfg(target_os = "windows")]
415        {
416            let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
417                .iter()
418                .filter_map(|var| std::env::var(var).ok())
419                .map(PathBuf::from)
420                .collect();
421            roots.push(PathBuf::from(r"C:\Program Files"));
422            roots.push(PathBuf::from(r"C:\Program Files (x86)"));
423            for root in roots {
424                app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
425                app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
426            }
427        }
428        for p in app_candidates {
429            if p.exists() {
430                return Ok(resolve_chromium_wrapper_target(&p));
431            }
432        }
433    }
434
435    Err(Error::new(
436        ErrorCode::BrowserLaunchFailed,
437        "no browser binary found; set --browser-bin or install chromium",
438    ))
439}
440
441fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
442    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
443        return path.to_path_buf();
444    };
445    if name != "chromium" && name != "chromium-browser" {
446        return path.to_path_buf();
447    }
448    for candidate in [
449        "/usr/lib/chromium/chromium",
450        "/usr/lib/chromium-browser/chromium-browser",
451    ] {
452        let actual = PathBuf::from(candidate);
453        if actual.exists() {
454            return actual;
455        }
456    }
457    path.to_path_buf()
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn pick_ephemeral_port_returns_usable_local_port() {
466        let port = pick_ephemeral_port().expect("pick");
467        assert!(port > 0);
468        // Can rebind immediately — confirms the listener was dropped cleanly
469        // and the port is available for the lightpanda subprocess.
470        let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
471        drop(l);
472    }
473
474    #[test]
475    fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
476        let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
477        let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
478        // Same path → same seed across calls (the agent's identity
479        // contract: persistent profile keeps its fingerprint).
480        assert_eq!(
481            fingerprint_seed_from_path(&a),
482            fingerprint_seed_from_path(&a)
483        );
484        // Different paths → almost certainly different seeds.
485        assert_ne!(
486            fingerprint_seed_from_path(&a),
487            fingerprint_seed_from_path(&b)
488        );
489        // Zero is never returned (would no-op the upstream tool).
490        assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
491    }
492}