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