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 { 1 } else { h }
161}
162
163async fn ensure_download_dir(profile_dir: &std::path::Path) -> Result<PathBuf, Error> {
164    let dir = profile_dir.join("downloads");
165    tokio::fs::create_dir_all(&dir).await.map_err(|e| {
166        Error::new(
167            ErrorCode::IoError,
168            format!("create download dir {}: {e}", dir.display()),
169        )
170    })?;
171    Ok(dir)
172}
173
174/// Configure a subprocess `Command` for backend launch with full env
175/// isolation: scrub everything, then re-inject the small allowlist of vars
176/// we actually need plus the explicit `--engine-env` passthroughs.
177///
178/// Allowlist rationale:
179/// - `PATH` — child may shell-exec helper utilities (DNS resolvers, fonts).
180/// - `HOME` — chromium falls back here for some XDG paths even with
181///   `--user-data-dir`; lightpanda uses it for cache dirs.
182/// - `LANG`, `LC_*` — engine locale is an honest engine-level fingerprint
183///   surface; agents that want to override pass `--engine-env`.
184/// - `TZ` — same reasoning for timezone.
185/// - `TMPDIR` — chromium's child processes use this for IPC.
186/// - `DISPLAY` — only meaningful for headful mode, but always cheap to
187///   pass through; the engine ignores it under headless.
188///
189/// Deliberate omissions (these are silent egress / behavior leaks):
190/// - `HTTP_PROXY`, `HTTPS_PROXY`, `SOCKS_PROXY`, `NO_PROXY`,
191///   `ALL_PROXY` — explicit `--proxy-url` (future flag) only.
192/// - `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` — we override
193///   with `--user-data-dir`; honoring these too could escape the profile.
194/// - `BROWSER` — affects xdg-open inside the engine.
195/// - `CHROME_*`, `MOZ_*`, `LIGHTPANDA_*` — engine-specific tunables, except
196///   for the curated crash-reporter silence defaults below.
197fn apply_subprocess_env(cmd: &mut Command, engine_envs: &[(String, String)]) {
198    cmd.env_clear();
199    const ALLOWLIST: &[&str] = &[
200        "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "DISPLAY",
201    ];
202    // Windows-essential variables. Without SYSTEMROOT the browser cannot
203    // initialize winsock (`WSALookupServiceBegin` fails) and the CDP/DevTools
204    // HTTP server never binds — so a scrubbed env silently breaks every
205    // browser-backed fetch on Windows. These are OS plumbing, not ambient
206    // browsing config (HTTP_PROXY/XDG/BROWSER stay scrubbed per the isolation
207    // invariant).
208    #[cfg(windows)]
209    const WINDOWS_ALLOWLIST: &[&str] = &[
210        "SYSTEMROOT",
211        "SystemDrive",
212        "windir",
213        "TEMP",
214        "TMP",
215        "APPDATA",
216        "LOCALAPPDATA",
217        "USERPROFILE",
218        "ProgramData",
219        "ProgramFiles",
220        "ProgramFiles(x86)",
221        "ProgramW6432",
222        "PATHEXT",
223        "COMSPEC",
224        "NUMBER_OF_PROCESSORS",
225        "PROCESSOR_ARCHITECTURE",
226    ];
227    for key in ALLOWLIST {
228        if let Ok(value) = std::env::var(key) {
229            cmd.env(key, value);
230        }
231    }
232    #[cfg(windows)]
233    for key in WINDOWS_ALLOWLIST {
234        if let Ok(value) = std::env::var(key) {
235            cmd.env(key, value);
236        }
237    }
238    // Keep browser-owned crash reporter UI out of the takeover display. These
239    // are defaults only: explicit --engine-env entries below still override.
240    cmd.env("MOZ_CRASHREPORTER_DISABLE", "1");
241    cmd.env("MOZ_CRASHREPORTER_NO_REPORT", "1");
242    cmd.env("NO_EM_RESTART", "1");
243    for (k, v) in engine_envs {
244        cmd.env(k, v);
245    }
246}
247
248/// Spawn a task that reads `stderr` lines into a bounded ring buffer. Returns
249/// an empty ring when stderr is not piped.
250fn new_stderr_ring(stderr: Option<tokio::process::ChildStderr>) -> Arc<Mutex<VecDeque<String>>> {
251    let ring = Arc::new(Mutex::new(VecDeque::<String>::new()));
252    if let Some(stderr) = stderr {
253        let ring_w = ring.clone();
254        tokio::spawn(async move {
255            let mut reader = tokio::io::BufReader::new(stderr).lines();
256            while let Ok(Some(line)) = reader.next_line().await {
257                let mut guard = ring_w.lock().await;
258                if guard.len() >= STDERR_RING_CAP {
259                    guard.pop_front();
260                }
261                guard.push_back(line);
262            }
263        });
264    }
265    ring
266}
267
268async fn stderr_tail_summary(ring: &Arc<Mutex<VecDeque<String>>>) -> String {
269    let guard = ring.lock().await;
270    let mut summary = guard
271        .iter()
272        .rev()
273        .take(20)
274        .map(|line| crate::shared::redact::redact_userinfo_passwords(line))
275        .collect::<Vec<_>>();
276    summary.reverse();
277    let mut joined = summary.join(" | ");
278    const MAX: usize = 2000;
279    if joined.len() > MAX {
280        let start = joined.len() - MAX;
281        joined = format!("...{}", &joined[start..]);
282    }
283    joined
284}
285
286/// Look up a specifically named binary on the standard install paths.
287/// `override_bin` lets the host accept an explicit path for the primary
288/// binary (foxbridge); the secondary (camoufox) is always discovered.
289pub(crate) fn resolve_named_bin(
290    name: &str,
291    override_bin: &Option<PathBuf>,
292) -> Result<PathBuf, Error> {
293    if let Some(p) = override_bin
294        && p.file_name().and_then(|n| n.to_str()) == Some(name)
295        && p.exists()
296    {
297        return Ok(p.clone());
298    }
299    for dir in [
300        "/usr/local/bin",
301        "/usr/bin",
302        "/opt/camoufox",
303        "/opt/foxbridge",
304    ] {
305        let candidate = PathBuf::from(dir).join(name);
306        if candidate.exists() {
307            return Ok(candidate);
308        }
309    }
310    if let Some(candidate) = find_on_path(name) {
311        return Ok(candidate);
312    }
313    Err(Error::new(
314        ErrorCode::BrowserLaunchFailed,
315        format!("could not find {name} binary on PATH"),
316    ))
317}
318
319/// Walk `$PATH` for an executable named `name`, returning the first existing
320/// match. Uses the platform path separator so it works on Windows too.
321fn find_on_path(name: &str) -> Option<PathBuf> {
322    let path = std::env::var_os("PATH")?;
323    std::env::split_paths(&path)
324        .map(|dir| dir.join(name))
325        .find(|candidate| candidate.exists())
326}
327
328/// Reserve a localhost port by binding a TCP listener, reading its assigned
329/// port, and closing it. There is a small window before lightpanda binds
330/// the same port in which a third process could steal it — extremely
331/// unlikely in practice and the launch will fail loudly if it happens.
332pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
333    let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
334    let port = listener.local_addr()?.port();
335    drop(listener);
336    Ok(port)
337}
338
339/// Poll the given (host, port) with TCP connects until the target accepts
340/// a connection or `timeout` elapses.
341pub(crate) async fn wait_for_tcp_ready(
342    target: (&str, u16),
343    timeout: Duration,
344) -> Result<(), String> {
345    let deadline = tokio::time::Instant::now() + timeout;
346    let addr: SocketAddr = format!("{}:{}", target.0, target.1)
347        .parse()
348        .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
349    loop {
350        if tokio::time::Instant::now() >= deadline {
351            return Err(format!("timed out after {timeout:?}"));
352        }
353        match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
354            Ok(Ok(_)) => return Ok(()),
355            _ => tokio::time::sleep(Duration::from_millis(50)).await,
356        }
357    }
358}
359
360fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
361    if let Some(p) = &args.browser_bin {
362        if !p.exists() {
363            return Err(Error::new(
364                ErrorCode::BrowserLaunchFailed,
365                format!("--browser-bin {} does not exist", p.display()),
366            ));
367        }
368        return Ok(resolve_chromium_wrapper_target(p));
369    }
370    let candidates: Vec<&str> = match args.browser {
371        BrowserChoice::Lightpanda => vec!["lightpanda"],
372        BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
373        BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
374        BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
375        BrowserChoice::Brave => vec!["brave-browser", "brave"],
376        BrowserChoice::Chromium | BrowserChoice::Auto => vec![
377            "chromium",
378            "chromium-browser",
379            "google-chrome",
380            "google-chrome-stable",
381        ],
382        // Camoufox is launched via launch_camoufox(), not this helper —
383        // resolve_browser_bin is only reachable from chromium-family + the
384        // lightpanda path. Return an unambiguous error if the dispatcher
385        // somehow regressed.
386        BrowserChoice::Camoufox => {
387            return Err(Error::new(
388                ErrorCode::InternalError,
389                "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
390            ));
391        }
392    };
393    for name in candidates {
394        for dir in [
395            "/usr/bin",
396            "/usr/local/bin",
397            "/opt/google/chrome",
398            "/Applications/Google Chrome.app/Contents/MacOS",
399        ] {
400            let p = PathBuf::from(dir).join(name);
401            if p.exists() {
402                return Ok(resolve_chromium_wrapper_target(&p));
403            }
404        }
405        if let Some(p) = find_on_path(name) {
406            return Ok(resolve_chromium_wrapper_target(&p));
407        }
408    }
409
410    // Standard app-bundle / Program Files locations the name×dir loop can't
411    // express: macOS binaries contain a space ("Google Chrome") and Windows
412    // installs live outside $PATH. Only meaningful for the chromium/chrome
413    // family; on Linux this block is compiled out entirely.
414    #[cfg(any(target_os = "macos", target_os = "windows"))]
415    if matches!(
416        args.browser,
417        BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
418    ) {
419        let mut app_candidates: Vec<PathBuf> = Vec::new();
420        #[cfg(target_os = "macos")]
421        {
422            let mut roots = vec![PathBuf::from("/Applications")];
423            if let Ok(home) = std::env::var("HOME") {
424                roots.push(PathBuf::from(home).join("Applications"));
425            }
426            for root in roots {
427                app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
428                app_candidates.push(root.join(
429                    "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
430                ));
431                app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
432            }
433        }
434        #[cfg(target_os = "windows")]
435        {
436            let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
437                .iter()
438                .filter_map(|var| std::env::var(var).ok())
439                .map(PathBuf::from)
440                .collect();
441            roots.push(PathBuf::from(r"C:\Program Files"));
442            roots.push(PathBuf::from(r"C:\Program Files (x86)"));
443            for root in roots {
444                app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
445                app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
446            }
447        }
448        for p in app_candidates {
449            if p.exists() {
450                return Ok(resolve_chromium_wrapper_target(&p));
451            }
452        }
453    }
454
455    Err(Error::new(
456        ErrorCode::BrowserLaunchFailed,
457        "no browser binary found; set --browser-bin or install chromium",
458    ))
459}
460
461fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
462    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
463        return path.to_path_buf();
464    };
465    if name != "chromium" && name != "chromium-browser" {
466        return path.to_path_buf();
467    }
468    for candidate in [
469        "/usr/lib/chromium/chromium",
470        "/usr/lib/chromium-browser/chromium-browser",
471    ] {
472        let actual = PathBuf::from(candidate);
473        if actual.exists() {
474            return actual;
475        }
476    }
477    path.to_path_buf()
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn pick_ephemeral_port_returns_usable_local_port() {
486        let port = pick_ephemeral_port().expect("pick");
487        assert!(port > 0);
488        // Can rebind immediately — confirms the listener was dropped cleanly
489        // and the port is available for the lightpanda subprocess.
490        let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
491        drop(l);
492    }
493
494    #[test]
495    fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
496        let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
497        let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
498        // Same path → same seed across calls (the agent's identity
499        // contract: persistent profile keeps its fingerprint).
500        assert_eq!(
501            fingerprint_seed_from_path(&a),
502            fingerprint_seed_from_path(&a)
503        );
504        // Different paths → almost certainly different seeds.
505        assert_ne!(
506            fingerprint_seed_from_path(&a),
507            fingerprint_seed_from_path(&b)
508        );
509        // Zero is never returned (would no-op the upstream tool).
510        assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
511    }
512}