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
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! captchaforge, automatic CAPTCHA detection and solving for
//! Firefox + BiDi-driven headless browsers.
//!
//! Extracted from [golemn-browser](https://github.com/santhreal/golemn)
//! (originally GPL-3.0) and re-licensed MIT OR Apache-2.0 by the
//! original author so it can stand alone and be embedded by other
//! Santh-ecosystem tools (wafrift, gossan, sear, …).
//!
//! # Module map
//!
//! - [`detect`] (heuristic + DOM-based CAPTCHA identification).
//! - [`solver`], multi-strategy solving chain (behavioural, VLM,
//!   audio, third-party, pattern-cached, human fallback).
//! - [`stealth`] / [`stealth_profiles`], pre-navigation
//!   fingerprint hardening + named browser-vendor profiles.
//! - [`warmup`], natural pre-captcha activity (mouse / scroll
//!   entropy) so passive challenges score the visitor as human.
//! - [`behavior`], primitive realistic gestures (Bézier mouse,
//!   variable-cadence typing, hover dwell, touch swipe, jitter).
//! - [`cookies`] (capture / replay session cookies, vendor-filter).
//! - [`frame`] (same-origin iframe + cross-origin CDP frame walk).
//! - [`provider`] (pluggable vendor-rule registry).
//! - [`config`]. Tier-A `.captchaforge.toml` discovery.
//! - [`backends`] (local backend probe (Ollama / Whisper / Tesseract)).
//! - [`stt`] (speech-to-text endpoint ladder).
//! - [`sdk`], one-call orchestration ([`solve_url`], [`prepare_page`],
//!   [`auto_solve_with_retries`], [`dismiss_cookie_consent`],
//!   [`dismiss_chat_widget`], [`wait_for_no_captcha`]).
//! - [`prelude`] (glob-import target for the most-used types).
//!
//! Three layered consumption modes:
//!
//! 1. **One-call**: `captchaforge::solve_url(&page, url, None).await?`
//!    when you want the whole chain wired for you.
//! 2. **Building blocks**: call [`detect::detect`] then drive a
//!    custom [`solver::CaptchaSolverChain`] when you need
//!    telemetry, custom solvers, or non-default cache semantics.
//! 3. **Primitives**: embed individual modules ([`stealth`],
//!    [`behavior`], [`frame`], [`cookies`]) inside an existing
//!    BiDi flow without touching the solver chain.
//!
//! Originally extracted from
//! [golemn-browser](https://github.com/santhreal/golemn) and
//! re-licensed MIT OR Apache-2.0 by the original author so it can
//! stand alone and be embedded by other Santh-ecosystem tools
//! (wafrift, gossan, sear, …).
//!
//! # Example, one-call solve
//!
//! ```rust,no_run
//! use captchaforge::prelude::*;
//! # async fn run(page: &captchaforge::Page) -> anyhow::Result<()> {
//! match solve_url(page, "https://example.com", Some(StealthProfile::FirefoxLinux)).await? {
//!     None => println!("no captcha detected"),
//!     Some(r) if r.success => println!("solved via {:?} in {}ms", r.method, r.time_ms),
//!     Some(r) => println!("failed: {:?}", r.method),
//! }
//! # Ok(()) }
//! ```
//!
//! # Example, detect + custom chain
//!
//! ```rust,no_run
//! use captchaforge::{detect, solver::CaptchaSolverChain};
//! # async fn run(page: &captchaforge::Page) -> anyhow::Result<()> {
//! let info = detect::detect(page).await?;
//! if detect::is_captcha(&info) {
//!     let chain = CaptchaSolverChain::default_chain();
//!     // chain.solve(page, &info).await?;
//! }
//! # Ok(()) }
//! ```
//!
//! # Example, pattern store
//!
//! ```rust
//! use captchaforge::solver::{CaptchaType, PatternStore, SolveMethod};
//!
//! let store = PatternStore::default();
//! store.record("example.com", &CaptchaType::CloudflareTurnstile, true, 1200, SolveMethod::BehavioralBypass);
//! assert_eq!(store.best_method("example.com", &CaptchaType::CloudflareTurnstile), Some(SolveMethod::BehavioralBypass));
//! ```
//!
//! # Example, primitives only (no chain)
//!
//! ```rust,no_run
//! use captchaforge::behavior::{mouse_move_bezier, click_realistic};
//! use captchaforge::frame::evaluate_in_all_frames;
//! # async fn run(page: &captchaforge::Page) -> anyhow::Result<()> {
//! mouse_move_bezier(page, 0.0, 0.0, 400.0, 300.0).await?;
//! click_realistic(page, 400.0, 300.0).await?;
//! let titles: Vec<String> = evaluate_in_all_frames(page, "document.title").await?;
//! # let _ = titles;
//! # Ok(()) }
//! ```

