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
leeguooooo 649fa4ce94 chore(release): 0.27.0-fork.47 — tab-drift pin, snapshot -c keeps interactive, stale-ref 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:15:38 +09:00
leeguooooo 3ac69e822a fix: keep interactive nodes in snapshot -c; better stale-ref guidance (issue #2/#3)
- snapshot -c (compact) now always keeps lines with an interactive ARIA role
  (button/link/textbox/combobox/option/…), not only `ref=`/`": "` lines — so a
  clickable control can't vanish from compact output and leave the agent clicking
  an empty ref (issue #2 P1). Additive: only ever keeps more. compact tests green.
- stale-ref error now leads with "take a fresh snapshot" and points to the `eval`
  fallback for ref-churning SPAs, and demotes AGENT_BROWSER_VERIFY_REF=0 to a
  flagged last resort instead of presenting it as the fix (issue #3 P1).
2026-06-11 23:15:37 +09:00
leeguooooo d7a0ed85f9 fix(tabs): pin the active tab by target_id — stop command drift (issue #2/#3 P0)
The session's active tab was a bare index into `pages`, which drifts when a
foreign/user/other-session tab is passively discovered, a tab closes, or the list
reorders — so `eval`/`screenshot`/`snapshot`/`click` could land on the wrong page.
With login state that's a safety bug (a fetch firing on the wrong origin), and it
made screenshot disagree with snapshot/eval.

Pin the intended tab by stable target_id (`active_target_id`), set on every
explicit open / tab new / tab switch / connect. `active_session_id` and
`active_target_id` resolve through it (falling back to the index only if the
pinned tab is gone), so all commands stick to the agent's tab regardless of
passive churn — and they all agree.

Verified (--cdp, multi-tab): a window.open foreign tab no longer drifts eval;
tab new / switch re-pin correctly.
2026-06-11 23:10:29 +09:00
leeguooooo 9eaa5495ae chore(release): 0.27.0-fork.46 — cap --annotate legend
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 22:58:58 +09:00
leeguooooo 68734fcb36 fix(screenshot): cap the --annotate legend (don't flood the terminal)
Dense pages produced hundreds of legend lines on stdout (Hermes: HN dumped 320).
Print the first 40 with a "… and N more" summary; every marker is still drawn in
the image, and --json still returns the full list.
2026-06-11 22:58:56 +09:00
leeguooooo 4b33dbadb4 chore(release): 0.27.0-fork.45 — pick combobox atomic op
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 22:55:35 +09:00
leeguooooo fc73ee6c90 feat(pick): atomic combobox select for react-select / ARIA / native (issue #2 P1)
The biggest manual-cost point in the dogfood reports: `select @ref` is a silent
no-op on non-native dropdowns, and click+wait+Enter on react-select/ARIA/portal
menus took ~20 turns of hand-written eval to get right.

New `pick <selector> --option "<text>"` does it atomically in one in-page async
routine: native <select> → set value + input/change; custom widget → focus +
open (pointer/mouse sequence), poll up to 2.5s for the option to render anywhere
(portals included), match by visible text, scroll it in, fire the full
pointer/mouse sequence. ERRORS loudly if the option never appears — no silent
success.

Verified headless: native <select> → "Gamma"; portal combobox → "欧洲"
(non-ASCII); missing option → explicit error. Documented in the skill.
2026-06-11 22:55:32 +09:00
leeguooooo 28d3748c06 chore(release): 0.27.0-fork.44 — eval origin stamp, type --focused, humanize bogus warn
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 22:48:32 +09:00
leeguooooo fa47a0b8e5 feat: eval prints its origin URL, type --focused, AGENT_BROWSER_HUMANIZE bogus warn
- eval now prints `eval @ <url>` to stderr (stdout stays the raw value) so an
  agent can catch tab drift — e.g. a logged-in fetch that hit the wrong origin —
  before trusting the result. Mitigates the issue #2/#3 P0 safety concern. (eval
  already returned the origin; the default output just never surfaced it.)
- `type --focused <text>`: type into the currently-focused element with no
  selector, for custom widgets that move focus to a hidden input (issue #2 P3).
- AGENT_BROWSER_HUMANIZE set to an unrecognized value now warns once (like the
  --humanize flag) instead of being silently ignored (Hermes #3).
2026-06-11 22:48:31 +09:00
leeguooooo 36c593631c chore(release): 0.27.0-fork.43 — issue/Hermes batch 1 (silent-click, live env, eval --file, tab-list, 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 22:35:35 +09:00
leeguooooo b92757412d fix(click): occlusion guard for selector clicks — no more silent false success
A coordinate click resolved from a CSS selector (incl. the getByText/find path's
located node) skipped the occlusion check that @ref clicks already get, so an
overlay on top made the click land on the overlay while still reporting ✓ Done —
the worst failure mode for an agent (Hermes #1, issue #2/#3). Now: if the click
point doesn't hit the target (elementFromPoint isn't the element / a descendant /
an ancestor wrapper), dispatch through the DOM instead, which fires the real
handler. Best-effort probe (a flaky check never blocks the normal path); skipped
for strict CLICK_MODE=coord and non-left/multi-clicks.

Verified: occluded button click hits 0→1 (was silent ✓Done); normal click
unaffected.
2026-06-11 22:34:12 +09:00
leeguooooo 6ecda4d706 fix: per-invocation env (CLICK_MODE / HUMANIZE) reaches a running daemon
Root cause behind Hermes #1 (CLICK_MODE=dom "does nothing") and #2 (--humanize
"does nothing"): both are env vars the daemon reads, but the daemon's env is
frozen at spawn — set them on a command to an already-running daemon and they
were silently ignored. (Confirmed: setting CLICK_MODE=dom at daemon spawn made
dom_click fire; setting it later did not.)

Fix: the client forwards AGENT_BROWSER_CLICK_MODE / AGENT_BROWSER_HUMANIZE in the
command envelope (_clickMode/_humanize); execute_command applies them per command
— mirrors CLICK_MODE into the process env (interaction::click reads it fresh) and
sets the humanize session level. Each command is authoritative.

Verified on an already-running daemon: CLICK_MODE=dom now fires dom_click
(hits 0→1); --humanize human typing applies.
2026-06-11 22:29:39 +09:00
leeguooooo 123510db2b feat: eval --file, tab-list URL truncation, skill doc fixes (issue #2/#3 + Hermes)
- `eval --file <path>`: read JS from a file, sent verbatim — avoids shell-mangling
  of non-ASCII identifiers/strings (Chinese), quotes, and large scripts (issue #3).
- `tab list`: truncate multi-KB URLs (JWT/OTP login links) middle-out with a char
  count so the list stays readable (issue #3).
- skill: fix the snapshot example to match real output
  (`- role "name" [ref=eN]`, not `@e1 [role]`); document that eval runs in the
  page MAIN world with persistent state (top-level `const` collides — use IIFE /
  window / unique names) and to prefer --file/--stdin/-b for non-ASCII or big JS.
2026-06-11 22:22:07 +09:00
leeguooooo abb65c632b chore(release): 0.27.0-fork.42 — close session-owned tabs on exit (no tab leak)
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 21:54:44 +09:00
leeguooooo e803bffbbb fix(tabs): close the session's own tabs on exit (stop leaking into the user's Chrome)
When connected to the user's real Chrome, mgr.close() disconnected but never
closed the tabs the session opened — so every session (especially one that
failed before calling close, or a forgotten one) left its tabs piling up in the
user's browser. Idle-timeout and shutdown have the same exit path.

Track the target_ids this session creates via Target.createTarget in
`created_targets` (only ever our own tabs — never the user's existing tabs, which
the raw-CDP path attaches to, nor other sessions'). On close(), for the connected
path (not a launched browser, which Browser.close handles wholesale), close each
of those targets — the extension maps Target.closeTarget → chrome.tabs.remove.

Verified against a throwaway --cdp Chrome: open + 2 `tab new` → 3 pages; `close`
→ back to 1 (our 2 closed, the pre-existing tab untouched).
2026-06-11 21:54:42 +09:00
leeguooooo 96ee2f9758 chore(release): 0.27.0-fork.41 — relay self-heals silently (no user action on blip)
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 21:30:38 +09:00
leeguooooo 0a3d2a91a6 fix(connect): self-heal the relay silently — ~15s retry, no user action
fork.40 errored after 5s ("reload the extension"), which still pushed the problem
onto the user. Extend the relay-reconnect wait to ~15s when the extension is
installed: enough for the MV3 service worker to wake and reconnect on its own
(onStartup after a Chrome restart, or the keepalive alarm). The loop re-checks
the relay file each iteration, so a mid-wait recovery is picked up instantly and
the full window is only spent when the extension is genuinely down. End users no
longer have to do anything when the relay blips.
2026-06-11 21:30:37 +09:00
leeguooooo 1e5dfd35cb chore(release): 0.27.0-fork.40 — no consent-dialog fallback when extension is installed
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 21:17:03 +09:00
leeguooooo b6b2ca56ca fix(connect): never fall back to the consent-dialog raw port when the extension is installed
Root cause of the recurring "Allow remote debugging?" dialog: when the ab-connect
relay was momentarily down (MV3 service worker drops the relay-url file across a
Chrome restart / idle wake), auto_connect_cdp silently fell through to the raw
:9222 DevToolsActivePort path — which pops Chrome 136+'s consent modal, the exact
thing the extension exists to avoid. Even a relay-aware build hit this if it
connected during the blip.

Fix: if the native-messaging host is installed (connect::host_installed() — the
durable signal that the user chose the extension path), auto_connect retries the
relay for ~5s while the SW reconnects, and then ERRORS with an actionable message
instead of attaching to a raw debug port. The raw :9222 path now runs only when
no extension is set up (where the dialog is expected). `--cdp <port>` still forces
the raw path explicitly.
2026-06-11 21:17:02 +09:00
leeguooooo 6d740093dc chore(release): 0.27.0-fork.39 — cookies set --curl preserves full attributes
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 21:09:04 +09:00
leeguooooo c884fb4f57 fix(cookies): preserve full cookie attributes in cookies set --curl JSON import
The JSON-array branch of parse_curl_cookies dropped every field except
name/value, so importing a full cookie export (httpOnly session tokens,
per-domain cookies spanning multiple hosts, secure/sameSite/expiry) could
not reconstruct a usable auth state — a single --domain override cannot
cover an export that spans .chatgpt.com, .openai.com, etc.

Pass through url/domain/path/secure/httpOnly/sameSite/expires when present,
accepting common aliases from DevTools / EditThisCookie exports
(http_only, same_site, no_restriction, expirationDate). Bare {name,value}
exports are unchanged. Added a round-trip test.
2026-06-11 21:09:04 +09:00
leeguooooo 57ef011817 docs: document humanize (human-like input) + silent operation
- README + README.zh: new Anti-detection subsections — "Human-like input
  (behavioural stealth)" (curved trajectories / jitter / cadence / eased
  scroll-drag, adaptive per-page escalation, off|fast|human) with the
  trajectory contrast table, and "Silent operation" (background tabs, no
  foreground stealing, focus-emulated). Added AGENT_BROWSER_HUMANIZE to the
  tuning-knobs table.
- skill core: agents told operation is silent by default and how/when to use
  --humanize (leave on auto; force human for known behavioural targets).
2026-06-11 21:04:35 +09:00
20 changed files with 1175 additions and 77 deletions
+21 -1
View File
@@ -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.)
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
@@ -244,6 +263,7 @@ We deliberately **don't ship our own bot detector** — the strongest, most hone
| Variable | Default | Effect |
|---|---|---|
| `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_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"). |
+19
View File
@@ -148,6 +148,25 @@ agent-browser --launch --profile auto open https://x.com/home
`--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)
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.38"
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.38"
version = "0.27.0-fork.51"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+272 -26
View File
@@ -101,7 +101,62 @@ pub fn parse_curl_cookies(raw: &str) -> Result<Vec<Value>, String> {
.get("value")
.and_then(|v| v.as_str())
.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);
}
@@ -320,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 }))
@@ -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(" ") }))
}
"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 {
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(" ") }))
}
"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" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "hover".to_string(),
@@ -786,17 +890,31 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Eval ===
"eval" => {
// Check for flags: -b/--base64 or --stdin
let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) =
// Check for flags: -b/--base64, --stdin, or --file <path>
let (is_base64, is_stdin, is_file, script_parts): (bool, bool, bool, &[&str]) =
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
(true, false, &rest[1..])
(true, false, false, &rest[1..])
} 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 {
(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
let stdin = io::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 }))
}
// === 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" })),
@@ -1094,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),
@@ -1275,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:
@@ -2199,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]",
@@ -2381,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"];
@@ -2859,6 +3008,27 @@ mod tests {
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]
fn test_parse_curl_cookies_json_array() {
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[0]["name"], "a");
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]["value"], "2");
}
@@ -3412,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]
+13
View File
@@ -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) {
if json {
println!(
+31 -2
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
@@ -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)
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
+272 -12
View File
@@ -1160,6 +1160,23 @@ impl Drop for DaemonState {
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
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
.get("id")
.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,
"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,
@@ -1306,6 +1324,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"fill" => handle_fill(cmd, state).await,
"type" => handle_type(cmd, state).await,
"press" => handle_press(cmd, state).await,
"pick" => handle_pick(cmd, state).await,
"hover" => handle_hover(cmd, state).await,
"scroll" => handle_scroll(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 }))
}
/// 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 {
@@ -2879,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())
@@ -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> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
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
.get("selector")
.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 }))
}
/// 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> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
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
}
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());
@@ -7738,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> {
+149 -2
View File
@@ -309,6 +309,19 @@ pub struct BrowserManager {
pub ignore_https_errors: bool,
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
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,
/// 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
@@ -433,6 +446,8 @@ impl BrowserManager {
download_path: download_path.clone(),
ignore_https_errors,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -523,6 +538,8 @@ impl BrowserManager {
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -539,6 +556,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
manager.active_page_index = 0;
manager.pin_active_target();
manager.enable_domains_direct().await?;
} else {
manager.discover_and_attach_targets().await?;
@@ -586,6 +604,8 @@ impl BrowserManager {
None,
)
.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
.client
@@ -611,6 +631,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.pin_active_target();
self.enable_domains(&attach_result.session_id).await?;
} else {
for target in &page_targets {
@@ -640,6 +661,7 @@ impl BrowserManager {
}
self.active_page_index = 0;
self.pin_active_target();
let session_id = self.pages[0].session_id.clone();
self.enable_domains(&session_id).await?;
}
@@ -726,9 +748,31 @@ impl BrowserManager {
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> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.session_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
@@ -892,6 +936,24 @@ impl BrowserManager {
.client
.send_command_no_params("Browser.close", None)
.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() {
@@ -963,7 +1025,7 @@ impl BrowserManager {
pub fn active_target_id(&self) -> Result<&str, String> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.target_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
@@ -993,6 +1055,8 @@ impl BrowserManager {
None,
)
.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
.client
@@ -1158,6 +1222,8 @@ impl BrowserManager {
None,
)
.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
.client
@@ -1187,6 +1253,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = index;
self.pin_active_target();
Ok(json!({
"tabId": format_tab_id(tab_id),
@@ -1206,6 +1273,7 @@ impl BrowserManager {
}
self.active_page_index = index;
self.pin_active_target();
let session_id = self.pages[index].session_id.clone();
self.enable_domains(&session_id).await?;
@@ -1549,6 +1617,7 @@ impl BrowserManager {
let index = self.pages.len();
self.pages.push(page);
self.active_page_index = index;
self.pin_active_target();
}
/// 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) {
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();
}
}
}
@@ -1750,6 +1829,8 @@ async fn initialize_lightpanda_manager(
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -2034,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()];
+39 -7
View File
@@ -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
// debug port, attaching there would pop the consent dialog and defeat the
// whole zero-interaction extension path.
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 the extension is installed, it is the *intended* transport. The relay
// URL file comes and goes with the MV3 service worker (a Chrome restart or an
// idle SW briefly drops it), so a single failed probe doesn't mean "no
// extension" — retry for a few seconds while it reconnects. Crucially, when
// the extension is set up we must NEVER fall through to the raw :9222 path
// below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact
// 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();
+6 -2
View File
@@ -556,8 +556,12 @@ async fn verify_ref_identity(
Err(format!(
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
The DOM mutated between snapshot and interaction (typical with React/Vue \
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
reusing nodes during re-render). Fix: take a fresh `snapshot` and re-target \
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,
))
}
+78 -1
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
use super::humanize;
pub async fn click(
@@ -56,6 +56,33 @@ pub async fn click(
match resolved {
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
// centre (Fast/Human). Zero size or Off → exact centre.
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
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
/// 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;
}
/// 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,
+34 -1
View File
@@ -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 {
let lines: Vec<&str> = tree.lines().collect();
if lines.is_empty() {
@@ -1314,7 +1347,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
let mut keep = vec![false; lines.len()];
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;
// Mark ancestors
let my_indent = count_indent(line);
+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);
+98 -1
View File
@@ -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) {
if opts.json {
if opts.content_boundaries {
@@ -338,12 +352,55 @@ 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;
}
// Eval 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();
print_with_boundaries(&formatted, origin, opts);
return;
@@ -421,7 +478,15 @@ 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.
let url = truncate_middle(url, 120);
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active {
color::cyan("")
@@ -530,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 {
@@ -733,8 +807,22 @@ 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()) {
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 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("");
@@ -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!(
+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.38",
"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",
+66 -10
View File
@@ -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
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`
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
URL: https://example.com/login
@e1 [heading] "Log in"
@e2 [form]
@e3 [input type="email"] placeholder="Email"
@e4 [input type="password"] placeholder="Password"
@e5 [button type="submit"] "Continue"
@e6 [link] "Forgot password?"
- heading "Log in" [level=1, ref=e1]
- textbox "Email" [ref=e2]
- textbox "Password" [ref=e3]
- button "Continue" [ref=e4]
- link "Forgot password?" [ref=e5]
```
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):
```bash
@@ -202,8 +219,16 @@ agent-browser press Enter # press a key at current focus
agent-browser press Control+a # key combination
agent-browser check @e3 # check checkbox
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 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 scroll down 500 # scroll page (up/down/left/right)
agent-browser scrollintoview @e1 # scroll element into view
@@ -372,9 +397,23 @@ Array.from(rows).map(r => ({
EOF
```
Prefer `eval --stdin` (heredoc) or `eval -b <base64>` for any JS with
quotes or special characters. Inline `agent-browser eval "..."` works
only for simple expressions.
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
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
@@ -419,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
@@ -488,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.