Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf7de2dd6 | ||
|
|
658bf4226f | ||
|
|
37cd9b91e1 | ||
|
|
b2c4aa0004 | ||
|
|
ec8d01ef4c | ||
|
|
0e5409a81e | ||
|
|
3ded30c210 | ||
|
|
6e50f0ecab | ||
|
|
6f71f4e1ff | ||
|
|
31ef0d7e6a | ||
|
|
42560b56fc | ||
|
|
0c7534d9b2 | ||
|
|
ad4fb14ed9 | ||
|
|
a976287f03 | ||
|
|
649fa4ce94 | ||
|
|
3ac69e822a | ||
|
|
d7a0ed85f9 | ||
|
|
9eaa5495ae | ||
|
|
68734fcb36 | ||
|
|
4b33dbadb4 | ||
|
|
fc73ee6c90 | ||
|
|
28d3748c06 | ||
|
|
fa47a0b8e5 | ||
|
|
36c593631c | ||
|
|
b92757412d | ||
|
|
6ecda4d706 | ||
|
|
123510db2b | ||
|
|
abb65c632b | ||
|
|
e803bffbbb | ||
|
|
96ee2f9758 | ||
|
|
0a3d2a91a6 | ||
|
|
1e5dfd35cb | ||
|
|
b6b2ca56ca | ||
|
|
6d740093dc | ||
|
|
c884fb4f57 | ||
|
|
57ef011817 |
@@ -225,7 +225,26 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
|
|||||||
|
|
||||||
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
|
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
|
||||||
|
|
||||||
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 passes the suite above — with one caveat: CreepJS reports **~20% stealth** because the srcdoc-iframe `contentWindow` patch trips its `hasIframeProxy` probe (the proxy that hides automation is itself a tell). Everything else is clean (`0% headless`, sannysoft/browserscan green, Cloudflare passed). Set **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** to drop that patch for a clean **0% stealth** (trades the niche srcdoc-iframe masking). The **extension-connect path** (your real Chrome) injects zero JS and is unaffected — it's the genuine 0% path.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@@ -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.38"
|
version = "0.27.0-fork.51"
|
||||||
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.38"
|
version = "0.27.0-fork.51"
|
||||||
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"
|
||||||
|
|||||||
+272
-26
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -320,12 +375,25 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// === Core Actions ===
|
// === Core Actions ===
|
||||||
"click" => {
|
"click" => {
|
||||||
let new_tab = rest.contains(&"--new-tab");
|
let new_tab = rest.contains(&"--new-tab");
|
||||||
|
// Coordinate click as a first-class form (issue #8.4): when the only
|
||||||
|
// handle is a pixel position, no element/selector is needed.
|
||||||
|
// click <x> <y> e.g. click 449 320
|
||||||
|
// click <x>,<y> e.g. click 449,320
|
||||||
|
// click --coords <x>,<y> | --coords <x> <y>
|
||||||
|
let coord_args: Vec<&str> = rest
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|a| *a != "--new-tab" && *a != "--coords")
|
||||||
|
.collect();
|
||||||
|
if let Some((x, y)) = parse_coords(&coord_args) {
|
||||||
|
return Ok(json!({ "id": id, "action": "click", "x": x, "y": y }));
|
||||||
|
}
|
||||||
let sel = rest
|
let sel = rest
|
||||||
.iter()
|
.iter()
|
||||||
.find(|arg| **arg != "--new-tab")
|
.find(|arg| **arg != "--new-tab")
|
||||||
.ok_or_else(|| ParseError::MissingArguments {
|
.ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "click".to_string(),
|
context: "click".to_string(),
|
||||||
usage: "click <selector> [--new-tab]",
|
usage: "click <selector> | click <x> <y> | click --coords <x>,<y> [--new-tab]",
|
||||||
})?;
|
})?;
|
||||||
if new_tab {
|
if new_tab {
|
||||||
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
|
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
|
||||||
@@ -348,12 +416,48 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
||||||
}
|
}
|
||||||
"type" => {
|
"type" => {
|
||||||
|
// `type --focused <text>` types into whatever element currently has
|
||||||
|
// focus (no selector) — for custom widgets that move focus to a hidden
|
||||||
|
// input after you open them.
|
||||||
|
if rest.first() == Some(&"--focused") {
|
||||||
|
return Ok(json!({
|
||||||
|
"id": id, "action": "type", "focused": true,
|
||||||
|
"text": rest[1..].join(" "),
|
||||||
|
}));
|
||||||
|
}
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "type".to_string(),
|
context: "type".to_string(),
|
||||||
usage: "type <selector> <text>",
|
usage: "type <selector> <text> (or: type --focused <text>)",
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
|
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
|
||||||
}
|
}
|
||||||
|
"pick" => {
|
||||||
|
// pick <selector|@ref> --option "<text>" — atomic combobox select:
|
||||||
|
// open the control, wait for options (incl. portal menus), match by
|
||||||
|
// text, fire the right event sequence, verify. Covers native <select>,
|
||||||
|
// ARIA combobox/listbox, and react-select.
|
||||||
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
|
context: "pick".to_string(),
|
||||||
|
usage: "pick <selector> --option \"<text>\"",
|
||||||
|
})?;
|
||||||
|
let opt_pos = rest.iter().position(|a| *a == "--option" || *a == "-o");
|
||||||
|
let option = match opt_pos {
|
||||||
|
Some(p) => rest[p + 1..].join(" "),
|
||||||
|
None => {
|
||||||
|
return Err(ParseError::MissingArguments {
|
||||||
|
context: "pick".to_string(),
|
||||||
|
usage: "pick <selector> --option \"<text>\"",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if option.is_empty() {
|
||||||
|
return Err(ParseError::MissingArguments {
|
||||||
|
context: "pick".to_string(),
|
||||||
|
usage: "pick <selector> --option \"<text>\"",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(json!({ "id": id, "action": "pick", "selector": sel, "option": option }))
|
||||||
|
}
|
||||||
"hover" => {
|
"hover" => {
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "hover".to_string(),
|
context: "hover".to_string(),
|
||||||
@@ -786,17 +890,31 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
|
|
||||||
// === Eval ===
|
// === Eval ===
|
||||||
"eval" => {
|
"eval" => {
|
||||||
// Check for flags: -b/--base64 or --stdin
|
// Check for flags: -b/--base64, --stdin, or --file <path>
|
||||||
let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) =
|
let (is_base64, is_stdin, is_file, script_parts): (bool, bool, bool, &[&str]) =
|
||||||
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
|
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
|
||||||
(true, false, &rest[1..])
|
(true, false, false, &rest[1..])
|
||||||
} else if rest.first() == Some(&"--stdin") {
|
} else if rest.first() == Some(&"--stdin") {
|
||||||
(false, true, &rest[1..])
|
(false, true, false, &rest[1..])
|
||||||
|
} else if rest.first() == Some(&"--file") {
|
||||||
|
(false, false, true, &rest[1..])
|
||||||
} else {
|
} else {
|
||||||
(false, false, rest.as_slice())
|
(false, false, false, rest.as_slice())
|
||||||
};
|
};
|
||||||
|
|
||||||
let script = if is_stdin {
|
let script = if is_file {
|
||||||
|
// Read the script from a file. Avoids shell-mangling of inline JS
|
||||||
|
// (non-ASCII identifiers/strings, quotes, large scripts) — the file
|
||||||
|
// is read as UTF-8 and sent verbatim.
|
||||||
|
let path = script_parts.first().ok_or(ParseError::InvalidValue {
|
||||||
|
message: "eval --file requires a path".to_string(),
|
||||||
|
usage: "eval --file <path>",
|
||||||
|
})?;
|
||||||
|
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
|
||||||
|
message: format!("eval --file: cannot read {path}: {e}"),
|
||||||
|
usage: "eval --file <path>",
|
||||||
|
})?
|
||||||
|
} else if is_stdin {
|
||||||
// Read script from stdin
|
// Read script from stdin
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let lines: Vec<String> = stdin
|
let lines: Vec<String> = stdin
|
||||||
@@ -826,6 +944,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Stealth self-check ===
|
||||||
|
"stealth" => {
|
||||||
|
// `stealth [status]` — local stealth self-check: mode, live probes
|
||||||
|
// (navigator.webdriver, window.chrome, plugins, UA), and the list of
|
||||||
|
// active overrides. --json for a stable machine-readable shape.
|
||||||
|
// (Distinct from `doctor`, which checks install/env/Chrome health.)
|
||||||
|
Ok(json!({ "id": id, "action": "stealth_status" }))
|
||||||
|
}
|
||||||
|
|
||||||
// === Close ===
|
// === Close ===
|
||||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||||
|
|
||||||
@@ -1094,6 +1221,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
parse_get(&get_args, &id)
|
parse_get(&get_args, &id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hyphen/underscore aliases for `get text <selector>` — agents naturally
|
||||||
|
// guess `get-text` / `get_text` (issue #8.4).
|
||||||
|
"get-text" | "get_text" => {
|
||||||
|
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||||
|
get_args.push("text");
|
||||||
|
get_args.extend_from_slice(&rest);
|
||||||
|
parse_get(&get_args, &id)
|
||||||
|
}
|
||||||
|
|
||||||
// === Is (state checks) ===
|
// === Is (state checks) ===
|
||||||
"is" => parse_is(&rest, &id),
|
"is" => parse_is(&rest, &id),
|
||||||
|
|
||||||
@@ -1275,7 +1411,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Tabs ===
|
// === Tabs ===
|
||||||
"tab" => {
|
// `tabs` (plural) is a natural guess for the `tab` subcommand tree —
|
||||||
|
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
|
||||||
|
"tab" | "tabs" => {
|
||||||
match rest.first().copied() {
|
match rest.first().copied() {
|
||||||
Some("new") => {
|
Some("new") => {
|
||||||
// Accepted forms:
|
// Accepted forms:
|
||||||
@@ -2199,19 +2337,6 @@ fn parse_is(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||||
const VALID: &[&str] = &[
|
|
||||||
"role",
|
|
||||||
"text",
|
|
||||||
"label",
|
|
||||||
"placeholder",
|
|
||||||
"alt",
|
|
||||||
"title",
|
|
||||||
"testid",
|
|
||||||
"first",
|
|
||||||
"last",
|
|
||||||
"nth",
|
|
||||||
];
|
|
||||||
|
|
||||||
let locator = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let locator = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "find".to_string(),
|
context: "find".to_string(),
|
||||||
usage: "find <locator> <value> [action] [text]",
|
usage: "find <locator> <value> [action] [text]",
|
||||||
@@ -2381,13 +2506,37 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
}
|
}
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
_ => Err(ParseError::UnknownSubcommand {
|
_ => Err(ParseError::InvalidValue {
|
||||||
subcommand: locator.to_string(),
|
// The user passed a value where a locator keyword was expected — the
|
||||||
valid_options: VALID,
|
// classic `find "I'm not a robot" click` mistake (issue #8.4). Lead
|
||||||
|
// with the corrected command using their own value, then the menu.
|
||||||
|
message: format!(
|
||||||
|
"`{loc}` is not a find locator. To match by visible text, name the locator:\n \
|
||||||
|
agent-browser find text \"{loc}\" click\n\n\
|
||||||
|
Locators: role, text, label, placeholder, alt, title, testid, first, last, nth\n\
|
||||||
|
Examples:\n \
|
||||||
|
agent-browser find text \"Sign in\" click\n \
|
||||||
|
agent-browser find role button --name \"Submit\" click\n \
|
||||||
|
agent-browser find label \"Email\" fill you@example.com",
|
||||||
|
loc = locator,
|
||||||
|
),
|
||||||
|
usage: "find <locator> <value> [action] [text]",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a coordinate pair from `["449","320"]`, `["449,320"]`, or `["449, 320"]`.
|
||||||
|
/// Returns None if the args aren't a clean numeric pair (so callers fall back to
|
||||||
|
/// treating the argument as a selector). Used by first-class coordinate `click`.
|
||||||
|
fn parse_coords(args: &[&str]) -> Option<(f64, f64)> {
|
||||||
|
let (a, b) = match args {
|
||||||
|
[one] => one.split_once(',')?,
|
||||||
|
[a, b] => (*a, *b),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||||
const VALID: &[&str] = &["move", "down", "up", "wheel"];
|
const VALID: &[&str] = &["move", "down", "up", "wheel"];
|
||||||
|
|
||||||
@@ -2859,6 +3008,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 +3036,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");
|
||||||
}
|
}
|
||||||
@@ -3412,6 +3585,79 @@ mod tests {
|
|||||||
assert_eq!(cmd["action"], "reload");
|
assert_eq!(cmd["action"], "reload");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === issue #8.4: CLI ergonomics ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_coords_two_args() {
|
||||||
|
let cmd = parse_command(&args("click 449 320"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "click");
|
||||||
|
assert_eq!(cmd["x"], 449.0);
|
||||||
|
assert_eq!(cmd["y"], 320.0);
|
||||||
|
assert!(cmd.get("selector").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_coords_comma() {
|
||||||
|
let cmd = parse_command(&args("click 449,320"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["x"], 449.0);
|
||||||
|
assert_eq!(cmd["y"], 320.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_coords_flag() {
|
||||||
|
let cmd = parse_command(&args("click --coords 449,320"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["x"], 449.0);
|
||||||
|
assert_eq!(cmd["y"], 320.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_click_selector_not_coords() {
|
||||||
|
let cmd = parse_command(&args("click button.submit"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "click");
|
||||||
|
assert_eq!(cmd["selector"], "button.submit");
|
||||||
|
assert!(cmd.get("x").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tabs_alias_lists() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command(&args("tabs"), &default_flags()).unwrap()["action"],
|
||||||
|
"tab_list"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_command(&args("tabs list"), &default_flags()).unwrap()["action"],
|
||||||
|
"tab_list"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_command(&args("tabs new"), &default_flags()).unwrap()["action"],
|
||||||
|
"tab_new"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_text_hyphen_and_underscore_aliases() {
|
||||||
|
for verb in ["get-text", "get_text"] {
|
||||||
|
let cmd = parse_command(&args(&format!("{verb} .price")), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "gettext", "{verb}");
|
||||||
|
assert_eq!(cmd["selector"], ".price", "{verb}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_bare_value_suggests_text_locator() {
|
||||||
|
// `find "I'm not a robot" click` — value where a locator keyword was
|
||||||
|
// expected. Error must steer to the corrected `find text ...` form.
|
||||||
|
let input: Vec<String> = vec![
|
||||||
|
"find".to_string(),
|
||||||
|
"I'm not a robot".to_string(),
|
||||||
|
"click".to_string(),
|
||||||
|
];
|
||||||
|
let err = parse_command(&input, &default_flags()).unwrap_err();
|
||||||
|
let msg = err.format();
|
||||||
|
assert!(msg.contains("find text"), "got: {msg}");
|
||||||
|
assert!(msg.contains("I'm not a robot"), "got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
// === Core Actions ===
|
// === Core Actions ===
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -344,6 +344,19 @@ fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the ab-connect native-messaging host manifest is present — i.e. the
|
||||||
|
/// user has set up the extension path. When installed, auto-connect treats the
|
||||||
|
/// dialog-free extension relay as the *intended* transport and refuses to fall
|
||||||
|
/// back to a raw debug port (which would pop Chrome 136+'s "Allow remote
|
||||||
|
/// debugging?" consent modal). The relay-url file comes and goes with the
|
||||||
|
/// service worker; this manifest is the durable signal that the extension is
|
||||||
|
/// the chosen path.
|
||||||
|
pub fn host_installed() -> bool {
|
||||||
|
native_messaging_dirs()
|
||||||
|
.into_iter()
|
||||||
|
.any(|d| d.join(format!("{HOST_NAME}.json")).exists())
|
||||||
|
}
|
||||||
|
|
||||||
fn report(json: bool, ok: bool, msg: &str) {
|
fn report(json: bool, ok: bool, msg: &str) {
|
||||||
if json {
|
if json {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
+31
-2
@@ -625,7 +625,10 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
|||||||
// version (e.g. after an upgrade), kill it and start a fresh one.
|
// version (e.g. after an upgrade), kill it and start a fresh one.
|
||||||
if !daemon_version_matches(session) {
|
if !daemon_version_matches(session) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{} Daemon version mismatch detected, restarting...",
|
"{} Daemon version mismatch detected, restarting... \
|
||||||
|
In-memory context (active tab, refs, captured requests) is reset. \
|
||||||
|
If the next read looks blank or lands on the wrong page, re-open \
|
||||||
|
your target URL before retrying (issue #8.2).",
|
||||||
crate::color::warning_indicator()
|
crate::color::warning_indicator()
|
||||||
);
|
);
|
||||||
// Best-effort: ask the old daemon for its current URL so the
|
// Best-effort: ask the old daemon for its current URL so the
|
||||||
@@ -821,7 +824,33 @@ fn connect(session: &str) -> Result<Connection, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
|
pub fn send_command(mut cmd: Value, session: &str) -> Result<Response, String> {
|
||||||
|
// Forward per-invocation env to the daemon. The daemon's environment is
|
||||||
|
// frozen at spawn, so settings like AGENT_BROWSER_CLICK_MODE /
|
||||||
|
// AGENT_BROWSER_HUMANIZE (incl. the --humanize flag, which sets the latter)
|
||||||
|
// are otherwise silently ignored on an already-running daemon. Carry them in
|
||||||
|
// the envelope so they apply to THIS command.
|
||||||
|
if let Some(obj) = cmd.as_object_mut() {
|
||||||
|
if let Ok(m) = std::env::var("AGENT_BROWSER_CLICK_MODE") {
|
||||||
|
obj.insert("_clickMode".to_string(), Value::String(m));
|
||||||
|
}
|
||||||
|
if let Ok(h) = std::env::var("AGENT_BROWSER_HUMANIZE") {
|
||||||
|
// Only forward a recognized level; warn once (like the --humanize flag
|
||||||
|
// does) when the env var is set to garbage, instead of silently
|
||||||
|
// ignoring it.
|
||||||
|
if crate::native::humanize::HumanizeLevel::parse(&h).is_some() {
|
||||||
|
obj.insert("_humanize".to_string(), Value::String(h));
|
||||||
|
} else {
|
||||||
|
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||||
|
WARNED.call_once(|| {
|
||||||
|
eprintln!(
|
||||||
|
"warning: AGENT_BROWSER_HUMANIZE must be off|fast|human, got {h:?} (ignored)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
||||||
const MAX_RETRIES: u32 = 5;
|
const MAX_RETRIES: u32 = 5;
|
||||||
const RETRY_DELAY_MS: u64 = 200;
|
const RETRY_DELAY_MS: u64 = 200;
|
||||||
|
|||||||
+272
-12
@@ -1160,6 +1160,23 @@ impl Drop for DaemonState {
|
|||||||
|
|
||||||
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||||
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Apply per-invocation overrides the client forwarded (the daemon's own env
|
||||||
|
// is frozen at spawn). CLICK_MODE is read fresh from the process env by
|
||||||
|
// interaction::click, so mirror it here — set when this command provided it,
|
||||||
|
// clear otherwise, so a value from an earlier command never leaks forward.
|
||||||
|
match cmd.get("_clickMode").and_then(|v| v.as_str()) {
|
||||||
|
Some(m) if !m.is_empty() => std::env::set_var("AGENT_BROWSER_CLICK_MODE", m),
|
||||||
|
_ => std::env::remove_var("AGENT_BROWSER_CLICK_MODE"),
|
||||||
|
}
|
||||||
|
// Humanize: set the session level from the client's --humanize / env. Only
|
||||||
|
// set when provided (don't clear — the adaptive per-navigation detector also
|
||||||
|
// owns this level between explicit overrides).
|
||||||
|
if let Some(h) = cmd.get("_humanize").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(level) = super::humanize::HumanizeLevel::parse(h) {
|
||||||
|
super::humanize::set_detected_level(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
let id = cmd
|
let id = cmd
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1299,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"content" => handle_content(state).await,
|
"content" => handle_content(state).await,
|
||||||
"evaluate" => handle_evaluate(cmd, state).await,
|
"evaluate" => handle_evaluate(cmd, state).await,
|
||||||
"close" => handle_close(state).await,
|
"close" => handle_close(state).await,
|
||||||
|
"stealth_status" => handle_stealth_status(state).await,
|
||||||
"snapshot" => handle_snapshot(cmd, state).await,
|
"snapshot" => handle_snapshot(cmd, state).await,
|
||||||
"screenshot" => handle_screenshot(cmd, state).await,
|
"screenshot" => handle_screenshot(cmd, state).await,
|
||||||
"click" => handle_click(cmd, state).await,
|
"click" => handle_click(cmd, state).await,
|
||||||
@@ -1306,6 +1324,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"fill" => handle_fill(cmd, state).await,
|
"fill" => handle_fill(cmd, state).await,
|
||||||
"type" => handle_type(cmd, state).await,
|
"type" => handle_type(cmd, state).await,
|
||||||
"press" => handle_press(cmd, state).await,
|
"press" => handle_press(cmd, state).await,
|
||||||
|
"pick" => handle_pick(cmd, state).await,
|
||||||
"hover" => handle_hover(cmd, state).await,
|
"hover" => handle_hover(cmd, state).await,
|
||||||
"scroll" => handle_scroll(cmd, state).await,
|
"scroll" => handle_scroll(cmd, state).await,
|
||||||
"select" => handle_select(cmd, state).await,
|
"select" => handle_select(cmd, state).await,
|
||||||
@@ -2662,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
|||||||
Ok(json!({ "result": result, "origin": url }))
|
Ok(json!({ "result": result, "origin": url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local stealth self-check: reports the active mode, live fingerprint probes,
|
||||||
|
/// and the list of applied overrides — so an agent (or human) can confirm
|
||||||
|
/// stealth is working without driving an external detector, and audit exactly
|
||||||
|
/// what's patched on this path (issue #5).
|
||||||
|
async fn handle_stealth_status(state: &DaemonState) -> Result<Value, String> {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let connect = mgr.is_cdp_connection();
|
||||||
|
|
||||||
|
let probe_js = r#"(() => {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
return {
|
||||||
|
webdriver: navigator.webdriver === true,
|
||||||
|
hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null,
|
||||||
|
plugins: navigator.plugins ? navigator.plugins.length : 0,
|
||||||
|
languages: navigator.languages || [],
|
||||||
|
platform: navigator.platform || '',
|
||||||
|
headlessUA: /Headless/i.test(ua),
|
||||||
|
};
|
||||||
|
})()"#;
|
||||||
|
let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null);
|
||||||
|
let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
let has_chrome = p
|
||||||
|
.get("hasWindowChrome")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let headless_ua = p
|
||||||
|
.get("headlessUA")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let checks = json!([
|
||||||
|
{ "name": "navigator.webdriver is false", "pass": !webdriver },
|
||||||
|
{ "name": "window.chrome present", "pass": has_chrome },
|
||||||
|
{ "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins },
|
||||||
|
{ "name": "userAgent has no 'Headless'", "pass": !headless_ua },
|
||||||
|
]);
|
||||||
|
let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua;
|
||||||
|
|
||||||
|
let overrides = if connect {
|
||||||
|
json!([
|
||||||
|
"navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)",
|
||||||
|
"Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)",
|
||||||
|
"zero JS patches injected — the browser's real fingerprint is used as-is",
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
let iframe_proxy =
|
||||||
|
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1");
|
||||||
|
json!([
|
||||||
|
"navigator.webdriver removed; navigator.languages/locale normalized",
|
||||||
|
"window.chrome / chrome.runtime shimmed; navigator.platform fixed",
|
||||||
|
"WebGL vendor/renderer, plugins, permissions normalized",
|
||||||
|
format!(
|
||||||
|
"srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)",
|
||||||
|
if iframe_proxy {
|
||||||
|
"ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)",
|
||||||
|
if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off (opt-in)"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL",
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"stealthStatus": {
|
||||||
|
"mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" },
|
||||||
|
"ok": ok,
|
||||||
|
"checks": checks,
|
||||||
|
"overrides": overrides,
|
||||||
|
"probe": p,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
||||||
if let Some(ref mgr) = state.browser {
|
if let Some(ref mgr) = state.browser {
|
||||||
if let Some(ref session_name) = state.session_name {
|
if let Some(ref session_name) = state.session_name {
|
||||||
@@ -2879,11 +2981,32 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||||
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
||||||
}
|
}
|
||||||
|
// Stamp which page was captured so a screenshot of the wrong tab is obvious
|
||||||
|
// (issue #8.1: relay sessions can drift to whatever tab the user activated).
|
||||||
|
if let Ok(url) = mgr.get_url().await {
|
||||||
|
if !url.is_empty() {
|
||||||
|
response["origin"] = json!(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
// First-class coordinate click (issue #8.4): click a raw viewport point with
|
||||||
|
// no element resolution. Parsed from `click <x> <y>` / `click --coords x,y`.
|
||||||
|
if let (Some(x), Some(y)) = (
|
||||||
|
cmd.get("x").and_then(|v| v.as_f64()),
|
||||||
|
cmd.get("y").and_then(|v| v.as_f64()),
|
||||||
|
) {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
|
||||||
|
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
|
||||||
|
interaction::click_at_point(&mgr.client, &session_id, x, y, button, click_count).await?;
|
||||||
|
return Ok(json!({ "clicked": { "x": x, "y": y } }));
|
||||||
|
}
|
||||||
|
|
||||||
let selector = cmd
|
let selector = cmd
|
||||||
.get("selector")
|
.get("selector")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3014,6 +3137,22 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
|
||||||
|
// `type --focused <text>`: type into the currently-focused element without a
|
||||||
|
// selector (custom widgets that move focus to a hidden input on open).
|
||||||
|
if cmd
|
||||||
|
.get("focused")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let text = cmd
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing 'text' parameter")?;
|
||||||
|
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None).await?;
|
||||||
|
return Ok(json!({ "typed": text, "focused": true }));
|
||||||
|
}
|
||||||
|
|
||||||
let selector = cmd
|
let selector = cmd
|
||||||
.get("selector")
|
.get("selector")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3039,6 +3178,103 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
Ok(json!({ "typed": text }))
|
Ok(json!({ "typed": text }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomic combobox select: `pick <selector> --option "<text>"`. Opens the control
|
||||||
|
/// (so a portal-rendered menu mounts), polls for the option by visible text, then
|
||||||
|
/// fires the full pointer/mouse event sequence on it — covering native `<select>`,
|
||||||
|
/// ARIA combobox/listbox, and react-select, which a bare `click`+`press Enter`
|
||||||
|
/// can't do reliably. Runs as one in-page async routine so the open→render→pick
|
||||||
|
/// dance happens without round-trips that let the menu collapse between commands.
|
||||||
|
async fn handle_pick(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
let selector = cmd
|
||||||
|
.get("selector")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing 'selector' parameter")?;
|
||||||
|
let option = cmd
|
||||||
|
.get("option")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing 'option' parameter")?;
|
||||||
|
|
||||||
|
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||||
|
&mgr.client,
|
||||||
|
&session_id,
|
||||||
|
&state.ref_map,
|
||||||
|
selector,
|
||||||
|
&state.iframe_sessions,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let func = format!(
|
||||||
|
r#"async function() {{
|
||||||
|
const want = {opt};
|
||||||
|
const norm = s => (s || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const matches = el => norm(el.textContent).toLowerCase().includes(want.toLowerCase());
|
||||||
|
const el = this;
|
||||||
|
const fire = (n, t) => n.dispatchEvent(new MouseEvent(t, {{ bubbles: true, cancelable: true, view: window }}));
|
||||||
|
|
||||||
|
// Native <select>: set the matching option and dispatch input/change.
|
||||||
|
if (el.tagName === 'SELECT') {{
|
||||||
|
const opt = [...el.options].find(matches);
|
||||||
|
if (!opt) return {{ ok: false, error: 'no <option> matched ' + JSON.stringify(want) }};
|
||||||
|
el.value = opt.value;
|
||||||
|
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||||
|
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||||
|
return {{ ok: true, picked: norm(opt.textContent), value: el.value, kind: 'select' }};
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Custom widget: open it.
|
||||||
|
(el.focus && el.focus());
|
||||||
|
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach(t => fire(el, t));
|
||||||
|
|
||||||
|
// Poll for the option to render anywhere in the document (portals
|
||||||
|
// mount the menu outside the trigger), then click it.
|
||||||
|
const sel = '[role=option], [role=listbox] [role=option], li[role=option], [class*=option], [class*=item]';
|
||||||
|
const find = () => [...document.querySelectorAll(sel)].find(o => o.offsetParent !== null && matches(o));
|
||||||
|
const deadline = Date.now() + 2500;
|
||||||
|
let opt = find();
|
||||||
|
while (!opt && Date.now() < deadline) {{
|
||||||
|
await new Promise(r => setTimeout(r, 80));
|
||||||
|
opt = find();
|
||||||
|
}}
|
||||||
|
if (!opt) return {{ ok: false, error: 'option ' + JSON.stringify(want) + ' did not appear after opening the control' }};
|
||||||
|
(opt.scrollIntoView && opt.scrollIntoView({{ block: 'center' }}));
|
||||||
|
['pointermove', 'pointerover', 'mouseover', 'pointerdown', 'mousedown', 'mouseup', 'click'].forEach(t => fire(opt, t));
|
||||||
|
return {{ ok: true, picked: norm(opt.textContent), kind: 'custom' }};
|
||||||
|
}}"#,
|
||||||
|
opt = serde_json::to_string(option).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result: super::cdp::types::EvaluateResult = mgr
|
||||||
|
.client
|
||||||
|
.send_command_typed(
|
||||||
|
"Runtime.callFunctionOn",
|
||||||
|
&super::cdp::types::CallFunctionOnParams {
|
||||||
|
function_declaration: func,
|
||||||
|
object_id: Some(object_id),
|
||||||
|
arguments: None,
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(true),
|
||||||
|
},
|
||||||
|
Some(&effective_session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(ref ex) = result.exception_details {
|
||||||
|
return Err(format!("pick failed: {}", ex.text));
|
||||||
|
}
|
||||||
|
let val = result.result.value.unwrap_or(Value::Null);
|
||||||
|
if val.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
Ok(json!({ "picked": val.get("picked"), "selector": selector }))
|
||||||
|
} else {
|
||||||
|
Err(val
|
||||||
|
.get("error")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("pick failed")
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
@@ -7685,23 +7921,40 @@ pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enable_request_tracking(state: &mut DaemonState) {
|
||||||
|
if state.request_tracking {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.request_tracking = true;
|
||||||
|
if let Some(ref mgr) = state.browser {
|
||||||
|
if let Ok(session_id) = mgr.active_session_id() {
|
||||||
|
let _ = mgr
|
||||||
|
.client
|
||||||
|
.send_command_no_params("Network.enable", Some(session_id))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
|
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
state.tracked_requests.clear();
|
state.tracked_requests.clear();
|
||||||
|
// Enable Network capture NOW, on `--clear`, not lazily on the next read.
|
||||||
|
// `--clear` is the canonical "start capturing fresh" call, so requests
|
||||||
|
// fired between it and the following `requests` read must be tracked.
|
||||||
|
// Lazy-enabling only on read missed exactly those → intermittent
|
||||||
|
// "No requests captured" on the first try, fine on retry (issue #8.3).
|
||||||
|
enable_request_tracking(state).await;
|
||||||
return Ok(json!({ "cleared": true }));
|
return Ok(json!({ "cleared": true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.request_tracking {
|
enable_request_tracking(state).await;
|
||||||
state.request_tracking = true;
|
// Current page URL, so a `requests` read on a drifted/wrong tab is obvious
|
||||||
if let Some(ref mgr) = state.browser {
|
// and "0 captured" can't be confused with "wrong page" (issues #8.1/#8.3).
|
||||||
if let Ok(session_id) = mgr.active_session_id() {
|
let origin = match state.browser.as_ref() {
|
||||||
let _ = mgr
|
Some(mgr) => mgr.get_url().await.ok().filter(|u| !u.is_empty()),
|
||||||
.client
|
None => None,
|
||||||
.send_command_no_params("Network.enable", Some(session_id))
|
};
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let filter = cmd.get("filter").and_then(|v| v.as_str());
|
let filter = cmd.get("filter").and_then(|v| v.as_str());
|
||||||
let type_filter = cmd.get("type").and_then(|v| v.as_str());
|
let type_filter = cmd.get("type").and_then(|v| v.as_str());
|
||||||
@@ -7738,7 +7991,14 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(json!({ "requests": requests }))
|
// NB: do NOT add a top-level `count` field here — the human formatter treats
|
||||||
|
// any `{count}` as a `get count` result and prints just the number, which
|
||||||
|
// would swallow the request list. The list length is self-evident.
|
||||||
|
let mut response = json!({ "requests": requests });
|
||||||
|
if let Some(o) = origin {
|
||||||
|
response["origin"] = json!(o);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
+149
-2
@@ -309,6 +309,19 @@ pub struct BrowserManager {
|
|||||||
pub ignore_https_errors: bool,
|
pub ignore_https_errors: bool,
|
||||||
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
||||||
visited_origins: HashSet<String>,
|
visited_origins: HashSet<String>,
|
||||||
|
/// Target IDs of tabs THIS session created via `Target.createTarget`. When
|
||||||
|
/// connected to the user's real Chrome (not a launched browser), these are
|
||||||
|
/// closed on `close()` so the session's tabs don't pile up in the user's
|
||||||
|
/// browser after it ends. Only ever holds tabs we created — never the user's
|
||||||
|
/// existing tabs or other sessions' tabs — so closing them is always safe.
|
||||||
|
created_targets: HashSet<String>,
|
||||||
|
/// The session's *intended* active tab, pinned by stable target_id rather
|
||||||
|
/// than the fragile `active_page_index`. Set on every explicit open / tab new
|
||||||
|
/// / tab switch. `active_session_id` resolves through this so a foreign tab
|
||||||
|
/// opening (passive discovery), a tab closing, or list reordering can't drift
|
||||||
|
/// the session's commands onto the wrong page — the wrong-origin-fetch hazard
|
||||||
|
/// in the dogfood reports. Falls back to the index if the pinned tab is gone.
|
||||||
|
active_target_id: Option<String>,
|
||||||
next_tab_id: u32,
|
next_tab_id: u32,
|
||||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||||
@@ -433,6 +446,8 @@ impl BrowserManager {
|
|||||||
download_path: download_path.clone(),
|
download_path: download_path.clone(),
|
||||||
ignore_https_errors,
|
ignore_https_errors,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
|
active_target_id: None,
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -523,6 +538,8 @@ impl BrowserManager {
|
|||||||
download_path: None,
|
download_path: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
|
active_target_id: None,
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -539,6 +556,7 @@ impl BrowserManager {
|
|||||||
target_type: "page".to_string(),
|
target_type: "page".to_string(),
|
||||||
});
|
});
|
||||||
manager.active_page_index = 0;
|
manager.active_page_index = 0;
|
||||||
|
manager.pin_active_target();
|
||||||
manager.enable_domains_direct().await?;
|
manager.enable_domains_direct().await?;
|
||||||
} else {
|
} else {
|
||||||
manager.discover_and_attach_targets().await?;
|
manager.discover_and_attach_targets().await?;
|
||||||
@@ -586,6 +604,8 @@ impl BrowserManager {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach_result: AttachToTargetResult = self
|
let attach_result: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -611,6 +631,7 @@ impl BrowserManager {
|
|||||||
target_type: "page".to_string(),
|
target_type: "page".to_string(),
|
||||||
});
|
});
|
||||||
self.active_page_index = 0;
|
self.active_page_index = 0;
|
||||||
|
self.pin_active_target();
|
||||||
self.enable_domains(&attach_result.session_id).await?;
|
self.enable_domains(&attach_result.session_id).await?;
|
||||||
} else {
|
} else {
|
||||||
for target in &page_targets {
|
for target in &page_targets {
|
||||||
@@ -640,6 +661,7 @@ impl BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_page_index = 0;
|
self.active_page_index = 0;
|
||||||
|
self.pin_active_target();
|
||||||
let session_id = self.pages[0].session_id.clone();
|
let session_id = self.pages[0].session_id.clone();
|
||||||
self.enable_domains(&session_id).await?;
|
self.enable_domains(&session_id).await?;
|
||||||
}
|
}
|
||||||
@@ -726,9 +748,31 @@ impl BrowserManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Index of the session's active page, resolved through the pinned
|
||||||
|
/// `active_target_id` (stable across reorder/removal/passive discovery) and
|
||||||
|
/// falling back to `active_page_index` when nothing is pinned or the pin is
|
||||||
|
/// gone. This is what keeps commands on the tab the agent actually opened.
|
||||||
|
fn resolved_active_index(&self) -> usize {
|
||||||
|
if let Some(tid) = &self.active_target_id {
|
||||||
|
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.active_page_index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the current active page by target_id so later commands stick to it.
|
||||||
|
/// Call after any explicit open / tab new / tab switch.
|
||||||
|
fn pin_active_target(&mut self) {
|
||||||
|
self.active_target_id = self
|
||||||
|
.pages
|
||||||
|
.get(self.active_page_index)
|
||||||
|
.map(|p| p.target_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_session_id(&self) -> Result<&str, String> {
|
pub fn active_session_id(&self) -> Result<&str, String> {
|
||||||
self.pages
|
self.pages
|
||||||
.get(self.active_page_index)
|
.get(self.resolved_active_index())
|
||||||
.map(|p| p.session_id.as_str())
|
.map(|p| p.session_id.as_str())
|
||||||
.ok_or_else(|| "No active page".to_string())
|
.ok_or_else(|| "No active page".to_string())
|
||||||
}
|
}
|
||||||
@@ -892,6 +936,24 @@ impl BrowserManager {
|
|||||||
.client
|
.client
|
||||||
.send_command_no_params("Browser.close", None)
|
.send_command_no_params("Browser.close", None)
|
||||||
.await;
|
.await;
|
||||||
|
} else {
|
||||||
|
// Connected to the user's real Chrome: we must NOT close their
|
||||||
|
// browser, but we DO own the tabs this session created. Close them so
|
||||||
|
// they don't pile up in the user's window (in their per-session tab
|
||||||
|
// group) every time a session ends, idles out, or the daemon shuts
|
||||||
|
// down. `created_targets` only holds tabs we made via
|
||||||
|
// Target.createTarget — never the user's existing tabs or other
|
||||||
|
// sessions' — so this is always safe. Best-effort per tab.
|
||||||
|
for target_id in self.created_targets.drain() {
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"Target.closeTarget",
|
||||||
|
&CloseTargetParams { target_id },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut process) = self.browser_process.take() {
|
if let Some(mut process) = self.browser_process.take() {
|
||||||
@@ -963,7 +1025,7 @@ impl BrowserManager {
|
|||||||
|
|
||||||
pub fn active_target_id(&self) -> Result<&str, String> {
|
pub fn active_target_id(&self) -> Result<&str, String> {
|
||||||
self.pages
|
self.pages
|
||||||
.get(self.active_page_index)
|
.get(self.resolved_active_index())
|
||||||
.map(|p| p.target_id.as_str())
|
.map(|p| p.target_id.as_str())
|
||||||
.ok_or_else(|| "No active page".to_string())
|
.ok_or_else(|| "No active page".to_string())
|
||||||
}
|
}
|
||||||
@@ -993,6 +1055,8 @@ impl BrowserManager {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach_result: AttachToTargetResult = self
|
let attach_result: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -1158,6 +1222,8 @@ impl BrowserManager {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach: AttachToTargetResult = self
|
let attach: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -1187,6 +1253,7 @@ impl BrowserManager {
|
|||||||
target_type: "page".to_string(),
|
target_type: "page".to_string(),
|
||||||
});
|
});
|
||||||
self.active_page_index = index;
|
self.active_page_index = index;
|
||||||
|
self.pin_active_target();
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"tabId": format_tab_id(tab_id),
|
"tabId": format_tab_id(tab_id),
|
||||||
@@ -1206,6 +1273,7 @@ impl BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_page_index = index;
|
self.active_page_index = index;
|
||||||
|
self.pin_active_target();
|
||||||
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?;
|
||||||
|
|
||||||
@@ -1549,6 +1617,7 @@ impl BrowserManager {
|
|||||||
let index = self.pages.len();
|
let index = self.pages.len();
|
||||||
self.pages.push(page);
|
self.pages.push(page);
|
||||||
self.active_page_index = index;
|
self.active_page_index = index;
|
||||||
|
self.pin_active_target();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a passively-discovered page WITHOUT changing the active tab.
|
/// Add a passively-discovered page WITHOUT changing the active tab.
|
||||||
@@ -1572,8 +1641,18 @@ impl BrowserManager {
|
|||||||
|
|
||||||
pub fn remove_page_by_target_id(&mut self, target_id: &str) {
|
pub fn remove_page_by_target_id(&mut self, target_id: &str) {
|
||||||
if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
|
if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
|
||||||
|
let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id);
|
||||||
self.pages.remove(pos);
|
self.pages.remove(pos);
|
||||||
self.update_active_page_after_removal(pos);
|
self.update_active_page_after_removal(pos);
|
||||||
|
// If we just removed the pinned active target, the pin now dangles and
|
||||||
|
// `resolved_active_index` silently falls back to `active_page_index`.
|
||||||
|
// After a passive about:blank discovery that index can point at a blank
|
||||||
|
// tab, so `wait` → eval/snapshot lands on about:blank (issue #7). Re-pin
|
||||||
|
// to the surviving active page so the pin is never left pointing at a
|
||||||
|
// target that no longer exists.
|
||||||
|
if removed_was_pinned {
|
||||||
|
self.pin_active_target();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1750,6 +1829,8 @@ async fn initialize_lightpanda_manager(
|
|||||||
download_path: None,
|
download_path: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
|
active_target_id: None,
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -2034,6 +2115,72 @@ mod tests {
|
|||||||
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// issue #7: removing the pinned active target must re-anchor the pin to a
|
||||||
|
// surviving page. Models `remove_page_by_target_id`'s index + re-pin steps
|
||||||
|
// purely (BrowserManager needs a live CDP client, so the method itself can't
|
||||||
|
// be unit-constructed). The invariant: after removal the pin never dangles
|
||||||
|
// and never silently resolves to a passively-discovered about:blank tab.
|
||||||
|
fn simulate_remove(
|
||||||
|
target_ids: &[&str],
|
||||||
|
active_index: usize,
|
||||||
|
pinned: &str,
|
||||||
|
remove_id: &str,
|
||||||
|
) -> (Vec<String>, usize, Option<String>) {
|
||||||
|
let pos = target_ids.iter().position(|t| *t == remove_id).unwrap();
|
||||||
|
let removed_was_pinned = pinned == remove_id;
|
||||||
|
let mut pages: Vec<String> = target_ids.iter().map(|s| s.to_string()).collect();
|
||||||
|
pages.remove(pos);
|
||||||
|
let new_active = active_page_index_after_removal(active_index, pos, pages.len());
|
||||||
|
let new_pin = if removed_was_pinned {
|
||||||
|
pages.get(new_active).cloned()
|
||||||
|
} else {
|
||||||
|
Some(pinned.to_string())
|
||||||
|
};
|
||||||
|
(pages, new_active, new_pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_active<'a>(
|
||||||
|
pages: &'a [String],
|
||||||
|
active_index: usize,
|
||||||
|
pin: &Option<String>,
|
||||||
|
) -> &'a str {
|
||||||
|
if let Some(tid) = pin {
|
||||||
|
if let Some(p) = pages.iter().find(|p| *p == tid) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages.get(active_index).map(|s| s.as_str()).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_removing_unpinned_blank_keeps_pin_on_real_page() {
|
||||||
|
// pages = [creepjs(pinned, active), about:blank]; a passive blank closes.
|
||||||
|
let (pages, active, pin) = simulate_remove(&["creepjs", "blank"], 0, "creepjs", "blank");
|
||||||
|
assert_eq!(resolve_active(&pages, active, &pin), "creepjs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_removing_pinned_page_repins_to_survivor_not_dangling() {
|
||||||
|
// pages = [blank, creepjs(pinned, active)]; the pinned page itself closes.
|
||||||
|
let (pages, active, pin) = simulate_remove(&["blank", "creepjs"], 1, "creepjs", "creepjs");
|
||||||
|
// pin must point at a page that still exists (no dangling fallback).
|
||||||
|
let resolved = resolve_active(&pages, active, &pin);
|
||||||
|
assert!(
|
||||||
|
pages.iter().any(|p| p == resolved),
|
||||||
|
"resolved a dangling target"
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, "blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_falls_back_cleanly_when_pin_dangles() {
|
||||||
|
// A stale pin (target already gone) must resolve to a real surviving page,
|
||||||
|
// never panic or return the missing id.
|
||||||
|
let pages = vec!["creepjs".to_string(), "blank".to_string()];
|
||||||
|
let pin = Some("gone".to_string());
|
||||||
|
assert_eq!(resolve_active(&pages, 0, &pin), "creepjs");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_launch_options_extensions_and_cdp() {
|
fn test_validate_launch_options_extensions_and_cdp() {
|
||||||
let ext = vec!["/path/to/ext".to_string()];
|
let ext = vec!["/path/to/ext".to_string()];
|
||||||
|
|||||||
@@ -783,14 +783,46 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
// / :9222 probes below: if the user's Chrome happens to also be listening on a
|
// / :9222 probes below: if the user's Chrome happens to also be listening on a
|
||||||
// debug port, attaching there would pop the consent dialog and defeat the
|
// debug port, attaching there would pop the consent dialog and defeat the
|
||||||
// whole zero-interaction extension path.
|
// whole zero-interaction extension path.
|
||||||
if let Some(relay) = crate::connect::relay_url() {
|
// If the extension is installed, it is the *intended* transport. The relay
|
||||||
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
|
// URL file comes and goes with the MV3 service worker (a Chrome restart or an
|
||||||
// A bare TCP liveness check (no WS upgrade) confirms it is actually
|
// idle SW briefly drops it), so a single failed probe doesn't mean "no
|
||||||
// accepting before we commit, mirroring the consent-free probe used for
|
// extension" — retry for a few seconds while it reconnects. Crucially, when
|
||||||
// DevToolsActivePort.
|
// the extension is set up we must NEVER fall through to the raw :9222 path
|
||||||
if relay_is_live(&relay).await {
|
// below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact
|
||||||
return Ok(relay);
|
// thing the extension exists to avoid.
|
||||||
|
// ~15s of retries (500ms apart) when the extension is installed: long enough
|
||||||
|
// for the MV3 service worker to wake and reconnect on its own (onStartup
|
||||||
|
// after a Chrome restart, or the keepalive alarm) so the relay self-heals
|
||||||
|
// with NO user action. The loop re-checks the relay file every iteration, so
|
||||||
|
// a recovery mid-wait is picked up immediately — the full window is only ever
|
||||||
|
// spent when the extension is genuinely down.
|
||||||
|
let host_installed = crate::connect::host_installed();
|
||||||
|
let relay_attempts = if host_installed { 30 } else { 1 };
|
||||||
|
for attempt in 0..relay_attempts {
|
||||||
|
if let Some(relay) = crate::connect::relay_url() {
|
||||||
|
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
|
||||||
|
// A bare TCP liveness check (no WS upgrade) confirms it is actually
|
||||||
|
// accepting before we commit, mirroring the consent-free probe used
|
||||||
|
// for DevToolsActivePort.
|
||||||
|
if relay_is_live(&relay).await {
|
||||||
|
return Ok(relay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if host_installed && attempt + 1 < relay_attempts {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host_installed {
|
||||||
|
return Err(
|
||||||
|
"The agent-browser-stealth extension is installed, but its relay \
|
||||||
|
isn't connected right now. Wake it up — click the extension's \
|
||||||
|
toolbar icon, or reload it at chrome://extensions — then retry. \
|
||||||
|
(agent-browser will not attach to a raw --remote-debugging-port \
|
||||||
|
while the extension is set up, because that pops Chrome's \"Allow \
|
||||||
|
remote debugging?\" dialog. Use --cdp <port> to force the raw path.)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_data_dirs = get_chrome_user_data_dirs();
|
let user_data_dirs = get_chrome_user_data_dirs();
|
||||||
|
|||||||
@@ -556,8 +556,12 @@ async fn verify_ref_identity(
|
|||||||
Err(format!(
|
Err(format!(
|
||||||
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
|
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
|
||||||
The DOM mutated between snapshot and interaction (typical with React/Vue \
|
The DOM mutated between snapshot and interaction (typical with React/Vue \
|
||||||
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
|
reusing nodes during re-render). Fix: take a fresh `snapshot` and re-target \
|
||||||
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
|
with the new ref. For SPAs where refs churn every interaction, drive the \
|
||||||
|
element directly with `eval` (e.g. `eval \"document.querySelector(...).click()\"`), \
|
||||||
|
which doesn't depend on refs.\n\
|
||||||
|
(Last resort: AGENT_BROWSER_VERIFY_REF=0 disables this safety check — only \
|
||||||
|
if you accept clicks may land on a re-rendered/wrong node.)",
|
||||||
ref_id, expected_role, expected_name, actual_role, actual_name,
|
ref_id, expected_role, expected_name, actual_role, actual_name,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use super::cdp::client::CdpClient;
|
use super::cdp::client::CdpClient;
|
||||||
use super::cdp::types::*;
|
use super::cdp::types::*;
|
||||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
|
||||||
use super::humanize;
|
use super::humanize;
|
||||||
|
|
||||||
pub async fn click(
|
pub async fn click(
|
||||||
@@ -56,6 +56,33 @@ pub async fn click(
|
|||||||
|
|
||||||
match resolved {
|
match resolved {
|
||||||
Ok((cx, cy, w, h, effective_session_id)) => {
|
Ok((cx, cy, w, h, effective_session_id)) => {
|
||||||
|
// Occlusion guard for the CSS-selector path. `@ref` clicks are already
|
||||||
|
// occlusion-checked in resolve_element_center, but a plain selector
|
||||||
|
// resolves to coordinates without that check — so an overlay (modal
|
||||||
|
// backdrop, sticky banner, the getByText located node sitting under a
|
||||||
|
// full-screen layer) would make the coordinate click land on the
|
||||||
|
// overlay and still report success. If the click point doesn't hit the
|
||||||
|
// target, dispatch through the DOM instead (targets the element
|
||||||
|
// directly). Skipped for strict `coord` mode and non-left/multi-clicks.
|
||||||
|
if mode != "coord"
|
||||||
|
&& button == "left"
|
||||||
|
&& click_count == 1
|
||||||
|
&& parse_ref(selector_or_ref).is_none()
|
||||||
|
&& point_misses_element(client, &effective_session_id, selector_or_ref).await
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[click] target occluded at its click point; dispatching through \
|
||||||
|
the DOM (set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
||||||
|
);
|
||||||
|
return dom_click(
|
||||||
|
client,
|
||||||
|
session_id,
|
||||||
|
ref_map,
|
||||||
|
selector_or_ref,
|
||||||
|
iframe_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
// Land on a jittered point inside the element rather than its exact
|
// Land on a jittered point inside the element rather than its exact
|
||||||
// centre (Fast/Human). Zero size or Off → exact centre.
|
// centre (Fast/Human). Zero size or Off → exact centre.
|
||||||
let (tx, ty) = humanize::landing_point(
|
let (tx, ty) = humanize::landing_point(
|
||||||
@@ -92,6 +119,42 @@ pub async fn click(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if a coordinate click at the selector's centre would land on something
|
||||||
|
/// OTHER than the element (an overlay on top), i.e. the element is occluded.
|
||||||
|
/// `false` when not occluded, the element is missing, or the probe fails (so we
|
||||||
|
/// never block a click on a flaky probe — the normal coordinate path runs).
|
||||||
|
async fn point_misses_element(client: &CdpClient, session_id: &str, selector: &str) -> bool {
|
||||||
|
let js = format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const el = document.querySelector({sel});
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) return false;
|
||||||
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
if (!hit) return false;
|
||||||
|
// Not occluded if the hit is the element, a descendant, or an ancestor
|
||||||
|
// wrapper (clicking those still reaches the element's handlers).
|
||||||
|
return !(hit === el || el.contains(hit) || hit.contains(el));
|
||||||
|
}})()"#,
|
||||||
|
sel = serde_json::to_string(selector).unwrap_or_default()
|
||||||
|
);
|
||||||
|
match client
|
||||||
|
.send_command_typed::<_, EvaluateResult>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
&EvaluateParams {
|
||||||
|
expression: js,
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(false),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r.result.value.and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
||||||
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
||||||
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
||||||
@@ -1080,6 +1143,20 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Click at a raw viewport coordinate, bypassing element/selector resolution
|
||||||
|
/// (issue #8.4 first-class coordinate click). Honors the humanize trajectory and
|
||||||
|
/// press dwell exactly like a selector click — it shares `dispatch_click`.
|
||||||
|
pub async fn click_at_point(
|
||||||
|
client: &CdpClient,
|
||||||
|
session_id: &str,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
button: &str,
|
||||||
|
click_count: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
dispatch_click(client, session_id, x, y, button, click_count).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn dispatch_click(
|
async fn dispatch_click(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|||||||
@@ -1305,6 +1305,39 @@ fn render_tree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if a snapshot line names an interactive ARIA role. Compaction keeps
|
||||||
|
/// these even without a `ref=`/`": "` marker, so a clickable control never gets
|
||||||
|
/// dropped from `-c` output (the dogfood reports saw a button present in the full
|
||||||
|
/// snapshot vanish from compact, leaving the agent clicking an empty ref).
|
||||||
|
fn is_interactive_line(line: &str) -> bool {
|
||||||
|
const ROLES: &[&str] = &[
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"textbox",
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"combobox",
|
||||||
|
"listbox",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
"option",
|
||||||
|
"switch",
|
||||||
|
"slider",
|
||||||
|
"spinbutton",
|
||||||
|
"searchbox",
|
||||||
|
"tab ",
|
||||||
|
"clickable",
|
||||||
|
"focusable",
|
||||||
|
"editable",
|
||||||
|
];
|
||||||
|
let t = line.trim_start();
|
||||||
|
// Lines look like `- button "Label" [ref=e1]`; match the role token after the
|
||||||
|
// leading "- " marker.
|
||||||
|
let t = t.strip_prefix("- ").unwrap_or(t);
|
||||||
|
ROLES.iter().any(|r| t.starts_with(r))
|
||||||
|
}
|
||||||
|
|
||||||
fn compact_tree(tree: &str, interactive: bool) -> String {
|
fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||||
let lines: Vec<&str> = tree.lines().collect();
|
let lines: Vec<&str> = tree.lines().collect();
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
@@ -1314,7 +1347,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
|
|||||||
let mut keep = vec![false; lines.len()];
|
let mut keep = vec![false; lines.len()];
|
||||||
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
if line.contains("ref=") || line.contains(": ") {
|
if line.contains("ref=") || line.contains(": ") || is_interactive_line(line) {
|
||||||
keep[i] = true;
|
keep[i] = true;
|
||||||
// Mark ancestors
|
// Mark ancestors
|
||||||
let my_indent = count_indent(line);
|
let my_indent = count_indent(line);
|
||||||
|
|||||||
@@ -51,18 +51,19 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
|||||||
vec![locale, base_lang]
|
vec![locale, base_lang]
|
||||||
};
|
};
|
||||||
let config_line = format!(
|
let config_line = format!(
|
||||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
|
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
|
||||||
locale,
|
locale,
|
||||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||||
hide_canvas_enabled(),
|
hide_canvas_enabled(),
|
||||||
canvas_noise_seed(),
|
canvas_noise_seed(),
|
||||||
|
disable_iframe_proxy_enabled(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
||||||
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
||||||
// declaration and the whole script dies with a redeclaration SyntaxError.
|
// declaration and the whole script dies with a redeclaration SyntaxError.
|
||||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
|
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };"#,
|
||||||
) {
|
) {
|
||||||
format!("{}{}", config_line, rest)
|
format!("{}{}", config_line, rest)
|
||||||
} else {
|
} else {
|
||||||
@@ -81,6 +82,18 @@ fn hide_canvas_enabled() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to DROP the srcdoc-iframe `contentWindow` Proxy patch (FullLaunch).
|
||||||
|
/// That patch masks automation in srcdoc iframes, but the JS `Proxy` is itself a
|
||||||
|
/// fingerprintable tell (CreepJS `hasIframeProxy` → ~20% stealth). Off by default
|
||||||
|
/// (keep the patch); `AGENT_BROWSER_DISABLE_IFRAME_PROXY=1` drops it for a clean
|
||||||
|
/// 0% CreepJS at the cost of that niche srcdoc-iframe masking.
|
||||||
|
fn disable_iframe_proxy_enabled() -> bool {
|
||||||
|
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY")
|
||||||
|
.ok()
|
||||||
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
||||||
/// device returns the same hash on repeated reads) but differs from the
|
/// device returns the same hash on repeated reads) but differs from the
|
||||||
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
|
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };
|
||||||
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
|
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
|
||||||
// the way real Chrome exposes these — as prototype getters, NOT instance own
|
// the way real Chrome exposes these — as prototype getters, NOT instance own
|
||||||
// properties. Adding an own property to the `navigator` instance is itself a
|
// properties. Adding an own property to the `navigator` instance is itself a
|
||||||
@@ -289,6 +289,10 @@ const __abRedefineNavProto = (name, getterImpl) => {
|
|||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
|
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
|
||||||
|
// The srcdoc-iframe contentWindow Proxy below is itself a fingerprintable tell
|
||||||
|
// (CreepJS `hasIframeProxy`). Honor the opt-out so callers can trade the niche
|
||||||
|
// srcdoc masking for a clean 0% CreepJS fingerprint.
|
||||||
|
if (typeof __abStealth !== 'undefined' && __abStealth.disableIframeProxy) return;
|
||||||
const nativeCreateElement = document.createElement.bind(document);
|
const nativeCreateElement = document.createElement.bind(document);
|
||||||
const nativeSrcdocDescriptor =
|
const nativeSrcdocDescriptor =
|
||||||
typeof HTMLIFrameElement !== 'undefined'
|
typeof HTMLIFrameElement !== 'undefined'
|
||||||
@@ -304,12 +308,39 @@ const __abRedefineNavProto = (name, getterImpl) => {
|
|||||||
try {
|
try {
|
||||||
if (iframe.contentWindow) return;
|
if (iframe.contentWindow) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Native window methods are bound to the real Window via an internal slot;
|
||||||
|
// calling them with the Proxy as `this` throws "Illegal invocation". Wrap
|
||||||
|
// each function in an apply/construct trap that swaps the Proxy receiver for
|
||||||
|
// the real window, while passing `.prototype`/`.name`/`.toString`/identity
|
||||||
|
// straight through (a plain `.bind()` would drop `.prototype` and break
|
||||||
|
// `instanceof`). Cached so repeated reads return the same function.
|
||||||
|
const fnProxyCache = new WeakMap();
|
||||||
|
const bindToRealWindow = (fn) => {
|
||||||
|
let wrapped = fnProxyCache.get(fn);
|
||||||
|
if (wrapped) return wrapped;
|
||||||
|
try {
|
||||||
|
wrapped = new Proxy(fn, {
|
||||||
|
apply(target, thisArg, args) {
|
||||||
|
return Reflect.apply(target, thisArg === proxy ? window : thisArg, args);
|
||||||
|
},
|
||||||
|
construct(target, args, newTarget) {
|
||||||
|
return Reflect.construct(target, args, newTarget);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
wrapped = fn;
|
||||||
|
}
|
||||||
|
fnProxyCache.set(fn, wrapped);
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
const proxy = new Proxy(window, {
|
const proxy = new Proxy(window, {
|
||||||
get(target, key) {
|
get(target, key) {
|
||||||
if (key === 'self') return proxy;
|
if (key === 'self') return proxy;
|
||||||
if (key === 'frameElement') return iframe;
|
if (key === 'frameElement') return iframe;
|
||||||
if (key === '0') return undefined;
|
if (key === '0') return undefined;
|
||||||
return Reflect.get(target, key, target);
|
const value = Reflect.get(target, key, target);
|
||||||
|
if (typeof value === 'function') return bindToRealWindow(value);
|
||||||
|
return value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
iframeProxyMap.set(iframe, proxy);
|
iframeProxyMap.set(iframe, proxy);
|
||||||
|
|||||||
+98
-1
@@ -130,6 +130,20 @@ fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shorten an over-long string by keeping its head and tail and eliding the
|
||||||
|
/// middle, with a char count. Used so multi-KB URLs (JWT/OTP login links) don't
|
||||||
|
/// flood `tab list`.
|
||||||
|
fn truncate_middle(s: &str, max: usize) -> String {
|
||||||
|
let n = s.chars().count();
|
||||||
|
if n <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let keep = max.saturating_sub(1) / 2;
|
||||||
|
let head: String = s.chars().take(keep).collect();
|
||||||
|
let tail: String = s.chars().skip(n - keep).collect();
|
||||||
|
format!("{head}…{tail} [{n} chars]")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||||
if opts.json {
|
if opts.json {
|
||||||
if opts.content_boundaries {
|
if opts.content_boundaries {
|
||||||
@@ -338,12 +352,55 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
println!("{}", enabled);
|
println!("{}", enabled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Stealth self-check (`stealth status` / `doctor`)
|
||||||
|
if let Some(s) = data.get("stealthStatus") {
|
||||||
|
let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
println!(
|
||||||
|
"{} stealth: {} · mode: {}",
|
||||||
|
if ok {
|
||||||
|
color::success_indicator().to_string()
|
||||||
|
} else {
|
||||||
|
color::cyan("•")
|
||||||
|
},
|
||||||
|
if ok {
|
||||||
|
"all checks pass"
|
||||||
|
} else {
|
||||||
|
"some checks need attention"
|
||||||
|
},
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) {
|
||||||
|
for c in checks {
|
||||||
|
let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!(" {} {}", if pass { "✓" } else { "✗" }, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) {
|
||||||
|
println!(" applied overrides:");
|
||||||
|
for o in ovs.iter().filter_map(|v| v.as_str()) {
|
||||||
|
println!(" {}", color::dim(&format!("· {o}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
|
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
|
||||||
println!("{}", checked);
|
println!("{}", checked);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Eval result
|
// Eval result
|
||||||
if let Some(result) = data.get("result") {
|
if let Some(result) = data.get("result") {
|
||||||
|
// Surface which page the eval actually ran on — to stderr, so it
|
||||||
|
// never corrupts the parsed value on stdout. Lets an agent catch tab
|
||||||
|
// drift (commands landing on the wrong tab) before trusting a result,
|
||||||
|
// e.g. a logged-in `fetch` that hit the wrong origin. (In
|
||||||
|
// content-boundaries mode the origin is already in the banner.)
|
||||||
|
if !opts.content_boundaries {
|
||||||
|
if let Some(o) = origin.filter(|o| !o.is_empty()) {
|
||||||
|
eprintln!("eval @ {o}");
|
||||||
|
}
|
||||||
|
}
|
||||||
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
||||||
print_with_boundaries(&formatted, origin, opts);
|
print_with_boundaries(&formatted, origin, opts);
|
||||||
return;
|
return;
|
||||||
@@ -421,7 +478,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
.get("title")
|
.get("title")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Untitled");
|
.unwrap_or("Untitled");
|
||||||
|
// A page can set its title to a multi-KB string (e.g. equal to a
|
||||||
|
// giant JWT/OTP URL); truncate it like the URL so the row stays
|
||||||
|
// readable.
|
||||||
|
let title = truncate_middle(title, 120);
|
||||||
|
let title = title.as_str();
|
||||||
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
|
||||||
|
// the list stays readable instead of flooding the terminal.
|
||||||
|
let url = truncate_middle(url, 120);
|
||||||
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let marker = if active {
|
let marker = if active {
|
||||||
color::cyan("→")
|
color::cyan("→")
|
||||||
@@ -530,6 +595,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
// Network requests
|
// Network requests
|
||||||
if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) {
|
if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) {
|
||||||
|
// Stamp the page these requests were read from, mirroring `eval @ url`,
|
||||||
|
// so a read against a drifted/wrong tab is obvious (issue #8.1).
|
||||||
|
if let Some(o) = data
|
||||||
|
.get("origin")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|o| !o.is_empty())
|
||||||
|
{
|
||||||
|
eprintln!("network @ {o}");
|
||||||
|
}
|
||||||
if requests.is_empty() {
|
if requests.is_empty() {
|
||||||
println!("No requests captured");
|
println!("No requests captured");
|
||||||
} else {
|
} else {
|
||||||
@@ -733,8 +807,22 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
color::success_indicator(),
|
color::success_indicator(),
|
||||||
color::green(path)
|
color::green(path)
|
||||||
);
|
);
|
||||||
|
// Stamp which page was captured (mirrors `eval @ url`) so a
|
||||||
|
// screenshot of the wrong/drifted tab is obvious (issue #8.1).
|
||||||
|
if let Some(o) = data
|
||||||
|
.get("origin")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|o| !o.is_empty())
|
||||||
|
{
|
||||||
|
eprintln!("screenshot @ {o}");
|
||||||
|
}
|
||||||
if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) {
|
if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) {
|
||||||
for ann in annotations {
|
// Cap the printed legend on dense pages (it can be
|
||||||
|
// hundreds of lines and flood the terminal). The image
|
||||||
|
// still shows every marker; --json returns the full list.
|
||||||
|
const LEGEND_CAP: usize = 40;
|
||||||
|
let total = annotations.len();
|
||||||
|
for ann in annotations.iter().take(LEGEND_CAP) {
|
||||||
let num = ann.get("number").and_then(|n| n.as_u64()).unwrap_or(0);
|
let num = ann.get("number").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||||
let ref_id = ann.get("ref").and_then(|r| r.as_str()).unwrap_or("");
|
let ref_id = ann.get("ref").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
let role = ann.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
let role = ann.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
@@ -756,6 +844,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if total > LEGEND_CAP {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
color::dim(&format!(
|
||||||
|
"… and {} more markers (shown in the image; --json for the full list)",
|
||||||
|
total - LEGEND_CAP
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"pdf" => println!(
|
"pdf" => println!(
|
||||||
|
|||||||
@@ -199,10 +199,31 @@ async function handleForwardCdpCommand(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Everything else → chrome.debugger on the resolved tab.
|
// Everything else → chrome.debugger on the resolved tab.
|
||||||
const tabId =
|
//
|
||||||
(sessionId ? tabForSession(sessionId) : null) ??
|
// A daemon-supplied sessionId/targetId MUST resolve to a real attached tab.
|
||||||
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
|
// The old code fell through to anyConnectedTab() when it didn't, which
|
||||||
anyConnectedTab()
|
// silently ran the command (eval/screenshot/network) on an arbitrary tab —
|
||||||
|
// exactly the "ran on the wrong page with no warning" failure in issue #8.1,
|
||||||
|
// and the blank-screenshot symptom after a service-worker restart (#8.2).
|
||||||
|
// Fail loudly instead so the agent sees an actionable error, not bad data.
|
||||||
|
let tabId
|
||||||
|
if (sessionId) {
|
||||||
|
tabId = tabForSession(sessionId)
|
||||||
|
if (!tabId) {
|
||||||
|
throw new Error(
|
||||||
|
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
|
||||||
|
`navigated across processes, or lost after an extension restart). ` +
|
||||||
|
`Re-attach by re-opening your target URL before retrying.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (typeof params?.targetId === 'string') {
|
||||||
|
tabId = tabForTarget(params.targetId)
|
||||||
|
if (!tabId) throw new Error(`no attached tab for targetId ${params.targetId} (${method})`)
|
||||||
|
} else {
|
||||||
|
// No session/target specified — a browser-level command that legitimately
|
||||||
|
// applies to any attached tab.
|
||||||
|
tabId = anyConnectedTab()
|
||||||
|
}
|
||||||
if (!tabId) throw new Error(`no attached tab for ${method}`)
|
if (!tabId) throw new Error(`no attached tab for ${method}`)
|
||||||
const dbg = { tabId }
|
const dbg = { tabId }
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.38",
|
"version": "0.27.0-fork.51",
|
||||||
"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",
|
||||||
|
|||||||
+66
-10
@@ -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:
|
||||||
@@ -168,14 +182,17 @@ Snapshot output looks like:
|
|||||||
Page: Example - Log in
|
Page: Example - Log in
|
||||||
URL: https://example.com/login
|
URL: https://example.com/login
|
||||||
|
|
||||||
@e1 [heading] "Log in"
|
- heading "Log in" [level=1, ref=e1]
|
||||||
@e2 [form]
|
- textbox "Email" [ref=e2]
|
||||||
@e3 [input type="email"] placeholder="Email"
|
- textbox "Password" [ref=e3]
|
||||||
@e4 [input type="password"] placeholder="Password"
|
- button "Continue" [ref=e4]
|
||||||
@e5 [button type="submit"] "Continue"
|
- link "Forgot password?" [ref=e5]
|
||||||
@e6 [link] "Forgot password?"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each line is `- <role> "<accessible name>" [<attrs>, ref=eN]`, indented by nesting
|
||||||
|
depth. You pass the ref to commands as `@eN` (e.g. `click @e4`). Refs are
|
||||||
|
assigned fresh on every snapshot.
|
||||||
|
|
||||||
For unstructured reading (no refs needed):
|
For unstructured reading (no refs needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -202,8 +219,16 @@ agent-browser press Enter # press a key at current focus
|
|||||||
agent-browser press Control+a # key combination
|
agent-browser press Control+a # key combination
|
||||||
agent-browser check @e3 # check checkbox
|
agent-browser check @e3 # check checkbox
|
||||||
agent-browser uncheck @e3 # uncheck
|
agent-browser uncheck @e3 # uncheck
|
||||||
agent-browser select @e4 "option-value" # select dropdown option
|
agent-browser select @e4 "option-value" # native <select> only
|
||||||
agent-browser select @e4 "a" "b" # select multiple
|
agent-browser select @e4 "a" "b" # select multiple
|
||||||
|
agent-browser pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
|
||||||
|
# native): opens it, waits for the menu
|
||||||
|
# (incl. portal-rendered), matches by
|
||||||
|
# visible text, fires the right events,
|
||||||
|
# and ERRORS if the option never shows
|
||||||
|
# (no silent no-op). Use this for custom
|
||||||
|
# dropdowns where `select` returns ✓ but
|
||||||
|
# changes nothing.
|
||||||
agent-browser upload @e5 file1.pdf # upload file(s)
|
agent-browser upload @e5 file1.pdf # upload file(s)
|
||||||
agent-browser scroll down 500 # scroll page (up/down/left/right)
|
agent-browser scroll down 500 # scroll page (up/down/left/right)
|
||||||
agent-browser scrollintoview @e1 # scroll element into view
|
agent-browser scrollintoview @e1 # scroll element into view
|
||||||
@@ -372,9 +397,23 @@ Array.from(rows).map(r => ({
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer `eval --stdin` (heredoc) or `eval -b <base64>` for any JS with
|
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
|
||||||
quotes or special characters. Inline `agent-browser eval "..."` works
|
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
|
||||||
only for simple expressions.
|
large scripts — inline `agent-browser eval "..."` is shell-mangled and works
|
||||||
|
only for simple ASCII expressions.
|
||||||
|
|
||||||
|
**`eval` runs in the page's MAIN world and state persists across calls**, so a
|
||||||
|
top-level `const x`/`let x`/`var x` in one call collides with the next
|
||||||
|
(`SyntaxError: Identifier 'x' has already been declared`). Either use unique
|
||||||
|
names, assign to `window.x`, or wrap the body in an IIFE
|
||||||
|
(`(() => { const x = …; return x; })()`).
|
||||||
|
|
||||||
|
**For array/object results, use `eval --json`** — the plain renderer
|
||||||
|
pretty-prints across multiple lines, which `tail`/`head`/pipes mangle; `--json`
|
||||||
|
emits one parseable line. Also note **`type`/`fill` insert text without firing
|
||||||
|
`keydown`/`keyup`** (CDP insertText) — the value lands, but a page that gates on
|
||||||
|
key events (some search-as-you-type widgets) won't react; use `keyboard type` (or
|
||||||
|
`press` per key) when real keystrokes are required.
|
||||||
|
|
||||||
### Screenshot
|
### Screenshot
|
||||||
|
|
||||||
@@ -419,6 +458,19 @@ agent-browser --session b fill @e1 "bob@test.com"
|
|||||||
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
|
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
|
||||||
shell.
|
shell.
|
||||||
|
|
||||||
|
**Concurrent agents MUST each use a distinct `--session <name>`.** Within one
|
||||||
|
session, commands are pinned to the tab you opened (by target_id, so a foreign
|
||||||
|
tab can't drift your `eval`/`screenshot`). Two agents sharing the *same* session
|
||||||
|
(e.g. both on the bare default) share one daemon and one active tab and will
|
||||||
|
clobber each other.
|
||||||
|
|
||||||
|
True multi-agent isolation requires the **extension-connect path**: each
|
||||||
|
`--session` gets its own colored Chrome tab group, so sessions never touch each
|
||||||
|
other's tabs. **Raw `--cdp <port>` does NOT isolate** — every session attaches to
|
||||||
|
the same browser's existing targets, so a second session's first `open` can
|
||||||
|
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
|
||||||
|
extension (each with a distinct `--session`), not raw `--cdp`.
|
||||||
|
|
||||||
### Mock network requests
|
### Mock network requests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -488,6 +540,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons,
|
|||||||
agent-browser doctor --offline --quick # fast, local-only
|
agent-browser doctor --offline --quick # fast, local-only
|
||||||
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
|
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
|
||||||
agent-browser doctor --json # structured output for programmatic consumption
|
agent-browser doctor --json # structured output for programmatic consumption
|
||||||
|
agent-browser stealth status # stealth self-check: mode + live probes
|
||||||
|
agent-browser stealth status --json # (webdriver/chrome/plugins/UA) + applied
|
||||||
|
# overrides. Gate a sensitive flow on this
|
||||||
|
# instead of driving an external detector.
|
||||||
```
|
```
|
||||||
|
|
||||||
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
|
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
|
||||||
|
|||||||
Reference in New Issue
Block a user