captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Browser launch via rustenium Firefox (replaces runtime_headless).
//!
//! CLI, examples, and bindings should use [`drive_browser`] instead of
//! hand-rolling `captchaforge::browser::launch_firefox` + handler tasks.

use anyhow::{Context, Result};

use runtime_foxdriver::{launch_firefox_self_managed, FoxBrowserConfig, Page, ProxyConfig};

use crate::StealthProfile;

/// Options for [`drive_browser`].
#[derive(Debug, Clone)]
pub struct BrowserDriveOptions {
    /// When false, launch a visible window.
    pub headless: bool,
    /// Disable chromium sandbox (no-op for Firefox, kept for API compat).
    pub no_sandbox: bool,
    /// Optional launch-time User-Agent override. Used by wrappers that must keep
    /// the CAPTCHA-solving browser coherent with a role-bound external session.
    pub user_agent: Option<String>,
    /// Optional Accept-Language value, translated into Firefox's
    /// `intl.accept_languages` pref before first navigation.
    pub accept_language: Option<String>,
    /// Optional proxy URL for the browser session.
    pub proxy_url: Option<String>,
    /// Optional coherent Guise persona applied before first navigation.
    pub profile: Option<StealthProfile>,
}

impl Default for BrowserDriveOptions {
    fn default() -> Self {
        Self {
            headless: true,
            no_sandbox: true,
            user_agent: None,
            accept_language: None,
            proxy_url: None,
            profile: None,
        }
    }
}

/// Build launch config aligned with the old runtime-headless shape.
#[must_use]
pub fn launch_options(opts: &BrowserDriveOptions) -> FoxBrowserConfig {
    let profile_overrides = opts.profile.as_ref().map(guise::profile_to_overrides);
    let user_agent = opts
        .user_agent
        .clone()
        .or_else(|| profile_overrides.as_ref().map(|p| p.user_agent.clone()));
    let mut user_js = String::new();
    if let Some(accept_language) = firefox_accept_language_pref(opts.accept_language.as_deref()) {
        user_js.push_str(&accept_language);
    }
    let mut config = FoxBrowserConfig {
        headless: opts.headless,
        user_agent,
        user_js_content: if user_js.is_empty() {
            None
        } else {
            Some(user_js)
        },
        ..Default::default()
    };
    if let Some(profile) = profile_overrides.as_ref() {
        config.viewport_width = profile.screen_width;
        config.viewport_height = profile.screen_height;
    }
    config
}

fn launch_options_with_proxy(opts: &BrowserDriveOptions) -> Result<FoxBrowserConfig> {
    let mut config = launch_options(opts);
    if let Some(proxy_url) = opts
        .proxy_url
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        config.proxy = Some(
            ProxyConfig::from_url(proxy_url)
                .map_err(|err| anyhow::anyhow!("parse browser proxy URL {proxy_url:?}: {err}"))?,
        );
    }
    Ok(config)
}

fn firefox_accept_language_pref(value: Option<&str>) -> Option<String> {
    let value = value?.trim();
    if value.is_empty() {
        return None;
    }
    let langs: Vec<String> = value
        .split(',')
        .filter_map(|part| part.split(';').next().map(str::trim))
        .filter(|part| !part.is_empty())
        .map(ToOwned::to_owned)
        .collect();
    if langs.is_empty() {
        return None;
    }
    let joined = langs.join(", ");
    let escaped = serde_json::to_string(&joined).ok()?;
    Some(format!(
        "user_pref(\"intl.accept_languages\", {escaped});\n"
    ))
}