#![forbid(unsafe_code)]

pub mod adversarial_replay;
pub mod audio_dsp;
pub mod backends;
/// Compatibility path for behavior primitives (now backed by rustenium BiDi).
pub use guise::human::behavior;
pub mod browser;
pub mod browser_runtime;
pub mod config;
pub mod cookies;
pub mod detect;
pub mod fingerprint_lru;
pub mod frame;
pub mod frame_graph;
pub(crate) mod http_client;
pub mod keystroke_timing;
pub mod mobile_screenshot;
pub mod mobile_webview;

pub mod plugin;
pub mod prelude;
pub mod provider;
pub mod rule_watcher;
pub mod sdk;
pub mod solver;
pub mod stealth_profiles;

pub mod stt;
pub mod telemetry;
pub mod trace_ingest;
pub mod training_corpus;
pub mod vendor_scraper;
/// Local vision backend: YOLOv8 + CRNN inference via ONNX Runtime.
///
/// Feature-gated behind `vision`. Without the feature, the module
/// is empty and the solver chain falls back to VLM / OCR.
pub mod vision;
pub mod vlm_dataset;
pub mod waf_gate;
pub mod warmup;

pub use config::Config;
pub use cookies::CapturedCookie;
pub use guise as stealth;

pub use guise::{ProfileBundle, StealthProfile};
/// Primary browser page handle (now backed by Firefox + BiDi via rustenium).
pub use runtime_foxdriver::Page;
pub use stealth_profiles::{
    apply_default_stealth_profile, apply_stealth, apply_stealth_profile, profile_js,
    profile_to_overrides, ProfileOverrides,
};

// Back-compat re-export so code that used `golemn_browser::captcha_detect`
// can `use captchaforge::captcha_detect` instead.
pub use detect as captcha_detect;

pub use provider::{CaptchaProvider, ProviderRegistry};

// Top-level re-exports for the most common SDK surface so users can
// `use captchaforge::{Capabilities, AudioCaptchaSolver, ...}` without
// remembering which submodule each name lives in.
pub use backends::{Capabilities, OcrBackend, SttBackend, SttKind, VlmBackend};
pub use solver::{
    AudioCaptchaSolver, BehavioralCaptchaSolver, CaptchaSolveResult, CaptchaSolver,
    CaptchaSolverChain, CaptchaType, OcrCaptchaSolver, SolveMethod, TokenCache, VlmCaptchaSolver,
};

// One-call SDK helpers. Their definitions live in [`sdk`], these
// re-exports give SDK consumers a flat `captchaforge::solve_url(…)`
// import path matching the established `captchaforge::auto_solve(…)`
// shape, without forcing a `captchaforge::sdk::` prefix.
pub use sdk::{
    auto_solve_with_retries, dismiss_chat_widget, dismiss_cookie_consent, prepare_page, solve_url,
    wait_for_no_captcha,
};

