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 Ok(path) = std::env::var("PATH") {
290        for dir in path.split(':') {
291            let candidate = PathBuf::from(dir).join(name);
292            if candidate.exists() {
293                return Ok(candidate);
294            }
295        }
296    }
297    Err(Error::new(
298        ErrorCode::BrowserLaunchFailed,
299        format!("could not find {name} binary on PATH"),
300    ))
301}
302
303/// Reserve a localhost port by binding a TCP listener, reading its assigned
304/// port, and closing it. There is a small window before lightpanda binds
305/// the same port in which a third process could steal it — extremely
306/// unlikely in practice and the launch will fail loudly if it happens.
307pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
308    let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
309    let port = listener.local_addr()?.port();
310    drop(listener);
311    Ok(port)
312}
313
314/// Poll the given (host, port) with TCP connects until the target accepts
315/// a connection or `timeout` elapses.
316pub(crate) async fn wait_for_tcp_ready(
317    target: (&str, u16),
318    timeout: Duration,
319) -> Result<(), String> {
320    let deadline = tokio::time::Instant::now() + timeout;
321    let addr: SocketAddr = format!("{}:{}", target.0, target.1)
322        .parse()
323        .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
324    loop {
325        if tokio::time::Instant::now() >= deadline {
326            return Err(format!("timed out after {timeout:?}"));
327        }
328        match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
329            Ok(Ok(_)) => return Ok(()),
330            _ => tokio::time::sleep(Duration::from_millis(50)).await,
331        }
332    }
333}
334
335fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
336    if let Some(p) = &args.browser_bin {
337        if !p.exists() {
338            return Err(Error::new(
339                ErrorCode::BrowserLaunchFailed,
340                format!("--browser-bin {} does not exist", p.display()),
341            ));
342        }
343        return Ok(resolve_chromium_wrapper_target(p));
344    }
345    let candidates: Vec<&str> = match args.browser {
346        BrowserChoice::Lightpanda => vec!["lightpanda"],
347        BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
348        BrowserChoice::ChromeShell => vec!["chrome-headless-shell"],
349        BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
350        BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
351        BrowserChoice::Brave => vec!["brave-browser", "brave"],
352        BrowserChoice::Chromium | BrowserChoice::Auto => vec![
353            "chromium",
354            "chromium-browser",
355            "google-chrome",
356            "google-chrome-stable",
357        ],
358        // Camoufox is launched via launch_camoufox(), not this helper —
359        // resolve_browser_bin is only reachable from chromium-family + the
360        // lightpanda path. Return an unambiguous error if the dispatcher
361        // somehow regressed.
362        BrowserChoice::Camoufox => {
363            return Err(Error::new(
364                ErrorCode::InternalError,
365                "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
366            ));
367        }
368    };
369    for name in candidates {
370        for dir in [
371            "/usr/bin",
372            "/usr/local/bin",
373            "/opt/google/chrome",
374            "/Applications/Google Chrome.app/Contents/MacOS",
375        ] {
376            let p = PathBuf::from(dir).join(name);
377            if p.exists() {
378                return Ok(resolve_chromium_wrapper_target(&p));
379            }
380        }
381        // Walk $PATH.
382        if let Ok(path) = std::env::var("PATH") {
383            for dir in path.split(':') {
384                let p = PathBuf::from(dir).join(name);
385                if p.exists() {
386                    return Ok(resolve_chromium_wrapper_target(&p));
387                }
388            }
389        }
390    }
391
392    // Standard app-bundle / Program Files locations the name×dir loop can't
393    // express: macOS binaries contain a space ("Google Chrome") and Windows
394    // installs live outside $PATH. Only meaningful for the chromium/chrome
395    // family; on Linux this block is compiled out entirely.
396    #[cfg(any(target_os = "macos", target_os = "windows"))]
397    if matches!(
398        args.browser,
399        BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
400    ) {
401        let mut app_candidates: Vec<PathBuf> = Vec::new();
402        #[cfg(target_os = "macos")]
403        {
404            let mut roots = vec![PathBuf::from("/Applications")];
405            if let Ok(home) = std::env::var("HOME") {
406                roots.push(PathBuf::from(home).join("Applications"));
407            }
408            for root in roots {
409                app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
410                app_candidates.push(root.join(
411                    "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
412                ));
413                app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
414            }
415        }
416        #[cfg(target_os = "windows")]
417        {
418            let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
419                .iter()
420                .filter_map(|var| std::env::var(var).ok())
421                .map(PathBuf::from)
422                .collect();
423            roots.push(PathBuf::from(r"C:\Program Files"));
424            roots.push(PathBuf::from(r"C:\Program Files (x86)"));
425            for root in roots {
426                app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
427                app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
428            }
429        }
430        for p in app_candidates {
431            if p.exists() {
432                return Ok(resolve_chromium_wrapper_target(&p));
433            }
434        }
435    }
436
437    Err(Error::new(
438        ErrorCode::BrowserLaunchFailed,
439        "no browser binary found; set --browser-bin or install chromium",
440    ))
441}
442
443fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
444    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
445        return path.to_path_buf();
446    };
447    if name != "chromium" && name != "chromium-browser" {
448        return path.to_path_buf();
449    }
450    for candidate in [
451        "/usr/lib/chromium/chromium",
452        "/usr/lib/chromium-browser/chromium-browser",
453    ] {
454        let actual = PathBuf::from(candidate);
455        if actual.exists() {
456            return actual;
457        }
458    }
459    path.to_path_buf()
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn pick_ephemeral_port_returns_usable_local_port() {
468        let port = pick_ephemeral_port().expect("pick");
469        assert!(port > 0);
470        // Can rebind immediately — confirms the listener was dropped cleanly
471        // and the port is available for the lightpanda subprocess.
472        let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
473        drop(l);
474    }
475
476    #[test]
477    fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
478        let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
479        let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
480        // Same path → same seed across calls (the agent's identity
481        // contract: persistent profile keeps its fingerprint).
482        assert_eq!(
483            fingerprint_seed_from_path(&a),
484            fingerprint_seed_from_path(&a)
485        );
486        // Different paths → almost certainly different seeds.
487        assert_ne!(
488            fingerprint_seed_from_path(&a),
489            fingerprint_seed_from_path(&b)
490        );
491        // Zero is never returned (would no-op the upstream tool).
492        assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
493    }
494}