//! Human-like input behaviour for stealth. //! //! When chrome-use drives a real Chrome over CDP, the input events it //! dispatches are already `isTrusted` — but a click that teleports the cursor //! straight to an element's exact centre, with no approach path and zero delay //! between move/press/release, is a behavioural tell that advanced anti-bot //! vendors (Akamai, PerimeterX, DataDome) look for. //! //! This module produces **human-like motion plans** — curved, eased cursor //! trajectories and variable keystroke timing — as *pure data*. It performs no //! I/O and knows nothing about CDP: callers turn the returned steps into //! `Input.dispatchMouseEvent` / `dispatchKeyEvent` calls. Keeping the maths pure //! makes the easing/jitter/detection logic unit-testable and deterministic //! (every randomised value comes from a caller-supplied seed). //! //! Design (see brainstorm 2026-06-11): //! - Three levels: [`HumanizeLevel::Off`] (instant, today's behaviour), //! `Fast` (a few cheap eased steps), `Human` (full curved trajectory + jitter). //! - Baseline is `Off`; the daemon escalates a session to `Human` when //! [`detect_level`] spots a known anti-bot vendor on the page. `--humanize` / //! `AGENT_BROWSER_HUMANIZE` force a fixed level. //! - Humanization only changes *how* the cursor reaches a target, never *which* //! element is hit: the landing jitter stays inside the caller-provided bounds. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; // ---- daemon-wide runtime state ------------------------------------------- // // The pure motion maths above are stateless. The daemon drives one active page // at a time, so we keep the *current* humanize level and last cursor position // in process-global slots rather than threading them through every call site. // (The adaptive detector flips the level per navigation; `dispatch_click` reads // the level + cursor here, so no signature in the click/type call graph has to // change.) /// `AGENT_BROWSER_HUMANIZE` forces a fixed level, overriding the adaptive /// detector. Parsed once. fn env_override() -> Option { static OVERRIDE: OnceLock> = OnceLock::new(); *OVERRIDE.get_or_init(|| { std::env::var("AGENT_BROWSER_HUMANIZE") .ok() .and_then(|s| HumanizeLevel::parse(&s)) }) } fn session_level() -> &'static Mutex { static LEVEL: OnceLock> = OnceLock::new(); LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off)) } fn last_cursor_slot() -> &'static Mutex<(f64, f64)> { static CURSOR: OnceLock> = OnceLock::new(); CURSOR.get_or_init(|| Mutex::new((0.0, 0.0))) } /// The level that should apply right now: the env override if set, else the /// level the detector last chose for the active page. pub fn active_level() -> HumanizeLevel { env_override().unwrap_or_else(|| *session_level().lock().unwrap()) } /// Set by the adaptive detector after navigation. Ignored while an env override /// is in force (so `--humanize` always wins). pub fn set_detected_level(level: HumanizeLevel) { *session_level().lock().unwrap() = level; } /// Where the virtual cursor currently sits, so the next move starts from there /// instead of teleporting. pub fn last_cursor() -> (f64, f64) { *last_cursor_slot().lock().unwrap() } /// Record the cursor landing point after a move/click. pub fn set_last_cursor(p: (f64, f64)) { *last_cursor_slot().lock().unwrap() = p; } /// A fresh seed per action so repeated clicks on the same point still vary, /// without touching the wall clock or a global RNG (both would break replay). pub fn next_seed() -> u64 { static COUNTER: AtomicU64 = AtomicU64::new(0x1234_5678); COUNTER .fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed) .rotate_left(17) } /// How human-like input motion should be. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum HumanizeLevel { /// Instant: a single move to the exact point, no delays. Original behaviour. #[default] Off, /// A few eased steps with small delays — cheap cover for ordinary sites. Fast, /// Full curved, decelerating trajectory with landing jitter and press /// dwell — for pages guarded by behavioural anti-bot systems. Human, } impl HumanizeLevel { /// Parse a user-supplied level (`--humanize` / `AGENT_BROWSER_HUMANIZE`). pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "off" | "none" | "instant" | "0" => Some(Self::Off), "fast" | "light" | "low" => Some(Self::Fast), "human" | "full" | "high" | "max" => Some(Self::Human), _ => None, } } fn is_off(self) -> bool { matches!(self, Self::Off) } } /// One step of a humanized cursor move: dispatch `mouseMoved` to (`x`, `y`), /// then sleep for `delay` before the next step. The final step's point is where /// the press/release should land. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MoveStep { pub x: f64, pub y: f64, pub delay: Duration, } /// Tiny deterministic PRNG (xorshift64*). Seeded by the caller so trajectories /// are reproducible in tests; we avoid pulling in the `rand` crate and never /// call a wall-clock/global RNG (which would also break workflow replay). struct Rng(u64); impl Rng { fn new(seed: u64) -> Self { // Avoid the zero state, which xorshift cannot escape. Rng(seed ^ 0x9E37_79B9_7F4A_7C15) } fn next_u64(&mut self) -> u64 { let mut x = self.0; x ^= x >> 12; x ^= x << 25; x ^= x >> 27; self.0 = x; x.wrapping_mul(0x2545_F491_4F6C_DD1D) } /// Uniform in [0, 1). fn unit(&mut self) -> f64 { // Top 53 bits → f64 mantissa. (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 } /// Uniform in [-1, 1). fn signed(&mut self) -> f64 { self.unit() * 2.0 - 1.0 } } /// Smootherstep ease (zero velocity at both ends) — used to bias the per-step /// timing so the cursor accelerates away from the start and decelerates into /// the target, the way a hand does. fn ease(t: f64) -> f64 { let t = t.clamp(0.0, 1.0); t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } /// Cubic Bézier point at parameter `t`. fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) { let u = 1.0 - t; let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); ( a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1, ) } /// Pick a landing point inside `bbox` (`x`, `y`, `width`, `height`). `Off` /// returns the exact centre; `Fast`/`Human` jitter around the centre but stay /// well inside the element so the click still lands on it. pub fn landing_point(bbox: (f64, f64, f64, f64), level: HumanizeLevel, seed: u64) -> (f64, f64) { let (bx, by, bw, bh) = bbox; let cx = bx + bw / 2.0; let cy = by + bh / 2.0; if level.is_off() || bw <= 1.0 || bh <= 1.0 { return (cx, cy); } // Keep within the inner 60% so jitter never lands on a neighbouring element // or the element's padding/edge. let spread = match level { HumanizeLevel::Human => 0.30, _ => 0.15, }; let mut rng = Rng::new(seed); ( cx + rng.signed() * bw * spread, cy + rng.signed() * bh * spread, ) } /// Build the cursor path from `from` to `to`. The last [`MoveStep`] is the /// landing point. `Off` yields a single zero-delay step at `to` (today's /// teleport), so callers can use one code path for every level. pub fn move_path( from: (f64, f64), to: (f64, f64), level: HumanizeLevel, seed: u64, ) -> Vec { if level.is_off() { return vec![MoveStep { x: to.0, y: to.1, delay: Duration::ZERO, }]; } let dist = (to.0 - from.0).hypot(to.1 - from.1); if dist < 1.0 { return vec![MoveStep { x: to.0, y: to.1, delay: Duration::ZERO, }]; } let (steps, total_ms, arc) = match level { HumanizeLevel::Fast => { let s = ((dist / 120.0).round() as usize).clamp(3, 6); (s, (dist * 0.35).clamp(40.0, 130.0), 0.06) } // Off handled above. _ => { let s = ((dist / 45.0).round() as usize).clamp(8, 24); (s, (dist * 0.9).clamp(140.0, 650.0), 0.16) } }; let mut rng = Rng::new(seed); // Two control points along the line, pushed perpendicular to it to bow the // path into a gentle, slightly asymmetric arc. let (dx, dy) = (to.0 - from.0, to.1 - from.1); let (nx, ny) = (-dy / dist, dx / dist); // unit normal let bow = dist * arc * rng.signed(); let ctrl = |frac: f64, jitter: f64, rng: &mut Rng| { let base = (from.0 + dx * frac, from.1 + dy * frac); let off = bow * (1.0 + jitter * rng.signed()); (base.0 + nx * off, base.1 + ny * off) }; let p1 = ctrl(0.33, 0.4, &mut rng); let p2 = ctrl(0.66, 0.4, &mut rng); let mut out = Vec::with_capacity(steps); let mut prev_ease = 0.0; for i in 1..=steps { let t = i as f64 / steps as f64; // Ease maps wall-time progress so most points cluster near the ends // (slow start, slow finish, fast middle). let te = ease(t); let (x, y) = bezier(from, p1, p2, to, te); let frac = te - prev_ease; prev_ease = te; out.push(MoveStep { x, y, delay: Duration::from_micros((total_ms * frac * 1000.0).max(0.0) as u64), }); } // Guarantee the final point is exactly the target. if let Some(last) = out.last_mut() { last.x = to.0; last.y = to.1; } out } /// Split a wheel scroll of (`total_dx`, `total_dy`) into eased segments. `Off` /// returns a single instant segment (today's one-shot scroll); `Fast`/`Human` /// break it into several accelerate-then-decelerate chunks with small, /// jittered inter-segment delays, the way a trackpad/wheel flick actually /// lands. The segment deltas always sum to the requested total. pub fn scroll_segments( total_dx: f64, total_dy: f64, level: HumanizeLevel, seed: u64, ) -> Vec<(f64, f64, Duration)> { if level.is_off() { return vec![(total_dx, total_dy, Duration::ZERO)]; } let (segs, base_ms) = match level { HumanizeLevel::Fast => (4usize, 18.0), _ => (9usize, 28.0), }; let mut rng = Rng::new(seed); let mut out = Vec::with_capacity(segs); let mut prev = 0.0; for i in 1..=segs { let f = ease(i as f64 / segs as f64); let frac = f - prev; prev = f; let jitter = 1.0 + 0.3 * rng.signed(); out.push(( total_dx * frac, total_dy * frac, Duration::from_millis((base_ms * jitter).max(4.0) as u64), )); } out } /// Dwell between `mousePressed` and `mouseReleased` (a real click isn't /// instantaneous). Zero for `Off`. pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration { match level { HumanizeLevel::Off => Duration::ZERO, HumanizeLevel::Fast => Duration::from_millis(20 + (seed % 30)), HumanizeLevel::Human => Duration::from_millis(50 + (seed % 90)), } } /// Per-character delays for typing `len` characters. `Off` is all-zero (use a /// single `Input.insertText`); `Fast`/`Human` produce variable inter-keystroke /// gaps with the occasional longer "think" pause, like a real typist. pub fn keystroke_delays(len: usize, level: HumanizeLevel, seed: u64) -> Vec { if level.is_off() || len == 0 { return vec![Duration::ZERO; len]; } let (mean, jitter, pause_chance, pause_extra) = match level { HumanizeLevel::Fast => (25.0, 15.0, 0.0, 0.0), _ => (95.0, 55.0, 0.06, 220.0), }; let mut rng = Rng::new(seed); (0..len) .map(|_| { let mut ms = (mean + rng.signed() * jitter).max(8.0); if pause_chance > 0.0 && rng.unit() < pause_chance { ms += rng.unit() * pause_extra; } Duration::from_millis(ms as u64) }) .collect() } /// Page signals sampled after navigation, used to decide whether to escalate a /// session to [`HumanizeLevel::Human`]. All strings are matched case-insensitively. #[derive(Debug, Default, Clone)] pub struct DetectSignals { /// Cookie names present on the document (e.g. `_abck`, `datadome`). pub cookie_names: Vec, /// `src` of loaded scripts. pub script_urls: Vec, /// Names of suspicious globals on `window` (e.g. `_px`, `bmak`). pub window_globals: Vec, } /// Known behavioural anti-bot fingerprints: (substring, vendor). Matched against /// cookie names, script URLs, and window globals. const VENDOR_MARKERS: &[(&str, &str)] = &[ ("_abck", "akamai"), ("bm_sz", "akamai"), ("ak_bmsc", "akamai"), ("bmak", "akamai"), ("_px", "perimeterx"), ("perimeterx", "perimeterx"), ("px-cloud", "perimeterx"), ("datadome", "datadome"), ("kpsdk", "kasada"), ("incap_ses", "imperva"), ("visid_incap", "imperva"), ("reese84", "imperva"), ("__cf_bm", "cloudflare-bot-mgmt"), ]; /// Decide the level for a page. Returns `Human` if any known anti-bot vendor is /// present, otherwise `baseline`. Misses just stay at baseline and false hits /// only cost a little latency, so matching is deliberately liberal. pub fn detect_level(signals: &DetectSignals, baseline: HumanizeLevel) -> HumanizeLevel { let hay: Vec = signals .cookie_names .iter() .chain(signals.script_urls.iter()) .chain(signals.window_globals.iter()) .map(|s| s.to_ascii_lowercase()) .collect(); let matched = VENDOR_MARKERS .iter() .any(|(marker, _)| hay.iter().any(|h| h.contains(marker))); if matched { HumanizeLevel::Human } else { baseline } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_accepts_known_levels_and_rejects_junk() { assert_eq!(HumanizeLevel::parse("off"), Some(HumanizeLevel::Off)); assert_eq!(HumanizeLevel::parse(" FAST "), Some(HumanizeLevel::Fast)); assert_eq!(HumanizeLevel::parse("Human"), Some(HumanizeLevel::Human)); assert_eq!(HumanizeLevel::parse("max"), Some(HumanizeLevel::Human)); assert_eq!(HumanizeLevel::parse("wat"), None); } #[test] fn off_level_teleports_in_one_step() { let path = move_path((0.0, 0.0), (100.0, 50.0), HumanizeLevel::Off, 1); assert_eq!(path.len(), 1); assert_eq!((path[0].x, path[0].y), (100.0, 50.0)); assert_eq!(path[0].delay, Duration::ZERO); } #[test] fn humanized_path_is_multi_step_and_lands_exactly_on_target() { let to = (640.0, 480.0); let path = move_path((10.0, 10.0), to, HumanizeLevel::Human, 42); assert!(path.len() >= 8, "human path should have many steps"); let last = path.last().unwrap(); assert_eq!((last.x, last.y), to, "final point must equal the target"); // Path must actually leave the straight line at some point (it's a curve). let straight = path.iter().all(|s| { let t = (s.x - 10.0) / (to.0 - 10.0); (s.y - (10.0 + t * (to.1 - 10.0))).abs() < 0.5 }); assert!(!straight, "human path should bow off the straight line"); } #[test] fn fast_path_is_shorter_than_human() { let fast = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Fast, 7); let human = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Human, 7); assert!(fast.len() < human.len()); assert!((3..=6).contains(&fast.len())); } #[test] fn move_path_is_deterministic_for_a_seed() { let a = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99); let b = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99); assert_eq!(a, b); let c = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 100); assert_ne!(a, c, "different seeds should differ"); } #[test] fn landing_point_stays_inside_bounds_and_centres_when_off() { let bbox = (100.0, 100.0, 40.0, 20.0); assert_eq!(landing_point(bbox, HumanizeLevel::Off, 1), (120.0, 110.0)); for seed in 0..200 { let (x, y) = landing_point(bbox, HumanizeLevel::Human, seed); assert!(x > 100.0 && x < 140.0, "x {x} escaped bbox"); assert!(y > 100.0 && y < 120.0, "y {y} escaped bbox"); } } #[test] fn keystroke_delays_zero_when_off_and_positive_otherwise() { assert!(keystroke_delays(5, HumanizeLevel::Off, 1) .iter() .all(|d| *d == Duration::ZERO)); let human = keystroke_delays(20, HumanizeLevel::Human, 3); assert_eq!(human.len(), 20); assert!(human.iter().all(|d| *d >= Duration::from_millis(8))); } #[test] fn scroll_segments_sum_to_total_and_single_when_off() { let off = scroll_segments(0.0, 600.0, HumanizeLevel::Off, 1); assert_eq!(off.len(), 1); assert_eq!((off[0].0, off[0].1), (0.0, 600.0)); assert_eq!(off[0].2, Duration::ZERO); let human = scroll_segments(0.0, 600.0, HumanizeLevel::Human, 5); assert!(human.len() >= 5); let total_dy: f64 = human.iter().map(|s| s.1).sum(); assert!( (total_dy - 600.0).abs() < 1e-6, "segments must sum to total" ); assert!(human.iter().all(|s| s.2 >= Duration::from_millis(4))); } #[test] fn detect_escalates_on_known_vendor_else_baseline() { let mut s = DetectSignals::default(); assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Off); s.cookie_names = vec!["sessionid".into(), "_abck".into()]; assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Human); let s2 = DetectSignals { script_urls: vec!["https://cdn.example.com/DataDome-tags.js".into()], ..Default::default() }; assert_eq!(detect_level(&s2, HumanizeLevel::Off), HumanizeLevel::Human); let s3 = DetectSignals { window_globals: vec!["_pxAppId".into()], ..Default::default() }; assert_eq!(detect_level(&s3, HumanizeLevel::Fast), HumanizeLevel::Human); // Unknown signals keep the baseline. let s4 = DetectSignals { cookie_names: vec!["cart".into(), "theme".into()], ..Default::default() }; assert_eq!(detect_level(&s4, HumanizeLevel::Fast), HumanizeLevel::Fast); } }