/// One-call convenience: detect + solve in a single API.
///
/// Equivalent to:
///
/// ```ignore
/// let info = captchaforge::detect::detect(page).await?;
/// if !captchaforge::detect::is_captcha(&info) {
///     return Ok(None);
/// }
/// let chain = captchaforge::solver::CaptchaSolverChain::default_chain();
/// Ok(Some(chain.solve(page, &info).await))
/// ```
///
/// Returns:
///   * `Ok(None)`: no captcha was detected on the page.
///   * `Ok(Some(result))`: captcha detected; `result.success` indicates
///     whether the chain solved it. Failed solves still return Some
///     so the caller can inspect the screenshot for human fallback.
///   * `Err(_)`: the detection step itself failed (page eval error,
///     CDP transport error). Solver errors are swallowed into a
///     no-op `CaptchaSolveResult` and don't surface here.
///
/// Power users that need a custom chain (telemetry, token cache,
/// pattern store, custom solvers) construct their own
/// [`solver::CaptchaSolverChain`] and call `chain.solve()` directly.
///
/// For a "navigate + solve in one call" entry point, see
/// [`solve_url`] (re-exported from [`sdk`]).
pub async fn auto_solve(page: &crate::Page) -> anyhow::Result<Option<solver::CaptchaSolveResult>> {
    // Probe + log local backends on the first call per process so
    // operators see at a glance which solver paths are wired (Ollama
    // VLM / Whisper STT / Tesseract OCR). Cached behind a OnceLock
    // so the network probes only run once even if auto_solve is
    // called per-request in a high-throughput service.
    {
        use std::sync::OnceLock;
        static LOGGED: OnceLock<()> = OnceLock::new();
        if LOGGED.get().is_none() {
            let caps = backends::probe().await;
            tracing::info!(
                capabilities = %caps.summary(),
                "captchaforge: backend probe (run `captchaforge doctor` for details)"
            );
            for hint in caps.install_hints() {
                tracing::warn!("captchaforge install hint: {hint}");
            }
            let _ = LOGGED.set(());
        }
    }
    // Best-effort max-coherence stealth: scrub automation tells while leaving
    // the REAL Firefox identity (UA/platform/hardware/WebGL) genuine, so every
    // surface stays self-consistent across JS/HTTP/TLS. (Pinning a fake persona
    // here would desync the JS UA from the unspoofed HTTP User-Agent header, a
    // tell. Use `prepare_page(Some(persona))` + `launch_profiled_firefox` for an
    // explicit, fully-coherent cross-OS persona.) Errors are non-fatal: some
    // Firefox builds reject BiDi injection mid-session, and we'd rather still
    // try to solve than refuse.
    if let Err(e) = crate::apply_stealth(page).await {
        // Law 10: this is a recall-relevant failure (the page may still carry
        // automation tells the scrub would have removed), so it must be LOUD, not
        // hidden at debug. We still continue, a half-stealthed solve attempt is
        // more useful to the operator than refusing (but they can see it happened).
        tracing::warn!(
            error = %e,
            "captchaforge: apply_stealth failed; SOLVING ANYWAY on a page that may \
             still expose automation tells (webdriver/CDP), detection recall is degraded"
        );
    }
    let info = detect::detect(page).await?;
    if !detect::is_captcha(&info) {
        return Ok(None);
    }
    // Discover Tier-A config (.captchaforge.toml etc) and build the
    // chain from it, so a user's TOML overrides for cache TTL, VLM
    // endpoint, third-party service all reach this one-call path.
    // Discovery returns Config::default() when no file is present, so
    // a no-config install gets the same chain shape as before.
    // Config::build_chain() also installs the bundled provider registry.
    let cfg = config::Config::discover()?;
    let chain = cfg.build_chain()?;
    Ok(Some(chain.solve(page, &info).await))
}

