1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! 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()`."
);
}
}