Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d740093dc | ||
|
|
c884fb4f57 | ||
|
|
57ef011817 | ||
|
|
2c3bcb8f3d | ||
|
|
5a61a64559 | ||
|
|
1c2e594003 | ||
|
|
9bd6587278 |
@@ -227,6 +227,25 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
|
|||||||
|
|
||||||
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
||||||
|
|
||||||
|
### Human-like input (behavioural stealth)
|
||||||
|
|
||||||
|
Fingerprint stealth isn't the whole story — the strongest anti-bot vendors (Akamai, PerimeterX, DataDome) also score *behaviour*. A click that teleports the cursor to an element's exact centre with no approach path and zero press delay is a tell, **even though our CDP events are `isTrusted`**.
|
||||||
|
|
||||||
|
With humanize on, the cursor moves like a hand: clicks follow a curved, decelerating Bézier path and land on a jittered point *inside* the element (never the dead centre); typing uses variable inter-keystroke timing; scrolling eases in segments; drags follow a curve. It's **adaptive** — every navigation is probed for known anti-bot vendors (cookies / scripts / globals) and a guarded page auto-escalates to full human motion, while ordinary sites stay instant (zero overhead).
|
||||||
|
|
||||||
|
What the page's own `mousemove` stream sees (this *is* what a behavioural detector analyses):
|
||||||
|
|
||||||
|
| | trajectory |
|
||||||
|
|---|---|
|
||||||
|
| **off** (default) | straight lines · dead-centre · instant |
|
||||||
|
| **human** | curved trails · slow-in/slow-out · off-centre landings |
|
||||||
|
|
||||||
|
Control with `--humanize off\|fast\|human` or `AGENT_BROWSER_HUMANIZE`. Default `off`; the adaptive detector escalates per page.
|
||||||
|
|
||||||
|
### Silent operation
|
||||||
|
|
||||||
|
Driving your real Chrome should never interrupt your work. The agent operates **entirely in the background**: new tabs open un-focused (in their own colored per-session tab group), the agent **never force-fronts a tab**, and `Emulation.setFocusEmulationEnabled` keeps each agent tab rendering and reporting `document.hasFocus()` / `visibilityState: 'visible'`. So screenshots still work, pages aren't render-throttled, and "the tab was hidden the whole session" never becomes its own bot tell. You keep working in your active tab; the agent works alongside you, silently. (Surfacing a tab stays available as an explicit command.)
|
||||||
|
|
||||||
### Verify it yourself
|
### Verify it yourself
|
||||||
|
|
||||||
Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
|
Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
|
||||||
@@ -244,6 +263,7 @@ We deliberately **don't ship our own bot detector** — the strongest, most hone
|
|||||||
| Variable | Default | Effect |
|
| Variable | Default | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AGENT_BROWSER_CAPTURE_CONSOLE` | off | Enable `Runtime` domain so `console` / `errors` capture page output. Off keeps the stealthiest profile. |
|
| `AGENT_BROWSER_CAPTURE_CONSOLE` | off | Enable `Runtime` domain so `console` / `errors` capture page output. Off keeps the stealthiest profile. |
|
||||||
|
| `AGENT_BROWSER_HUMANIZE` | off | Human-like input motion: `off` (instant), `fast` (light eased trajectory), `human` (full curved trajectory + landing jitter + typing cadence + eased scroll/drag). Also `--humanize`. Default `off`; the adaptive detector auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. |
|
||||||
| `AGENT_BROWSER_TIMEZONE` | unset | `--launch` only. An IANA id (e.g. `Asia/Tokyo`) sets the timezone natively (Intl + Date follow, no JS lie) to match a proxy; `auto` derives one from the locale. |
|
| `AGENT_BROWSER_TIMEZONE` | unset | `--launch` only. An IANA id (e.g. `Asia/Tokyo`) sets the timezone natively (Intl + Date follow, no JS lie) to match a proxy; `auto` derives one from the locale. |
|
||||||
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | `--launch` only. Auto-forces WebRTC through the proxy when one is set (no real-IP leak). `1` hides the local IP without a proxy; `0` opts out. |
|
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | `--launch` only. Auto-forces WebRTC through the proxy when one is set (no real-IP leak). `1` hides the local IP without a proxy; `0` opts out. |
|
||||||
| `AGENT_BROWSER_HIDE_CANVAS` | off | `--launch` only. Adds session-stable canvas/audio fingerprint noise. Off by default (noise is itself a "lie"). |
|
| `AGENT_BROWSER_HIDE_CANVAS` | off | `--launch` only. Adds session-stable canvas/audio fingerprint noise. Off by default (noise is itself a "lie"). |
|
||||||
|
|||||||
@@ -148,6 +148,25 @@ agent-browser --launch --profile auto open https://x.com/home
|
|||||||
|
|
||||||
`--launch` 独立模式下会改用一整套隐身补丁,同样过上述检测。
|
`--launch` 独立模式下会改用一整套隐身补丁,同样过上述检测。
|
||||||
|
|
||||||
|
### 类人输入(行为隐身)
|
||||||
|
|
||||||
|
指纹隐身只是一半——最强的反爬厂商(Akamai、PerimeterX、DataDome)还会给**行为**打分。点击时光标瞬移到元素正中心、没有接近轨迹、按下即抬起,这本身就是破绽,**哪怕我们的 CDP 事件是 `isTrusted`**。
|
||||||
|
|
||||||
|
开启 humanize 后,光标像手在动:点击走带减速的贝塞尔曲线、落在元素内**偏离正中心**的抖动点;打字用变速的击键间隔;滚动分段缓动;拖拽走曲线。而且**自适应**——每次导航探测页面是否有已知反爬厂商(cookie/脚本/全局变量),命中就自动升到全套类人动作,普通站点保持瞬时(零开销)。
|
||||||
|
|
||||||
|
页面自己的 `mousemove` 流看到的(行为检测器分析的正是这个):
|
||||||
|
|
||||||
|
| | 轨迹 |
|
||||||
|
|---|---|
|
||||||
|
| **off**(默认) | 直线 · 死磕正中心 · 瞬时 |
|
||||||
|
| **human** | 曲线 · 先慢后快再慢 · 落点偏移 |
|
||||||
|
|
||||||
|
用 `--humanize off\|fast\|human` 或 `AGENT_BROWSER_HUMANIZE` 控制。默认 `off`,自适应检测器按页面自动升档。
|
||||||
|
|
||||||
|
### 静默操作
|
||||||
|
|
||||||
|
操作你的真实 Chrome 不该打断你的工作。agent **全程在后台操作**:新标签后台打开(在自己的彩色会话标签组里),**从不强制把标签拽到前台**,并用 `Emulation.setFocusEmulationEnabled` 让每个 agent 标签照常渲染、`document.hasFocus()` / `visibilityState` 仍报 `visible`。于是截图正常、页面不被降频,"标签全程隐藏"也不会变成新的机器人信号。你在自己的标签里照常工作,agent 在旁边默默干活。(想置顶某个标签仍可显式调用命令。)
|
||||||
|
|
||||||
## 与上游的差异
|
## 与上游的差异
|
||||||
|
|
||||||
基于 [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser):
|
基于 [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser):
|
||||||
|
|||||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.36"
|
version = "0.27.0-fork.39"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.36"
|
version = "0.27.0-fork.39"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+80
-1
@@ -101,7 +101,62 @@ pub fn parse_curl_cookies(raw: &str) -> Result<Vec<Value>, String> {
|
|||||||
.get("value")
|
.get("value")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| format!("cookies[{}] missing string value", i))?;
|
.ok_or_else(|| format!("cookies[{}] missing string value", i))?;
|
||||||
out.push(json!({ "name": name, "value": value }));
|
let mut cookie = json!({ "name": name, "value": value });
|
||||||
|
let obj = cookie.as_object_mut().unwrap();
|
||||||
|
// Preserve any CDP Network.setCookie attributes present on the
|
||||||
|
// source object so a full auth state round-trips: httpOnly session
|
||||||
|
// tokens, per-domain cookies (a single export spans .chatgpt.com,
|
||||||
|
// .openai.com, ...), and secure/sameSite/expiry. A bare
|
||||||
|
// {name,value} export is unchanged. Common aliases from DevTools /
|
||||||
|
// EditThisCookie / extension exports are accepted.
|
||||||
|
if let Some(v) = c.get("url").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("url".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("domain").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("domain".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("path").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("path".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("secure").and_then(|v| v.as_bool()) {
|
||||||
|
obj.insert("secure".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("httpOnly")
|
||||||
|
.or_else(|| c.get("httponly"))
|
||||||
|
.or_else(|| c.get("http_only"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
{
|
||||||
|
obj.insert("httpOnly".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("sameSite")
|
||||||
|
.or_else(|| c.get("samesite"))
|
||||||
|
.or_else(|| c.get("same_site"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
let norm = match v.to_lowercase().as_str() {
|
||||||
|
"strict" => "Strict",
|
||||||
|
"lax" => "Lax",
|
||||||
|
"none" | "no_restriction" => "None",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
if !norm.is_empty() {
|
||||||
|
obj.insert("sameSite".into(), json!(norm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CDP `expires` is seconds since the Unix epoch (f64). Accept
|
||||||
|
// `expires` or EditThisCookie's `expirationDate`.
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("expires")
|
||||||
|
.or_else(|| c.get("expirationDate"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
{
|
||||||
|
if v > 0.0 {
|
||||||
|
obj.insert("expires".into(), json!(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cookie);
|
||||||
}
|
}
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
}
|
}
|
||||||
@@ -2859,6 +2914,27 @@ mod tests {
|
|||||||
assert_eq!(cmd["action"], "cookies_clear");
|
assert_eq!(cmd["action"], "cookies_clear");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_curl_cookies_json_preserves_attributes() {
|
||||||
|
// A full cookie export (httpOnly session token, per-domain, secure,
|
||||||
|
// sameSite, expiry) must round-trip — not get flattened to name/value.
|
||||||
|
let input = r#"[
|
||||||
|
{"name":"__Secure-next-auth.session-token","value":"eyJ.tok","domain":".chatgpt.com","path":"/","secure":true,"httpOnly":true,"sameSite":"Lax","expires":1893456000},
|
||||||
|
{"name":"cf_clearance","value":"abc","domain":".openai.com","path":"/","secure":true,"http_only":true,"same_site":"no_restriction"}
|
||||||
|
]"#;
|
||||||
|
let out = parse_curl_cookies(input).unwrap();
|
||||||
|
assert_eq!(out.len(), 2);
|
||||||
|
assert_eq!(out[0]["domain"], ".chatgpt.com");
|
||||||
|
assert_eq!(out[0]["secure"], true);
|
||||||
|
assert_eq!(out[0]["httpOnly"], true);
|
||||||
|
assert_eq!(out[0]["sameSite"], "Lax");
|
||||||
|
assert_eq!(out[0]["expires"], 1893456000.0);
|
||||||
|
// alias keys (http_only, same_site=no_restriction) normalize to CDP shape
|
||||||
|
assert_eq!(out[1]["domain"], ".openai.com");
|
||||||
|
assert_eq!(out[1]["httpOnly"], true);
|
||||||
|
assert_eq!(out[1]["sameSite"], "None");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_curl_cookies_json_array() {
|
fn test_parse_curl_cookies_json_array() {
|
||||||
let input = r#"[{"name":"a","value":"1"},{"name":"b","value":"2"}]"#;
|
let input = r#"[{"name":"a","value":"1"},{"name":"b","value":"2"}]"#;
|
||||||
@@ -2866,6 +2942,9 @@ mod tests {
|
|||||||
assert_eq!(out.len(), 2);
|
assert_eq!(out.len(), 2);
|
||||||
assert_eq!(out[0]["name"], "a");
|
assert_eq!(out[0]["name"], "a");
|
||||||
assert_eq!(out[0]["value"], "1");
|
assert_eq!(out[0]["value"], "1");
|
||||||
|
// bare {name,value} stays minimal — no spurious attribute keys
|
||||||
|
assert!(out[0].get("domain").is_none());
|
||||||
|
assert!(out[0].get("secure").is_none());
|
||||||
assert_eq!(out[1]["name"], "b");
|
assert_eq!(out[1]["name"], "b");
|
||||||
assert_eq!(out[1]["value"], "2");
|
assert_eq!(out[1]["value"], "2");
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-15
@@ -1511,12 +1511,11 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
/// subsequent navigations don't hijack the user's existing tabs.
|
/// subsequent navigations don't hijack the user's existing tabs.
|
||||||
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||||
let mut mgr = BrowserManager::connect_auto().await?;
|
let mut mgr = BrowserManager::connect_auto().await?;
|
||||||
|
// tab_new creates the tab in the background (CreateTargetParams.background),
|
||||||
|
// so attaching to the user's Chrome never steals their foreground tab. We
|
||||||
|
// deliberately do NOT bring it to front — silent operation.
|
||||||
mgr.tab_new(None, None).await?;
|
mgr.tab_new(None, None).await?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let _ = mgr
|
|
||||||
.client
|
|
||||||
.send_command("Page.bringToFront", None, Some(&session_id))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Liveness probe: confirm the CDP session can actually round-trip
|
// Liveness probe: confirm the CDP session can actually round-trip
|
||||||
// before returning success. Without this, a zombie CDP socket (process
|
// before returning success. Without this, a zombie CDP socket (process
|
||||||
@@ -5513,6 +5512,12 @@ 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_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);
|
let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
|
||||||
|
// 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
|
mgr.client
|
||||||
.send_command(
|
.send_command(
|
||||||
"Input.dispatchMouseEvent",
|
"Input.dispatchMouseEvent",
|
||||||
@@ -5520,12 +5525,16 @@ async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result<Value, String>
|
|||||||
"type": "mouseWheel",
|
"type": "mouseWheel",
|
||||||
"x": x,
|
"x": x,
|
||||||
"y": y,
|
"y": y,
|
||||||
"deltaX": delta_x,
|
"deltaX": dx,
|
||||||
"deltaY": delta_y,
|
"deltaY": dy,
|
||||||
})),
|
})),
|
||||||
Some(&session_id),
|
Some(&session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if !delay.is_zero() {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
|
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
|
||||||
}
|
}
|
||||||
@@ -6522,7 +6531,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'target' parameter")?;
|
.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,
|
&mgr.client,
|
||||||
&session_id,
|
&session_id,
|
||||||
&state.ref_map,
|
&state.ref_map,
|
||||||
@@ -6530,7 +6539,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
&state.iframe_sessions,
|
&state.iframe_sessions,
|
||||||
)
|
)
|
||||||
.await?;
|
.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,
|
&mgr.client,
|
||||||
&session_id,
|
&session_id,
|
||||||
&state.ref_map,
|
&state.ref_map,
|
||||||
@@ -6555,12 +6564,26 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Move in steps to target, keeping the left button held (buttons: 1) so
|
// Move to the target with the left button held (buttons: 1) so the browser
|
||||||
// that the browser sees a drag rather than a plain pointer move.
|
// sees a drag. At Off this is the original linear 10-step path; at
|
||||||
let steps = 10;
|
// Fast/Human it follows humanize's curved, decelerating trajectory.
|
||||||
for i in 1..=steps {
|
let level = humanize::active_level();
|
||||||
let cx = sx + (tx - sx) * (i as f64) / (steps as f64);
|
let drag_path: Vec<(f64, f64, std::time::Duration)> =
|
||||||
let cy = sy + (ty - sy) * (i as f64) / (steps as f64);
|
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
|
mgr.client
|
||||||
.send_command(
|
.send_command(
|
||||||
"Input.dispatchMouseEvent",
|
"Input.dispatchMouseEvent",
|
||||||
@@ -6568,7 +6591,9 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
Some(&target_session_id),
|
Some(&target_session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
if !delay.is_zero() {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up at target
|
// Mouse up at target
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -687,6 +688,20 @@ impl BrowserManager {
|
|||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// Silent operation: agent tabs are driven in the background (we never
|
||||||
|
// force them to the foreground), so emulate focus. Without this a
|
||||||
|
// backgrounded tab is render-throttled and reports `document.hidden` /
|
||||||
|
// `!document.hasFocus()` — which both breaks timing-sensitive pages and
|
||||||
|
// is itself a bot signal (a real user looks at the page). Best-effort;
|
||||||
|
// ignored on engines without Emulation support.
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.send_command(
|
||||||
|
"Emulation.setFocusEmulationEnabled",
|
||||||
|
Some(json!({ "enabled": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +988,7 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1137,6 +1153,7 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: target_url.to_string(),
|
url: target_url.to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: Some(true),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1192,11 +1209,10 @@ impl BrowserManager {
|
|||||||
let session_id = self.pages[index].session_id.clone();
|
let session_id = self.pages[index].session_id.clone();
|
||||||
self.enable_domains(&session_id).await?;
|
self.enable_domains(&session_id).await?;
|
||||||
|
|
||||||
// Bring tab to front
|
// Silent: switching the agent's *internal* active page must not yank the
|
||||||
let _ = self
|
// user's foreground tab. The page is driven in the background (focus is
|
||||||
.client
|
// emulated in enable_domains); the explicit `bringToFront` command is the
|
||||||
.send_command("Page.bringToFront", None, Some(&session_id))
|
// only way a tab is deliberately surfaced.
|
||||||
.await;
|
|
||||||
|
|
||||||
let url = self.get_url().await.unwrap_or_default();
|
let url = self.get_url().await.unwrap_or_default();
|
||||||
let title = self.get_title().await.unwrap_or_default();
|
let title = self.get_title().await.unwrap_or_default();
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ pub struct CreateTargetParams {
|
|||||||
/// endpoint never receives an unknown parameter.
|
/// endpoint never receives an unknown parameter.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub agent_group: Option<String>,
|
pub agent_group: Option<String>,
|
||||||
|
/// Create the tab in the background so opening it never steals the user's
|
||||||
|
/// foreground tab (silent operation). Standard CDP param; the ab-connect
|
||||||
|
/// extension creates its tabs `active: false` regardless, so this only
|
||||||
|
/// affects the raw-CDP (no extension) path.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub background: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -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(
|
pub async fn resolve_element_center(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
ref_map: &RefMap,
|
ref_map: &RefMap,
|
||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
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) {
|
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||||
let entry = ref_map
|
let entry = ref_map
|
||||||
.get(&ref_id)
|
.get(&ref_id)
|
||||||
@@ -263,7 +267,7 @@ pub async fn resolve_element_center(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(r) = result {
|
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
|
// Occlusion check: a transient overlay (X.com's "click
|
||||||
// outside to close" mask, modal backdrop, sticky banner,
|
// outside to close" mask, modal backdrop, sticky banner,
|
||||||
// etc.) can land on top of our target between snapshot
|
// 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)
|
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
|
||||||
.await?;
|
.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
|
// 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),
|
Some(effective_session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (x, y) = box_model_center(&result.model);
|
let (x, y, w, h) = box_model_dims(&result.model);
|
||||||
return Ok((x, y, effective_session_id.to_string()));
|
return Ok((x, y, w, h, effective_session_id.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS selector
|
// CSS selector
|
||||||
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
|
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(
|
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(
|
pub async fn get_element_text(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|||||||
@@ -277,6 +277,41 @@ pub fn move_path(
|
|||||||
out
|
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
|
/// Dwell between `mousePressed` and `mouseReleased` (a real click isn't
|
||||||
/// instantaneous). Zero for `Off`.
|
/// instantaneous). Zero for `Off`.
|
||||||
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
||||||
@@ -435,6 +470,23 @@ mod tests {
|
|||||||
assert!(human.iter().all(|d| *d >= Duration::from_millis(8)));
|
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]
|
#[test]
|
||||||
fn detect_escalates_on_known_vendor_else_baseline() {
|
fn detect_escalates_on_known_vendor_else_baseline() {
|
||||||
let mut s = DetectSignals::default();
|
let mut s = DetectSignals::default();
|
||||||
|
|||||||
@@ -55,8 +55,15 @@ pub async fn click(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match resolved {
|
match resolved {
|
||||||
Ok((x, y, effective_session_id)) => {
|
Ok((cx, cy, w, h, effective_session_id)) => {
|
||||||
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
|
// 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) => {
|
Err(e) => {
|
||||||
// (B) The coordinate path failed — typically a persistent overlay
|
// (B) The coordinate path failed — typically a persistent overlay
|
||||||
@@ -191,7 +198,7 @@ pub async fn hover(
|
|||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
iframe_sessions: &HashMap<String, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (x, y, effective_session_id) = resolve_element_center(
|
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
ref_map,
|
ref_map,
|
||||||
@@ -999,7 +1006,7 @@ pub async fn tap_touch(
|
|||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
iframe_sessions: &HashMap<String, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (x, y, effective_session_id) = resolve_element_center(
|
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
ref_map,
|
ref_map,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ async fn collect_storage_via_temp_target(
|
|||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
// Transient internal target (storage collection) — never grouped.
|
// Transient internal target (storage collection) — never grouped.
|
||||||
agent_group: None,
|
agent_group: None,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.36",
|
"version": "0.27.0-fork.39",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ connect) > a headed launched browser > headless (forbidden).** A genuine human
|
|||||||
browser has no headless/automation tells at all, so prefer it for anything
|
browser has no headless/automation tells at all, so prefer it for anything
|
||||||
anti-bot-sensitive.
|
anti-bot-sensitive.
|
||||||
|
|
||||||
|
**Silent by default.** When driving the user's real Chrome the agent works
|
||||||
|
entirely in the background — new tabs open un-focused, the agent never force-
|
||||||
|
fronts a tab, and focus is emulated so the page still renders and reports
|
||||||
|
`visibilityState: 'visible'`. You don't need to do anything; just don't expect
|
||||||
|
the user's view to follow you (use the explicit `bringToFront` only if you
|
||||||
|
deliberately want to surface a tab).
|
||||||
|
|
||||||
|
**Human-like input for behavioural anti-bot.** Beyond fingerprint stealth,
|
||||||
|
`--humanize off|fast|human` (or `AGENT_BROWSER_HUMANIZE`) makes clicks follow a
|
||||||
|
curved, decelerating path with in-element landing jitter, typing use variable
|
||||||
|
cadence, and scroll/drag ease. Default `off`; a per-navigation detector
|
||||||
|
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
|
||||||
|
on auto; force `human` only when you already know the target scores behaviour.
|
||||||
|
|
||||||
## Two ways to drive a page — and when to drop to `eval`
|
## Two ways to drive a page — and when to drop to `eval`
|
||||||
|
|
||||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||||
|
|||||||
Reference in New Issue
Block a user