#[cfg(test)]
mod law10_audit {
    /// Law 10, no silent fallback. The solve path takes degraded-but-continuing
    /// paths when a precise mechanism fails, and each MUST be surfaced LOUD
    /// (`warn`+), never hidden at `tracing::debug!` (off by default). Two marker
    /// classes are guarded:
    ///
    /// 1. `(continuing)` swallows, best-effort "continue on failure" decisions
    ///    (apply_stealth, warmup action, training-corpus append). Continuing is
    ///    defensible (a half-stealthed/under-warmed solve still helps), but the
    ///    failure must be visible.
    /// 2. `fallback` clicks: `behavioral.rs` degrades from a precise BiDi
    ///    per-frame element find to a HARDCODED iframe-geometry offset when the
    ///    cross-origin iframe is opaque to frame enumeration. That click can miss
    ///    if the live geometry differs, so the degraded path must be loud too.
    ///
    /// The guard fails if either marker ever appears on a `debug!` line.
    #[test]
    fn no_continuing_failure_is_swallowed_at_debug_level() {
        // include_str! paths resolve relative to THIS file (src/lib.rs).
        let sources = [
            ("src/lib.rs", include_str!("lib.rs")),
            ("src/warmup.rs", include_str!("warmup.rs")),
            ("src/solver/chain.rs", include_str!("solver/chain.rs")),
            (
                "src/solver/behavioral.rs",
                include_str!("solver/behavioral.rs"),
            ),
        ];
        // Markers that mean "this log line announces a degraded/continuing path".
        // Matched case-insensitively against the debug! line.
        let degraded_markers = ["(continuing)", "fallback"];
        for (name, src) in sources {
            for (idx, line) in src.lines().enumerate() {
                let trimmed = line.trim_start();
                let is_debug_macro =
                    trimmed.starts_with("tracing::debug!") || trimmed.starts_with("debug!(");
                if !is_debug_macro {
                    continue;
                }
                let lowered = line.to_ascii_lowercase();
                if let Some(marker) = degraded_markers
                    .iter()
                    .find(|m| lowered.contains(&m.to_ascii_lowercase()))
                {
                    panic!(
                        "{name}:{}: a degraded/continuing path (marker {marker:?}) is announced \
                         at tracing::debug! (Law 10, a silent fallback; debug is off by default). \
                         Use tracing::warn! so the operator sees the degraded solve: {}",
                        idx + 1,
                        trimmed
                    );
                }
            }
        }
    }

    /// Law 10 (no solve-path action may be bound-and-discarded with `let _ = …await`).
    ///
    /// The marker guard above only catches *logged* degraded paths (`debug!` lines).
    /// It is blind to the more silent regression: `let _ = <action>.await`, which has
    /// NO log line at all. The solver tree once carried 11 such sites, swallowed
    /// clicks, pointer moves, frame evaluates, and a triangle draw, and two of them
    /// (`vlm`, `math_captcha`) fed an unconditional `success: true`, so a pick whose
    /// trusted clicks all failed reported a solved captcha. Every one was converted to
    /// surface (`if let Err(e) = … { warn!(…) }`) or to gate the result on the action's
    /// outcome. This guard walks `src/solver` and fails if any `let _ = …await`
    /// regresses, so the class cannot come back unobserved.
    ///
    /// The banned token is assembled with `concat!` so this audit file is immune to
    /// the literal it forbids; pure-comment lines are skipped.
    #[test]
    fn no_solve_path_action_is_bound_and_discarded() {
        use std::fs;
        use std::path::{Path, PathBuf};

        fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
            let Ok(entries) = fs::read_dir(dir) else {
                return;
            };
            for entry in entries.flatten() {
                let p = entry.path();
                if p.is_dir() {
                    rs_files(&p, out);
                } else if p.extension().and_then(|e| e.to_str()) == Some("rs")
                    && p.file_name().and_then(|n| n.to_str()) != Some("tests.rs")
                {
                    out.push(p);
                }
            }
        }

        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
        let mut files = Vec::new();
        rs_files(&root.join("src/solver"), &mut files);
        assert!(
            files.len() >= 10,
            "solver-tree walk found only {} files, mis-rooted",
            files.len()
        );

        let banned = concat!("let _", " = ");
        for f in &files {
            let Ok(src) = fs::read_to_string(f) else {
                continue;
            };
            for (idx, line) in src.lines().enumerate() {
                let trimmed = line.trim_start();
                if trimmed.starts_with("//") {
                    continue;
                }
                if trimmed.contains(banned) && trimmed.contains(".await") {
                    panic!(
                        "{}:{}: a solve-path action is bound-and-discarded (`let _ = …await`). Law 10. \
                         A swallowed click/move/evaluate either wastes the solve silently or (vlm/math) \
                         fabricates `success: true`. Surface it (`if let Err(e) = … {{ warn!(…) }}`) or \
                         gate the result on the action's outcome, never bind-and-discard. Line: {}",
                        f.display(),
                        idx + 1,
                        trimmed
                    );
                }
            }
        }
    }
}

