Compare commits

..
Author SHA1 Message Date
leeguooooo 1c2e594003 chore(release): 0.27.0-fork.37 — complete humanize (bbox jitter + wheel/drag easing)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 19:53:27 +09:00
leeguooooo 9bd6587278 feat(stealth): in-bbox landing jitter + eased wheel/drag (humanize v4)
Completes the humanize suite:
- Clicks land on a jittered point inside the element's box (Fast/Human) instead
  of its exact centre. `resolve_element_center` now also returns the element
  width/height (box_model_dims); the CSS-selector path reports zero size → land
  on centre (no jitter, no regression). Jitter is clamped to the inner box so the
  click never misses.
- Wheel scrolls split into eased, jittered segments (humanize::scroll_segments,
  unit-tested) instead of one instant jump.
- Drag follows the curved trajectory at Fast/Human (linear 10-step at Off).

Off is unchanged throughout. 9/9 unit tests; verified headless — jittered click
still lands (→ iana.org), segmented scroll moves the page.
2026-06-11 19:52:33 +09:00
leeguooooo df53b1a70e chore(release): 0.27.0-fork.36 — human-like input stealth (humanize)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Ships the humanize feature: human-like cursor trajectories (Bézier + easing +
press dwell), variable typing cadence, an adaptive per-navigation anti-bot
detector that auto-escalates guarded pages to Human, and `--humanize` /
AGENT_BROWSER_HUMANIZE control. Default Off → unchanged for ordinary sites.
2026-06-11 19:42:07 +09:00
leeguooooo a6f0193779 feat(stealth): humanized typing cadence + --humanize flag (v3)
- Typing: type_text_into_active_context now uses variable, human-like
  inter-keystroke gaps from humanize::keystroke_delays when no explicit --delay
  is given (Fast/Human); Off stays instant. Explicit --delay still wins.
- CLI: `--humanize off|fast|human` surfaces AGENT_BROWSER_HUMANIZE so the
  session's daemon (a child that inherits this env) applies it, overriding the
  adaptive detector. Invalid values warn and are ignored.

Verified headless: `--humanize human` + type lands "hello world" correctly.
Deferred: in-bbox landing jitter (helper ready, needs bbox threaded) + wheel/drag
easing.
2026-06-11 19:40:32 +09:00
leeguooooo bab58991fe feat(stealth): adaptive anti-bot detection drives humanize level (v2)
After each navigation, probe the loaded page for known behavioural anti-bot
vendor fingerprints — cookies (_abck/Akamai, _px/PerimeterX, datadome,
reese84/Imperva, …), script URLs, and window globals — and escalate this
session to HumanizeLevel::Human when one is present, else fall back to the Off
baseline. So ordinary sites run at full speed (instant clicks) and only pages
actually guarded by behavioural detection pay for human-like motion.

`AGENT_BROWSER_HUMANIZE` still forces a fixed level and short-circuits the probe.
Best-effort: a failed probe leaves the level unchanged. Verified end-to-end
(headless --launch): a HUMANIZE=human click on example.com traverses the curved
trajectory and lands correctly (→ iana.org), identical outcome to Off.
2026-06-11 19:29:49 +09:00
leeguooooo c5d4c8908d feat(stealth): human-like click trajectories (humanize v1)
Behavioural stealth: a click that teleports the cursor to an element's exact
centre with no approach path and zero press/release delay is a tell that
advanced anti-bot vendors (Akamai/PerimeterX/DataDome) flag, even though our CDP
events are isTrusted.

New `native::humanize` module — pure, unit-tested motion maths (cubic-Bézier
eased trajectories, in-bounds landing jitter, variable keystroke cadence, and an
anti-bot vendor detector) plus a small daemon-wide runtime (current level + last
cursor + per-action seed). `dispatch_click` now moves along a curved,
decelerating path from the last cursor position and dwells before releasing.

