Compare commits

..
Author SHA1 Message Date
leeguooooo 0cf7de2dd6 style: rustfmt the issue #7 regression tests (CI format gate)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-12 01:42:36 +09:00
leeguooooo 658bf4226f chore(release): 0.27.0-fork.51 — --launch Illegal-invocation fix, tab-pin hardening (#7), field-report ergonomics + observability (#8)
- fix(stealth): bind iframe contentWindow proxy methods to the real window
  (--launch "Illegal invocation" on srcdoc-iframe pages)
- fix(tabs): re-pin active target when the pinned page is removed (issue #7)
- feat(cli): coordinate click (click <x> <y> / --coords), tabs/get-text
  aliases, clearer find error (issue #8.4)
- fix(observability): screenshot/network stamp @ <url>; network --clear
  enables capture immediately (issues #8.1/#8.3)
- fix(ab-connect 0.4.2): stale sessionId fails loudly instead of routing to a
  random tab (issue #8.1) — needs a Chrome Web Store republish
- restart notice now flags in-memory context reset (issue #8.2)
2026-06-12 01:39:29 +09:00
leeguooooo 37cd9b91e1 fix(ab-connect): fail loudly on a stale sessionId instead of routing to a random tab (issue #8.1)
handleForwardCdpCommand fell through to anyConnectedTab() when a
daemon-supplied sessionId didn't map to an attached tab, so eval/screenshot/
network silently ran on an arbitrary tab — the root of "eval ran on the
wrong page, no warning" and the blank-screenshot-after-restart symptom.

Now: a provided sessionId/targetId MUST resolve to a real tab or the command
throws an actionable error ("stale sessionId … re-open your target URL").
anyConnectedTab() is only used for genuinely browser-level commands that
specify neither. Manifest 0.4.1 → 0.4.2 (needs a Chrome Web Store republish
for installed users to pick this up).
2026-06-12 01:34:15 +09:00
leeguooooo b2c4aa0004 fix(observability): stamp page URL on screenshot/network; enable capture on --clear (issue #8)
Field report #8: in extension-relay sessions, reads (eval/screenshot/network)
could silently run against whatever tab drifted into focus, with no signal,
and network capture was intermittently empty.

- #8.1: screenshot and `network requests` now print `screenshot @ <url>` /
  `network @ <url>` to stderr (mirrors the existing `eval @ <url>`), and the
  responses carry `origin`. A read against the wrong/drifted tab — and the
  "0 captured" vs "wrong page" ambiguity — is now obvious.
- #8.3: `network requests --clear` now enables Network capture immediately
  instead of lazily on the next read, so requests fired between `--clear` and
  the following read are tracked (fixes the "No requests captured" on first
  try, works on retry" race). Extracted enable_request_tracking helper.
- #8.2: the daemon version-mismatch restart notice now spells out that
  in-memory context (active tab, refs, captured requests) is reset and tells
  the user to re-open the target URL if the next read looks blank/wrong.

Verified on a launched browser: coordinate clicks land, screenshot/network
stamps appear, and a fetch after --clear is captured on the first read.
2026-06-12 01:34:15 +09:00
leeguooooo ec8d01ef4c feat(cli): coordinate click + command aliases + clearer find error (issue #8.4)
Field-report ergonomics fixes so agents stop wasting a round on a wrong guess:

- Coordinate click is now first-class: `click <x> <y>`, `click <x>,<y>`,
  and `click --coords <x>,<y>` dispatch a raw viewport-point click (no
  element resolution), reusing the humanize trajectory + press dwell. Was
  previously only reachable via eval(elementFromPoint(...).click()).
- Aliases: `tabs` (plural) → the `tab` subcommand tree; `get-text`/`get_text`
  → `get text <selector>`.
- `find <value> <action>` with a bare value (no locator keyword), e.g.
  `find "I'm not a robot" click`, now errors with the corrected command
  (`find text "I'm not a robot" click`) plus concrete examples, instead of
  a bare "Valid options: role, text, ..." list.

Adds parse-layer regression tests for every form.
2026-06-12 01:21:39 +09:00
leeguooooo 0e5409a81e fix(tabs): re-pin active target when the pinned page is removed (issue #7)
remove_page_by_target_id left active_target_id dangling when the pinned
page itself was removed, so resolved_active_index silently fell back to
active_page_index — which after a passive about:blank discovery can point
at a blank tab. That matches issue #7's intermittent symptom: `wait` then
eval/snapshot landing on about:blank in a --launch session.

Re-pin to the surviving active page after removing the pinned target so
the pin is never left pointing at a target that no longer exists. Adds
pure regression tests for the re-anchor invariant (BrowserManager needs a
live CDP client, so the method can't be unit-constructed directly).
2026-06-12 01:05:33 +09:00
leeguooooo 3ded30c210 fix(stealth): bind iframe contentWindow proxy methods to the real window
The srcdoc-iframe contentWindow Proxy returned native window methods
unbound, so iframe.contentWindow.getComputedStyle()/addEventListener()/
setTimeout() ran with the Proxy as `this` and threw "Illegal invocation"
on any page that uses a srcdoc iframe under --launch (FullLaunch). The
sibling matchMedia proxy already bound its methods; this one did not.

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() drops .prototype and breaks
instanceof/constructors). Cached in a WeakMap for stable identity.

Verified before/after on a launched stealth browser: getComputedStyle,
addEventListener, setTimeout all OK; .prototype preserved.
2026-06-12 01:05:33 +09:00
leeguooooo 6e50f0ecab chore(release): 0.27.0-fork.50 — tab-title truncation + multi-agent/eval/type docs
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 23:55:01 +09:00
leeguooooo 6f71f4e1ff fix: truncate tab-list title too; doc raw --cdp isolation limit + eval/type notes
From Hermes's fork.49 re-dogfood (9/11 fixes confirmed PASS):
- tab list: a page can set its title to a multi-KB string (= a giant URL); cap
  the title column like the URL so the row stays readable.
- skill: clarify that true multi-agent isolation needs the extension-connect path
  (per-session tab groups) — raw `--cdp` shares the browser, so a sibling
  session's `open` can navigate your tab. Use the extension for concurrent agents.
- skill: prefer `eval --json` for array/object results (plain render is
  multi-line / pipe-hostile); note type/fill don't fire keydown (use `keyboard
  type` when key events are required).

(Hermes's "find-text click bypasses humanize" was a false alarm — verified both
paths curve; the apparent 1-vs-12 was cursor continuity on the same target.)
2026-06-11 23:55:00 +09:00
leeguooooo 31ef0d7e6a chore(release): 0.27.0-fork.49 — embed stealth-status + multi-agent skill guidance
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 23:36:14 +09:00
leeguooooo 42560b56fc docs(skill): concurrent agents must use distinct --session (issue #6)
Within a session, commands are pinned to the agent's opened tab (fork.47). But
two agents on the same (default) session share one daemon + active tab and
clobber each other. Document that each concurrent agent must use a unique
--session — which gives it its own isolated tab group on the shared real Chrome.
2026-06-11 23:35:03 +09:00
leeguooooo 0c7534d9b2 chore(release): 0.27.0-fork.48 — iframe-proxy toggle (#4) + stealth status (#5)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 23:33:26 +09:00
leeguooooo ad4fb14ed9 feat: stealth status self-check command (issue #5)
Local stealth verification with no external detector: reports mode (connect vs
launch), live fingerprint probes (navigator.webdriver / window.chrome / plugins /
UA-headless) as pass/fail, and an audit of the active overrides for the path
(incl. the iframe-proxy state from #4). `--json` for a stable shape agents can
gate a sensitive flow on. Distinct from `doctor` (install/env health).
2026-06-11 23:33:24 +09:00
leeguooooo a976287f03 fix(stealth): AGENT_BROWSER_DISABLE_IFRAME_PROXY for a clean 0% CreepJS (issue #4)
--launch mode scored ~20% stealth on CreepJS because the srcdoc-iframe
contentWindow Proxy trips `hasIframeProxy` — the proxy that hides automation is
itself a fingerprintable tell (violates this fork's own "native > JS lies" rule).
Add a config-driven opt-out (no detectable global): AGENT_BROWSER_DISABLE_IFRAME_PROXY=1
drops the patch via __abStealth.disableIframeProxy → the iframe IIFE early-returns
→ clean 0% CreepJS, trading the niche srcdoc-iframe masking. Default keeps current
behavior. README now documents the --launch 20% honestly and scopes the headline
0% to the extension-connect path. Verified: launch + srcdoc page intact with the
toggle; stealth tests green (config strip-prefix kept in sync).
2026-06-11 23:25:24 +09:00
15 changed files with 528 additions and 44 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ 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.)
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)
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.47"
version = "0.27.0-fork.51"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.47"
version = "0.27.0-fork.51"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+135 -18
View File
@@ -375,12 +375,25 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Core Actions ===
"click" => {
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
.iter()
.find(|arg| **arg != "--new-tab")
.ok_or_else(|| ParseError::MissingArguments {
context: "click".to_string(),
usage: "click <selector> [--new-tab]",
usage: "click <selector> | click <x> <y> | click --coords <x>,<y> [--new-tab]",
})?;
if new_tab {
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
@@ -931,6 +944,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
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" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
@@ -1199,6 +1221,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
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" => parse_is(&rest, &id),
@@ -1380,7 +1411,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
}
// === 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() {
Some("new") => {
// Accepted forms:
@@ -2304,19 +2337,6 @@ fn parse_is(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 {
context: "find".to_string(),
usage: "find <locator> <value> [action] [text]",
@@ -2486,13 +2506,37 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
Ok(cmd)
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(),
valid_options: VALID,
_ => Err(ParseError::InvalidValue {
// The user passed a value where a locator keyword was expected — the
// 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> {
const VALID: &[&str] = &["move", "down", "up", "wheel"];
@@ -3541,6 +3585,79 @@ mod tests {
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 ===
#[test]
+4 -1
View File
@@ -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.
if !daemon_version_matches(session) {
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()
);
// Best-effort: ask the old daemon for its current URL so the
+141 -12
View File
@@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"content" => handle_content(state).await,
"evaluate" => handle_evaluate(cmd, state).await,
"close" => handle_close(state).await,
"stealth_status" => handle_stealth_status(state).await,
"snapshot" => handle_snapshot(cmd, state).await,
"screenshot" => handle_screenshot(cmd, state).await,
"click" => handle_click(cmd, state).await,
@@ -2680,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
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> {
if let Some(ref mgr) = state.browser {
if let Some(ref session_name) = state.session_name {
@@ -2897,11 +2981,32 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
response["annotations"] = serde_json::to_value(&result.annotations)
.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)
}
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
.get("selector")
.and_then(|v| v.as_str())
@@ -7816,23 +7921,40 @@ pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
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> {
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
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 }));
}
if !state.request_tracking {
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;
}
}
}
enable_request_tracking(state).await;
// Current page URL, so a `requests` read on a drifted/wrong tab is obvious
// and "0 captured" can't be confused with "wrong page" (issues #8.1/#8.3).
let origin = match state.browser.as_ref() {
Some(mgr) => mgr.get_url().await.ok().filter(|u| !u.is_empty()),
None => None,
};
let filter = cmd.get("filter").and_then(|v| v.as_str());
let type_filter = cmd.get("type").and_then(|v| v.as_str());
@@ -7869,7 +7991,14 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
})
.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> {
+76
View File
@@ -1641,8 +1641,18 @@ impl BrowserManager {
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) {
let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id);
self.pages.remove(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();
}
}
}
@@ -2105,6 +2115,72 @@ mod tests {
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]
fn test_validate_launch_options_extensions_and_cdp() {
let ext = vec!["/path/to/ext".to_string()];
+14
View File
@@ -1143,6 +1143,20 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
.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(
client: &CdpClient,
session_id: &str,
+15 -2
View File
@@ -51,18 +51,19 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
vec![locale, base_lang]
};
let config_line = format!(
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
hide_canvas_enabled(),
canvas_noise_seed(),
disable_iframe_proxy_enabled(),
);
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
// otherwise the fallback below prepends a SECOND `const __abStealth`
// declaration and the whole script dies with a redeclaration SyntaxError.
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)
} else {
@@ -81,6 +82,18 @@ fn hide_canvas_enabled() -> bool {
.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
/// 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".
+33 -2
View File
@@ -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),
// the way real Chrome exposes these — as prototype getters, NOT instance own
// properties. Adding an own property to the `navigator` instance is itself a
@@ -289,6 +289,10 @@ const __abRedefineNavProto = (name, getterImpl) => {
})();
(function(){
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 nativeSrcdocDescriptor =
typeof HTMLIFrameElement !== 'undefined'
@@ -304,12 +308,39 @@ const __abRedefineNavProto = (name, getterImpl) => {
try {
if (iframe.contentWindow) return;
} 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, {
get(target, key) {
if (key === 'self') return proxy;
if (key === 'frameElement') return iframe;
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);
+56
View File
@@ -352,6 +352,39 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
println!("{}", enabled);
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()) {
println!("{}", checked);
return;
@@ -445,6 +478,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.get("title")
.and_then(|v| v.as_str())
.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("");
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal.
@@ -557,6 +595,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
// Network requests
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() {
println!("No requests captured");
} else {
@@ -760,6 +807,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
color::success_indicator(),
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()) {
// Cap the printed legend on dense pages (it can be
// hundreds of lines and flood the terminal). The image
+25 -4
View File
@@ -199,10 +199,31 @@ async function handleForwardCdpCommand(msg) {
}
// Everything else → chrome.debugger on the resolved tab.
const tabId =
(sessionId ? tabForSession(sessionId) : null) ??
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
anyConnectedTab()
//
// A daemon-supplied sessionId/targetId MUST resolve to a real attached tab.
// The old code fell through to anyConnectedTab() when it didn't, which
// 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}`)
const dbg = { tabId }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"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.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.47",
"version": "0.27.0-fork.51",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+24
View File
@@ -408,6 +408,13 @@ top-level `const x`/`let x`/`var x` in one call collides with the next
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
```bash
@@ -451,6 +458,19 @@ agent-browser --session b fill @e1 "bob@test.com"
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
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
```bash
@@ -520,6 +540,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons,
agent-browser doctor --offline --quick # fast, local-only
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
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.