#[cfg(test)]
mod no_second_browser_audit {
    //! C033: README invariant: captchaforge solves on an ALREADY-OPEN,
    //! role-bound reynard page over BiDi. The solve path NEVER launches Chrome,
    //! Chromium, or a second browser. The ONLY sanctioned launcher is
    //! `browser_runtime.rs` (the explicit standalone-drive entry point), and it
    //! launches reynard/**Firefox**: never Chrome. This guard walks the
    //! solve-path source and fails if a browser-launch call leaks onto it, or if a
    //! Chrome/Chromium *driver* token appears anywhere in the crate (a Firefox-only
    //! tool must not link a Chrome driver). Auto-covers new solver files because it
    //! walks the directory rather than an enumerated list.
    use std::fs;
    use std::path::{Path, PathBuf};

    /// Recursively collect every `.rs` file under `dir` (skips `browser_runtime.rs`,
    /// the one sanctioned launcher).
    fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
        let Ok(entries) = fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let p = entry.path();
            if p.is_dir() {
                rs_files(&p, out);
            } else if p.extension().and_then(|e| e.to_str()) == Some("rs")
                && p.file_name().and_then(|n| n.to_str()) != Some("browser_runtime.rs")
            {
                out.push(p);
            }
        }
    }

    /// Lines that are pure comments don't count, only code references do. (A
    /// design doc may legitimately mention "chromium" or "launch" in prose.)
    fn is_full_line_comment(line: &str) -> bool {
        let t = line.trim_start();
        t.starts_with("//")
    }

    #[test]
    fn solve_path_never_launches_a_browser() {
        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
        let mut files = Vec::new();
        rs_files(&root.join("src/solver"), &mut files);
        for extra in ["src/lib.rs", "src/sdk.rs", "src/warmup.rs", "src/detect.rs"] {
            files.push(root.join(extra));
        }
        // Built via concat! so this audit file is immune to its own token list.
        // Each token is split across concat! so this audit file never contains the
        // joined literal (otherwise it would flag itself). The first also matches
        // the `_self_managed` launcher by substring.
        let launch_tokens = [
            concat!("launch_", "firefox"),
            concat!("launch_", "profiled_firefox"),
            concat!("connect_over", "_cdp"),
        ];
        for f in &files {
            let Ok(src) = fs::read_to_string(f) else {
                continue;
            };
            for (idx, line) in src.lines().enumerate() {
                if is_full_line_comment(line) {
                    continue;
                }
                for tok in launch_tokens {
                    assert!(
                        !line.contains(tok),
                        "{}:{}: a solve-path file references `{tok}`: the README invariant is that \
                         captchaforge solves on an already-open reynard page and NEVER launches a \
                         browser in the solve path (only browser_runtime.rs may launch). Line: {}",
                        f.display(),
                        idx + 1,
                        line.trim()
                    );
                }
            }
        }
    }

    #[test]
    fn no_chrome_driver_token_anywhere_firefox_only_tool() {
        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
        let mut files = Vec::new();
        rs_files(&root.join("src"), &mut files);
        // concat! keeps the joined literal out of this file → self-immune.
        let chrome_driver_tokens = [
            concat!("chromium", "oxide"),
            concat!("chrome", "driver"),
            concat!("Chrome", "Builder"),
            concat!("BrowserType", "::Chrom"),
        ];
        for f in &files {
            let Ok(src) = fs::read_to_string(f) else {
                continue;
            };
            for (idx, line) in src.lines().enumerate() {
                if is_full_line_comment(line) {
                    continue;
                }
                for tok in chrome_driver_tokens {
                    assert!(
                        !line.contains(tok),
                        "{}:{}: Chrome-driver token `{tok}` in a Firefox/reynard-only tool, a \
                         coherence break with the 'never launches Chrome' invariant. Line: {}",
                        f.display(),
                        idx + 1,
                        line.trim()
                    );
                }
            }
        }
    }
}