Three levels off|fast|human. Default is Off → byte-for-byte the old teleport, so
nothing changes until opted in. `AGENT_BROWSER_HUMANIZE=human` forces it now;
the adaptive per-navigation detector (set_detected_level) and type/wheel/drag
coverage land next. 8/8 unit tests; fmt + clippy clean.
2026-06-11 19:24:07 +09:00
9 changed files with 736 additions and 58 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.35"
version = "0.27.0-fork.37"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.35"
version = "0.27.0-fork.37"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+17
View File
@@ -248,6 +248,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--screenshot-format",
"--idle-timeout",
"--model",
"--humanize",
];
let mut i = 0;
while i < args.len() {
@@ -796,6 +797,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--humanize" => {
// Human-like input motion level (off|fast|human). Surface it as
// AGENT_BROWSER_HUMANIZE so the daemon — spawned as a child that
// inherits this process's env — picks it up and it overrides the
// adaptive detector. Applies when the session's daemon launches.
if let Some(s) = args.get(i + 1) {
match crate::native::humanize::HumanizeLevel::parse(s) {
Some(_) => std::env::set_var("AGENT_BROWSER_HUMANIZE", s),
None => eprintln!(
"warning: --humanize must be off|fast|human, got {s:?} (ignored)"
),
}
i += 1;
}
}
"--screenshot-dir" => {
if let Some(s) = args.get(i + 1) {
flags.screenshot_dir = Some(s.clone());
@@ -922,6 +938,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--screenshot-format",
"--idle-timeout",
"--model",
"--humanize",
];
let mut i = 0;
+92 -23
View File
@@ -23,6 +23,7 @@ use super::cdp::types::{
use super::cookies;
use super::diff;
use super::element::RefMap;
use super::humanize;
use super::inspect_server::InspectServer;
use super::interaction;
use super::network::{self, DomainFilter, EventTracker};
@@ -2512,7 +2513,49 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
mgr.navigate(url, wait_until).await
let result = mgr.navigate(url, wait_until).await?;
// Adaptive humanize: sample the freshly loaded page for known behavioural
// anti-bot vendors and escalate this session to Human if any are present.
detect_and_set_humanize(mgr).await;
Ok(result)
}
/// After navigation, probe the page for known anti-bot vendor fingerprints
/// (cookies / script URLs / `window` globals) and set this session's humanize
/// level accordingly — `Human` when a vendor is detected, else the `Off`
/// baseline. Best-effort: any failure leaves the level unchanged. Skipped when
/// `AGENT_BROWSER_HUMANIZE` is set, since the override always wins and the probe
/// would be wasted work.
async fn detect_and_set_humanize(mgr: &BrowserManager) {
if std::env::var("AGENT_BROWSER_HUMANIZE").is_ok() {
return;
}
let js = r#"(() => { try {
const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean);
const scripts = Array.from(document.scripts, s => s.src || '').filter(Boolean);
const re = /_px|bmak|_abck|datadome|reese84|kpsdk|incap_ses|visid_incap|akam/i;
const globals = Object.getOwnPropertyNames(window).filter(k => re.test(k));
return { cookies, scripts, globals };
} catch (e) { return {}; } })()"#;
let Ok(val) = mgr.evaluate(js, None).await else {
return;
};
let to_strings = |v: Option<&Value>| -> Vec<String> {
v.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
};
let signals = humanize::DetectSignals {
cookie_names: to_strings(val.get("cookies")),
script_urls: to_strings(val.get("scripts")),
window_globals: to_strings(val.get("globals")),
};
let level = humanize::detect_level(&signals, humanize::HumanizeLevel::Off);
humanize::set_detected_level(level);
}
async fn handle_url(state: &DaemonState) -> Result<Value, String> {
@@ -5470,19 +5513,29 @@ async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result<Value, String>
let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({
"type": "mouseWheel",
"x": x,
"y": y,
"deltaX": delta_x,
"deltaY": delta_y,
})),
Some(&session_id),
)
.await?;
// Humanize: at Off this is one instant wheel event (unchanged); at
// Fast/Human the scroll is split into eased, slightly-jittered segments so
// it ramps and settles like a real wheel/trackpad flick.
let level = humanize::active_level();
let seed = humanize::next_seed();
for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({
"type": "mouseWheel",
"x": x,
"y": y,
"deltaX": dx,
"deltaY": dy,
})),
Some(&session_id),
)
.await?;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
}
@@ -6479,7 +6532,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
.and_then(|v| v.as_str())
.ok_or("Missing 'target' parameter")?;
let (sx, sy, source_session_id) = super::element::resolve_element_center(
let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center(
&mgr.client,
&session_id,
&state.ref_map,
@@ -6487,7 +6540,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
&state.iframe_sessions,
)
.await?;
let (tx, ty, target_session_id) = super::element::resolve_element_center(
let (tx, ty, _, _, target_session_id) = super::element::resolve_element_center(
&mgr.client,
&session_id,
&state.ref_map,
@@ -6512,12 +6565,26 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
)
.await?;
// Move in steps to target, keeping the left button held (buttons: 1) so
// that the browser sees a drag rather than a plain pointer move.
let steps = 10;
for i in 1..=steps {
let cx = sx + (tx - sx) * (i as f64) / (steps as f64);
let cy = sy + (ty - sy) * (i as f64) / (steps as f64);
// Move to the target with the left button held (buttons: 1) so the browser
// sees a drag. At Off this is the original linear 10-step path; at
// Fast/Human it follows humanize's curved, decelerating trajectory.
let level = humanize::active_level();
let drag_path: Vec<(f64, f64, std::time::Duration)> =
if matches!(level, humanize::HumanizeLevel::Off) {
(1..=10)
.map(|i| {
let cx = sx + (tx - sx) * (i as f64) / 10.0;
let cy = sy + (ty - sy) * (i as f64) / 10.0;
(cx, cy, std::time::Duration::from_millis(10))
})
.collect()
} else {
humanize::move_path((sx, sy), (tx, ty), level, humanize::next_seed())
.into_iter()
.map(|s| (s.x, s.y, s.delay))
.collect()
};
for (cx, cy, delay) in drag_path {
mgr.client
.send_command(
"Input.dispatchMouseEvent",
@@ -6525,7 +6592,9 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
Some(&target_session_id),
)
.await?;
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
// Mouse up at target
+40 -6
View File
@@ -200,13 +200,17 @@ async fn relocate_stale_ref(
}
}
/// Resolve a `@ref` or CSS selector to a click point. Returns
/// `(centre_x, centre_y, width, height, session_id)`. Width/height come from the
/// element's box model and feed humanize's in-bounds landing jitter; the CSS
/// selector path returns zero size (→ land on centre, no jitter).
pub async fn resolve_element_center(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(f64, f64, String), String> {
) -> Result<(f64, f64, f64, f64, String), String> {
if let Some(ref_id) = parse_ref(selector_or_ref) {
let entry = ref_map
.get(&ref_id)
@@ -263,7 +267,7 @@ pub async fn resolve_element_center(
.await;
if let Ok(r) = result {
let (x, y) = box_model_center(&r.model);
let (x, y, w, h) = box_model_dims(&r.model);
// Occlusion check: a transient overlay (X.com's "click
// outside to close" mask, modal backdrop, sticky banner,
// etc.) can land on top of our target between snapshot
@@ -279,7 +283,7 @@ pub async fn resolve_element_center(
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
.await?;
}
return Ok((x, y, effective_session_id.to_string()));
return Ok((x, y, w, h, effective_session_id.to_string()));
}
// backend_node_id is stale; re-query the accessibility tree below
}
@@ -316,13 +320,14 @@ pub async fn resolve_element_center(
Some(effective_session_id),
)
.await?;
let (x, y) = box_model_center(&result.model);
return Ok((x, y, effective_session_id.to_string()));
let (x, y, w, h) = box_model_dims(&result.model);
return Ok((x, y, w, h, effective_session_id.to_string()));
}
// CSS selector
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
Ok((x, y, session_id.to_string()))
// No box model on the CSS-selector fast path → zero size → land on centre.
Ok((x, y, 0.0, 0.0, session_id.to_string()))
}
pub async fn resolve_element_object_id(
@@ -872,6 +877,35 @@ fn box_model_center(model: &BoxModel) -> (f64, f64) {
}
}
/// Centre plus width/height of the content box, derived from the quad's
/// bounding extent. Width/height feed humanize's in-bounds landing jitter; a
/// degenerate quad yields zero size, which the jitter treats as "land on
/// centre" (no jitter).
fn box_model_dims(model: &BoxModel) -> (f64, f64, f64, f64) {
let (cx, cy) = box_model_center(model);
if model.content.len() >= 8 {
let xs = [
model.content[0],
model.content[2],
model.content[4],
model.content[6],
];
let ys = [
model.content[1],
model.content[3],
model.content[5],
model.content[7],
];
let w = xs.iter().cloned().fold(f64::MIN, f64::max)
- xs.iter().cloned().fold(f64::MAX, f64::min);
let h = ys.iter().cloned().fold(f64::MIN, f64::max)
- ys.iter().cloned().fold(f64::MAX, f64::min);
(cx, cy, w.max(0.0), h.max(0.0))
} else {
(cx, cy, 0.0, 0.0)
}
}
pub async fn get_element_text(
client: &CdpClient,
session_id: &str,
+517
View File
@@ -0,0 +1,517 @@
//! Human-like input behaviour for stealth.
//!
//! When agent-browser 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<HumanizeLevel> {
static OVERRIDE: OnceLock<Option<HumanizeLevel>> = OnceLock::new();
*OVERRIDE.get_or_init(|| {
std::env::var("AGENT_BROWSER_HUMANIZE")
.ok()
.and_then(|s| HumanizeLevel::parse(&s))
})
}
fn session_level() -> &'static Mutex<HumanizeLevel> {
static LEVEL: OnceLock<Mutex<HumanizeLevel>> = OnceLock::new();
LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off))
}
fn last_cursor_slot() -> &'static Mutex<(f64, f64)> {
static CURSOR: OnceLock<Mutex<(f64, f64)>> = 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<Self> {
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<MoveStep> {
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<Duration> {
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<String>,
/// `src` of loaded scripts.
pub script_urls: Vec<String>,
/// Names of suspicious globals on `window` (e.g. `_px`, `bmak`).
pub window_globals: Vec<String>,
}
/// 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<String> = 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);
}
}
+65 -26
View File
@@ -5,6 +5,7 @@ use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
use super::humanize;
pub async fn click(
client: &CdpClient,
@@ -54,8 +55,15 @@ pub async fn click(
.await;
match resolved {
Ok((x, y, effective_session_id)) => {
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
Ok((cx, cy, w, h, effective_session_id)) => {
// Land on a jittered point inside the element rather than its exact
// centre (Fast/Human). Zero size or Off → exact centre.
let (tx, ty) = humanize::landing_point(
(cx - w / 2.0, cy - h / 2.0, w, h),
humanize::active_level(),
humanize::next_seed(),
);
dispatch_click(client, &effective_session_id, tx, ty, button, click_count).await
}
Err(e) => {
// (B) The coordinate path failed — typically a persistent overlay
@@ -190,7 +198,7 @@ pub async fn hover(
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y, effective_session_id) = resolve_element_center(
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
@@ -349,9 +357,18 @@ pub async fn type_text_into_active_context(
text: &str,
delay_ms: Option<u64>,
) -> Result<(), String> {
let delay = delay_ms.unwrap_or(0);
// Per-character timing: an explicit `delay_ms` wins (caller asked for a
// fixed cadence); otherwise fall back to humanize — variable, human-like
// inter-keystroke gaps at Fast/Human, all-zero (instant) at Off.
let chars: Vec<char> = text.chars().collect();
let cadence: Vec<std::time::Duration> = match delay_ms {
Some(d) => vec![std::time::Duration::from_millis(d); chars.len()],
None => {
humanize::keystroke_delays(chars.len(), humanize::active_level(), humanize::next_seed())
}
};
for ch in text.chars() {
for (i, ch) in chars.into_iter().enumerate() {
if matches!(ch, '\n' | '\r' | '\t') {
let (key, code, key_code) = char_to_key_info(ch);
let text_str = key_text(&key);
@@ -403,8 +420,9 @@ pub async fn type_text_into_active_context(
.await?;
}
if delay > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
let gap = cadence[i];
if !gap.is_zero() {
tokio::time::sleep(gap).await;
}
}
@@ -988,7 +1006,7 @@ pub async fn tap_touch(
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y, effective_session_id) = resolve_element_center(
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
@@ -1070,24 +1088,38 @@ async fn dispatch_click(
button: &str,
click_count: i32,
) -> Result<(), String> {
// Move
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x,
y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
// Move toward the target along a human-like path. At HumanizeLevel::Off this
// is a single zero-delay step to (x, y) — identical to the old teleport — so
// the default behaviour is unchanged. At Fast/Human it's a curved,
// decelerating trajectory starting from where the cursor last landed, which
// removes the "instant jump to exact centre, no prior movement" tell that
// behavioural anti-bot systems flag.
let level = humanize::active_level();
let start = humanize::last_cursor();
let seed = humanize::next_seed();
for step in humanize::move_path(start, (x, y), level, seed) {
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x: step.x,
y: step.y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(session_id),
)
.await?;
if !step.delay.is_zero() {
tokio::time::sleep(step.delay).await;
}
}
humanize::set_last_cursor((x, y));
let button_value = match button {
"right" => 2,
@@ -1114,6 +1146,13 @@ async fn dispatch_click(
)
.await?;
// Hold briefly before releasing — a real click isn't instantaneous. Zero at
// HumanizeLevel::Off.
let dwell = humanize::press_dwell(level, seed);
if !dwell.is_zero() {
tokio::time::sleep(dwell).await;
}
// Release
client
.send_command_typed::<_, Value>(
+2
View File
@@ -17,6 +17,8 @@ pub mod diff;
#[allow(dead_code)]
pub mod element;
#[allow(dead_code)]
pub mod humanize;
#[allow(dead_code)]
pub mod inspect_server;
#[allow(dead_code)]
pub mod interaction;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.35",
"version": "0.27.0-fork.37",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",