/// Launch Firefox, navigate to `url`, run `f` with the live page, then tear down.
///
/// Uses [`launch_firefox_self_managed`], foxdriver owns the spawn and polls the
/// debug port until it is live, instead of rustenium's fixed 500 ms post-spawn
/// sleep. That fixed sleep races any slow-binding build (it cost the reynard gate
/// a `ConnectionRefused` panic); the readiness poll makes every launch on this
/// path robust. The binary is resolved from `PATH`/standard locations when no
/// `executable_path` is set, so existing PATH-relying callers are unaffected.
pub async fn drive_browser<F, Fut, T>(url: &str, opts: BrowserDriveOptions, f: F) -> Result<T>
where
    F: FnOnce(Page) -> Fut,
    Fut: std::future::Future<Output = Result<T>>,
{
    let mut config = launch_options_with_proxy(&opts)?;
    // Default to the reynard STEALTH engine. A plain Firefox driven over BiDi leaks
    // `navigator.webdriver = true` and is BOT-DETECTABLE, so solving on it defeats the
    // whole stack, reynard must not be a silent opt-in. Resolve via the SHARED guise
    // resolver (`REYNARD_BIN`/`GUISE_REYNARD_BIN`/conventional install paths); on the
    // plain-Firefox fallback, warn LOUDLY rather than silently shipping a detectable
    // browser (Law 10). An explicit `executable_path` (rare) is respected.
    if config.executable_path.is_none() {
        match guise::browser::resolve_reynard_bin() {
            Some(bin) => config.executable_path = Some(bin),
            None => tracing::warn!(
                "captchaforge: no reynard stealth engine found (set REYNARD_BIN, or install via \
                 scripts/install-reynard.sh), launching PLAIN Firefox, which leaks \
                 navigator.webdriver=true over BiDi and is BOT-DETECTABLE."
            ),
        }
    }
    // X007 / X014, enforce the unified persona coherence contract before spending a
    // launch, the SAME gate guise's own launch paths apply. `drive_browser` spawns
    // the foxdriver Page directly (for the readiness-poll robustness documented
    // above) instead of routing through `guise::launch_profiled_firefox`, so it must
    // call the shared enforcement primitive itself, otherwise an incoherent persona
    // launches undetected (the exact gap this closes on captchaforge's own path).
    if let Some(profile) = opts.profile.as_ref() {
        guise::enforce_persona_launch_coherence(profile, config.proxy.is_none())?;
    }
    let page = launch_firefox_self_managed(config)
        .await
        .map_err(|e| anyhow::anyhow!("launch firefox (is it installed and on PATH?): {e}"))?;
    crate::prepare_page(&page, opts.profile)
        .await
        .context("prepare browser before first navigation")?;
    tokio::time::timeout(std::time::Duration::from_secs(30), page.goto(url))
        .await
        .map_err(|_| anyhow::anyhow!("navigate to {url} timed out after 30s"))?
        .with_context(|| format!("navigate to {url}"))?;
    f(page).await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn launch_options_headless_maps_correctly() {
        let opts = launch_options(&BrowserDriveOptions {
            headless: true,
            ..BrowserDriveOptions::default()
        });
        assert!(opts.headless);
    }

    #[test]
    fn launch_options_headful_maps_correctly() {
        let opts = launch_options(&BrowserDriveOptions {
            headless: false,
            ..BrowserDriveOptions::default()
        });
        assert!(!opts.headless);
    }

    #[test]
    fn launch_options_apply_user_agent_and_accept_language() {
        let opts = launch_options(&BrowserDriveOptions {
            user_agent: Some("Mozilla/5.0 Firefox/133.0".into()),
            accept_language: Some("en-US,en;q=0.5".into()),
            ..BrowserDriveOptions::default()
        });
        assert_eq!(
            opts.user_agent.as_deref(),
            Some("Mozilla/5.0 Firefox/133.0")
        );
        let user_js = opts.user_js_content.as_deref().unwrap_or_default();
        assert!(user_js.contains("intl.accept_languages"));
        assert!(user_js.contains("en-US, en"));
    }

    #[test]
    fn launch_options_keep_default_viewport_without_profile() {
        let opts = launch_options(&BrowserDriveOptions::default());
        let defaults = FoxBrowserConfig::default();
        assert_eq!(opts.viewport_width, defaults.viewport_width);
        assert_eq!(opts.viewport_height, defaults.viewport_height);
    }

    #[test]
    fn drive_browser_enforces_persona_coherence_before_launch() {
        // X007 / X014 wiring fence: `drive_browser` spawns the foxdriver Page
        // directly (bypassing guise's gated `launch_profiled_firefox`), so it MUST
        // call the shared coherence gate itself before launching. A future edit that
        // drops the call would let an incoherent persona launch undetected, this
        // catches that. Source-walk because the only incoherent input would be a
        // non-existent `StealthProfile` enum variant, so no behavioral test can
        // express it; self-immune via `concat!` token assembly (the joined literal
        // never appears in this test's own source).
        let src = include_str!("browser_runtime.rs");
        let gate = concat!("enforce_persona_launch", "_coherence(");
        let called = src
            .lines()
            .any(|l| !l.trim_start().starts_with("//") && l.contains(gate));
        assert!(
            called,
            "drive_browser must call {gate} before launch, the persona coherence gate is \
             UNWIRED on captchaforge's launch path, so an incoherent persona would launch \
             undetected. Restore `guise::enforce_persona_launch_coherence(profile, ...)?`."
        );
    }

    #[test]
    fn drive_browser_defaults_to_the_reynard_stealth_engine() {
        // Wiring fence: `drive_browser` MUST resolve the reynard stealth engine
        // (`guise::browser::resolve_reynard_bin`) and only fall back to plain Firefox
        // loudly. Plain Firefox over BiDi leaks navigator.webdriver=true, solving on
        // it defeats the whole stack, so a future edit dropping the resolver would
        // silently ship a detectable browser. Source-walk (the resolution reads the
        // process env/filesystem, which a behavioral unit test should not mutate);
        // the resolver's own precedence is unit-tested in guise. Self-immune via
        // `concat!` (the joined literal never appears in this test's source).
        let src = include_str!("browser_runtime.rs");
        let resolver = concat!("resolve_reynard", "_bin(");
        let called = src
            .lines()
            .any(|l| !l.trim_start().starts_with("//") && l.contains(resolver));
        assert!(
            called,
            "drive_browser must call {resolver} so captchaforge defaults to the reynard \
             stealth engine, without it every solve runs on plain Firefox (navigator.webdriver \
             = true over BiDi), BOT-DETECTABLE. Restore `guise::browser::resolve_reynard_bin()`."
        );
    }
}