Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d3748c06 | ||
|
|
fa47a0b8e5 | ||
|
|
36c593631c | ||
|
|
b92757412d | ||
|
|
6ecda4d706 | ||
|
|
123510db2b | ||
|
|
abb65c632b | ||
|
|
e803bffbbb | ||
|
|
96ee2f9758 | ||
|
|
0a3d2a91a6 | ||
|
|
1e5dfd35cb | ||
|
|
b6b2ca56ca | ||
|
|
6d740093dc | ||
|
|
c884fb4f57 | ||
|
|
57ef011817 | ||
|
|
2c3bcb8f3d | ||
|
|
5a61a64559 | ||
|
|
1c2e594003 | ||
|
|
9bd6587278 | ||
|
|
df53b1a70e | ||
|
|
a6f0193779 | ||
|
|
bab58991fe | ||
|
|
c5d4c8908d | ||
|
|
9ac8bae981 | ||
|
|
3302762a32 | ||
|
|
73cf32edc8 | ||
|
|
85fd019f62 | ||
|
|
9b4d924e48 | ||
|
|
9ad011d93c | ||
|
|
ebd220274b | ||
|
|
7a4559ac96 | ||
|
|
bc9622994e | ||
|
|
5d202c06a6 | ||
|
|
0966c630a7 | ||
|
|
d1f574013d | ||
|
|
c3b8855252 | ||
|
|
a9ff0a3fea | ||
|
|
af50605a3b | ||
|
|
9b1f98b966 | ||
|
|
cf4c27d13d | ||
|
|
372eaf2ef6 | ||
|
|
dcefc729e8 | ||
|
|
f4a8f79a22 | ||
|
|
6cf74817d8 | ||
|
|
14ffd30417 | ||
|
|
17686fdbf8 | ||
|
|
22532d756c | ||
|
|
68e2e351b1 | ||
|
|
d95d32831e | ||
|
|
1a4c440d9e | ||
|
|
d1fbdaadeb |
+31
-32
@@ -49,33 +49,12 @@ jobs:
|
|||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||||
|
|
||||||
dashboard:
|
|
||||||
name: Dashboard
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --filter dashboard
|
|
||||||
working-directory: packages/dashboard
|
|
||||||
|
|
||||||
- name: Build dashboard
|
|
||||||
run: pnpm build
|
|
||||||
working-directory: packages/dashboard
|
|
||||||
|
|
||||||
rust-cross:
|
rust-cross:
|
||||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
# Fail fast on a hung test instead of running to GitHub's 6h default.
|
||||||
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
@@ -108,6 +87,13 @@ jobs:
|
|||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: rust
|
needs: rust
|
||||||
|
# Fail fast on a hung e2e test instead of GitHub's 6h default.
|
||||||
|
timeout-minutes: 30
|
||||||
|
# This fork forbids headless by default (always-headed for stealth), but CI
|
||||||
|
# runners have no display. Opt into the documented display-less escape so
|
||||||
|
# launched Chrome can start; e2e tests exercise functionality, not stealth.
|
||||||
|
env:
|
||||||
|
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -135,6 +121,10 @@ jobs:
|
|||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
needs: rust-cross
|
needs: rust-cross
|
||||||
|
# Headless-forbidden fork on a headless CI runner — opt into the escape so
|
||||||
|
# `agent-browser open` can launch Chrome.
|
||||||
|
env:
|
||||||
|
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -174,7 +164,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$env:PATH = "$pwd\bin;$env:PATH"
|
$env:PATH = "$pwd\bin;$env:PATH"
|
||||||
Write-Host "--- Opening page ---"
|
Write-Host "--- Opening page ---"
|
||||||
bin/agent-browser-win32-x64.exe open https://example.com
|
# --launch: spawn a standalone browser. Without it, `open` defaults to
|
||||||
|
# auto-connect and looks for an existing Chrome on a debug port — which
|
||||||
|
# a fresh CI runner doesn't have, so it errors "Could not connect".
|
||||||
|
bin/agent-browser-win32-x64.exe --launch open https://example.com
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||||
Write-Host "--- Taking snapshot ---"
|
Write-Host "--- Taking snapshot ---"
|
||||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||||
@@ -256,17 +249,23 @@ jobs:
|
|||||||
echo "Symlink correctly points to native binary"
|
echo "Symlink correctly points to native binary"
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Verify shim points to native binary (Windows)
|
- name: Verify CLI works (and prefers the native shim) (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: |
|
run: |
|
||||||
$shimPath = "$(npm prefix -g)\agent-browser.cmd"
|
# The CLI must work. The native-shim rewrite is a best-effort speedup
|
||||||
$content = Get-Content $shimPath -Raw
|
# (npm often creates the .cmd AFTER postinstall runs, so the rewrite
|
||||||
echo "Shim path: $shimPath"
|
# can't happen and the JS wrapper — which spawns the native binary — is
|
||||||
|
# the valid fallback). Require functionality; prefer, but don't require,
|
||||||
|
# the native shim.
|
||||||
|
$ver = agent-browser --version
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "agent-browser --version failed"; exit 1 }
|
||||||
|
echo "CLI version: $ver"
|
||||||
|
$content = Get-Content "$(npm prefix -g)\agent-browser.cmd" -Raw
|
||||||
echo "Shim content:"
|
echo "Shim content:"
|
||||||
echo $content
|
echo $content
|
||||||
if ($content -notmatch "agent-browser-win32-x64\.exe") {
|
if ($content -match "agent-browser-win32-x64\.exe") {
|
||||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
echo "OK: shim points directly to the native binary (zero overhead)"
|
||||||
exit 1
|
} else {
|
||||||
|
echo "INFO: shim uses the JS wrapper fallback (functional; native-shim optimization not applied)"
|
||||||
}
|
}
|
||||||
echo "Shim correctly points to native binary"
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
# agent-browser-stealth
|
# agent-browser-stealth
|
||||||
|
|
||||||
|
**English** · [简体中文](README.zh.md)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Stealth fork of [agent-browser](https://github.com/vercel-labs/agent-browser) — connects to your real Chrome, shares your login sessions, and is undetectable by anti-bot systems.
|
Stealth fork of [agent-browser](https://github.com/vercel-labs/agent-browser) — connects to your real Chrome, shares your login sessions, and is undetectable by anti-bot systems.
|
||||||
|
|
||||||
For basic usage, commands, and API reference, see the [upstream documentation](https://github.com/vercel-labs/agent-browser).
|
For basic usage, commands, and API reference, see the [upstream documentation](https://github.com/vercel-labs/agent-browser).
|
||||||
|
|
||||||
|
## Give your AI agent the browser you already live in
|
||||||
|
|
||||||
|
**No fresh Chrome. No re-login. No "are you a robot?" walls.**
|
||||||
|
|
||||||
|
agent-browser-stealth points **any** agent — Claude Code, Cursor, Codex, your own scripts — at the **Chrome you're already signed into everything on**. It clicks in *your* window, so you watch it work and grab the wheel the moment it hits a 2FA prompt or captcha. And because it's literally your real browser (over a one-click extension, native messaging — no debug port), sites read it as 100% human: **[CreepJS scores it 0% bot](#anti-detection).**
|
||||||
|
|
||||||
|
**Why not just use…**
|
||||||
|
|
||||||
|
- **Playwright / Puppeteer / browser-use?** They boot an *empty* browser — so you redo every login, fight every captcha, and still get flagged as automation. We use the session you already have.
|
||||||
|
- **Claude's Chrome extension?** Great, but it only drives Claude. This drives *any* agent or CLI.
|
||||||
|
- **A raw `--remote-debugging-port`** (web-access, etc.)? Chrome 136+ pops **"Allow remote debugging?"** on *every* connect. This never does — one-click Store extension, native messaging.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Full feature comparison</b> (the receipts)</summary>
|
||||||
|
|
||||||
|
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / raw CDP port | Playwright · Puppeteer · browser-use | **agent-browser-stealth** |
|
||||||
|
|---|:---:|:---:|:---:|:---:|
|
||||||
|
| Works with **any** agent / CLI (not one app) | ❌ Claude only | ✅ | ✅ | ✅ |
|
||||||
|
| Drives your **real, logged-in** Chrome | ✅ | ✅ | ❌ fresh empty profile | ✅ |
|
||||||
|
| **No "Allow remote debugging?" popup** | ✅ | ❌ every connect | — (own browser) | ✅ native messaging |
|
||||||
|
| Real-browser fingerprint (CreepJS ~0%)¹ | ✅ | ✅ | ❌ automation markers / headless | ✅ **verified 0%** |
|
||||||
|
| **No `Runtime.enable` CDP leak** (rebrowser)² | — | ❌ leaks | ❌ leaks | ✅ **off by default** |
|
||||||
|
| Many agents on **one** real Chrome, isolated tab groups³ | ❌ single app | ⚠️ shared tabs, no isolation | ❌ separate browsers | ✅ |
|
||||||
|
| Permissions footprint | 16 incl. `<all_urls>` | full CDP | full control | **7, no `<all_urls>`** |
|
||||||
|
|
||||||
|
<sub>¹ All three real-Chrome tools score ~0% on CreepJS (it's a real browser); we've measured ours. ² rebrowser's `runtimeEnableLeak` — verified clean on our relay path; Claude in Chrome not independently tested (—). ³ web-access can run parallel sub-agents on one browser, but without per-session isolation; each `--session` here gets its own colored, command-isolated tab group. See [Anti-detection](#anti-detection) for the measured numbers.</sub>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Why this fork?
|
## Why this fork?
|
||||||
|
|
||||||
<img src="assets/fingerprint.png" alt="real but undetectable fingerprint" width="300" align="right" />
|
<img src="assets/fingerprint.png" alt="real but undetectable fingerprint" width="300" align="right" />
|
||||||
@@ -22,6 +53,47 @@ For basic usage, commands, and API reference, see the [upstream documentation](h
|
|||||||
| User collaboration | Separate window | Same window, take over anytime |
|
| User collaboration | Separate window | Same window, take over anytime |
|
||||||
| CAPTCHA | Agent stuck | You solve it, agent continues |
|
| CAPTCHA | Agent stuck | You solve it, agent continues |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Your **agent-browser CLI** talks to a tiny **browser extension** over Chrome
|
||||||
|
**native messaging** — a local inter-process channel, *no network socket, no
|
||||||
|
token, no remote server*. The extension uses `chrome.debugger` to drive the tabs
|
||||||
|
you target in **your own, already-logged-in Chrome**, then hands results back to
|
||||||
|
the CLI. Everything stays on your machine.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Each `--session` gets its **own colored Chrome tab group**, so multiple agents
|
||||||
|
can share one real browser concurrently without stepping on each other — or your
|
||||||
|
own tabs.
|
||||||
|
|
||||||
|
## Why the extension (not a raw debug port)
|
||||||
|
|
||||||
|
Other local tools drive Chrome over a raw `--remote-debugging-port` (CDP). Since
|
||||||
|
**Chrome 136**, every such connection pops a blocking **"Allow remote debugging?"**
|
||||||
|
consent dialog — and the port has to be enabled up front. Our extension uses
|
||||||
|
native messaging instead: **install once, then zero per-use confirmation.**
|
||||||
|
|
||||||
|
| | **agent-browser-stealth** (this extension) | web-access (raw CDP port) | Claude in Chrome (chrome.debugger) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Connect method | native messaging — no port, no token | `--remote-debugging-port` | `chrome.debugger` |
|
||||||
|
| **"Allow remote debugging?" popup** | **never** ✅ | **every connection** 🔴 | no |
|
||||||
|
| Uses your real login | yes | yes | yes |
|
||||||
|
| `Runtime.enable` (CDP) leak¹ | **off by default → clean** ✅ | domain enabled | n/a |
|
||||||
|
| CreepJS stealth score² | **0% stealth · 0% headless** ✅ | real Chrome | real Chrome |
|
||||||
|
| Per-session tab groups / concurrent agents | **yes** ✅ | no | no |
|
||||||
|
| Built for the agent-browser CLI | yes | a separate proxy | a single-app assistant |
|
||||||
|
|
||||||
|
> ¹ Verified against [rebrowser-bot-detector](https://bot-detector.rebrowser.net/):
|
||||||
|
> our relay reports `runtimeEnableLeak: 🟢 No leak` and `navigatorWebdriver: 🟢`.
|
||||||
|
> ² Verified against [CreepJS](https://abrahamjuliot.github.io/creepjs/) on the
|
||||||
|
> connected real-Chrome path — see [Anti-detection](#anti-detection).
|
||||||
|
>
|
||||||
|
> The consent dialog isn't hypothetical: a raw-port tool pops it on **every**
|
||||||
|
> attach (Chrome 136+ security). The extension path never does.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,12 +131,26 @@ fresh one.
|
|||||||
|
|
||||||
## Setup: connect to your Chrome
|
## Setup: connect to your Chrome
|
||||||
|
|
||||||
Attaching uses the Chrome DevTools Protocol, which Chrome only exposes when it is
|
**Recommended — the browser extension (one click, no popups).** Install the
|
||||||
**launched with a remote-debugging port**. This is a startup flag, not a setting
|
[**agent-browser-stealth** extension from the Chrome Web Store](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),
|
||||||
— the `chrome://inspect` toggle alone is **not** enough (it only enables target
|
then register the local bridge once:
|
||||||
discovery, not the CDP attach).
|
|
||||||
|
|
||||||
**Recommended — fully quit Chrome, then relaunch with the port:**
|
```bash
|
||||||
|
agent-browser extension install # register the native-messaging host (one-time)
|
||||||
|
agent-browser open https://x.com/home
|
||||||
|
```
|
||||||
|
|
||||||
|
`agent-browser open` then drives your real, logged-in Chrome over **native
|
||||||
|
messaging** — no debug port, no token, and **no "Allow remote debugging?" dialog,
|
||||||
|
ever**. The extension auto-updates and survives Chrome restarts, so it stays
|
||||||
|
connected with zero per-use confirmation (ideal for unattended/agent use).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Alternative — raw remote-debugging port (pops a consent dialog)</summary>
|
||||||
|
|
||||||
|
Without the extension, agent-browser attaches over the Chrome DevTools Protocol,
|
||||||
|
which Chrome only exposes when **launched with a remote-debugging port** (a
|
||||||
|
startup flag — the `chrome://inspect` toggle alone is not enough):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
# macOS
|
||||||
@@ -74,9 +160,10 @@ google-chrome --remote-debugging-port=9222
|
|||||||
# Windows: add --remote-debugging-port=9222 to your Chrome shortcut's target
|
# Windows: add --remote-debugging-port=9222 to your Chrome shortcut's target
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run `agent-browser open <url>` — it auto-discovers the port and attaches.
|
Then `agent-browser open <url>` auto-discovers the port. On first attach,
|
||||||
On first attach, **Chrome 136+ shows an "Allow remote debugging?" dialog — click
|
**Chrome 136+ shows an "Allow remote debugging?" dialog** — click Allow once (it
|
||||||
Allow once** (it persists for that Chrome session).
|
persists for that Chrome session). The extension above avoids this entirely.
|
||||||
|
</details>
|
||||||
|
|
||||||
**No setup / don't want to touch your real Chrome?** Use
|
**No setup / don't want to touch your real Chrome?** Use
|
||||||
`agent-browser --launch open <url>` to spawn a fresh isolated stealth browser
|
`agent-browser --launch open <url>` to spawn a fresh isolated stealth browser
|
||||||
@@ -140,6 +227,25 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
|
|||||||
|
|
||||||
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
||||||
|
|
||||||
|
### Human-like input (behavioural stealth)
|
||||||
|
|
||||||
|
Fingerprint stealth isn't the whole story — the strongest anti-bot vendors (Akamai, PerimeterX, DataDome) also score *behaviour*. A click that teleports the cursor to an element's exact centre with no approach path and zero press delay is a tell, **even though our CDP events are `isTrusted`**.
|
||||||
|
|
||||||
|
With humanize on, the cursor moves like a hand: clicks follow a curved, decelerating Bézier path and land on a jittered point *inside* the element (never the dead centre); typing uses variable inter-keystroke timing; scrolling eases in segments; drags follow a curve. It's **adaptive** — every navigation is probed for known anti-bot vendors (cookies / scripts / globals) and a guarded page auto-escalates to full human motion, while ordinary sites stay instant (zero overhead).
|
||||||
|
|
||||||
|
What the page's own `mousemove` stream sees (this *is* what a behavioural detector analyses):
|
||||||
|
|
||||||
|
| | trajectory |
|
||||||
|
|---|---|
|
||||||
|
| **off** (default) | straight lines · dead-centre · instant |
|
||||||
|
| **human** | curved trails · slow-in/slow-out · off-centre landings |
|
||||||
|
|
||||||
|
Control with `--humanize off\|fast\|human` or `AGENT_BROWSER_HUMANIZE`. Default `off`; the adaptive detector escalates per page.
|
||||||
|
|
||||||
|
### Silent operation
|
||||||
|
|
||||||
|
Driving your real Chrome should never interrupt your work. The agent operates **entirely in the background**: new tabs open un-focused (in their own colored per-session tab group), the agent **never force-fronts a tab**, and `Emulation.setFocusEmulationEnabled` keeps each agent tab rendering and reporting `document.hasFocus()` / `visibilityState: 'visible'`. So screenshots still work, pages aren't render-throttled, and "the tab was hidden the whole session" never becomes its own bot tell. You keep working in your active tab; the agent works alongside you, silently. (Surfacing a tab stays available as an explicit command.)
|
||||||
|
|
||||||
### Verify it yourself
|
### Verify it yourself
|
||||||
|
|
||||||
Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
|
Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
|
||||||
@@ -157,6 +263,7 @@ We deliberately **don't ship our own bot detector** — the strongest, most hone
|
|||||||
| Variable | Default | Effect |
|
| Variable | Default | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AGENT_BROWSER_CAPTURE_CONSOLE` | off | Enable `Runtime` domain so `console` / `errors` capture page output. Off keeps the stealthiest profile. |
|
| `AGENT_BROWSER_CAPTURE_CONSOLE` | off | Enable `Runtime` domain so `console` / `errors` capture page output. Off keeps the stealthiest profile. |
|
||||||
|
| `AGENT_BROWSER_HUMANIZE` | off | Human-like input motion: `off` (instant), `fast` (light eased trajectory), `human` (full curved trajectory + landing jitter + typing cadence + eased scroll/drag). Also `--humanize`. Default `off`; the adaptive detector auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. |
|
||||||
| `AGENT_BROWSER_TIMEZONE` | unset | `--launch` only. An IANA id (e.g. `Asia/Tokyo`) sets the timezone natively (Intl + Date follow, no JS lie) to match a proxy; `auto` derives one from the locale. |
|
| `AGENT_BROWSER_TIMEZONE` | unset | `--launch` only. An IANA id (e.g. `Asia/Tokyo`) sets the timezone natively (Intl + Date follow, no JS lie) to match a proxy; `auto` derives one from the locale. |
|
||||||
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | `--launch` only. Auto-forces WebRTC through the proxy when one is set (no real-IP leak). `1` hides the local IP without a proxy; `0` opts out. |
|
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | `--launch` only. Auto-forces WebRTC through the proxy when one is set (no real-IP leak). `1` hides the local IP without a proxy; `0` opts out. |
|
||||||
| `AGENT_BROWSER_HIDE_CANVAS` | off | `--launch` only. Adds session-stable canvas/audio fingerprint noise. Off by default (noise is itself a "lie"). |
|
| `AGENT_BROWSER_HIDE_CANVAS` | off | `--launch` only. Adds session-stable canvas/audio fingerprint noise. Off by default (noise is itself a "lie"). |
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
# agent-browser-stealth
|
||||||
|
|
||||||
|
[English](README.md) · **简体中文**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[agent-browser](https://github.com/vercel-labs/agent-browser) 的隐身分支 —— 直接连接**你自己**正在用的、已登录的 Chrome,复用你的登录态,对反爬/反自动化系统**完全不可检测**。
|
||||||
|
|
||||||
|
基础用法、命令与 API 参考见[上游文档](https://github.com/vercel-labs/agent-browser)。
|
||||||
|
|
||||||
|
## 把你**已经登录好**的浏览器,交给你的 AI agent
|
||||||
|
|
||||||
|
**不用开新 Chrome。不用重新登录。不用跟"你是不是机器人"较劲。**
|
||||||
|
|
||||||
|
agent-browser-stealth 让**任意** agent(Claude Code、Cursor、Codex、你自己的脚本)直接操作你**已经登录了所有网站**的那个 Chrome。它在**你的窗口里**点击,你看着它干活,撞到 2FA / 验证码的瞬间你接管一下,它接着跑。因为它**就是你的真实浏览器**(一键装的扩展、原生消息、无调试端口),网站眼里它 100% 是人:**[CreepJS 实测 0% 机器人](#反检测)。**
|
||||||
|
|
||||||
|
**为什么不用……**
|
||||||
|
|
||||||
|
- **Playwright / Puppeteer / browser-use?** 它们开的是**空**浏览器 —— 每个登录你重做、每个验证码你硬扛、最后还被标成自动化。我们直接用你**现成的**会话。
|
||||||
|
- **Claude 的 Chrome 插件?** 很好,但**只能给 Claude 用**。我们给**任意** agent / CLI 用。
|
||||||
|
- **裸 `--remote-debugging-port`**(web-access 等)? Chrome 136+ **每次连都弹** "Allow remote debugging?"。我们**永不弹** —— 商店一键装,原生消息。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>完整对比矩阵</b>(要细节的看这里)</summary>
|
||||||
|
|
||||||
|
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / 裸 CDP 端口 | Playwright · Puppeteer · browser-use | **agent-browser-stealth** |
|
||||||
|
|---|:---:|:---:|:---:|:---:|
|
||||||
|
| **任意** agent / CLI 都能用(不绑单一 app) | ❌ 仅 Claude | ✅ | ✅ | ✅ |
|
||||||
|
| 驱动你**真实、已登录**的 Chrome | ✅ | ✅ | ❌ 全新空 profile | ✅ |
|
||||||
|
| **不弹 "Allow remote debugging?"** | ✅ | ❌ 每次连都弹 | —(自带浏览器) | ✅ 原生消息 |
|
||||||
|
| 真实浏览器指纹(CreepJS ~0%)¹ | ✅ | ✅ | ❌ 自动化特征 / headless | ✅ **已实测 0%** |
|
||||||
|
| **无 `Runtime.enable` CDP 泄漏**(rebrowser)² | — | ❌ 泄漏 | ❌ 泄漏 | ✅ **默认关闭** |
|
||||||
|
| 多 agent 共用**同一个**真实 Chrome、标签组隔离³ | ❌ 单 app | ⚠️ 共享 tab、无隔离 | ❌ 各开各的浏览器 | ✅ |
|
||||||
|
| 权限面 | 16 个,含 `<all_urls>` | 完整 CDP | 完全控制 | **7 个,无 `<all_urls>`** |
|
||||||
|
|
||||||
|
<sub>¹ 三家"真实 Chrome"工具在 CreepJS 上都 ~0%(毕竟是真浏览器),我们的是实测过的。² rebrowser `runtimeEnableLeak` —— 我们的中继路径实测无泄漏;Claude in Chrome 未独立测试(—)。³ web-access 也能跑并行子 agent,但无每会话隔离;本工具每个 `--session` 拿到自己彩色、命令隔离的标签组。实测数字见 [反检测](#反检测)。</sub>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 为什么要 fork
|
||||||
|
|
||||||
|
<img src="assets/fingerprint.png" alt="真实但不可检测的指纹" width="300" align="right" />
|
||||||
|
|
||||||
|
**agent-browser**(上游)启动的是空 profile 的全新浏览器:你得重新登录,网站也能看出是自动化。
|
||||||
|
|
||||||
|
**agent-browser-stealth** 连接你**现有**的 Chrome —— cookies、会话、浏览器指纹全是真的,因为它**就是**你的真实浏览器。
|
||||||
|
|
||||||
|
| | agent-browser | agent-browser-stealth |
|
||||||
|
|---|---|---|
|
||||||
|
| 浏览器 | 启动新 Chrome | 连接你的 Chrome |
|
||||||
|
| 登录态 | 空,要重新登 | 你现有的会话 |
|
||||||
|
| 指纹 | 带自动化标记 | 你的真实指纹 |
|
||||||
|
| 协作 | 独立窗口 | 同一窗口,随时接管 |
|
||||||
|
| 验证码 | Agent 卡住 | 你点一下,Agent 继续 |
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
你的 **agent-browser CLI** 通过 Chrome **原生消息(native messaging)** 和一个小**浏览器扩展**通信 —— 这是本机进程间通道,**无网络端口、无 token、无远程服务器**。扩展用 `chrome.debugger` 驱动你指定的标签页(在你**已登录**的 Chrome 里),再把结果交还给 CLI。全程都在你本机。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
每个 `--session` 拿到**自己的彩色标签组**,多个 agent 共用同一个真实浏览器、互不干扰,也不动你自己的标签页。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
从最新的 [GitHub Release](https://github.com/leeguooooo/agent-browser-stealth/releases) 下载对应平台的预编译二进制,安装 `agent-browser`(以及 `abs` 别名)。无需 npm,无需 token。
|
||||||
|
|
||||||
|
### 安装 AI agent skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add leeguooooo/agent-browser-stealth
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `skills/agent-browser` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
|
||||||
|
|
||||||
|
## 连接你的 Chrome
|
||||||
|
|
||||||
|
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**agent-browser-stealth** 扩展](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser extension install # 注册原生消息 host(一次性)
|
||||||
|
agent-browser open https://x.com/home
|
||||||
|
```
|
||||||
|
|
||||||
|
之后 `agent-browser open` 就通过**原生消息**驱动你真实、已登录的 Chrome —— 无调试端口、无 token、**永远不弹 "Allow remote debugging?"**。扩展自动更新、重启不掉,零确认(适合无人值守 / agent 场景)。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>备选 —— 裸 remote-debugging 端口(会弹同意框)</summary>
|
||||||
|
|
||||||
|
不装扩展时,agent-browser 退回用 CDP 连接,而 Chrome 只在带 remote-debugging 端口启动时才暴露它:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
open -a "Google Chrome" --args --remote-debugging-port=9222
|
||||||
|
# Linux
|
||||||
|
google-chrome --remote-debugging-port=9222
|
||||||
|
# Windows: 给 Chrome 快捷方式 target 加 --remote-debugging-port=9222
|
||||||
|
```
|
||||||
|
|
||||||
|
然后 `agent-browser open <url>` 自动发现端口。首次连接 **Chrome 136+ 会弹 "Allow remote debugging?"** —— 点一次 Allow(该 Chrome 会话内持续有效)。上面的扩展则完全避开这个框。
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接你的 Chrome 并导航
|
||||||
|
agent-browser open https://example.com
|
||||||
|
|
||||||
|
# 一切都在你已登录的浏览器里进行
|
||||||
|
agent-browser click "Post"
|
||||||
|
agent-browser fill "Title" "Hello World"
|
||||||
|
agent-browser screenshot ./page.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 在你的 Chrome 里操作 —— 你能实时看到开标签、加载、点击。任意时刻都能接管(比如手动过验证码),然后让 agent 继续。
|
||||||
|
|
||||||
|
### 独立模式(`--launch`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 临时:全新空 profile —— 无 cookie 无登录(适合 CI / 测试)
|
||||||
|
agent-browser --launch open https://example.com
|
||||||
|
|
||||||
|
# 保留登录:用你真实的 Chrome profile 启动
|
||||||
|
agent-browser --launch --profile auto open https://x.com/home
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反检测
|
||||||
|
|
||||||
|
连接你真实 Chrome 时,我们**零** JS 注入 —— 浏览器指纹完全是真的。指导原则是 **native CDP/Chrome 覆盖优先于 JS 谎言**:被重定义的 getter 本身可被检测,原生覆盖则不会。
|
||||||
|
|
||||||
|
- `navigator.webdriver = false` 走 `Emulation.setAutomationOverride`(原生,CreepJS 类说谎检测查不出)。
|
||||||
|
- **`Runtime.enable` 默认关闭** —— 活着的 `Runtime` 域是可被检测的 CDP 信号(patchright/rebrowser 的 "runtime leak"),即便连的是你真实 Chrome。只在你主动开启 console/错误捕获时才启用。
|
||||||
|
|
||||||
|
**实测结果(连接真实 Chrome,中继路径):**
|
||||||
|
|
||||||
|
| 检测站 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | **0% stealth · 0% headless**(零 override 痕迹) |
|
||||||
|
| [bot.incolumitas.com](https://bot.incolumitas.com/) | 全部 OK(overflowTest / overrideTest / puppeteerExtraStealth / worker 一致性) |
|
||||||
|
| [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) | `runtimeEnableLeak` 🟢 · `pwInitScripts` 🟢 |
|
||||||
|
| [bot.sannysoft.com](https://bot.sannysoft.com) | 全绿 |
|
||||||
|
|
||||||
|
`--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):
|
||||||
|
|
||||||
|
- **默认 auto-connect** —— `agent-browser open` 连你的 Chrome 而非启新的
|
||||||
|
- **CDP 原生隐身** —— `Emulation.setAutomationOverride` 而非 JS 补丁
|
||||||
|
- **双隐身模式** —— 真实 Chrome 零补丁,`--launch` 全补丁
|
||||||
|
- **`--launch` / `--new`** —— 显式启动独立浏览器
|
||||||
|
- **CI 自动检测** —— 设了 `CI` 环境变量时走独立模式
|
||||||
|
|
||||||
|
所有上游功能(命令、快照、截图、录制、标签、会话等)保持一致。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0(与上游一致)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.29"
|
version = "0.27.0-fork.44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.29"
|
version = "0.27.0-fork.44"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+159
-28
@@ -101,7 +101,62 @@ pub fn parse_curl_cookies(raw: &str) -> Result<Vec<Value>, String> {
|
|||||||
.get("value")
|
.get("value")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| format!("cookies[{}] missing string value", i))?;
|
.ok_or_else(|| format!("cookies[{}] missing string value", i))?;
|
||||||
out.push(json!({ "name": name, "value": value }));
|
let mut cookie = json!({ "name": name, "value": value });
|
||||||
|
let obj = cookie.as_object_mut().unwrap();
|
||||||
|
// Preserve any CDP Network.setCookie attributes present on the
|
||||||
|
// source object so a full auth state round-trips: httpOnly session
|
||||||
|
// tokens, per-domain cookies (a single export spans .chatgpt.com,
|
||||||
|
// .openai.com, ...), and secure/sameSite/expiry. A bare
|
||||||
|
// {name,value} export is unchanged. Common aliases from DevTools /
|
||||||
|
// EditThisCookie / extension exports are accepted.
|
||||||
|
if let Some(v) = c.get("url").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("url".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("domain").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("domain".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("path").and_then(|v| v.as_str()) {
|
||||||
|
obj.insert("path".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c.get("secure").and_then(|v| v.as_bool()) {
|
||||||
|
obj.insert("secure".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("httpOnly")
|
||||||
|
.or_else(|| c.get("httponly"))
|
||||||
|
.or_else(|| c.get("http_only"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
{
|
||||||
|
obj.insert("httpOnly".into(), json!(v));
|
||||||
|
}
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("sameSite")
|
||||||
|
.or_else(|| c.get("samesite"))
|
||||||
|
.or_else(|| c.get("same_site"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
let norm = match v.to_lowercase().as_str() {
|
||||||
|
"strict" => "Strict",
|
||||||
|
"lax" => "Lax",
|
||||||
|
"none" | "no_restriction" => "None",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
if !norm.is_empty() {
|
||||||
|
obj.insert("sameSite".into(), json!(norm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CDP `expires` is seconds since the Unix epoch (f64). Accept
|
||||||
|
// `expires` or EditThisCookie's `expirationDate`.
|
||||||
|
if let Some(v) = c
|
||||||
|
.get("expires")
|
||||||
|
.or_else(|| c.get("expirationDate"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
{
|
||||||
|
if v > 0.0 {
|
||||||
|
obj.insert("expires".into(), json!(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cookie);
|
||||||
}
|
}
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
}
|
}
|
||||||
@@ -348,9 +403,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
||||||
}
|
}
|
||||||
"type" => {
|
"type" => {
|
||||||
|
// `type --focused <text>` types into whatever element currently has
|
||||||
|
// focus (no selector) — for custom widgets that move focus to a hidden
|
||||||
|
// input after you open them.
|
||||||
|
if rest.first() == Some(&"--focused") {
|
||||||
|
return Ok(json!({
|
||||||
|
"id": id, "action": "type", "focused": true,
|
||||||
|
"text": rest[1..].join(" "),
|
||||||
|
}));
|
||||||
|
}
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "type".to_string(),
|
context: "type".to_string(),
|
||||||
usage: "type <selector> <text>",
|
usage: "type <selector> <text> (or: type --focused <text>)",
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
|
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
|
||||||
}
|
}
|
||||||
@@ -540,7 +604,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
|
|
||||||
// === Wait ===
|
// === Wait ===
|
||||||
"wait" => {
|
"wait" => {
|
||||||
// Check for --url flag: wait --url "**/dashboard"
|
// Check for --url flag: wait --url "**/dashboard" [--timeout ms]
|
||||||
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
||||||
let url = rest
|
let url = rest
|
||||||
.get(idx + 1)
|
.get(idx + 1)
|
||||||
@@ -548,7 +612,23 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
context: "wait --url".to_string(),
|
context: "wait --url".to_string(),
|
||||||
usage: "wait --url <pattern>",
|
usage: "wait --url <pattern>",
|
||||||
})?;
|
})?;
|
||||||
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
|
if url.is_empty() {
|
||||||
|
return Err(ParseError::InvalidValue {
|
||||||
|
message: "wait --url needs a non-empty pattern (an empty pattern would \
|
||||||
|
match any URL)."
|
||||||
|
.to_string(),
|
||||||
|
usage: "wait --url <pattern>",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut cmd = json!({ "id": id, "action": "waitforurl", "url": url });
|
||||||
|
// Parse --timeout (without it the default applies — and a
|
||||||
|
// non-matching pattern would otherwise wait the full default).
|
||||||
|
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
|
||||||
|
if let Some(ms) = rest.get(t_idx + 1).and_then(|s| s.parse::<u64>().ok()) {
|
||||||
|
cmd["timeout"] = json!(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for --load flag: wait --load networkidle
|
// Check for --load flag: wait --load networkidle
|
||||||
@@ -620,7 +700,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// racing into a half-rendered UI.
|
// racing into a half-rendered UI.
|
||||||
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
||||||
Some("detached")
|
Some("detached")
|
||||||
} else if rest.iter().any(|&s| s == "--hidden") {
|
} else if rest.contains(&"--hidden") {
|
||||||
Some("hidden")
|
Some("hidden")
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -770,17 +850,31 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
|
|
||||||
// === Eval ===
|
// === Eval ===
|
||||||
"eval" => {
|
"eval" => {
|
||||||
// Check for flags: -b/--base64 or --stdin
|
// Check for flags: -b/--base64, --stdin, or --file <path>
|
||||||
let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) =
|
let (is_base64, is_stdin, is_file, script_parts): (bool, bool, bool, &[&str]) =
|
||||||
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
|
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
|
||||||
(true, false, &rest[1..])
|
(true, false, false, &rest[1..])
|
||||||
} else if rest.first() == Some(&"--stdin") {
|
} else if rest.first() == Some(&"--stdin") {
|
||||||
(false, true, &rest[1..])
|
(false, true, false, &rest[1..])
|
||||||
|
} else if rest.first() == Some(&"--file") {
|
||||||
|
(false, false, true, &rest[1..])
|
||||||
} else {
|
} else {
|
||||||
(false, false, rest.as_slice())
|
(false, false, false, rest.as_slice())
|
||||||
};
|
};
|
||||||
|
|
||||||
let script = if is_stdin {
|
let script = if is_file {
|
||||||
|
// Read the script from a file. Avoids shell-mangling of inline JS
|
||||||
|
// (non-ASCII identifiers/strings, quotes, large scripts) — the file
|
||||||
|
// is read as UTF-8 and sent verbatim.
|
||||||
|
let path = script_parts.first().ok_or(ParseError::InvalidValue {
|
||||||
|
message: "eval --file requires a path".to_string(),
|
||||||
|
usage: "eval --file <path>",
|
||||||
|
})?;
|
||||||
|
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
|
||||||
|
message: format!("eval --file: cannot read {path}: {e}"),
|
||||||
|
usage: "eval --file <path>",
|
||||||
|
})?
|
||||||
|
} else if is_stdin {
|
||||||
// Read script from stdin
|
// Read script from stdin
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let lines: Vec<String> = stdin
|
let lines: Vec<String> = stdin
|
||||||
@@ -1069,8 +1163,8 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||||
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
||||||
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||||
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value"
|
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value" | "count" | "box"
|
||||||
| "count" | "box" | "styles" | "attr" => {
|
| "styles" | "attr" => {
|
||||||
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
||||||
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||||
get_args.push(sub);
|
get_args.push(sub);
|
||||||
@@ -2843,6 +2937,27 @@ mod tests {
|
|||||||
assert_eq!(cmd["action"], "cookies_clear");
|
assert_eq!(cmd["action"], "cookies_clear");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_curl_cookies_json_preserves_attributes() {
|
||||||
|
// A full cookie export (httpOnly session token, per-domain, secure,
|
||||||
|
// sameSite, expiry) must round-trip — not get flattened to name/value.
|
||||||
|
let input = r#"[
|
||||||
|
{"name":"__Secure-next-auth.session-token","value":"eyJ.tok","domain":".chatgpt.com","path":"/","secure":true,"httpOnly":true,"sameSite":"Lax","expires":1893456000},
|
||||||
|
{"name":"cf_clearance","value":"abc","domain":".openai.com","path":"/","secure":true,"http_only":true,"same_site":"no_restriction"}
|
||||||
|
]"#;
|
||||||
|
let out = parse_curl_cookies(input).unwrap();
|
||||||
|
assert_eq!(out.len(), 2);
|
||||||
|
assert_eq!(out[0]["domain"], ".chatgpt.com");
|
||||||
|
assert_eq!(out[0]["secure"], true);
|
||||||
|
assert_eq!(out[0]["httpOnly"], true);
|
||||||
|
assert_eq!(out[0]["sameSite"], "Lax");
|
||||||
|
assert_eq!(out[0]["expires"], 1893456000.0);
|
||||||
|
// alias keys (http_only, same_site=no_restriction) normalize to CDP shape
|
||||||
|
assert_eq!(out[1]["domain"], ".openai.com");
|
||||||
|
assert_eq!(out[1]["httpOnly"], true);
|
||||||
|
assert_eq!(out[1]["sameSite"], "None");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_curl_cookies_json_array() {
|
fn test_parse_curl_cookies_json_array() {
|
||||||
let input = r#"[{"name":"a","value":"1"},{"name":"b","value":"2"}]"#;
|
let input = r#"[{"name":"a","value":"1"},{"name":"b","value":"2"}]"#;
|
||||||
@@ -2850,6 +2965,9 @@ mod tests {
|
|||||||
assert_eq!(out.len(), 2);
|
assert_eq!(out.len(), 2);
|
||||||
assert_eq!(out[0]["name"], "a");
|
assert_eq!(out[0]["name"], "a");
|
||||||
assert_eq!(out[0]["value"], "1");
|
assert_eq!(out[0]["value"], "1");
|
||||||
|
// bare {name,value} stays minimal — no spurious attribute keys
|
||||||
|
assert!(out[0].get("domain").is_none());
|
||||||
|
assert!(out[0].get("secure").is_none());
|
||||||
assert_eq!(out[1]["name"], "b");
|
assert_eq!(out[1]["name"], "b");
|
||||||
assert_eq!(out[1]["value"], "2");
|
assert_eq!(out[1]["value"], "2");
|
||||||
}
|
}
|
||||||
@@ -3803,6 +3921,29 @@ mod tests {
|
|||||||
assert_eq!(cmd["url"], "**/dashboard");
|
assert_eq!(cmd["url"], "**/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_url_empty_pattern_rejected() {
|
||||||
|
// An empty pattern would match any URL — reject it rather than silently
|
||||||
|
// always-match. (Build argv directly: split_whitespace can't yield "".)
|
||||||
|
let argv = vec!["wait".to_string(), "--url".to_string(), String::new()];
|
||||||
|
let err = parse_command(&argv, &default_flags());
|
||||||
|
assert!(err.is_err(), "empty --url pattern should be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_url_with_timeout() {
|
||||||
|
// --timeout must be parsed for the --url path; without it a non-matching
|
||||||
|
// pattern waits the full default (and could wedge the daemon).
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("wait --url **/dashboard --timeout 3000"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitforurl");
|
||||||
|
assert_eq!(cmd["url"], "**/dashboard");
|
||||||
|
assert_eq!(cmd["timeout"], 3000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wait_load() {
|
fn test_wait_load() {
|
||||||
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
||||||
@@ -5177,11 +5318,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_find_role_missing_action_verb_with_name_flag() {
|
fn test_find_role_missing_action_verb_with_name_flag() {
|
||||||
let err = parse_command(
|
let err =
|
||||||
&args("find role button --name Submit"),
|
parse_command(&args("find role button --name Submit"), &default_flags()).unwrap_err();
|
||||||
&default_flags(),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
let msg = err.format();
|
let msg = err.format();
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("Missing action verb"),
|
msg.contains("Missing action verb"),
|
||||||
@@ -5199,11 +5337,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_find_testid_missing_action_verb_with_exact_flag() {
|
fn test_find_testid_missing_action_verb_with_exact_flag() {
|
||||||
let err = parse_command(
|
let err = parse_command(&args("find testid foo --exact"), &default_flags()).unwrap_err();
|
||||||
&args("find testid foo --exact"),
|
|
||||||
&default_flags(),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.format().contains("Missing action verb"));
|
assert!(err.format().contains("Missing action verb"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5252,11 +5386,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wait_gone_with_timeout() {
|
fn test_wait_gone_with_timeout() {
|
||||||
let cmd = parse_command(
|
let cmd =
|
||||||
&args("wait .modal --gone --timeout 2000"),
|
parse_command(&args("wait .modal --gone --timeout 2000"), &default_flags()).unwrap();
|
||||||
&default_flags(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cmd["selector"], ".modal");
|
assert_eq!(cmd["selector"], ".modal");
|
||||||
assert_eq!(cmd["state"], "detached");
|
assert_eq!(cmd["state"], "detached");
|
||||||
assert_eq!(cmd["timeout"], 2000);
|
assert_eq!(cmd["timeout"], 2000);
|
||||||
|
|||||||
+59
-11
@@ -24,6 +24,11 @@ pub const HOST_NAME: &str = "com.agent_browser.connect";
|
|||||||
/// that extension talk to this host, and the force-install policy references it.
|
/// that extension talk to this host, and the force-install policy references it.
|
||||||
pub const EXTENSION_ID: &str = "ciiljdlhdpfckdcfkphgmfalanpdejep";
|
pub const EXTENSION_ID: &str = "ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||||
|
|
||||||
|
/// The Chrome Web Store assigns its own id (the manifest "key" is stripped from
|
||||||
|
/// store uploads), so the published build has a different origin than the local
|
||||||
|
/// Load-unpacked one. Allow both to talk to the native-messaging host.
|
||||||
|
pub const STORE_EXTENSION_ID: &str = "knfcmbamhjmaonkfnjhldjedeobeafmk";
|
||||||
|
|
||||||
/// Update URL the force-install policy points at. MUST be the Chrome Web Store
|
/// Update URL the force-install policy points at. MUST be the Chrome Web Store
|
||||||
/// endpoint: Chrome 149 tags any **off-Web-Store** force-installed extension
|
/// endpoint: Chrome 149 tags any **off-Web-Store** force-installed extension
|
||||||
/// `[BLOCKED]` on an unmanaged browser (verified on macOS — chrome://policy shows
|
/// `[BLOCKED]` on an unmanaged browser (verified on macOS — chrome://policy shows
|
||||||
@@ -34,7 +39,8 @@ pub const UPDATE_URL: &str = "https://clients2.google.com/service/update2/crx";
|
|||||||
|
|
||||||
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
|
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
|
||||||
/// and the fallback when the force-install profile can't be approved headlessly.
|
/// and the fallback when the force-install profile can't be approved headlessly.
|
||||||
pub const STORE_URL: &str = "https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
|
pub const STORE_URL: &str =
|
||||||
|
"https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||||
|
|
||||||
/// Stable identifiers for the generated Chrome configuration profile, so a
|
/// Stable identifiers for the generated Chrome configuration profile, so a
|
||||||
/// re-install replaces (rather than duplicates) it in System Settings.
|
/// re-install replaces (rather than duplicates) it in System Settings.
|
||||||
@@ -52,7 +58,11 @@ pub fn run_connect(args: &[String], json: bool) {
|
|||||||
let removed = remove_host_manifests();
|
let removed = remove_host_manifests();
|
||||||
let profile_removed = remove_force_install_profile();
|
let profile_removed = remove_force_install_profile();
|
||||||
if json {
|
if json {
|
||||||
report(json, true, &format!("removed {removed} native-host manifest(s)"));
|
report(
|
||||||
|
json,
|
||||||
|
true,
|
||||||
|
&format!("removed {removed} native-host manifest(s)"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("✓ removed {removed} native-host manifest(s).");
|
println!("✓ removed {removed} native-host manifest(s).");
|
||||||
if profile_removed {
|
if profile_removed {
|
||||||
@@ -94,7 +104,10 @@ pub fn run_connect(args: &[String], json: bool) {
|
|||||||
}
|
}
|
||||||
match profile {
|
match profile {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
println!("\n✓ Chrome force-install profile written:\n {}", path.display());
|
println!(
|
||||||
|
"\n✓ Chrome force-install profile written:\n {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
println!(
|
println!(
|
||||||
"\nGet the extension into Chrome (one-time). Either:\n\
|
"\nGet the extension into Chrome (one-time). Either:\n\
|
||||||
@@ -174,7 +187,10 @@ fn install_native_host() -> Result<Vec<String>, String> {
|
|||||||
"description": "agent-browser connect — native messaging host",
|
"description": "agent-browser connect — native messaging host",
|
||||||
"path": launcher.display().to_string(),
|
"path": launcher.display().to_string(),
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"allowed_origins": [format!("chrome-extension://{EXTENSION_ID}/")],
|
"allowed_origins": [
|
||||||
|
format!("chrome-extension://{EXTENSION_ID}/"),
|
||||||
|
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -219,10 +235,12 @@ fn install_force_install_profile(no_open: bool) -> Result<PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `.mobileconfig` payload: a user-scope Chrome policy that force-installs
|
/// The `.mobileconfig` payload: a user-scope Chrome policy that force-installs
|
||||||
/// the extension by id from our hosted update manifest. User scope installs
|
/// the extension from the Chrome Web Store. User scope installs without admin —
|
||||||
/// without admin — just a one-time approval click.
|
/// just a one-time approval click. Must use the STORE id (the Web Store update
|
||||||
|
/// server serves the published extension under the id it assigned, not the local
|
||||||
|
/// Load-unpacked id).
|
||||||
fn force_install_mobileconfig() -> String {
|
fn force_install_mobileconfig() -> String {
|
||||||
let forcelist = format!("{EXTENSION_ID};{UPDATE_URL}");
|
let forcelist = format!("{STORE_EXTENSION_ID};{UPDATE_URL}");
|
||||||
format!(
|
format!(
|
||||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -300,7 +318,12 @@ fn native_messaging_dirs() -> Vec<PathBuf> {
|
|||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
{
|
{
|
||||||
if let Some(config) = dirs::config_dir() {
|
if let Some(config) = dirs::config_dir() {
|
||||||
for sub in ["google-chrome", "chromium", "microsoft-edge", "BraveSoftware/Brave-Browser"] {
|
for sub in [
|
||||||
|
"google-chrome",
|
||||||
|
"chromium",
|
||||||
|
"microsoft-edge",
|
||||||
|
"BraveSoftware/Brave-Browser",
|
||||||
|
] {
|
||||||
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
|
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,6 +344,19 @@ fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the ab-connect native-messaging host manifest is present — i.e. the
|
||||||
|
/// user has set up the extension path. When installed, auto-connect treats the
|
||||||
|
/// dialog-free extension relay as the *intended* transport and refuses to fall
|
||||||
|
/// back to a raw debug port (which would pop Chrome 136+'s "Allow remote
|
||||||
|
/// debugging?" consent modal). The relay-url file comes and goes with the
|
||||||
|
/// service worker; this manifest is the durable signal that the extension is
|
||||||
|
/// the chosen path.
|
||||||
|
pub fn host_installed() -> bool {
|
||||||
|
native_messaging_dirs()
|
||||||
|
.into_iter()
|
||||||
|
.any(|d| d.join(format!("{HOST_NAME}.json")).exists())
|
||||||
|
}
|
||||||
|
|
||||||
fn report(json: bool, ok: bool, msg: &str) {
|
fn report(json: bool, ok: bool, msg: &str) {
|
||||||
if json {
|
if json {
|
||||||
println!(
|
println!(
|
||||||
@@ -347,7 +383,11 @@ fn nm_log(line: &str) {
|
|||||||
if let Some(p) = path.parent() {
|
if let Some(p) = path.parent() {
|
||||||
let _ = std::fs::create_dir_all(p);
|
let _ = std::fs::create_dir_all(p);
|
||||||
}
|
}
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
let _ = writeln!(f, "{line}");
|
let _ = writeln!(f, "{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,7 +427,10 @@ pub fn relay_url() -> Option<String> {
|
|||||||
/// file) so only this user's agent-browser — not arbitrary local processes —
|
/// file) so only this user's agent-browser — not arbitrary local processes —
|
||||||
/// can drive the browser. No token, no user interaction.
|
/// can drive the browser. No token, no user interaction.
|
||||||
pub fn run_nm_host() {
|
pub fn run_nm_host() {
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
nm_log(&format!("[nm-host] runtime build failed: {e}"));
|
nm_log(&format!("[nm-host] runtime build failed: {e}"));
|
||||||
@@ -531,6 +574,9 @@ async fn nm_host_main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
// The handshake-callback Result type is dictated by tokio-tungstenite's
|
||||||
|
// accept_hdr_async contract; its Err variant (an http Response) can't be shrunk.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
async fn handle_cdp_client(
|
async fn handle_cdp_client(
|
||||||
stream: tokio::net::TcpStream,
|
stream: tokio::net::TcpStream,
|
||||||
guid: String,
|
guid: String,
|
||||||
@@ -539,7 +585,9 @@ async fn handle_cdp_client(
|
|||||||
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
|
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||||
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
|
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||||
clients: std::sync::Arc<
|
clients: std::sync::Arc<
|
||||||
tokio::sync::Mutex<std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>>,
|
tokio::sync::Mutex<
|
||||||
|
std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>,
|
||||||
|
>,
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
use crate::native::relay::ClientRoute;
|
use crate::native::relay::ClientRoute;
|
||||||
|
|||||||
+27
-1
@@ -821,7 +821,33 @@ fn connect(session: &str) -> Result<Connection, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
|
pub fn send_command(mut cmd: Value, session: &str) -> Result<Response, String> {
|
||||||
|
// Forward per-invocation env to the daemon. The daemon's environment is
|
||||||
|
// frozen at spawn, so settings like AGENT_BROWSER_CLICK_MODE /
|
||||||
|
// AGENT_BROWSER_HUMANIZE (incl. the --humanize flag, which sets the latter)
|
||||||
|
// are otherwise silently ignored on an already-running daemon. Carry them in
|
||||||
|
// the envelope so they apply to THIS command.
|
||||||
|
if let Some(obj) = cmd.as_object_mut() {
|
||||||
|
if let Ok(m) = std::env::var("AGENT_BROWSER_CLICK_MODE") {
|
||||||
|
obj.insert("_clickMode".to_string(), Value::String(m));
|
||||||
|
}
|
||||||
|
if let Ok(h) = std::env::var("AGENT_BROWSER_HUMANIZE") {
|
||||||
|
// Only forward a recognized level; warn once (like the --humanize flag
|
||||||
|
// does) when the env var is set to garbage, instead of silently
|
||||||
|
// ignoring it.
|
||||||
|
if crate::native::humanize::HumanizeLevel::parse(&h).is_some() {
|
||||||
|
obj.insert("_humanize".to_string(), Value::String(h));
|
||||||
|
} else {
|
||||||
|
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||||
|
WARNED.call_once(|| {
|
||||||
|
eprintln!(
|
||||||
|
"warning: AGENT_BROWSER_HUMANIZE must be off|fast|human, got {h:?} (ignored)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
||||||
const MAX_RETRIES: u32 = 5;
|
const MAX_RETRIES: u32 = 5;
|
||||||
const RETRY_DELAY_MS: u64 = 200;
|
const RETRY_DELAY_MS: u64 = 200;
|
||||||
|
|||||||
+2
-5
@@ -90,7 +90,7 @@ pub fn run_find_url(args: &[String], json: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Most-recently-added first (date_added is microseconds since 1601).
|
// Most-recently-added first (date_added is microseconds since 1601).
|
||||||
hits.sort_by(|a, b| b.date_added.cmp(&a.date_added));
|
hits.sort_by_key(|b| std::cmp::Reverse(b.date_added));
|
||||||
hits.truncate(limit);
|
hits.truncate(limit);
|
||||||
|
|
||||||
if json {
|
if json {
|
||||||
@@ -142,10 +142,7 @@ fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
|
|||||||
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
||||||
// URIs aren't pages you can visit, and their bodies can be huge.
|
// URIs aren't pages you can visit, and their bodies can be huge.
|
||||||
if url.is_empty()
|
if url.is_empty() || url.starts_with("javascript:") || url.starts_with("data:") {
|
||||||
|| url.starts_with("javascript:")
|
|
||||||
|| url.starts_with("data:")
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
||||||
|
|||||||
+18
-2
@@ -248,6 +248,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
|||||||
"--screenshot-format",
|
"--screenshot-format",
|
||||||
"--idle-timeout",
|
"--idle-timeout",
|
||||||
"--model",
|
"--model",
|
||||||
|
"--humanize",
|
||||||
];
|
];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@@ -460,8 +461,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||||
|| config.auto_connect.unwrap_or(true)),
|
|| config.auto_connect.unwrap_or(true)),
|
||||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH")
|
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH") || env::var("CI").is_ok(),
|
||||||
|| env::var("CI").is_ok(),
|
|
||||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||||
.ok()
|
.ok()
|
||||||
.or(config.session_name),
|
.or(config.session_name),
|
||||||
@@ -797,6 +797,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--humanize" => {
|
||||||
|
// Human-like input motion level (off|fast|human). Surface it as
|
||||||
|
// AGENT_BROWSER_HUMANIZE so the daemon — spawned as a child that
|
||||||
|
// inherits this process's env — picks it up and it overrides the
|
||||||
|
// adaptive detector. Applies when the session's daemon launches.
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
match crate::native::humanize::HumanizeLevel::parse(s) {
|
||||||
|
Some(_) => std::env::set_var("AGENT_BROWSER_HUMANIZE", s),
|
||||||
|
None => eprintln!(
|
||||||
|
"warning: --humanize must be off|fast|human, got {s:?} (ignored)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
"--screenshot-dir" => {
|
"--screenshot-dir" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
flags.screenshot_dir = Some(s.clone());
|
flags.screenshot_dir = Some(s.clone());
|
||||||
@@ -923,6 +938,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--screenshot-format",
|
"--screenshot-format",
|
||||||
"--idle-timeout",
|
"--idle-timeout",
|
||||||
"--model",
|
"--model",
|
||||||
|
"--humanize",
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
mod chat;
|
mod chat;
|
||||||
mod color;
|
mod color;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod connection;
|
|
||||||
mod connect;
|
mod connect;
|
||||||
|
mod connection;
|
||||||
mod doctor;
|
mod doctor;
|
||||||
mod findurl;
|
mod findurl;
|
||||||
mod flags;
|
mod flags;
|
||||||
|
|||||||
+310
-62
@@ -23,6 +23,7 @@ use super::cdp::types::{
|
|||||||
use super::cookies;
|
use super::cookies;
|
||||||
use super::diff;
|
use super::diff;
|
||||||
use super::element::RefMap;
|
use super::element::RefMap;
|
||||||
|
use super::humanize;
|
||||||
use super::inspect_server::InspectServer;
|
use super::inspect_server::InspectServer;
|
||||||
use super::interaction;
|
use super::interaction;
|
||||||
use super::network::{self, DomainFilter, EventTracker};
|
use super::network::{self, DomainFilter, EventTracker};
|
||||||
@@ -1159,6 +1160,23 @@ impl Drop for DaemonState {
|
|||||||
|
|
||||||
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||||
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Apply per-invocation overrides the client forwarded (the daemon's own env
|
||||||
|
// is frozen at spawn). CLICK_MODE is read fresh from the process env by
|
||||||
|
// interaction::click, so mirror it here — set when this command provided it,
|
||||||
|
// clear otherwise, so a value from an earlier command never leaks forward.
|
||||||
|
match cmd.get("_clickMode").and_then(|v| v.as_str()) {
|
||||||
|
Some(m) if !m.is_empty() => std::env::set_var("AGENT_BROWSER_CLICK_MODE", m),
|
||||||
|
_ => std::env::remove_var("AGENT_BROWSER_CLICK_MODE"),
|
||||||
|
}
|
||||||
|
// Humanize: set the session level from the client's --humanize / env. Only
|
||||||
|
// set when provided (don't clear — the adaptive per-navigation detector also
|
||||||
|
// owns this level between explicit overrides).
|
||||||
|
if let Some(h) = cmd.get("_humanize").and_then(|v| v.as_str()) {
|
||||||
|
if let Some(level) = super::humanize::HumanizeLevel::parse(h) {
|
||||||
|
super::humanize::set_detected_level(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
let id = cmd
|
let id = cmd
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1510,12 +1528,11 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
/// subsequent navigations don't hijack the user's existing tabs.
|
/// subsequent navigations don't hijack the user's existing tabs.
|
||||||
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||||
let mut mgr = BrowserManager::connect_auto().await?;
|
let mut mgr = BrowserManager::connect_auto().await?;
|
||||||
|
// tab_new creates the tab in the background (CreateTargetParams.background),
|
||||||
|
// so attaching to the user's Chrome never steals their foreground tab. We
|
||||||
|
// deliberately do NOT bring it to front — silent operation.
|
||||||
mgr.tab_new(None, None).await?;
|
mgr.tab_new(None, None).await?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let _ = mgr
|
|
||||||
.client
|
|
||||||
.send_command("Page.bringToFront", None, Some(&session_id))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Liveness probe: confirm the CDP session can actually round-trip
|
// Liveness probe: confirm the CDP session can actually round-trip
|
||||||
// before returning success. Without this, a zombie CDP socket (process
|
// before returning success. Without this, a zombie CDP socket (process
|
||||||
@@ -1525,10 +1542,14 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
|||||||
// about:blank. Failing here lets the caller surface the real error.
|
// about:blank. Failing here lets the caller surface the real error.
|
||||||
if let Err(e) = mgr
|
if let Err(e) = mgr
|
||||||
.client
|
.client
|
||||||
.send_command("Runtime.evaluate", Some(serde_json::json!({
|
.send_command(
|
||||||
"expression": "1",
|
"Runtime.evaluate",
|
||||||
"returnByValue": true,
|
Some(serde_json::json!({
|
||||||
})), Some(&session_id))
|
"expression": "1",
|
||||||
|
"returnByValue": true,
|
||||||
|
})),
|
||||||
|
Some(&session_id),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -1818,7 +1839,11 @@ async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
|
|||||||
|
|
||||||
/// Apply stealth to the active page session (initial connect/launch).
|
/// Apply stealth to the active page session (initial connect/launch).
|
||||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||||
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
|
let session_id = match state
|
||||||
|
.browser
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.active_session_id().ok())
|
||||||
|
{
|
||||||
Some(sid) => sid.to_string(),
|
Some(sid) => sid.to_string(),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
@@ -2504,7 +2529,49 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
mgr.navigate(url, wait_until).await
|
let result = mgr.navigate(url, wait_until).await?;
|
||||||
|
// Adaptive humanize: sample the freshly loaded page for known behavioural
|
||||||
|
// anti-bot vendors and escalate this session to Human if any are present.
|
||||||
|
detect_and_set_humanize(mgr).await;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After navigation, probe the page for known anti-bot vendor fingerprints
|
||||||
|
/// (cookies / script URLs / `window` globals) and set this session's humanize
|
||||||
|
/// level accordingly — `Human` when a vendor is detected, else the `Off`
|
||||||
|
/// baseline. Best-effort: any failure leaves the level unchanged. Skipped when
|
||||||
|
/// `AGENT_BROWSER_HUMANIZE` is set, since the override always wins and the probe
|
||||||
|
/// would be wasted work.
|
||||||
|
async fn detect_and_set_humanize(mgr: &BrowserManager) {
|
||||||
|
if std::env::var("AGENT_BROWSER_HUMANIZE").is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let js = r#"(() => { try {
|
||||||
|
const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean);
|
||||||
|
const scripts = Array.from(document.scripts, s => s.src || '').filter(Boolean);
|
||||||
|
const re = /_px|bmak|_abck|datadome|reese84|kpsdk|incap_ses|visid_incap|akam/i;
|
||||||
|
const globals = Object.getOwnPropertyNames(window).filter(k => re.test(k));
|
||||||
|
return { cookies, scripts, globals };
|
||||||
|
} catch (e) { return {}; } })()"#;
|
||||||
|
let Ok(val) = mgr.evaluate(js, None).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let to_strings = |v: Option<&Value>| -> Vec<String> {
|
||||||
|
v.and_then(|v| v.as_array())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let signals = humanize::DetectSignals {
|
||||||
|
cookie_names: to_strings(val.get("cookies")),
|
||||||
|
script_urls: to_strings(val.get("scripts")),
|
||||||
|
window_globals: to_strings(val.get("globals")),
|
||||||
|
};
|
||||||
|
let level = humanize::detect_level(&signals, humanize::HumanizeLevel::Off);
|
||||||
|
humanize::set_detected_level(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_url(state: &DaemonState) -> Result<Value, String> {
|
async fn handle_url(state: &DaemonState) -> Result<Value, String> {
|
||||||
@@ -2964,6 +3031,22 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
|
||||||
|
// `type --focused <text>`: type into the currently-focused element without a
|
||||||
|
// selector (custom widgets that move focus to a hidden input on open).
|
||||||
|
if cmd
|
||||||
|
.get("focused")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let text = cmd
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing 'text' parameter")?;
|
||||||
|
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None).await?;
|
||||||
|
return Ok(json!({ "typed": text, "focused": true }));
|
||||||
|
}
|
||||||
|
|
||||||
let selector = cmd
|
let selector = cmd
|
||||||
.get("selector")
|
.get("selector")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3486,16 +3569,53 @@ async fn wait_for_selector(
|
|||||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a URL glob (Playwright-style: `*` matches within a path segment,
|
||||||
|
/// `**` matches across segments, `?` matches one char) to an anchored regex.
|
||||||
|
fn url_glob_to_regex(glob: &str) -> String {
|
||||||
|
let mut re = String::from("^");
|
||||||
|
let mut chars = glob.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'*' => {
|
||||||
|
if chars.peek() == Some(&'*') {
|
||||||
|
chars.next();
|
||||||
|
re.push_str(".*"); // ** — any chars incl. '/'
|
||||||
|
} else {
|
||||||
|
re.push_str("[^/]*"); // * — any chars except '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'?' => re.push('.'),
|
||||||
|
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
|
||||||
|
re.push('\\');
|
||||||
|
re.push(c);
|
||||||
|
}
|
||||||
|
_ => re.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
re.push('$');
|
||||||
|
re
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_url(
|
async fn wait_for_url(
|
||||||
client: &super::cdp::client::CdpClient,
|
client: &super::cdp::client::CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
pattern: &str,
|
pattern: &str,
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let check_fn = format!(
|
// A pattern with glob metacharacters is matched as a glob (the core skill
|
||||||
"location.href.includes({})",
|
// documents `wait --url "**/dashboard"`); otherwise it's a plain substring
|
||||||
serde_json::to_string(pattern).unwrap_or_default()
|
// so exact / partial URLs keep working.
|
||||||
);
|
let check_fn = if pattern.contains('*') || pattern.contains('?') {
|
||||||
|
format!(
|
||||||
|
"(()=>{{try{{return new RegExp({}).test(location.href)}}catch(e){{return false}}}})()",
|
||||||
|
serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"location.href.includes({})",
|
||||||
|
serde_json::to_string(pattern).unwrap_or_default()
|
||||||
|
)
|
||||||
|
};
|
||||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3531,8 +3651,19 @@ async fn poll_until_true(
|
|||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let result: super::cdp::types::EvaluateResult = client
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
.send_command_typed(
|
if remaining.is_zero() {
|
||||||
|
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound each probe. A `Runtime.evaluate` issued while the page is
|
||||||
|
// navigating can hang (the execution context is being torn down); without
|
||||||
|
// a cap the `.await` would block past the deadline forever and wedge the
|
||||||
|
// daemon's request loop. Cap at the remaining budget (max 2s per probe).
|
||||||
|
let probe_cap = remaining.min(tokio::time::Duration::from_secs(2));
|
||||||
|
let probe = tokio::time::timeout(
|
||||||
|
probe_cap,
|
||||||
|
client.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||||
"Runtime.evaluate",
|
"Runtime.evaluate",
|
||||||
&super::cdp::types::EvaluateParams {
|
&super::cdp::types::EvaluateParams {
|
||||||
expression: expression.to_string(),
|
expression: expression.to_string(),
|
||||||
@@ -3540,24 +3671,31 @@ async fn poll_until_true(
|
|||||||
await_promise: Some(true),
|
await_promise: Some(true),
|
||||||
},
|
},
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
),
|
||||||
.await?;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if result
|
// A probe that timed out or errored (e.g. the execution context was
|
||||||
.result
|
// replaced mid-navigation) is transient — keep polling until the
|
||||||
.value
|
// deadline rather than failing or hanging.
|
||||||
.as_ref()
|
if let Ok(Ok(result)) = probe {
|
||||||
.and_then(|v| v.as_bool())
|
if result
|
||||||
.unwrap_or(false)
|
.result
|
||||||
{
|
.value
|
||||||
return Ok(());
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokio::time::Instant::now() >= deadline {
|
let nap = tokio::time::Duration::from_millis(100)
|
||||||
|
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||||
|
if nap.is_zero() {
|
||||||
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||||
}
|
}
|
||||||
|
tokio::time::sleep(nap).await;
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5407,19 +5545,29 @@ async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result<Value, String>
|
|||||||
let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||||
|
|
||||||
mgr.client
|
// Humanize: at Off this is one instant wheel event (unchanged); at
|
||||||
.send_command(
|
// Fast/Human the scroll is split into eased, slightly-jittered segments so
|
||||||
"Input.dispatchMouseEvent",
|
// it ramps and settles like a real wheel/trackpad flick.
|
||||||
Some(json!({
|
let level = humanize::active_level();
|
||||||
"type": "mouseWheel",
|
let seed = humanize::next_seed();
|
||||||
"x": x,
|
for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
|
||||||
"y": y,
|
mgr.client
|
||||||
"deltaX": delta_x,
|
.send_command(
|
||||||
"deltaY": delta_y,
|
"Input.dispatchMouseEvent",
|
||||||
})),
|
Some(json!({
|
||||||
Some(&session_id),
|
"type": "mouseWheel",
|
||||||
)
|
"x": x,
|
||||||
.await?;
|
"y": y,
|
||||||
|
"deltaX": dx,
|
||||||
|
"deltaY": dy,
|
||||||
|
})),
|
||||||
|
Some(&session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !delay.is_zero() {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
|
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
|
||||||
}
|
}
|
||||||
@@ -5996,6 +6144,54 @@ async fn execute_subaction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CSS selector that matches an ARIA `role` — both an explicit `role="X"`
|
||||||
|
/// attribute AND the HTML elements that carry that role *implicitly*. The naive
|
||||||
|
/// `[role="X"], X` form fails for every role whose implicit element has a
|
||||||
|
/// different tag than the role name (e.g. role `link` ⇒ `<a href>`, not `<link>`;
|
||||||
|
/// role `heading` ⇒ `<h1>`..`<h6>`), which made `find role link/heading` never
|
||||||
|
/// match real elements.
|
||||||
|
fn role_to_query(role: &str) -> String {
|
||||||
|
let implicit = match role {
|
||||||
|
"link" => "a[href], area[href]",
|
||||||
|
"button" => "button, input[type=button], input[type=submit], input[type=reset], summary",
|
||||||
|
"heading" => "h1, h2, h3, h4, h5, h6",
|
||||||
|
"textbox" => {
|
||||||
|
"input[type=text], input[type=search], input[type=email], input[type=url], \
|
||||||
|
input[type=tel], input[type=password], input:not([type]), textarea"
|
||||||
|
}
|
||||||
|
"searchbox" => "input[type=search]",
|
||||||
|
"checkbox" => "input[type=checkbox]",
|
||||||
|
"radio" => "input[type=radio]",
|
||||||
|
"combobox" => "select",
|
||||||
|
"listbox" => "select[multiple]",
|
||||||
|
"slider" => "input[type=range]",
|
||||||
|
"spinbutton" => "input[type=number]",
|
||||||
|
"img" => "img",
|
||||||
|
"list" => "ul, ol",
|
||||||
|
"listitem" => "li",
|
||||||
|
"table" => "table",
|
||||||
|
"row" => "tr",
|
||||||
|
"cell" | "gridcell" => "td",
|
||||||
|
"columnheader" | "rowheader" => "th",
|
||||||
|
"article" => "article",
|
||||||
|
"navigation" => "nav",
|
||||||
|
"main" => "main",
|
||||||
|
"banner" => "header",
|
||||||
|
"contentinfo" => "footer",
|
||||||
|
"complementary" => "aside",
|
||||||
|
"figure" => "figure",
|
||||||
|
"separator" => "hr",
|
||||||
|
"progressbar" => "progress",
|
||||||
|
"group" => "fieldset",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
if implicit.is_empty() {
|
||||||
|
format!("[role=\"{role}\"], {role}")
|
||||||
|
} else {
|
||||||
|
format!("[role=\"{role}\"], {implicit}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
|
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
|
||||||
match name {
|
match name {
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
@@ -6016,27 +6212,25 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
let name = cmd.get("name").and_then(|v| v.as_str());
|
let name = cmd.get("name").and_then(|v| v.as_str());
|
||||||
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
// Accessible-name approximation: aria-label, then title/alt/value, then the
|
||||||
|
// element's text. Covers links (text), input buttons (value), images (alt).
|
||||||
let name_match = name
|
let name_match = name
|
||||||
.map(|n| {
|
.map(|n| {
|
||||||
|
let nj = serde_json::to_string(n).unwrap_or_default();
|
||||||
if exact {
|
if exact {
|
||||||
format!(
|
format!("__an === {nj}")
|
||||||
"el.getAttribute('aria-label') === {} || el.textContent.trim() === {}",
|
|
||||||
serde_json::to_string(n).unwrap_or_default(),
|
|
||||||
serde_json::to_string(n).unwrap_or_default()
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("__an.includes({nj})")
|
||||||
"(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})",
|
|
||||||
n = serde_json::to_string(n).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "true".to_string());
|
.unwrap_or_else(|| "true".to_string());
|
||||||
|
|
||||||
let js = format!(
|
let js = format!(
|
||||||
r#"(() => {{
|
r#"(() => {{
|
||||||
const els = document.querySelectorAll('[role="{role}"], {role}');
|
const els = document.querySelectorAll({selector});
|
||||||
for (const el of els) {{
|
for (const el of els) {{
|
||||||
|
const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
|
||||||
|
|| el.getAttribute('alt') || el.value || el.textContent || '').trim();
|
||||||
if ({name_match}) {{
|
if ({name_match}) {{
|
||||||
el.setAttribute('data-agent-browser-located', 'true');
|
el.setAttribute('data-agent-browser-located', 'true');
|
||||||
return true;
|
return true;
|
||||||
@@ -6044,7 +6238,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
}}
|
}}
|
||||||
return false;
|
return false;
|
||||||
}})()"#,
|
}})()"#,
|
||||||
role = role,
|
selector = serde_json::to_string(&role_to_query(role)).unwrap_or_default(),
|
||||||
name_match = name_match,
|
name_match = name_match,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6370,7 +6564,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'target' parameter")?;
|
.ok_or("Missing 'target' parameter")?;
|
||||||
|
|
||||||
let (sx, sy, source_session_id) = super::element::resolve_element_center(
|
let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center(
|
||||||
&mgr.client,
|
&mgr.client,
|
||||||
&session_id,
|
&session_id,
|
||||||
&state.ref_map,
|
&state.ref_map,
|
||||||
@@ -6378,7 +6572,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
&state.iframe_sessions,
|
&state.iframe_sessions,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (tx, ty, target_session_id) = super::element::resolve_element_center(
|
let (tx, ty, _, _, target_session_id) = super::element::resolve_element_center(
|
||||||
&mgr.client,
|
&mgr.client,
|
||||||
&session_id,
|
&session_id,
|
||||||
&state.ref_map,
|
&state.ref_map,
|
||||||
@@ -6403,12 +6597,26 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Move in steps to target, keeping the left button held (buttons: 1) so
|
// Move to the target with the left button held (buttons: 1) so the browser
|
||||||
// that the browser sees a drag rather than a plain pointer move.
|
// sees a drag. At Off this is the original linear 10-step path; at
|
||||||
let steps = 10;
|
// Fast/Human it follows humanize's curved, decelerating trajectory.
|
||||||
for i in 1..=steps {
|
let level = humanize::active_level();
|
||||||
let cx = sx + (tx - sx) * (i as f64) / (steps as f64);
|
let drag_path: Vec<(f64, f64, std::time::Duration)> =
|
||||||
let cy = sy + (ty - sy) * (i as f64) / (steps as f64);
|
if matches!(level, humanize::HumanizeLevel::Off) {
|
||||||
|
(1..=10)
|
||||||
|
.map(|i| {
|
||||||
|
let cx = sx + (tx - sx) * (i as f64) / 10.0;
|
||||||
|
let cy = sy + (ty - sy) * (i as f64) / 10.0;
|
||||||
|
(cx, cy, std::time::Duration::from_millis(10))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
humanize::move_path((sx, sy), (tx, ty), level, humanize::next_seed())
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.x, s.y, s.delay))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for (cx, cy, delay) in drag_path {
|
||||||
mgr.client
|
mgr.client
|
||||||
.send_command(
|
.send_command(
|
||||||
"Input.dispatchMouseEvent",
|
"Input.dispatchMouseEvent",
|
||||||
@@ -6416,7 +6624,9 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
Some(&target_session_id),
|
Some(&target_session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
if !delay.is_zero() {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up at target
|
// Mouse up at target
|
||||||
@@ -8422,6 +8632,44 @@ mod tests {
|
|||||||
use crate::test_utils::EnvGuard;
|
use crate::test_utils::EnvGuard;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_glob_to_regex() {
|
||||||
|
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
|
||||||
|
assert_eq!(url_glob_to_regex("**iana**"), "^.*iana.*$");
|
||||||
|
assert_eq!(
|
||||||
|
url_glob_to_regex("https://x.com/**"),
|
||||||
|
"^https://x\\.com/.*$"
|
||||||
|
);
|
||||||
|
// single * stays within a path segment
|
||||||
|
assert_eq!(url_glob_to_regex("/a/*/c"), "^/a/[^/]*/c$");
|
||||||
|
assert_eq!(url_glob_to_regex("/p?ge"), "^/p.ge$");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_glob_regex_matches() {
|
||||||
|
let re = regex_lite::Regex::new(&url_glob_to_regex("**/help/**")).unwrap();
|
||||||
|
assert!(re.is_match("https://www.iana.org/help/example-domains"));
|
||||||
|
assert!(!re.is_match("https://www.iana.org/about"));
|
||||||
|
let re2 = regex_lite::Regex::new(&url_glob_to_regex("https://www.iana.org/**")).unwrap();
|
||||||
|
assert!(re2.is_match("https://www.iana.org/help/example-domains"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_role_to_query_implicit_elements() {
|
||||||
|
// links are <a href>, not <link>; headings are h1..h6
|
||||||
|
assert_eq!(
|
||||||
|
role_to_query("link"),
|
||||||
|
"[role=\"link\"], a[href], area[href]"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
role_to_query("heading"),
|
||||||
|
"[role=\"heading\"], h1, h2, h3, h4, h5, h6"
|
||||||
|
);
|
||||||
|
assert!(role_to_query("button").contains("button"));
|
||||||
|
// unknown/custom roles fall back to the attribute + literal tag
|
||||||
|
assert_eq!(role_to_query("tablist"), "[role=\"tablist\"], tablist");
|
||||||
|
}
|
||||||
|
|
||||||
fn unique_socket_dir(label: &str) -> PathBuf {
|
fn unique_socket_dir(label: &str) -> PathBuf {
|
||||||
let nanos = std::time::SystemTime::now()
|
let nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identical_fingerprints_score_one() {
|
fn identical_fingerprints_score_one() {
|
||||||
let a = fp("button", "Submit", &[("id", "go"), ("class", "btn primary")]);
|
let a = fp(
|
||||||
|
"button",
|
||||||
|
"Submit",
|
||||||
|
&[("id", "go"), ("class", "btn primary")],
|
||||||
|
);
|
||||||
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
|
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +312,12 @@ mod tests {
|
|||||||
let mut b = fp("button", "OK", &[]);
|
let mut b = fp("button", "OK", &[]);
|
||||||
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
|
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
|
||||||
// b wrapped in an extra div — DOM path changed but mostly preserved
|
// b wrapped in an extra div — DOM path changed but mostly preserved
|
||||||
b.ancestors = vec!["form#f".into(), "div.wrap".into(), "div.col".into(), "body".into()];
|
b.ancestors = vec![
|
||||||
|
"form#f".into(),
|
||||||
|
"div.wrap".into(),
|
||||||
|
"div.col".into(),
|
||||||
|
"body".into(),
|
||||||
|
];
|
||||||
let s = score(&a, &b);
|
let s = score(&a, &b);
|
||||||
assert!(s > 0.85, "got {s}");
|
assert!(s > 0.85, "got {s}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,12 @@ pub struct BrowserManager {
|
|||||||
pub ignore_https_errors: bool,
|
pub ignore_https_errors: bool,
|
||||||
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
||||||
visited_origins: HashSet<String>,
|
visited_origins: HashSet<String>,
|
||||||
|
/// Target IDs of tabs THIS session created via `Target.createTarget`. When
|
||||||
|
/// connected to the user's real Chrome (not a launched browser), these are
|
||||||
|
/// closed on `close()` so the session's tabs don't pile up in the user's
|
||||||
|
/// browser after it ends. Only ever holds tabs we created — never the user's
|
||||||
|
/// existing tabs or other sessions' tabs — so closing them is always safe.
|
||||||
|
created_targets: HashSet<String>,
|
||||||
next_tab_id: u32,
|
next_tab_id: u32,
|
||||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||||
@@ -433,6 +439,7 @@ impl BrowserManager {
|
|||||||
download_path: download_path.clone(),
|
download_path: download_path.clone(),
|
||||||
ignore_https_errors,
|
ignore_https_errors,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -523,6 +530,7 @@ impl BrowserManager {
|
|||||||
download_path: None,
|
download_path: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -581,10 +589,13 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach_result: AttachToTargetResult = self
|
let attach_result: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -687,6 +698,20 @@ impl BrowserManager {
|
|||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// Silent operation: agent tabs are driven in the background (we never
|
||||||
|
// force them to the foreground), so emulate focus. Without this a
|
||||||
|
// backgrounded tab is render-throttled and reports `document.hidden` /
|
||||||
|
// `!document.hasFocus()` — which both breaks timing-sensitive pages and
|
||||||
|
// is itself a bot signal (a real user looks at the page). Best-effort;
|
||||||
|
// ignored on engines without Emulation support.
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.send_command(
|
||||||
|
"Emulation.setFocusEmulationEnabled",
|
||||||
|
Some(json!({ "enabled": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +902,24 @@ impl BrowserManager {
|
|||||||
.client
|
.client
|
||||||
.send_command_no_params("Browser.close", None)
|
.send_command_no_params("Browser.close", None)
|
||||||
.await;
|
.await;
|
||||||
|
} else {
|
||||||
|
// Connected to the user's real Chrome: we must NOT close their
|
||||||
|
// browser, but we DO own the tabs this session created. Close them so
|
||||||
|
// they don't pile up in the user's window (in their per-session tab
|
||||||
|
// group) every time a session ends, idles out, or the daemon shuts
|
||||||
|
// down. `created_targets` only holds tabs we made via
|
||||||
|
// Target.createTarget — never the user's existing tabs or other
|
||||||
|
// sessions' — so this is always safe. Best-effort per tab.
|
||||||
|
for target_id in self.created_targets.drain() {
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"Target.closeTarget",
|
||||||
|
&CloseTargetParams { target_id },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut process) = self.browser_process.take() {
|
if let Some(mut process) = self.browser_process.take() {
|
||||||
@@ -973,10 +1016,13 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach_result: AttachToTargetResult = self
|
let attach_result: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -1094,7 +1140,10 @@ impl BrowserManager {
|
|||||||
if !via_relay {
|
if !via_relay {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let name = DAEMON_SESSION.get().map(String::as_str).unwrap_or("default");
|
let name = DAEMON_SESSION
|
||||||
|
.get()
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("default");
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -1134,10 +1183,13 @@ impl BrowserManager {
|
|||||||
&CreateTargetParams {
|
&CreateTargetParams {
|
||||||
url: target_url.to_string(),
|
url: target_url.to_string(),
|
||||||
agent_group,
|
agent_group,
|
||||||
|
background: Some(true),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// We created this tab — own it so close() can clean it up.
|
||||||
|
self.created_targets.insert(result.target_id.clone());
|
||||||
|
|
||||||
let attach: AttachToTargetResult = self
|
let attach: AttachToTargetResult = self
|
||||||
.client
|
.client
|
||||||
@@ -1189,11 +1241,10 @@ impl BrowserManager {
|
|||||||
let session_id = self.pages[index].session_id.clone();
|
let session_id = self.pages[index].session_id.clone();
|
||||||
self.enable_domains(&session_id).await?;
|
self.enable_domains(&session_id).await?;
|
||||||
|
|
||||||
// Bring tab to front
|
// Silent: switching the agent's *internal* active page must not yank the
|
||||||
let _ = self
|
// user's foreground tab. The page is driven in the background (focus is
|
||||||
.client
|
// emulated in enable_domains); the explicit `bringToFront` command is the
|
||||||
.send_command("Page.bringToFront", None, Some(&session_id))
|
// only way a tab is deliberately surfaced.
|
||||||
.await;
|
|
||||||
|
|
||||||
let url = self.get_url().await.unwrap_or_default();
|
let url = self.get_url().await.unwrap_or_default();
|
||||||
let title = self.get_title().await.unwrap_or_default();
|
let title = self.get_title().await.unwrap_or_default();
|
||||||
@@ -1731,6 +1782,7 @@ async fn initialize_lightpanda_manager(
|
|||||||
download_path: None,
|
download_path: None,
|
||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
|
created_targets: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
capture_console: console_capture_enabled(),
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
@@ -1853,8 +1905,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn liveness_transport_error_is_dead_for_both_kinds() {
|
fn liveness_transport_error_is_dead_for_both_kinds() {
|
||||||
// A closed/reset WebSocket is a genuine death — reconnect in both cases.
|
// A closed/reset WebSocket is a genuine death — reconnect in both cases.
|
||||||
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, true));
|
assert!(!connection_alive_from_probe(
|
||||||
assert!(!connection_alive_from_probe(LivenessProbe::TransportError, false));
|
LivenessProbe::TransportError,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!connection_alive_from_probe(
|
||||||
|
LivenessProbe::TransportError,
|
||||||
|
false
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -783,14 +783,46 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
// / :9222 probes below: if the user's Chrome happens to also be listening on a
|
// / :9222 probes below: if the user's Chrome happens to also be listening on a
|
||||||
// debug port, attaching there would pop the consent dialog and defeat the
|
// debug port, attaching there would pop the consent dialog and defeat the
|
||||||
// whole zero-interaction extension path.
|
// whole zero-interaction extension path.
|
||||||
if let Some(relay) = crate::connect::relay_url() {
|
// If the extension is installed, it is the *intended* transport. The relay
|
||||||
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
|
// URL file comes and goes with the MV3 service worker (a Chrome restart or an
|
||||||
// A bare TCP liveness check (no WS upgrade) confirms it is actually
|
// idle SW briefly drops it), so a single failed probe doesn't mean "no
|
||||||
// accepting before we commit, mirroring the consent-free probe used for
|
// extension" — retry for a few seconds while it reconnects. Crucially, when
|
||||||
// DevToolsActivePort.
|
// the extension is set up we must NEVER fall through to the raw :9222 path
|
||||||
if relay_is_live(&relay).await {
|
// below: that pops Chrome 136+'s "Allow remote debugging?" dialog, the exact
|
||||||
return Ok(relay);
|
// thing the extension exists to avoid.
|
||||||
|
// ~15s of retries (500ms apart) when the extension is installed: long enough
|
||||||
|
// for the MV3 service worker to wake and reconnect on its own (onStartup
|
||||||
|
// after a Chrome restart, or the keepalive alarm) so the relay self-heals
|
||||||
|
// with NO user action. The loop re-checks the relay file every iteration, so
|
||||||
|
// a recovery mid-wait is picked up immediately — the full window is only ever
|
||||||
|
// spent when the extension is genuinely down.
|
||||||
|
let host_installed = crate::connect::host_installed();
|
||||||
|
let relay_attempts = if host_installed { 30 } else { 1 };
|
||||||
|
for attempt in 0..relay_attempts {
|
||||||
|
if let Some(relay) = crate::connect::relay_url() {
|
||||||
|
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
|
||||||
|
// A bare TCP liveness check (no WS upgrade) confirms it is actually
|
||||||
|
// accepting before we commit, mirroring the consent-free probe used
|
||||||
|
// for DevToolsActivePort.
|
||||||
|
if relay_is_live(&relay).await {
|
||||||
|
return Ok(relay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if host_installed && attempt + 1 < relay_attempts {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host_installed {
|
||||||
|
return Err(
|
||||||
|
"The agent-browser-stealth extension is installed, but its relay \
|
||||||
|
isn't connected right now. Wake it up — click the extension's \
|
||||||
|
toolbar icon, or reload it at chrome://extensions — then retry. \
|
||||||
|
(agent-browser will not attach to a raw --remote-debugging-port \
|
||||||
|
while the extension is set up, because that pops Chrome's \"Allow \
|
||||||
|
remote debugging?\" dialog. Use --cdp <port> to force the raw path.)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_data_dirs = get_chrome_user_data_dirs();
|
let user_data_dirs = get_chrome_user_data_dirs();
|
||||||
@@ -813,11 +845,13 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("No running Chrome with remote debugging found. Remote debugging is a \
|
Err(
|
||||||
|
"No running Chrome with remote debugging found. Remote debugging is a \
|
||||||
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
||||||
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
||||||
--cdp <port>/--launch."
|
--cdp <port>/--launch."
|
||||||
.to_string())
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||||
@@ -861,11 +895,7 @@ async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String
|
|||||||
async fn tcp_port_alive(port: u16) -> bool {
|
async fn tcp_port_alive(port: u16) -> bool {
|
||||||
let timeout = Duration::from_secs(1);
|
let timeout = Duration::from_secs(1);
|
||||||
matches!(
|
matches!(
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port)),).await,
|
||||||
timeout,
|
|
||||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
Ok(Ok(_))
|
Ok(Ok(_))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2181,7 +2211,11 @@ mod tests {
|
|||||||
let ws_path = "/devtools/browser/test-uuid-1234";
|
let ws_path = "/devtools/browser/test-uuid-1234";
|
||||||
|
|
||||||
let result = resolve_cdp_from_active_port(port, ws_path).await;
|
let result = resolve_cdp_from_active_port(port, ws_path).await;
|
||||||
assert!(result.is_ok(), "should succeed when port is live: {:?}", result);
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"should succeed when port is live: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.unwrap(),
|
result.unwrap(),
|
||||||
format!("ws://127.0.0.1:{}{}", port, ws_path),
|
format!("ws://127.0.0.1:{}{}", port, ws_path),
|
||||||
@@ -2207,11 +2241,8 @@ mod tests {
|
|||||||
// The liveness check connects then drops without writing anything.
|
// The liveness check connects then drops without writing anything.
|
||||||
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
|
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
|
||||||
let mut buf = [0u8; 128];
|
let mut buf = [0u8; 128];
|
||||||
let read = tokio::time::timeout(
|
let read =
|
||||||
Duration::from_millis(500),
|
tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await;
|
||||||
stream.read(&mut buf),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
match read {
|
match read {
|
||||||
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
|
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
|
||||||
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
|
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
|
||||||
|
|||||||
@@ -346,6 +346,11 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
// Spawns a real child process and binds a TCP server with timing-based
|
||||||
|
// readiness assumptions; flaky under CI load (intermittent "exited before
|
||||||
|
// CDP became ready" / connection-refused races). Run locally with
|
||||||
|
// `--ignored` when touching lightpanda startup.
|
||||||
|
#[ignore = "process spawn + socket timing race, flaky in CI"]
|
||||||
async fn waits_for_ready_without_logs() {
|
async fn waits_for_ready_without_logs() {
|
||||||
let port = unused_port();
|
let port = unused_port();
|
||||||
tokio::spawn(serve_json_version_once_after_delay(
|
tokio::spawn(serve_json_version_once_after_delay(
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ pub struct CreateTargetParams {
|
|||||||
/// endpoint never receives an unknown parameter.
|
/// endpoint never receives an unknown parameter.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub agent_group: Option<String>,
|
pub agent_group: Option<String>,
|
||||||
|
/// Create the tab in the background so opening it never steals the user's
|
||||||
|
/// foreground tab (silent operation). Standard CDP param; the ab-connect
|
||||||
|
/// extension creates its tabs `active: false` regardless, so this only
|
||||||
|
/// affects the raw-CDP (no extension) path.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub background: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -2252,9 +2252,13 @@ async fn e2e_save_state_cross_domain() {
|
|||||||
.await;
|
.await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
|
|
||||||
// Navigate to domain A and set cookie + localStorage
|
// Navigate to domain A and set cookie + localStorage. Use example.org (a
|
||||||
|
// stable IANA-reserved domain, like example.com below) rather than an
|
||||||
|
// external service such as httpbin.org — cookie/localStorage are set
|
||||||
|
// client-side via CDP, so the only requirement is that the page loads
|
||||||
|
// reliably. A flaky external domain made this test intermittently fail in CI.
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "2", "action": "navigate", "url": "https://httpbin.org/html" }),
|
&json!({ "id": "2", "action": "navigate", "url": "https://example.org/" }),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -2263,7 +2267,7 @@ async fn e2e_save_state_cross_domain() {
|
|||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({
|
&json!({
|
||||||
"id": "3", "action": "cookies_set",
|
"id": "3", "action": "cookies_set",
|
||||||
"name": "domainA_cookie", "value": "from_httpbin"
|
"name": "domainA_cookie", "value": "from_example_org"
|
||||||
}),
|
}),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
@@ -2330,7 +2334,7 @@ async fn e2e_save_state_cross_domain() {
|
|||||||
let has_domain_b = cookies.iter().any(|c| c["name"] == "domainB_cookie");
|
let has_domain_b = cookies.iter().any(|c| c["name"] == "domainB_cookie");
|
||||||
assert!(
|
assert!(
|
||||||
has_domain_a,
|
has_domain_a,
|
||||||
"Should include cross-domain cookie from httpbin.org: {:?}",
|
"Should include cross-domain cookie from example.org: {:?}",
|
||||||
cookies
|
cookies
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2341,21 +2345,26 @@ async fn e2e_save_state_cross_domain() {
|
|||||||
|
|
||||||
// Verify BOTH origins' localStorage are present
|
// Verify BOTH origins' localStorage are present
|
||||||
let origins = state_data["origins"].as_array().unwrap();
|
let origins = state_data["origins"].as_array().unwrap();
|
||||||
|
// Match full hostnames so the two example.* origins don't alias each other.
|
||||||
let has_origin_a = origins.iter().any(|o| {
|
let has_origin_a = origins.iter().any(|o| {
|
||||||
o["origin"].as_str().is_some_and(|s| s.contains("httpbin"))
|
o["origin"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|s| s.contains("example.org"))
|
||||||
&& o["localStorage"]
|
&& o["localStorage"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainA_key"))
|
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainA_key"))
|
||||||
});
|
});
|
||||||
let has_origin_b = origins.iter().any(|o| {
|
let has_origin_b = origins.iter().any(|o| {
|
||||||
o["origin"].as_str().is_some_and(|s| s.contains("example"))
|
o["origin"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|s| s.contains("example.com"))
|
||||||
&& o["localStorage"]
|
&& o["localStorage"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainB_key"))
|
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainB_key"))
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
has_origin_a,
|
has_origin_a,
|
||||||
"Should include localStorage from httpbin.org origin: {:?}",
|
"Should include localStorage from example.org origin: {:?}",
|
||||||
origins
|
origins
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+63
-13
@@ -200,13 +200,17 @@ async fn relocate_stale_ref(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a `@ref` or CSS selector to a click point. Returns
|
||||||
|
/// `(centre_x, centre_y, width, height, session_id)`. Width/height come from the
|
||||||
|
/// element's box model and feed humanize's in-bounds landing jitter; the CSS
|
||||||
|
/// selector path returns zero size (→ land on centre, no jitter).
|
||||||
pub async fn resolve_element_center(
|
pub async fn resolve_element_center(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
ref_map: &RefMap,
|
ref_map: &RefMap,
|
||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
iframe_sessions: &HashMap<String, String>,
|
||||||
) -> Result<(f64, f64, String), String> {
|
) -> Result<(f64, f64, f64, f64, String), String> {
|
||||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||||
let entry = ref_map
|
let entry = ref_map
|
||||||
.get(&ref_id)
|
.get(&ref_id)
|
||||||
@@ -263,7 +267,7 @@ pub async fn resolve_element_center(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(r) = result {
|
if let Ok(r) = result {
|
||||||
let (x, y) = box_model_center(&r.model);
|
let (x, y, w, h) = box_model_dims(&r.model);
|
||||||
// Occlusion check: a transient overlay (X.com's "click
|
// Occlusion check: a transient overlay (X.com's "click
|
||||||
// outside to close" mask, modal backdrop, sticky banner,
|
// outside to close" mask, modal backdrop, sticky banner,
|
||||||
// etc.) can land on top of our target between snapshot
|
// etc.) can land on top of our target between snapshot
|
||||||
@@ -276,14 +280,10 @@ pub async fn resolve_element_center(
|
|||||||
//
|
//
|
||||||
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
||||||
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
||||||
if let Err(e) =
|
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
|
||||||
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
|
.await?;
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Ok((x, y, effective_session_id.to_string()));
|
return Ok((x, y, w, h, effective_session_id.to_string()));
|
||||||
}
|
}
|
||||||
// backend_node_id is stale; re-query the accessibility tree below
|
// backend_node_id is stale; re-query the accessibility tree below
|
||||||
}
|
}
|
||||||
@@ -320,13 +320,14 @@ pub async fn resolve_element_center(
|
|||||||
Some(effective_session_id),
|
Some(effective_session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (x, y) = box_model_center(&result.model);
|
let (x, y, w, h) = box_model_dims(&result.model);
|
||||||
return Ok((x, y, effective_session_id.to_string()));
|
return Ok((x, y, w, h, effective_session_id.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS selector
|
// CSS selector
|
||||||
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
|
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
|
||||||
Ok((x, y, session_id.to_string()))
|
// No box model on the CSS-selector fast path → zero size → land on centre.
|
||||||
|
Ok((x, y, 0.0, 0.0, session_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resolve_element_object_id(
|
pub async fn resolve_element_object_id(
|
||||||
@@ -444,6 +445,18 @@ pub async fn resolve_element_object_id(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// A syntactically-invalid selector makes `document.querySelector` THROW.
|
||||||
|
// With returnByValue:false, Runtime.evaluate then returns the thrown
|
||||||
|
// DOMException as a remote object *with* an objectId — which would otherwise
|
||||||
|
// be mistaken for "the element" and silently no-op a `.click()` on it. Treat
|
||||||
|
// any thrown exception as a hard error so a typo'd selector fails loudly.
|
||||||
|
if let Some(ex) = result.exception_details {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid selector '{}': {}",
|
||||||
|
selector_or_ref, ex.text
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let object_id = result
|
let object_id = result
|
||||||
.result
|
.result
|
||||||
.object_id
|
.object_id
|
||||||
@@ -586,7 +599,9 @@ async fn verify_click_target(
|
|||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let Ok(resolved) = resolve_resp else { return Ok(()) };
|
let Ok(resolved) = resolve_resp else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
let Some(object_id) = resolved
|
let Some(object_id) = resolved
|
||||||
.get("object")
|
.get("object")
|
||||||
.and_then(|o| o.get("objectId"))
|
.and_then(|o| o.get("objectId"))
|
||||||
@@ -835,6 +850,12 @@ async fn resolve_by_selector(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// A syntactically-invalid CSS selector makes querySelector throw — surface
|
||||||
|
// that as "invalid selector" rather than a misleading "element not found".
|
||||||
|
if let Some(ex) = result.exception_details {
|
||||||
|
return Err(format!("Invalid selector '{}': {}", selector, ex.text));
|
||||||
|
}
|
||||||
|
|
||||||
let val = result.result.value.unwrap_or(Value::Null);
|
let val = result.result.value.unwrap_or(Value::Null);
|
||||||
let x = val.get("x").and_then(|v| v.as_f64());
|
let x = val.get("x").and_then(|v| v.as_f64());
|
||||||
let y = val.get("y").and_then(|v| v.as_f64());
|
let y = val.get("y").and_then(|v| v.as_f64());
|
||||||
@@ -856,6 +877,35 @@ fn box_model_center(model: &BoxModel) -> (f64, f64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Centre plus width/height of the content box, derived from the quad's
|
||||||
|
/// bounding extent. Width/height feed humanize's in-bounds landing jitter; a
|
||||||
|
/// degenerate quad yields zero size, which the jitter treats as "land on
|
||||||
|
/// centre" (no jitter).
|
||||||
|
fn box_model_dims(model: &BoxModel) -> (f64, f64, f64, f64) {
|
||||||
|
let (cx, cy) = box_model_center(model);
|
||||||
|
if model.content.len() >= 8 {
|
||||||
|
let xs = [
|
||||||
|
model.content[0],
|
||||||
|
model.content[2],
|
||||||
|
model.content[4],
|
||||||
|
model.content[6],
|
||||||
|
];
|
||||||
|
let ys = [
|
||||||
|
model.content[1],
|
||||||
|
model.content[3],
|
||||||
|
model.content[5],
|
||||||
|
model.content[7],
|
||||||
|
];
|
||||||
|
let w = xs.iter().cloned().fold(f64::MIN, f64::max)
|
||||||
|
- xs.iter().cloned().fold(f64::MAX, f64::min);
|
||||||
|
let h = ys.iter().cloned().fold(f64::MIN, f64::max)
|
||||||
|
- ys.iter().cloned().fold(f64::MAX, f64::min);
|
||||||
|
(cx, cy, w.max(0.0), h.max(0.0))
|
||||||
|
} else {
|
||||||
|
(cx, cy, 0.0, 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_element_text(
|
pub async fn get_element_text(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
//! Human-like input behaviour for stealth.
|
||||||
|
//!
|
||||||
|
//! When agent-browser drives a real Chrome over CDP, the input events it
|
||||||
|
//! dispatches are already `isTrusted` — but a click that teleports the cursor
|
||||||
|
//! straight to an element's exact centre, with no approach path and zero delay
|
||||||
|
//! between move/press/release, is a behavioural tell that advanced anti-bot
|
||||||
|
//! vendors (Akamai, PerimeterX, DataDome) look for.
|
||||||
|
//!
|
||||||
|
//! This module produces **human-like motion plans** — curved, eased cursor
|
||||||
|
//! trajectories and variable keystroke timing — as *pure data*. It performs no
|
||||||
|
//! I/O and knows nothing about CDP: callers turn the returned steps into
|
||||||
|
//! `Input.dispatchMouseEvent` / `dispatchKeyEvent` calls. Keeping the maths pure
|
||||||
|
//! makes the easing/jitter/detection logic unit-testable and deterministic
|
||||||
|
//! (every randomised value comes from a caller-supplied seed).
|
||||||
|
//!
|
||||||
|
//! Design (see brainstorm 2026-06-11):
|
||||||
|
//! - Three levels: [`HumanizeLevel::Off`] (instant, today's behaviour),
|
||||||
|
//! `Fast` (a few cheap eased steps), `Human` (full curved trajectory + jitter).
|
||||||
|
//! - Baseline is `Off`; the daemon escalates a session to `Human` when
|
||||||
|
//! [`detect_level`] spots a known anti-bot vendor on the page. `--humanize` /
|
||||||
|
//! `AGENT_BROWSER_HUMANIZE` force a fixed level.
|
||||||
|
//! - Humanization only changes *how* the cursor reaches a target, never *which*
|
||||||
|
//! element is hit: the landing jitter stays inside the caller-provided bounds.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ---- daemon-wide runtime state -------------------------------------------
|
||||||
|
//
|
||||||
|
// The pure motion maths above are stateless. The daemon drives one active page
|
||||||
|
// at a time, so we keep the *current* humanize level and last cursor position
|
||||||
|
// in process-global slots rather than threading them through every call site.
|
||||||
|
// (The adaptive detector flips the level per navigation; `dispatch_click` reads
|
||||||
|
// the level + cursor here, so no signature in the click/type call graph has to
|
||||||
|
// change.)
|
||||||
|
|
||||||
|
/// `AGENT_BROWSER_HUMANIZE` forces a fixed level, overriding the adaptive
|
||||||
|
/// detector. Parsed once.
|
||||||
|
fn env_override() -> Option<HumanizeLevel> {
|
||||||
|
static OVERRIDE: OnceLock<Option<HumanizeLevel>> = OnceLock::new();
|
||||||
|
*OVERRIDE.get_or_init(|| {
|
||||||
|
std::env::var("AGENT_BROWSER_HUMANIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| HumanizeLevel::parse(&s))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_level() -> &'static Mutex<HumanizeLevel> {
|
||||||
|
static LEVEL: OnceLock<Mutex<HumanizeLevel>> = OnceLock::new();
|
||||||
|
LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_cursor_slot() -> &'static Mutex<(f64, f64)> {
|
||||||
|
static CURSOR: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||||
|
CURSOR.get_or_init(|| Mutex::new((0.0, 0.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The level that should apply right now: the env override if set, else the
|
||||||
|
/// level the detector last chose for the active page.
|
||||||
|
pub fn active_level() -> HumanizeLevel {
|
||||||
|
env_override().unwrap_or_else(|| *session_level().lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set by the adaptive detector after navigation. Ignored while an env override
|
||||||
|
/// is in force (so `--humanize` always wins).
|
||||||
|
pub fn set_detected_level(level: HumanizeLevel) {
|
||||||
|
*session_level().lock().unwrap() = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the virtual cursor currently sits, so the next move starts from there
|
||||||
|
/// instead of teleporting.
|
||||||
|
pub fn last_cursor() -> (f64, f64) {
|
||||||
|
*last_cursor_slot().lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the cursor landing point after a move/click.
|
||||||
|
pub fn set_last_cursor(p: (f64, f64)) {
|
||||||
|
*last_cursor_slot().lock().unwrap() = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh seed per action so repeated clicks on the same point still vary,
|
||||||
|
/// without touching the wall clock or a global RNG (both would break replay).
|
||||||
|
pub fn next_seed() -> u64 {
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0x1234_5678);
|
||||||
|
COUNTER
|
||||||
|
.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
|
||||||
|
.rotate_left(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How human-like input motion should be.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum HumanizeLevel {
|
||||||
|
/// Instant: a single move to the exact point, no delays. Original behaviour.
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
/// A few eased steps with small delays — cheap cover for ordinary sites.
|
||||||
|
Fast,
|
||||||
|
/// Full curved, decelerating trajectory with landing jitter and press
|
||||||
|
/// dwell — for pages guarded by behavioural anti-bot systems.
|
||||||
|
Human,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HumanizeLevel {
|
||||||
|
/// Parse a user-supplied level (`--humanize` / `AGENT_BROWSER_HUMANIZE`).
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"off" | "none" | "instant" | "0" => Some(Self::Off),
|
||||||
|
"fast" | "light" | "low" => Some(Self::Fast),
|
||||||
|
"human" | "full" | "high" | "max" => Some(Self::Human),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_off(self) -> bool {
|
||||||
|
matches!(self, Self::Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of a humanized cursor move: dispatch `mouseMoved` to (`x`, `y`),
|
||||||
|
/// then sleep for `delay` before the next step. The final step's point is where
|
||||||
|
/// the press/release should land.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct MoveStep {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub delay: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny deterministic PRNG (xorshift64*). Seeded by the caller so trajectories
|
||||||
|
/// are reproducible in tests; we avoid pulling in the `rand` crate and never
|
||||||
|
/// call a wall-clock/global RNG (which would also break workflow replay).
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
// Avoid the zero state, which xorshift cannot escape.
|
||||||
|
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
let mut x = self.0;
|
||||||
|
x ^= x >> 12;
|
||||||
|
x ^= x << 25;
|
||||||
|
x ^= x >> 27;
|
||||||
|
self.0 = x;
|
||||||
|
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform in [0, 1).
|
||||||
|
fn unit(&mut self) -> f64 {
|
||||||
|
// Top 53 bits → f64 mantissa.
|
||||||
|
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform in [-1, 1).
|
||||||
|
fn signed(&mut self) -> f64 {
|
||||||
|
self.unit() * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smootherstep ease (zero velocity at both ends) — used to bias the per-step
|
||||||
|
/// timing so the cursor accelerates away from the start and decelerates into
|
||||||
|
/// the target, the way a hand does.
|
||||||
|
fn ease(t: f64) -> f64 {
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cubic Bézier point at parameter `t`.
|
||||||
|
fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
|
||||||
|
let u = 1.0 - t;
|
||||||
|
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||||
|
(
|
||||||
|
a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
|
||||||
|
a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a landing point inside `bbox` (`x`, `y`, `width`, `height`). `Off`
|
||||||
|
/// returns the exact centre; `Fast`/`Human` jitter around the centre but stay
|
||||||
|
/// well inside the element so the click still lands on it.
|
||||||
|
pub fn landing_point(bbox: (f64, f64, f64, f64), level: HumanizeLevel, seed: u64) -> (f64, f64) {
|
||||||
|
let (bx, by, bw, bh) = bbox;
|
||||||
|
let cx = bx + bw / 2.0;
|
||||||
|
let cy = by + bh / 2.0;
|
||||||
|
if level.is_off() || bw <= 1.0 || bh <= 1.0 {
|
||||||
|
return (cx, cy);
|
||||||
|
}
|
||||||
|
// Keep within the inner 60% so jitter never lands on a neighbouring element
|
||||||
|
// or the element's padding/edge.
|
||||||
|
let spread = match level {
|
||||||
|
HumanizeLevel::Human => 0.30,
|
||||||
|
_ => 0.15,
|
||||||
|
};
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
(
|
||||||
|
cx + rng.signed() * bw * spread,
|
||||||
|
cy + rng.signed() * bh * spread,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the cursor path from `from` to `to`. The last [`MoveStep`] is the
|
||||||
|
/// landing point. `Off` yields a single zero-delay step at `to` (today's
|
||||||
|
/// teleport), so callers can use one code path for every level.
|
||||||
|
pub fn move_path(
|
||||||
|
from: (f64, f64),
|
||||||
|
to: (f64, f64),
|
||||||
|
level: HumanizeLevel,
|
||||||
|
seed: u64,
|
||||||
|
) -> Vec<MoveStep> {
|
||||||
|
if level.is_off() {
|
||||||
|
return vec![MoveStep {
|
||||||
|
x: to.0,
|
||||||
|
y: to.1,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
let dist = (to.0 - from.0).hypot(to.1 - from.1);
|
||||||
|
if dist < 1.0 {
|
||||||
|
return vec![MoveStep {
|
||||||
|
x: to.0,
|
||||||
|
y: to.1,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
let (steps, total_ms, arc) = match level {
|
||||||
|
HumanizeLevel::Fast => {
|
||||||
|
let s = ((dist / 120.0).round() as usize).clamp(3, 6);
|
||||||
|
(s, (dist * 0.35).clamp(40.0, 130.0), 0.06)
|
||||||
|
}
|
||||||
|
// Off handled above.
|
||||||
|
_ => {
|
||||||
|
let s = ((dist / 45.0).round() as usize).clamp(8, 24);
|
||||||
|
(s, (dist * 0.9).clamp(140.0, 650.0), 0.16)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
|
||||||
|
// Two control points along the line, pushed perpendicular to it to bow the
|
||||||
|
// path into a gentle, slightly asymmetric arc.
|
||||||
|
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
|
||||||
|
let (nx, ny) = (-dy / dist, dx / dist); // unit normal
|
||||||
|
let bow = dist * arc * rng.signed();
|
||||||
|
let ctrl = |frac: f64, jitter: f64, rng: &mut Rng| {
|
||||||
|
let base = (from.0 + dx * frac, from.1 + dy * frac);
|
||||||
|
let off = bow * (1.0 + jitter * rng.signed());
|
||||||
|
(base.0 + nx * off, base.1 + ny * off)
|
||||||
|
};
|
||||||
|
let p1 = ctrl(0.33, 0.4, &mut rng);
|
||||||
|
let p2 = ctrl(0.66, 0.4, &mut rng);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(steps);
|
||||||
|
let mut prev_ease = 0.0;
|
||||||
|
for i in 1..=steps {
|
||||||
|
let t = i as f64 / steps as f64;
|
||||||
|
// Ease maps wall-time progress so most points cluster near the ends
|
||||||
|
// (slow start, slow finish, fast middle).
|
||||||
|
let te = ease(t);
|
||||||
|
let (x, y) = bezier(from, p1, p2, to, te);
|
||||||
|
let frac = te - prev_ease;
|
||||||
|
prev_ease = te;
|
||||||
|
out.push(MoveStep {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
delay: Duration::from_micros((total_ms * frac * 1000.0).max(0.0) as u64),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Guarantee the final point is exactly the target.
|
||||||
|
if let Some(last) = out.last_mut() {
|
||||||
|
last.x = to.0;
|
||||||
|
last.y = to.1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a wheel scroll of (`total_dx`, `total_dy`) into eased segments. `Off`
|
||||||
|
/// returns a single instant segment (today's one-shot scroll); `Fast`/`Human`
|
||||||
|
/// break it into several accelerate-then-decelerate chunks with small,
|
||||||
|
/// jittered inter-segment delays, the way a trackpad/wheel flick actually
|
||||||
|
/// lands. The segment deltas always sum to the requested total.
|
||||||
|
pub fn scroll_segments(
|
||||||
|
total_dx: f64,
|
||||||
|
total_dy: f64,
|
||||||
|
level: HumanizeLevel,
|
||||||
|
seed: u64,
|
||||||
|
) -> Vec<(f64, f64, Duration)> {
|
||||||
|
if level.is_off() {
|
||||||
|
return vec![(total_dx, total_dy, Duration::ZERO)];
|
||||||
|
}
|
||||||
|
let (segs, base_ms) = match level {
|
||||||
|
HumanizeLevel::Fast => (4usize, 18.0),
|
||||||
|
_ => (9usize, 28.0),
|
||||||
|
};
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
let mut out = Vec::with_capacity(segs);
|
||||||
|
let mut prev = 0.0;
|
||||||
|
for i in 1..=segs {
|
||||||
|
let f = ease(i as f64 / segs as f64);
|
||||||
|
let frac = f - prev;
|
||||||
|
prev = f;
|
||||||
|
let jitter = 1.0 + 0.3 * rng.signed();
|
||||||
|
out.push((
|
||||||
|
total_dx * frac,
|
||||||
|
total_dy * frac,
|
||||||
|
Duration::from_millis((base_ms * jitter).max(4.0) as u64),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dwell between `mousePressed` and `mouseReleased` (a real click isn't
|
||||||
|
/// instantaneous). Zero for `Off`.
|
||||||
|
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
||||||
|
match level {
|
||||||
|
HumanizeLevel::Off => Duration::ZERO,
|
||||||
|
HumanizeLevel::Fast => Duration::from_millis(20 + (seed % 30)),
|
||||||
|
HumanizeLevel::Human => Duration::from_millis(50 + (seed % 90)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-character delays for typing `len` characters. `Off` is all-zero (use a
|
||||||
|
/// single `Input.insertText`); `Fast`/`Human` produce variable inter-keystroke
|
||||||
|
/// gaps with the occasional longer "think" pause, like a real typist.
|
||||||
|
pub fn keystroke_delays(len: usize, level: HumanizeLevel, seed: u64) -> Vec<Duration> {
|
||||||
|
if level.is_off() || len == 0 {
|
||||||
|
return vec![Duration::ZERO; len];
|
||||||
|
}
|
||||||
|
let (mean, jitter, pause_chance, pause_extra) = match level {
|
||||||
|
HumanizeLevel::Fast => (25.0, 15.0, 0.0, 0.0),
|
||||||
|
_ => (95.0, 55.0, 0.06, 220.0),
|
||||||
|
};
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
(0..len)
|
||||||
|
.map(|_| {
|
||||||
|
let mut ms = (mean + rng.signed() * jitter).max(8.0);
|
||||||
|
if pause_chance > 0.0 && rng.unit() < pause_chance {
|
||||||
|
ms += rng.unit() * pause_extra;
|
||||||
|
}
|
||||||
|
Duration::from_millis(ms as u64)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page signals sampled after navigation, used to decide whether to escalate a
|
||||||
|
/// session to [`HumanizeLevel::Human`]. All strings are matched case-insensitively.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct DetectSignals {
|
||||||
|
/// Cookie names present on the document (e.g. `_abck`, `datadome`).
|
||||||
|
pub cookie_names: Vec<String>,
|
||||||
|
/// `src` of loaded scripts.
|
||||||
|
pub script_urls: Vec<String>,
|
||||||
|
/// Names of suspicious globals on `window` (e.g. `_px`, `bmak`).
|
||||||
|
pub window_globals: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known behavioural anti-bot fingerprints: (substring, vendor). Matched against
|
||||||
|
/// cookie names, script URLs, and window globals.
|
||||||
|
const VENDOR_MARKERS: &[(&str, &str)] = &[
|
||||||
|
("_abck", "akamai"),
|
||||||
|
("bm_sz", "akamai"),
|
||||||
|
("ak_bmsc", "akamai"),
|
||||||
|
("bmak", "akamai"),
|
||||||
|
("_px", "perimeterx"),
|
||||||
|
("perimeterx", "perimeterx"),
|
||||||
|
("px-cloud", "perimeterx"),
|
||||||
|
("datadome", "datadome"),
|
||||||
|
("kpsdk", "kasada"),
|
||||||
|
("incap_ses", "imperva"),
|
||||||
|
("visid_incap", "imperva"),
|
||||||
|
("reese84", "imperva"),
|
||||||
|
("__cf_bm", "cloudflare-bot-mgmt"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Decide the level for a page. Returns `Human` if any known anti-bot vendor is
|
||||||
|
/// present, otherwise `baseline`. Misses just stay at baseline and false hits
|
||||||
|
/// only cost a little latency, so matching is deliberately liberal.
|
||||||
|
pub fn detect_level(signals: &DetectSignals, baseline: HumanizeLevel) -> HumanizeLevel {
|
||||||
|
let hay: Vec<String> = signals
|
||||||
|
.cookie_names
|
||||||
|
.iter()
|
||||||
|
.chain(signals.script_urls.iter())
|
||||||
|
.chain(signals.window_globals.iter())
|
||||||
|
.map(|s| s.to_ascii_lowercase())
|
||||||
|
.collect();
|
||||||
|
let matched = VENDOR_MARKERS
|
||||||
|
.iter()
|
||||||
|
.any(|(marker, _)| hay.iter().any(|h| h.contains(marker)));
|
||||||
|
if matched {
|
||||||
|
HumanizeLevel::Human
|
||||||
|
} else {
|
||||||
|
baseline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_known_levels_and_rejects_junk() {
|
||||||
|
assert_eq!(HumanizeLevel::parse("off"), Some(HumanizeLevel::Off));
|
||||||
|
assert_eq!(HumanizeLevel::parse(" FAST "), Some(HumanizeLevel::Fast));
|
||||||
|
assert_eq!(HumanizeLevel::parse("Human"), Some(HumanizeLevel::Human));
|
||||||
|
assert_eq!(HumanizeLevel::parse("max"), Some(HumanizeLevel::Human));
|
||||||
|
assert_eq!(HumanizeLevel::parse("wat"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_level_teleports_in_one_step() {
|
||||||
|
let path = move_path((0.0, 0.0), (100.0, 50.0), HumanizeLevel::Off, 1);
|
||||||
|
assert_eq!(path.len(), 1);
|
||||||
|
assert_eq!((path[0].x, path[0].y), (100.0, 50.0));
|
||||||
|
assert_eq!(path[0].delay, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanized_path_is_multi_step_and_lands_exactly_on_target() {
|
||||||
|
let to = (640.0, 480.0);
|
||||||
|
let path = move_path((10.0, 10.0), to, HumanizeLevel::Human, 42);
|
||||||
|
assert!(path.len() >= 8, "human path should have many steps");
|
||||||
|
let last = path.last().unwrap();
|
||||||
|
assert_eq!((last.x, last.y), to, "final point must equal the target");
|
||||||
|
// Path must actually leave the straight line at some point (it's a curve).
|
||||||
|
let straight = path.iter().all(|s| {
|
||||||
|
let t = (s.x - 10.0) / (to.0 - 10.0);
|
||||||
|
(s.y - (10.0 + t * (to.1 - 10.0))).abs() < 0.5
|
||||||
|
});
|
||||||
|
assert!(!straight, "human path should bow off the straight line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_path_is_shorter_than_human() {
|
||||||
|
let fast = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Fast, 7);
|
||||||
|
let human = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Human, 7);
|
||||||
|
assert!(fast.len() < human.len());
|
||||||
|
assert!((3..=6).contains(&fast.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_path_is_deterministic_for_a_seed() {
|
||||||
|
let a = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||||
|
let b = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
let c = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 100);
|
||||||
|
assert_ne!(a, c, "different seeds should differ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn landing_point_stays_inside_bounds_and_centres_when_off() {
|
||||||
|
let bbox = (100.0, 100.0, 40.0, 20.0);
|
||||||
|
assert_eq!(landing_point(bbox, HumanizeLevel::Off, 1), (120.0, 110.0));
|
||||||
|
for seed in 0..200 {
|
||||||
|
let (x, y) = landing_point(bbox, HumanizeLevel::Human, seed);
|
||||||
|
assert!(x > 100.0 && x < 140.0, "x {x} escaped bbox");
|
||||||
|
assert!(y > 100.0 && y < 120.0, "y {y} escaped bbox");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keystroke_delays_zero_when_off_and_positive_otherwise() {
|
||||||
|
assert!(keystroke_delays(5, HumanizeLevel::Off, 1)
|
||||||
|
.iter()
|
||||||
|
.all(|d| *d == Duration::ZERO));
|
||||||
|
let human = keystroke_delays(20, HumanizeLevel::Human, 3);
|
||||||
|
assert_eq!(human.len(), 20);
|
||||||
|
assert!(human.iter().all(|d| *d >= Duration::from_millis(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_segments_sum_to_total_and_single_when_off() {
|
||||||
|
let off = scroll_segments(0.0, 600.0, HumanizeLevel::Off, 1);
|
||||||
|
assert_eq!(off.len(), 1);
|
||||||
|
assert_eq!((off[0].0, off[0].1), (0.0, 600.0));
|
||||||
|
assert_eq!(off[0].2, Duration::ZERO);
|
||||||
|
|
||||||
|
let human = scroll_segments(0.0, 600.0, HumanizeLevel::Human, 5);
|
||||||
|
assert!(human.len() >= 5);
|
||||||
|
let total_dy: f64 = human.iter().map(|s| s.1).sum();
|
||||||
|
assert!(
|
||||||
|
(total_dy - 600.0).abs() < 1e-6,
|
||||||
|
"segments must sum to total"
|
||||||
|
);
|
||||||
|
assert!(human.iter().all(|s| s.2 >= Duration::from_millis(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_escalates_on_known_vendor_else_baseline() {
|
||||||
|
let mut s = DetectSignals::default();
|
||||||
|
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Off);
|
||||||
|
|
||||||
|
s.cookie_names = vec!["sessionid".into(), "_abck".into()];
|
||||||
|
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||||
|
|
||||||
|
let s2 = DetectSignals {
|
||||||
|
script_urls: vec!["https://cdn.example.com/DataDome-tags.js".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(detect_level(&s2, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||||
|
|
||||||
|
let s3 = DetectSignals {
|
||||||
|
window_globals: vec!["_pxAppId".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(detect_level(&s3, HumanizeLevel::Fast), HumanizeLevel::Human);
|
||||||
|
|
||||||
|
// Unknown signals keep the baseline.
|
||||||
|
let s4 = DetectSignals {
|
||||||
|
cookie_names: vec!["cart".into(), "theme".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(detect_level(&s4, HumanizeLevel::Fast), HumanizeLevel::Fast);
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-32
@@ -4,7 +4,8 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use super::cdp::client::CdpClient;
|
use super::cdp::client::CdpClient;
|
||||||
use super::cdp::types::*;
|
use super::cdp::types::*;
|
||||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
|
||||||
|
use super::humanize;
|
||||||
|
|
||||||
pub async fn click(
|
pub async fn click(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
@@ -24,10 +25,24 @@ pub async fn click(
|
|||||||
// inside the viewport. Without this, an element below the fold (or revealed
|
// inside the viewport. Without this, an element below the fold (or revealed
|
||||||
// after scroll/popup) yields off-viewport coordinates and the click lands on
|
// after scroll/popup) yields off-viewport coordinates and the click lands on
|
||||||
// whatever currently occupies that point. Best-effort: ignore failures.
|
// whatever currently occupies that point. Best-effort: ignore failures.
|
||||||
scroll_into_view_if_needed(client, session_id, ref_map, selector_or_ref, iframe_sessions).await;
|
scroll_into_view_if_needed(
|
||||||
|
client,
|
||||||
|
session_id,
|
||||||
|
ref_map,
|
||||||
|
selector_or_ref,
|
||||||
|
iframe_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if mode == "dom" {
|
if mode == "dom" {
|
||||||
return dom_click(client, session_id, ref_map, selector_or_ref, iframe_sessions).await;
|
return dom_click(
|
||||||
|
client,
|
||||||
|
session_id,
|
||||||
|
ref_map,
|
||||||
|
selector_or_ref,
|
||||||
|
iframe_sessions,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = resolve_element_center(
|
let resolved = resolve_element_center(
|
||||||
@@ -40,8 +55,42 @@ pub async fn click(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match resolved {
|
match resolved {
|
||||||
Ok((x, y, effective_session_id)) => {
|
Ok((cx, cy, w, h, effective_session_id)) => {
|
||||||
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
|
// 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(
|
||||||
|
(cx - w / 2.0, cy - h / 2.0, w, h),
|
||||||
|
humanize::active_level(),
|
||||||
|
humanize::next_seed(),
|
||||||
|
);
|
||||||
|
dispatch_click(client, &effective_session_id, tx, ty, button, click_count).await
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// (B) The coordinate path failed — typically a persistent overlay
|
// (B) The coordinate path failed — typically a persistent overlay
|
||||||
@@ -57,13 +106,55 @@ pub async fn click(
|
|||||||
"[click] coordinate click failed ({e}); falling back to DOM dispatch \
|
"[click] coordinate click failed ({e}); falling back to DOM dispatch \
|
||||||
(set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
(set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
||||||
);
|
);
|
||||||
dom_click(client, session_id, ref_map, selector_or_ref, iframe_sessions)
|
dom_click(
|
||||||
.await
|
client,
|
||||||
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})"))
|
session_id,
|
||||||
|
ref_map,
|
||||||
|
selector_or_ref,
|
||||||
|
iframe_sessions,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if a coordinate click at the selector's centre would land on something
|
||||||
|
/// OTHER than the element (an overlay on top), i.e. the element is occluded.
|
||||||
|
/// `false` when not occluded, the element is missing, or the probe fails (so we
|
||||||
|
/// never block a click on a flaky probe — the normal coordinate path runs).
|
||||||
|
async fn point_misses_element(client: &CdpClient, session_id: &str, selector: &str) -> bool {
|
||||||
|
let js = format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const el = document.querySelector({sel});
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) return false;
|
||||||
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
if (!hit) return false;
|
||||||
|
// Not occluded if the hit is the element, a descendant, or an ancestor
|
||||||
|
// wrapper (clicking those still reaches the element's handlers).
|
||||||
|
return !(hit === el || el.contains(hit) || hit.contains(el));
|
||||||
|
}})()"#,
|
||||||
|
sel = serde_json::to_string(selector).unwrap_or_default()
|
||||||
|
);
|
||||||
|
match client
|
||||||
|
.send_command_typed::<_, EvaluateResult>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
&EvaluateParams {
|
||||||
|
expression: js,
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(false),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r.result.value.and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
||||||
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
||||||
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
||||||
@@ -170,7 +261,7 @@ pub async fn hover(
|
|||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
iframe_sessions: &HashMap<String, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (x, y, effective_session_id) = resolve_element_center(
|
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
ref_map,
|
ref_map,
|
||||||
@@ -329,9 +420,18 @@ pub async fn type_text_into_active_context(
|
|||||||
text: &str,
|
text: &str,
|
||||||
delay_ms: Option<u64>,
|
delay_ms: Option<u64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let delay = delay_ms.unwrap_or(0);
|
// Per-character timing: an explicit `delay_ms` wins (caller asked for a
|
||||||
|
// fixed cadence); otherwise fall back to humanize — variable, human-like
|
||||||
|
// inter-keystroke gaps at Fast/Human, all-zero (instant) at Off.
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let cadence: Vec<std::time::Duration> = match delay_ms {
|
||||||
|
Some(d) => vec![std::time::Duration::from_millis(d); chars.len()],
|
||||||
|
None => {
|
||||||
|
humanize::keystroke_delays(chars.len(), humanize::active_level(), humanize::next_seed())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for ch in text.chars() {
|
for (i, ch) in chars.into_iter().enumerate() {
|
||||||
if matches!(ch, '\n' | '\r' | '\t') {
|
if matches!(ch, '\n' | '\r' | '\t') {
|
||||||
let (key, code, key_code) = char_to_key_info(ch);
|
let (key, code, key_code) = char_to_key_info(ch);
|
||||||
let text_str = key_text(&key);
|
let text_str = key_text(&key);
|
||||||
@@ -383,8 +483,9 @@ pub async fn type_text_into_active_context(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if delay > 0 {
|
let gap = cadence[i];
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
if !gap.is_zero() {
|
||||||
|
tokio::time::sleep(gap).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +1069,7 @@ pub async fn tap_touch(
|
|||||||
selector_or_ref: &str,
|
selector_or_ref: &str,
|
||||||
iframe_sessions: &HashMap<String, String>,
|
iframe_sessions: &HashMap<String, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (x, y, effective_session_id) = resolve_element_center(
|
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
ref_map,
|
ref_map,
|
||||||
@@ -1050,24 +1151,38 @@ async fn dispatch_click(
|
|||||||
button: &str,
|
button: &str,
|
||||||
click_count: i32,
|
click_count: i32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Move
|
// Move toward the target along a human-like path. At HumanizeLevel::Off this
|
||||||
client
|
// is a single zero-delay step to (x, y) — identical to the old teleport — so
|
||||||
.send_command_typed::<_, Value>(
|
// the default behaviour is unchanged. At Fast/Human it's a curved,
|
||||||
"Input.dispatchMouseEvent",
|
// decelerating trajectory starting from where the cursor last landed, which
|
||||||
&DispatchMouseEventParams {
|
// removes the "instant jump to exact centre, no prior movement" tell that
|
||||||
event_type: "mouseMoved".to_string(),
|
// behavioural anti-bot systems flag.
|
||||||
x,
|
let level = humanize::active_level();
|
||||||
y,
|
let start = humanize::last_cursor();
|
||||||
button: None,
|
let seed = humanize::next_seed();
|
||||||
buttons: None,
|
for step in humanize::move_path(start, (x, y), level, seed) {
|
||||||
click_count: None,
|
client
|
||||||
delta_x: None,
|
.send_command_typed::<_, Value>(
|
||||||
delta_y: None,
|
"Input.dispatchMouseEvent",
|
||||||
modifiers: None,
|
&DispatchMouseEventParams {
|
||||||
},
|
event_type: "mouseMoved".to_string(),
|
||||||
Some(session_id),
|
x: step.x,
|
||||||
)
|
y: step.y,
|
||||||
.await?;
|
button: None,
|
||||||
|
buttons: None,
|
||||||
|
click_count: None,
|
||||||
|
delta_x: None,
|
||||||
|
delta_y: None,
|
||||||
|
modifiers: None,
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !step.delay.is_zero() {
|
||||||
|
tokio::time::sleep(step.delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
humanize::set_last_cursor((x, y));
|
||||||
|
|
||||||
let button_value = match button {
|
let button_value = match button {
|
||||||
"right" => 2,
|
"right" => 2,
|
||||||
@@ -1094,6 +1209,13 @@ async fn dispatch_click(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Hold briefly before releasing — a real click isn't instantaneous. Zero at
|
||||||
|
// HumanizeLevel::Off.
|
||||||
|
let dwell = humanize::press_dwell(level, seed);
|
||||||
|
if !dwell.is_zero() {
|
||||||
|
tokio::time::sleep(dwell).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Release
|
// Release
|
||||||
client
|
client
|
||||||
.send_command_typed::<_, Value>(
|
.send_command_typed::<_, Value>(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub mod diff;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod element;
|
pub mod element;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
pub mod humanize;
|
||||||
|
#[allow(dead_code)]
|
||||||
pub mod inspect_server;
|
pub mod inspect_server;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
@@ -27,12 +29,12 @@ pub mod policy;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod relay;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub mod react;
|
pub mod react;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
pub mod relay;
|
||||||
|
#[allow(dead_code)]
|
||||||
pub mod screenshot;
|
pub mod screenshot;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
|||||||
+39
-10
@@ -129,12 +129,18 @@ impl RelayState {
|
|||||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||||
}
|
}
|
||||||
"Target.getTargets" => {
|
"Target.getTargets" => {
|
||||||
let infos: Vec<Value> =
|
let infos: Vec<Value> = self
|
||||||
self.targets.values().map(|t| t.target_info.clone()).collect();
|
.targets
|
||||||
|
.values()
|
||||||
|
.map(|t| t.target_info.clone())
|
||||||
|
.collect();
|
||||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||||
}
|
}
|
||||||
"Target.attachToTarget" => {
|
"Target.attachToTarget" => {
|
||||||
let target_id = params.get("targetId").and_then(|t| t.as_str()).unwrap_or("");
|
let target_id = params
|
||||||
|
.get("targetId")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
match self.targets.get(target_id) {
|
match self.targets.get(target_id) {
|
||||||
Some(entry) => ClientRoute::Local(
|
Some(entry) => ClientRoute::Local(
|
||||||
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
|
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
|
||||||
@@ -237,7 +243,10 @@ impl RelayState {
|
|||||||
.to_string();
|
.to_string();
|
||||||
self.targets.insert(
|
self.targets.insert(
|
||||||
tid.to_string(),
|
tid.to_string(),
|
||||||
TargetEntry { session_id: sid, target_info: info.clone() },
|
TargetEntry {
|
||||||
|
session_id: sid,
|
||||||
|
target_info: info.clone(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +314,10 @@ mod tests {
|
|||||||
fn learns_target_from_attached_event_and_does_not_forward_it() {
|
fn learns_target_from_attached_event_and_does_not_forward_it() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
|
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
|
||||||
assert!(out.is_empty(), "attachedToTarget should be consumed, not forwarded");
|
assert!(
|
||||||
|
out.is_empty(),
|
||||||
|
"attachedToTarget should be consumed, not forwarded"
|
||||||
|
);
|
||||||
// Now getTargets must report it.
|
// Now getTargets must report it.
|
||||||
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
||||||
match route {
|
match route {
|
||||||
@@ -384,8 +396,14 @@ mod tests {
|
|||||||
fn reply_routes_back_to_the_issuing_client_with_original_id() {
|
fn reply_routes_back_to_the_issuing_client_with_original_id() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
// Two clients each send a command that happens to share original id 1.
|
// Two clients each send a command that happens to share original id 1.
|
||||||
let r1 = s.route_client_command(100, &json!({ "id": 1, "method": "Page.navigate", "params": {} }));
|
let r1 = s.route_client_command(
|
||||||
let r2 = s.route_client_command(200, &json!({ "id": 1, "method": "Page.reload", "params": {} }));
|
100,
|
||||||
|
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||||
|
);
|
||||||
|
let r2 = s.route_client_command(
|
||||||
|
200,
|
||||||
|
&json!({ "id": 1, "method": "Page.reload", "params": {} }),
|
||||||
|
);
|
||||||
let g1 = match r1 {
|
let g1 = match r1 {
|
||||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
@@ -419,7 +437,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn forward_command_error_is_wrapped_and_routed() {
|
fn forward_command_error_is_wrapped_and_routed() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
let r = s.route_client_command(5, &json!({ "id": 3, "method": "Page.navigate", "params": {} }));
|
let r = s.route_client_command(
|
||||||
|
5,
|
||||||
|
&json!({ "id": 3, "method": "Page.navigate", "params": {} }),
|
||||||
|
);
|
||||||
let gid = match r {
|
let gid = match r {
|
||||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
@@ -459,7 +480,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drop_client_clears_its_pending() {
|
fn drop_client_clears_its_pending() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
let r = s.route_client_command(9, &json!({ "id": 1, "method": "Page.navigate", "params": {} }));
|
let r = s.route_client_command(
|
||||||
|
9,
|
||||||
|
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||||
|
);
|
||||||
let gid = match r {
|
let gid = match r {
|
||||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
@@ -478,7 +502,12 @@ mod tests {
|
|||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
|
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
|
||||||
let ok = s.handle_ext_message(&req, "good");
|
let ok = s.handle_ext_message(&req, "good");
|
||||||
assert_eq!(ok, vec![RelayOut::ToExt(json!({ "type": "res", "id": "c1", "ok": true }))]);
|
assert_eq!(
|
||||||
|
ok,
|
||||||
|
vec![RelayOut::ToExt(
|
||||||
|
json!({ "type": "res", "id": "c1", "ok": true })
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
|
||||||
let bad = s.handle_ext_message(&req, "different");
|
let bad = s.handle_ext_message(&req, "different");
|
||||||
match &bad[0] {
|
match &bad[0] {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::adaptive::ElementFingerprint;
|
||||||
use super::cdp::client::CdpClient;
|
use super::cdp::client::CdpClient;
|
||||||
use super::cdp::types::{
|
use super::cdp::types::{
|
||||||
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
||||||
};
|
};
|
||||||
use super::adaptive::ElementFingerprint;
|
|
||||||
use super::element::{resolve_ax_session, RefMap};
|
use super::element::{resolve_ax_session, RefMap};
|
||||||
|
|
||||||
const INTERACTIVE_ROLES: &[&str] = &[
|
const INTERACTIVE_ROLES: &[&str] = &[
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ async fn collect_storage_via_temp_target(
|
|||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
// Transient internal target (storage collection) — never grouped.
|
// Transient internal target (storage collection) — never grouped.
|
||||||
agent_group: None,
|
agent_group: None,
|
||||||
|
background: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,9 +186,7 @@ fn resolve_timezone(locale: Option<&str>) -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if raw.eq_ignore_ascii_case("auto") {
|
if raw.eq_ignore_ascii_case("auto") {
|
||||||
return locale
|
return locale.and_then(locale_default_timezone).map(str::to_string);
|
||||||
.and_then(locale_default_timezone)
|
|
||||||
.map(str::to_string);
|
|
||||||
}
|
}
|
||||||
Some(raw.to_string())
|
Some(raw.to_string())
|
||||||
}
|
}
|
||||||
@@ -265,8 +263,7 @@ pub fn strip_source_url_labels(input: &str) -> String {
|
|||||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||||
let output = re_line.replace_all(input, "");
|
let output = re_line.replace_all(input, "");
|
||||||
// Remove /*# sourceURL=...*/ block comments
|
// Remove /*# sourceURL=...*/ block comments
|
||||||
let re_block =
|
let re_block = regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||||
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
|
||||||
re_block.replace_all(&output, "").to_string()
|
re_block.replace_all(&output, "").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +367,10 @@ mod timezone_tests {
|
|||||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||||
|
|
||||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
|
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
|
||||||
assert_eq!(resolve_timezone(Some("ja-JP")), Some("Asia/Tokyo".to_string()));
|
assert_eq!(
|
||||||
|
resolve_timezone(Some("ja-JP")),
|
||||||
|
Some("Asia/Tokyo".to_string())
|
||||||
|
);
|
||||||
assert_eq!(resolve_timezone(Some("xx-YY")), None);
|
assert_eq!(resolve_timezone(Some("xx-YY")), None);
|
||||||
assert_eq!(resolve_timezone(None), None);
|
assert_eq!(resolve_timezone(None), None);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
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 };
|
||||||
|
// 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
|
||||||
|
// detectable automation tell: real Chrome's `Object.getOwnPropertyNames(navigator)`
|
||||||
|
// is empty, so any name we leave on the instance is caught by rebrowser's
|
||||||
|
// `navigatorWebdriver` probe and similar checks. We mirror the proven `vendor`
|
||||||
|
// patch below: define on the prototype, native-mask the getter's toString, then
|
||||||
|
// delete any instance shadow. Falls back to an instance define only if the
|
||||||
|
// prototype is locked. (A top-level `const` like this is script-scoped, not a
|
||||||
|
// `window` property, so it does not leak — same as `__abStealth` above.)
|
||||||
|
const __abRedefineNavProto = (name, getterImpl) => {
|
||||||
|
try {
|
||||||
|
const proto = Object.getPrototypeOf(navigator);
|
||||||
|
const nativeGet = Object.getOwnPropertyDescriptor(proto, name) && Object.getOwnPropertyDescriptor(proto, name).get;
|
||||||
|
const getter = function () { return getterImpl(); };
|
||||||
|
if (nativeGet) {
|
||||||
|
Object.defineProperty(getter, 'name', { value: 'get ' + name, configurable: true });
|
||||||
|
Object.defineProperty(getter, 'toString', { value: () => nativeGet.toString(), configurable: true, writable: true });
|
||||||
|
}
|
||||||
|
Object.defineProperty(proto, name, { get: getter, configurable: true, enumerable: true });
|
||||||
|
try { delete navigator[name]; } catch (e) {}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
try { Object.defineProperty(navigator, name, { get: () => getterImpl(), configurable: true }); } catch (e2) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
(function(){
|
(function(){
|
||||||
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||||
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||||
@@ -354,18 +381,8 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
|||||||
const config = (typeof __abStealth === 'object' && __abStealth) ? __abStealth : null;
|
const config = (typeof __abStealth === 'object' && __abStealth) ? __abStealth : null;
|
||||||
if (!config || !Array.isArray(config.languages) || config.languages.length === 0) return;
|
if (!config || !Array.isArray(config.languages) || config.languages.length === 0) return;
|
||||||
const locale = typeof config.locale === 'string' ? config.locale : config.languages[0];
|
const locale = typeof config.locale === 'string' ? config.locale : config.languages[0];
|
||||||
try {
|
__abRedefineNavProto('language', () => locale);
|
||||||
Object.defineProperty(navigator, 'language', {
|
__abRedefineNavProto('languages', () => config.languages.slice());
|
||||||
get: () => locale,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
Object.defineProperty(navigator, 'languages', {
|
|
||||||
get: () => config.languages.slice(),
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
const ua = String(navigator.userAgent || '');
|
const ua = String(navigator.userAgent || '');
|
||||||
@@ -1055,10 +1072,15 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (defineContacts(navigator)) return;
|
// Prototype-first (like the vendor patch): real Chrome exposes navigator
|
||||||
try {
|
// members on the prototype, not as instance own properties. Define on the
|
||||||
defineContacts(Object.getPrototypeOf(navigator));
|
// prototype and remove any instance shadow so Object.getOwnPropertyNames(navigator)
|
||||||
} catch {}
|
// stays empty; fall back to the instance only if the prototype is locked.
|
||||||
|
if (defineContacts(Object.getPrototypeOf(navigator))) {
|
||||||
|
try { delete navigator.contacts; } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defineContacts(navigator);
|
||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
const ContentIndexCtor = typeof ContentIndex === 'function'
|
const ContentIndexCtor = typeof ContentIndex === 'function'
|
||||||
@@ -1265,12 +1287,7 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
|||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
};
|
};
|
||||||
try {
|
__abRedefineNavProto('userAgentData', () => patched);
|
||||||
Object.defineProperty(navigator, 'userAgentData', {
|
|
||||||
get: () => patched,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
|
|||||||
+40
-5
@@ -130,6 +130,20 @@ fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shorten an over-long string by keeping its head and tail and eliding the
|
||||||
|
/// middle, with a char count. Used so multi-KB URLs (JWT/OTP login links) don't
|
||||||
|
/// flood `tab list`.
|
||||||
|
fn truncate_middle(s: &str, max: usize) -> String {
|
||||||
|
let n = s.chars().count();
|
||||||
|
if n <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let keep = max.saturating_sub(1) / 2;
|
||||||
|
let head: String = s.chars().take(keep).collect();
|
||||||
|
let tail: String = s.chars().skip(n - keep).collect();
|
||||||
|
format!("{head}…{tail} [{n} chars]")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
|
||||||
if opts.json {
|
if opts.json {
|
||||||
if opts.content_boundaries {
|
if opts.content_boundaries {
|
||||||
@@ -228,12 +242,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
// Navigation response
|
// Navigation response
|
||||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
let title = data
|
||||||
println!("{} {}", color::success_indicator(), color::bold(title));
|
.get("title")
|
||||||
println!(" {}", color::dim(url));
|
.and_then(|v| v.as_str())
|
||||||
return;
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
match title {
|
||||||
|
Some(t) => {
|
||||||
|
println!("{} {}", color::success_indicator(), color::bold(t));
|
||||||
|
println!(" {}", color::dim(url));
|
||||||
|
}
|
||||||
|
// Title-less page: show the URL with the checkmark instead of an
|
||||||
|
// empty title line.
|
||||||
|
None => println!("{} {}", color::success_indicator(), color::dim(url)),
|
||||||
}
|
}
|
||||||
println!("{}", url);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
||||||
@@ -336,6 +358,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
// Eval result
|
// Eval result
|
||||||
if let Some(result) = data.get("result") {
|
if let Some(result) = data.get("result") {
|
||||||
|
// Surface which page the eval actually ran on — to stderr, so it
|
||||||
|
// never corrupts the parsed value on stdout. Lets an agent catch tab
|
||||||
|
// drift (commands landing on the wrong tab) before trusting a result,
|
||||||
|
// e.g. a logged-in `fetch` that hit the wrong origin. (In
|
||||||
|
// content-boundaries mode the origin is already in the banner.)
|
||||||
|
if !opts.content_boundaries {
|
||||||
|
if let Some(o) = origin.filter(|o| !o.is_empty()) {
|
||||||
|
eprintln!("eval @ {o}");
|
||||||
|
}
|
||||||
|
}
|
||||||
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
|
||||||
print_with_boundaries(&formatted, origin, opts);
|
print_with_boundaries(&formatted, origin, opts);
|
||||||
return;
|
return;
|
||||||
@@ -414,6 +446,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Untitled");
|
.unwrap_or("Untitled");
|
||||||
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
|
||||||
|
// the list stays readable instead of flooding the terminal.
|
||||||
|
let url = truncate_middle(url, 120);
|
||||||
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let marker = if active {
|
let marker = if active {
|
||||||
color::cyan("→")
|
color::cyan("→")
|
||||||
|
|||||||
+3
-1
@@ -84,7 +84,9 @@ fn embedded_skills_root() -> Option<PathBuf> {
|
|||||||
let _ = fs::create_dir_all(base.join("skills"));
|
let _ = fs::create_dir_all(base.join("skills"));
|
||||||
let _ = fs::create_dir_all(base.join("skill-data"));
|
let _ = fs::create_dir_all(base.join("skill-data"));
|
||||||
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|
||||||
|| EMBEDDED_SKILL_DATA.extract(base.join("skill-data")).is_err()
|
|| EMBEDDED_SKILL_DATA
|
||||||
|
.extract(base.join("skill-data"))
|
||||||
|
.is_err()
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -62,7 +62,10 @@ pub fn run_upgrade() {
|
|||||||
color::success_indicator()
|
color::success_indicator()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("{} Upgrade failed. Install manually:", color::error_indicator());
|
eprintln!(
|
||||||
|
"{} Upgrade failed. Install manually:",
|
||||||
|
color::error_indicator()
|
||||||
|
);
|
||||||
eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
|
eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ fn build_doctor_cmd(tmp: &TempDir, args: &[&str]) -> Command {
|
|||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `doctor --offline --quick` runs the full check suite and, on Windows, does
|
||||||
|
// not exit while its stdout is captured by `Command::output()` (the `--help`
|
||||||
|
// variant below exits fine) — so the test would block forever. The 767-test
|
||||||
|
// main suite passes on Windows; this is the one binary-spawning doctor check
|
||||||
|
// that hangs there. Skip it on Windows until the Windows doctor exit/pipe
|
||||||
|
// behavior is fixed; it still runs on Linux/macOS.
|
||||||
|
#[cfg_attr(
|
||||||
|
windows,
|
||||||
|
ignore = "doctor --offline hangs on Windows under captured stdout"
|
||||||
|
)]
|
||||||
#[test]
|
#[test]
|
||||||
fn doctor_offline_quick_json_emits_valid_payload() {
|
fn doctor_offline_quick_json_emits_valid_payload() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,9 @@ const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about
|
|||||||
|
|
||||||
/** @type {chrome.runtime.Port|null} */
|
/** @type {chrome.runtime.Port|null} */
|
||||||
let port = null
|
let port = null
|
||||||
|
/** Whether the native-messaging host (the local agent-browser CLI) is linked.
|
||||||
|
* Read by the popup status page. */
|
||||||
|
let hostConnected = false
|
||||||
let nextSession = 1
|
let nextSession = 1
|
||||||
/** tabId -> { sessionId, targetId } */
|
/** tabId -> { sessionId, targetId } */
|
||||||
const tabs = new Map()
|
const tabs = new Map()
|
||||||
@@ -92,13 +95,16 @@ function connectHost() {
|
|||||||
if (port) return
|
if (port) return
|
||||||
try {
|
try {
|
||||||
port = chrome.runtime.connectNative(HOST_NAME)
|
port = chrome.runtime.connectNative(HOST_NAME)
|
||||||
|
hostConnected = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
port = null
|
port = null
|
||||||
|
hostConnected = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
port.onMessage.addListener((msg) => void whenReady(() => onHostMessage(msg)))
|
port.onMessage.addListener((msg) => void whenReady(() => onHostMessage(msg)))
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
port = null
|
port = null
|
||||||
|
hostConnected = false
|
||||||
// Sessions are stale once the host is gone; the daemon re-discovers on
|
// Sessions are stale once the host is gone; the daemon re-discovers on
|
||||||
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
|
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
|
||||||
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
|
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
|
||||||
@@ -343,7 +349,18 @@ chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => detachTab(tabI
|
|||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost))
|
chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost))
|
||||||
chrome.runtime.onStartup.addListener(() => void whenReady(connectHost))
|
chrome.runtime.onStartup.addListener(() => void whenReady(connectHost))
|
||||||
chrome.action.onClicked.addListener(() => void whenReady(connectHost))
|
|
||||||
|
// Popup status page asks for the live pairing state. Attempt a (re)connect on
|
||||||
|
// demand so opening the popup also nudges the link awake, then report.
|
||||||
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
|
if (msg && msg.type === 'ab-status') {
|
||||||
|
if (!port) {
|
||||||
|
try { connectHost() } catch (e) {}
|
||||||
|
}
|
||||||
|
sendResponse({ connected: hostConnected, tabCount: tabs.size, host: HOST_NAME })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
// MV3 service workers get suspended; an alarm wakes us to keep the host link
|
// MV3 service workers get suspended; an alarm wakes us to keep the host link
|
||||||
// and badges fresh.
|
// and badges fresh.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.",
|
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "agent-browser-stealth"
|
"default_title": "agent-browser-stealth",
|
||||||
|
"default_popup": "popup.html"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #161a21;
|
||||||
|
--fg: #e6edf3;
|
||||||
|
--muted: #8b949e;
|
||||||
|
--cyan: #2ad4ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--amber: #d29922;
|
||||||
|
--border: #232a33;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header img { width: 32px; height: 32px; border-radius: 7px; }
|
||||||
|
header .title { font-weight: 600; font-size: 14px; }
|
||||||
|
header .ver { color: var(--muted); font-size: 11px; }
|
||||||
|
main { padding: 14px 16px 8px; }
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
background: var(--muted); flex: none;
|
||||||
|
box-shadow: 0 0 0 0 rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
.dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||||
|
.dot.off { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
|
||||||
|
.status .label { font-weight: 600; }
|
||||||
|
.status .sub { color: var(--muted); font-size: 11px; }
|
||||||
|
.desc { color: var(--muted); margin: 12px 2px 4px; }
|
||||||
|
.hint {
|
||||||
|
margin: 10px 0 2px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: #1d1a12;
|
||||||
|
border: 1px solid #3a3014;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e3c878;
|
||||||
|
font-size: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #0b0d10;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
footer .privacy { color: var(--muted); font-size: 11px; }
|
||||||
|
footer a { color: var(--cyan); text-decoration: none; font-size: 11px; cursor: pointer; }
|
||||||
|
footer a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="icons/icon128.png" alt="" />
|
||||||
|
<div>
|
||||||
|
<div class="title">agent-browser-stealth</div>
|
||||||
|
<div class="ver">local automation bridge</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="status">
|
||||||
|
<span id="dot" class="dot"></span>
|
||||||
|
<div>
|
||||||
|
<div class="label" id="statusLabel">Checking…</div>
|
||||||
|
<div class="sub" id="statusSub">contacting the local CLI</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="desc">
|
||||||
|
Lets your locally-installed <strong>agent-browser</strong> command-line tool
|
||||||
|
drive your own logged-in Chrome tabs — entirely on this machine, only when
|
||||||
|
you run a command. No remote server, no data collection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hint" id="hint">
|
||||||
|
Not linked yet. Install & pair the CLI, then reopen this popup:
|
||||||
|
<code>agent-browser extension install</code>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span class="privacy">No tracking · no remote server</span>
|
||||||
|
<a id="repo" data-href="https://github.com/leeguooooo/agent-browser-stealth">GitHub ↗</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Popup status page for agent-browser-stealth.
|
||||||
|
// Asks the service worker whether the native-messaging link to the local
|
||||||
|
// agent-browser CLI is live, and renders a paired / not-paired indicator.
|
||||||
|
|
||||||
|
const dot = document.getElementById('dot')
|
||||||
|
const label = document.getElementById('statusLabel')
|
||||||
|
const sub = document.getElementById('statusSub')
|
||||||
|
const hint = document.getElementById('hint')
|
||||||
|
|
||||||
|
let resolved = false
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
resolved = true
|
||||||
|
const connected = !!(state && state.connected)
|
||||||
|
dot.classList.remove('on', 'off')
|
||||||
|
if (connected) {
|
||||||
|
dot.classList.add('on')
|
||||||
|
label.textContent = 'Connected'
|
||||||
|
const n = state.tabCount | 0
|
||||||
|
sub.textContent =
|
||||||
|
n > 0
|
||||||
|
? `bridged to the local CLI · ${n} tab${n === 1 ? '' : 's'} attached`
|
||||||
|
: 'bridged to the local CLI · ready'
|
||||||
|
hint.style.display = 'none'
|
||||||
|
} else {
|
||||||
|
dot.classList.add('off')
|
||||||
|
label.textContent = 'Not paired'
|
||||||
|
sub.textContent = 'no local agent-browser CLI linked'
|
||||||
|
hint.style.display = 'block'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryStatus() {
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({ type: 'ab-status' }, (resp) => {
|
||||||
|
// lastError fires if the service worker can't be reached.
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
render({ connected: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(resp)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
render({ connected: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the repo in a real tab (no inline handlers under MV3 CSP).
|
||||||
|
const repo = document.getElementById('repo')
|
||||||
|
if (repo) {
|
||||||
|
repo.addEventListener('click', () => {
|
||||||
|
chrome.tabs.create({ url: repo.dataset.href })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query now, then once more shortly after — opening the popup also nudges the
|
||||||
|
// service worker to (re)connect the host, which may complete a beat later.
|
||||||
|
queryStatus()
|
||||||
|
setTimeout(queryStatus, 700)
|
||||||
|
|
||||||
|
// Never leave the popup stuck on "Checking…" if the worker never answers.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!resolved) render({ connected: false })
|
||||||
|
}, 1500)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Chrome Web Store 提交指南 — agent-browser connect</title>
|
<title>Chrome Web Store 提交指南 — agent-browser-stealth</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--fg:#1a1a1a;--muted:#5c5c5c;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--border:#e2e2e2;--bg:#fff;--code:#f5f5f7}
|
:root{--fg:#1a1a1a;--muted:#5c5c5c;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--border:#e2e2e2;--bg:#fff;--code:#f5f5f7}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Chrome Web Store 提交指南</h1>
|
<h1>Chrome Web Store 提交指南</h1>
|
||||||
<div class="sub">agent-browser connect · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
|
<div class="sub">agent-browser-stealth · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
|
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
|
||||||
@@ -53,13 +53,13 @@
|
|||||||
<h2>三、商店信息(直接复制以下文案)</h2>
|
<h2>三、商店信息(直接复制以下文案)</h2>
|
||||||
|
|
||||||
<h3>名称 / Name</h3>
|
<h3>名称 / Name</h3>
|
||||||
<pre>agent-browser connect</pre>
|
<pre>agent-browser-stealth</pre>
|
||||||
|
|
||||||
<h3>简介 / Summary(≤132 字符)</h3>
|
<h3>简介 / Summary(≤132 字符)</h3>
|
||||||
<pre>Let your own agent-browser CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
|
<pre>Let your own agent-browser CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
|
||||||
|
|
||||||
<h3>详细描述 / Description</h3>
|
<h3>详细描述 / Description</h3>
|
||||||
<pre>agent-browser connect is the in-browser half of the open-source agent-browser CLI. It lets the
|
<pre>agent-browser-stealth is the in-browser half of the open-source agent-browser CLI. It lets the
|
||||||
command-line tool you installed on this same computer automate the Chrome you're already logged
|
command-line tool you installed on this same computer automate the Chrome you're already logged
|
||||||
into — opening pages, clicking, filling forms, reading the DOM — driven entirely by you.
|
into — opening pages, clicking, filling forms, reading the DOM — driven entirely by you.
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ automate pages the user is working with, entirely on the user's machine and at t
|
|||||||
<tr><th>权限</th><th>理由(复制到对应输入框)</th></tr>
|
<tr><th>权限</th><th>理由(复制到对应输入框)</th></tr>
|
||||||
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local agent-browser CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
|
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local agent-browser CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
|
||||||
<tr><td class="field">tabs</td><td>Enumerate and target the correct open tab to attach automation to.</td></tr>
|
<tr><td class="field">tabs</td><td>Enumerate and target the correct open tab to attach automation to.</td></tr>
|
||||||
|
<tr><td class="field">tabGroups</td><td>Organizes the tabs the local agent-browser CLI drives into a labeled, colored Chrome tab group per automation session, so the user can see at a glance which tabs are under automation and they stay visually separated from the user's own tabs.</td></tr>
|
||||||
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the agent-browser CLI installed on the same machine. No network is used.</td></tr>
|
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the agent-browser CLI installed on the same machine. No network is used.</td></tr>
|
||||||
<tr><td class="field">storage</td><td>Persist small local pairing/configuration state for the extension.</td></tr>
|
<tr><td class="field">storage</td><td>Persist small local pairing/configuration state for the extension.</td></tr>
|
||||||
<tr><td class="field">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
|
<tr><td class="field">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Privacy Policy — agent-browser connect</title>
|
<title>Privacy Policy — agent-browser-stealth</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--fg:#1a1a1a; --muted:#5c5c5c; --accent:#2563eb; --border:#e2e2e2; --bg:#fff; --code:#f5f5f5;
|
--fg:#1a1a1a; --muted:#5c5c5c; --accent:#2563eb; --border:#e2e2e2; --bg:#fff; --code:#f5f5f5;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Privacy Policy — agent-browser connect</h1>
|
<h1>Privacy Policy — agent-browser-stealth</h1>
|
||||||
<div class="sub">Chrome extension (id <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>) · Last updated 2026-06-09</div>
|
<div class="sub">Chrome extension (id <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>) · Last updated 2026-06-09</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ user's own <code>agent-browser</code> command-line tool, running on the same com
|
|||||||
user's logged-in Chrome.</p>
|
user's logged-in Chrome.</p>
|
||||||
|
|
||||||
<h2>What the extension does</h2>
|
<h2>What the extension does</h2>
|
||||||
<p>agent-browser connect pairs Chrome with the locally-installed <code>agent-browser</code> CLI over
|
<p>agent-browser-stealth pairs Chrome with the locally-installed <code>agent-browser</code> CLI over
|
||||||
Chrome <em>native messaging</em> (a local inter-process channel; no network socket, no token). When
|
Chrome <em>native messaging</em> (a local inter-process channel; no network socket, no token). When
|
||||||
the user issues an automation command in the CLI, the extension relays Chrome DevTools Protocol
|
the user issues an automation command in the CLI, the extension relays Chrome DevTools Protocol
|
||||||
operations to the tab the user targets. Everything happens on the user's machine, initiated by the
|
operations to the tab the user targets. Everything happens on the user's machine, initiated by the
|
||||||
@@ -71,7 +71,7 @@ extension talks only to a program the user installed on the same computer.</p>
|
|||||||
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/agent-browser-stealth</code></p>
|
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/agent-browser-stealth</code></p>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
agent-browser connect is open source (Apache-2.0). This policy applies to the extension only.
|
agent-browser-stealth is open source (Apache-2.0). This policy applies to the extension only.
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.29",
|
"version": "0.27.0-fork.44",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"abs": "bin/agent-browser.js"
|
"abs": "bin/agent-browser.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky || true",
|
||||||
"version:sync": "node scripts/sync-version.js",
|
"version:sync": "node scripts/sync-version.js",
|
||||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||||
|
|||||||
@@ -27,17 +27,10 @@ if (!cargoVersionMatch) {
|
|||||||
|
|
||||||
const cargoVersion = cargoVersionMatch[1];
|
const cargoVersion = cargoVersionMatch[1];
|
||||||
|
|
||||||
// Read dashboard package.json version
|
|
||||||
const dashboardPkg = JSON.parse(readFileSync(join(rootDir, 'packages/dashboard/package.json'), 'utf-8'));
|
|
||||||
const dashboardVersion = dashboardPkg.version;
|
|
||||||
|
|
||||||
const mismatches = [];
|
const mismatches = [];
|
||||||
if (packageVersion !== cargoVersion) {
|
if (packageVersion !== cargoVersion) {
|
||||||
mismatches.push(` cli/Cargo.toml: ${cargoVersion}`);
|
mismatches.push(` cli/Cargo.toml: ${cargoVersion}`);
|
||||||
}
|
}
|
||||||
if (packageVersion !== dashboardVersion) {
|
|
||||||
mismatches.push(` packages/dashboard: ${dashboardVersion}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mismatches.length > 0) {
|
if (mismatches.length > 0) {
|
||||||
console.error('Version mismatch detected!');
|
console.error('Version mismatch detected!');
|
||||||
|
|||||||
+30
-10
@@ -1,11 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Build the Chrome Web Store upload package extensions/ab-connect.zip (and a signed
|
# Build the Chrome Web Store upload package extensions/ab-connect.zip (and a signed
|
||||||
# extensions/ab-connect.crx for reference) from extensions/ab-connect, keeping the
|
# extensions/ab-connect.crx for reference) from extensions/ab-connect.
|
||||||
# extension id constant via the stable signing key + manifest "key".
|
|
||||||
#
|
#
|
||||||
# The id MUST stay ciiljdlhdpfckdcfkphgmfalanpdejep so the native-messaging
|
# IMPORTANT — the "key" field:
|
||||||
# allowed_origins and the force-install policy keep matching. The id is pinned by
|
# * The unpacked DIR (Load-unpacked) and the signed .crx KEEP the manifest "key",
|
||||||
# the "key" field in manifest.json (kept in the uploaded zip on purpose).
|
# which pins the id to ciiljdlhdpfckdcfkphgmfalanpdejep so the native-messaging
|
||||||
|
# allowed_origins + managed force-install policy keep matching for local/dev use.
|
||||||
|
# * The Web Store UPLOAD zip MUST NOT contain "key" — the store rejects it
|
||||||
|
# ("manifest must not contain 'key'") and assigns its own id. So this script
|
||||||
|
# strips "key" from the manifest inside the zip only. After the first upload,
|
||||||
|
# note the store-assigned id and add it to the native-messaging allowed_origins
|
||||||
|
# (cli/src/connect.rs EXTENSION_ID) so the store build can pair too.
|
||||||
#
|
#
|
||||||
# The private key lives at .secrets/ab-connect.pem and is git-ignored.
|
# The private key lives at .secrets/ab-connect.pem and is git-ignored.
|
||||||
#
|
#
|
||||||
@@ -20,20 +25,35 @@ KEY=.secrets/ab-connect.pem
|
|||||||
EXT=extensions/ab-connect
|
EXT=extensions/ab-connect
|
||||||
CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
|
CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
|
||||||
|
|
||||||
# Web Store upload package (zip of the unpacked extension, dotfiles excluded).
|
# Web Store upload package: stage a copy with the "key" field removed, then zip.
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
cp -R "$EXT/." "$STAGE/"
|
||||||
|
python3 - "$STAGE/manifest.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
m = json.load(open(p))
|
||||||
|
m.pop("key", None) # the Web Store forbids the "key" field in uploads
|
||||||
|
json.dump(m, open(p, "w"), indent=2)
|
||||||
|
open(p, "a").write("\n")
|
||||||
|
PY
|
||||||
rm -f extensions/ab-connect.zip
|
rm -f extensions/ab-connect.zip
|
||||||
( cd "$EXT" && zip -rq ../ab-connect.zip . -x '.*' )
|
( cd "$STAGE" && zip -rq "$OLDPWD/extensions/ab-connect.zip" . -x '.*' )
|
||||||
[ -f extensions/ab-connect.zip ] || { echo "error: zip failed" >&2; exit 1; }
|
[ -f extensions/ab-connect.zip ] || { echo "error: zip failed" >&2; exit 1; }
|
||||||
|
if unzip -p extensions/ab-connect.zip manifest.json | grep -q '"key"'; then
|
||||||
|
echo "error: 'key' still present in upload zip" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "packed extensions/ab-connect.zip (key stripped for Web Store)"
|
||||||
|
|
||||||
# Signed crx (reference / non-store force-install for managed setups).
|
# Signed crx (reference / non-store force-install for managed setups) — keeps "key"
|
||||||
|
# via the signing key so the id stays ciiljdlhdpfckdcfkphgmfalanpdejep.
|
||||||
if [ -f "$KEY" ]; then
|
if [ -f "$KEY" ]; then
|
||||||
rm -f extensions/ab-connect.crx
|
rm -f extensions/ab-connect.crx
|
||||||
"$CHROME" --pack-extension="$PWD/$EXT" --pack-extension-key="$PWD/$KEY" >/dev/null 2>&1 || true
|
"$CHROME" --pack-extension="$PWD/$EXT" --pack-extension-key="$PWD/$KEY" >/dev/null 2>&1 || true
|
||||||
ID=$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null \
|
ID=$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null \
|
||||||
| openssl dgst -sha256 -binary | xxd -p -c256 | head -c32 | tr '0-9a-f' 'a-p')
|
| openssl dgst -sha256 -binary | xxd -p -c256 | head -c32 | tr '0-9a-f' 'a-p')
|
||||||
echo "extension id: $ID"
|
echo "local/crx extension id: $ID"
|
||||||
else
|
else
|
||||||
echo "note: $KEY missing — built zip only (no crx)."
|
echo "note: $KEY missing — built zip only (no crx)."
|
||||||
fi
|
fi
|
||||||
echo "packed extensions/ab-connect.zip"
|
|
||||||
echo "manifest version: $(grep -o '"version"[^,]*' "$EXT/manifest.json" | head -1)"
|
echo "manifest version: $(grep -o '"version"[^,]*' "$EXT/manifest.json" | head -1)"
|
||||||
|
|||||||
@@ -287,21 +287,20 @@ async function fixWindowsShims() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect architecture so ARM64 Windows is handled correctly
|
// Point the shims at the binary's ABSOLUTE path. The previous code rebuilt a
|
||||||
const cpuArch = arch() === 'arm64' ? 'arm64' : 'x64';
|
// relative `node_modules\agent-browser\bin\...` path, but this fork's package
|
||||||
const relativeBinaryPath = `node_modules\\agent-browser\\bin\\agent-browser-win32-${cpuArch}.exe`;
|
// is `agent-browser-stealth`, so that path never existed → the rewrite was
|
||||||
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
|
// skipped and the shim stayed the (slower) JS wrapper. `binaryPath` is the
|
||||||
|
// real absolute path to the native binary inside this package.
|
||||||
// Only rewrite shims if the native binary actually exists
|
if (!existsSync(binaryPath)) {
|
||||||
if (!existsSync(absoluteBinaryPath)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
|
const cmdContent = `@ECHO off\r\n"${binaryPath}" %*\r\n`;
|
||||||
writeFileSync(cmdShim, cmdContent);
|
writeFileSync(cmdShim, cmdContent);
|
||||||
|
|
||||||
const ps1Content = `#!/usr/bin/env pwsh\r\n$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n& "$basedir\\${relativeBinaryPath}" $args\r\nexit $LASTEXITCODE\r\n`;
|
const ps1Content = `#!/usr/bin/env pwsh\r\n& "${binaryPath}" $args\r\nexit $LASTEXITCODE\r\n`;
|
||||||
writeFileSync(ps1Shim, ps1Content);
|
writeFileSync(ps1Shim, ps1Content);
|
||||||
|
|
||||||
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
||||||
|
|||||||
+82
-21
@@ -15,6 +15,13 @@ Most normal web tasks (navigate, read, click, fill, extract, screenshot) are
|
|||||||
covered here. Load a specialized skill when the task falls outside browser
|
covered here. Load a specialized skill when the task falls outside browser
|
||||||
web pages — see [When to load another skill](#when-to-load-another-skill).
|
web pages — see [When to load another skill](#when-to-load-another-skill).
|
||||||
|
|
||||||
|
> **Hit a rough edge? Please report it.** If a command surprised you — a
|
||||||
|
> confusing error, a stale `@ref`, an occluded click, a flaky wait, a missing
|
||||||
|
> feature, or anything that cost you extra turns — open a quick issue at
|
||||||
|
> **<https://github.com/leeguooooo/agent-browser-stealth/issues>** with the exact
|
||||||
|
> command and what happened vs. what you expected. Agent-filed friction reports
|
||||||
|
> are how this tool gets sharper; a 30-second issue is genuinely valuable.
|
||||||
|
|
||||||
## The core loop
|
## The core loop
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -49,13 +56,42 @@ hand-constructed URL often doesn't.
|
|||||||
### Driving the user's real, already-open Chrome (extension)
|
### Driving the user's real, already-open Chrome (extension)
|
||||||
|
|
||||||
When the task needs the user's *live* logged-in window (their real session, the
|
When the task needs the user's *live* logged-in window (their real session, the
|
||||||
window they're looking at — not a fresh browser), use the extension connect flow:
|
window they're looking at — not a fresh browser), use the extension connect flow.
|
||||||
`agent-browser extension install` once, load `extensions/ab-connect` in
|
One-time setup:
|
||||||
`chrome://extensions` once (a GUI step you can perform with a **computer-use /
|
1. `agent-browser extension install` — registers the native-messaging host.
|
||||||
GUI-automation tool** like the `cua-driver` skill — see
|
2. Install the **agent-browser-stealth** extension. Easiest (and restart-stable):
|
||||||
`references/commands.md` → "Drive your real, logged-in Chrome"), then
|
the **Chrome Web Store**, one-click *Add to Chrome*:
|
||||||
`agent-browser extension connect`. After that it's zero-confirmation, zero-token
|
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
|
||||||
CLI. Use `--launch` instead when a fresh, isolated browser is fine.
|
(Dev fallback: `chrome://extensions` → Developer mode → *Load unpacked* →
|
||||||
|
`extensions/ab-connect`. Load-unpacked can be disabled on Chrome restart, so
|
||||||
|
prefer the Store build for unattended setups.)
|
||||||
|
|
||||||
|
Once installed, plain `agent-browser open <url>` auto-connects through the
|
||||||
|
extension relay — `auto_connect_cdp` **prefers the live relay over a raw
|
||||||
|
`--remote-debugging-port`**, so Chrome 136+'s "Allow remote debugging?" consent
|
||||||
|
popup never fires. `agent-browser extension connect` is the explicit form of the
|
||||||
|
same path. Zero-confirmation, zero-token. Use `--launch` instead when a fresh,
|
||||||
|
isolated browser is fine.
|
||||||
|
|
||||||
|
**If you DO hit the "Allow remote debugging?" dialog**, don't keep retrying (every
|
||||||
|
attempt re-pops it). One of two things is true:
|
||||||
|
|
||||||
|
1. **You're on a stale build.** The relay-preference that avoids this dialog
|
||||||
|
landed in **fork.30**. Run `agent-browser --version`: if it's below
|
||||||
|
`0.27.0-fork.30`, upgrade and retry:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||||
|
```
|
||||||
|
If `which -a agent-browser` shows more than one install, an old **npm/pnpm**
|
||||||
|
copy (the npm registry lags behind — Releases are the source of truth) may be
|
||||||
|
shadowing the upgraded one; remove the stale copy
|
||||||
|
(`npm rm -g agent-browser-stealth` / `pnpm rm -g agent-browser-stealth`) so the
|
||||||
|
`install.sh` build wins. A tool that bundles its *own* pinned copy
|
||||||
|
(e.g. `node .../agent-browser-stealth@0.24.x/.../agent-browser`) needs that
|
||||||
|
copy upgraded too.
|
||||||
|
2. **The extension/relay isn't live.** Tell the user to install the Store
|
||||||
|
extension (one click, above); after that the relay stays up and the dialog
|
||||||
|
never returns.
|
||||||
|
|
||||||
Each `--session` that connects gets its **own colored Chrome tab group** (named
|
Each `--session` that connects gets its **own colored Chrome tab group** (named
|
||||||
after the session) and drives only its own tabs — multiple agents share the one
|
after the session) and drives only its own tabs — multiple agents share the one
|
||||||
@@ -66,6 +102,20 @@ connect) > a headed launched browser > headless (forbidden).** A genuine human
|
|||||||
browser has no headless/automation tells at all, so prefer it for anything
|
browser has no headless/automation tells at all, so prefer it for anything
|
||||||
anti-bot-sensitive.
|
anti-bot-sensitive.
|
||||||
|
|
||||||
|
**Silent by default.** When driving the user's real Chrome the agent works
|
||||||
|
entirely in the background — new tabs open un-focused, the agent never force-
|
||||||
|
fronts a tab, and focus is emulated so the page still renders and reports
|
||||||
|
`visibilityState: 'visible'`. You don't need to do anything; just don't expect
|
||||||
|
the user's view to follow you (use the explicit `bringToFront` only if you
|
||||||
|
deliberately want to surface a tab).
|
||||||
|
|
||||||
|
**Human-like input for behavioural anti-bot.** Beyond fingerprint stealth,
|
||||||
|
`--humanize off|fast|human` (or `AGENT_BROWSER_HUMANIZE`) makes clicks follow a
|
||||||
|
curved, decelerating path with in-element landing jitter, typing use variable
|
||||||
|
cadence, and scroll/drag ease. Default `off`; a per-navigation detector
|
||||||
|
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
|
||||||
|
on auto; force `human` only when you already know the target scores behaviour.
|
||||||
|
|
||||||
## Two ways to drive a page — and when to drop to `eval`
|
## Two ways to drive a page — and when to drop to `eval`
|
||||||
|
|
||||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||||
@@ -132,14 +182,17 @@ Snapshot output looks like:
|
|||||||
Page: Example - Log in
|
Page: Example - Log in
|
||||||
URL: https://example.com/login
|
URL: https://example.com/login
|
||||||
|
|
||||||
@e1 [heading] "Log in"
|
- heading "Log in" [level=1, ref=e1]
|
||||||
@e2 [form]
|
- textbox "Email" [ref=e2]
|
||||||
@e3 [input type="email"] placeholder="Email"
|
- textbox "Password" [ref=e3]
|
||||||
@e4 [input type="password"] placeholder="Password"
|
- button "Continue" [ref=e4]
|
||||||
@e5 [button type="submit"] "Continue"
|
- link "Forgot password?" [ref=e5]
|
||||||
@e6 [link] "Forgot password?"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each line is `- <role> "<accessible name>" [<attrs>, ref=eN]`, indented by nesting
|
||||||
|
depth. You pass the ref to commands as `@eN` (e.g. `click @e4`). Refs are
|
||||||
|
assigned fresh on every snapshot.
|
||||||
|
|
||||||
For unstructured reading (no refs needed):
|
For unstructured reading (no refs needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -336,9 +389,16 @@ Array.from(rows).map(r => ({
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer `eval --stdin` (heredoc) or `eval -b <base64>` for any JS with
|
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
|
||||||
quotes or special characters. Inline `agent-browser eval "..."` works
|
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
|
||||||
only for simple expressions.
|
large scripts — inline `agent-browser eval "..."` is shell-mangled and works
|
||||||
|
only for simple ASCII expressions.
|
||||||
|
|
||||||
|
**`eval` runs in the page's MAIN world and state persists across calls**, so a
|
||||||
|
top-level `const x`/`let x`/`var x` in one call collides with the next
|
||||||
|
(`SyntaxError: Identifier 'x' has already been declared`). Either use unique
|
||||||
|
names, assign to `window.x`, or wrap the body in an IIFE
|
||||||
|
(`(() => { const x = …; return x; })()`).
|
||||||
|
|
||||||
### Screenshot
|
### Screenshot
|
||||||
|
|
||||||
@@ -359,13 +419,14 @@ Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
|||||||
```bash
|
```bash
|
||||||
agent-browser tab # list open tabs (with stable tabId)
|
agent-browser tab # list open tabs (with stable tabId)
|
||||||
agent-browser tab new https://docs... # open a new tab (and switch to it)
|
agent-browser tab new https://docs... # open a new tab (and switch to it)
|
||||||
agent-browser tab 2 # switch to tab 2
|
agent-browser tab t2 # switch to tab t2
|
||||||
agent-browser tab close 2 # close tab 2
|
agent-browser tab close t2 # close tab t2
|
||||||
```
|
```
|
||||||
|
|
||||||
Stable `tabId`s mean `tab 2` points at the same tab across commands even
|
Tab ids are stable strings (`t1`, `t2`, …), never reused within a session, so
|
||||||
when other tabs open or close. After switching, refs from a prior snapshot
|
the same id keeps referring to the same tab across commands. Positional
|
||||||
on a different tab no longer apply — re-snapshot.
|
integers are **not** accepted — use `t2`, not `2`. After switching, refs from a
|
||||||
|
prior snapshot on a different tab no longer apply — re-snapshot.
|
||||||
|
|
||||||
### Run multiple browsers in parallel
|
### Run multiple browsers in parallel
|
||||||
|
|
||||||
|
|||||||
@@ -330,11 +330,30 @@ One-time setup:
|
|||||||
```bash
|
```bash
|
||||||
agent-browser extension install # writes the native-messaging host manifest
|
agent-browser extension install # writes the native-messaging host manifest
|
||||||
```
|
```
|
||||||
Then load the extension **once** — this is a GUI step (Chrome's `chrome://extensions`
|
|
||||||
is privileged; the CLI can't load an unpacked extension):
|
The native-messaging host accepts **both** extension origins, so either install
|
||||||
|
works — but prefer the Store build:
|
||||||
|
|
||||||
|
1. **Chrome Web Store (recommended)** — one-click *Add to Chrome*:
|
||||||
|
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
|
||||||
|
Restart-stable and auto-updating (store id `knfcmbamhjmaonkfnjhldjedeobeafmk`).
|
||||||
|
2. **Load unpacked (dev)** — load `<repo>/extensions/ab-connect` from source;
|
||||||
|
its pinned `key` gives the stable id `ciiljdlhd…`. NOTE: Load-unpacked
|
||||||
|
extensions can be disabled/dropped on Chrome restart (Developer-mode handling),
|
||||||
|
which silently drops the relay — so for unattended setups use the Store build.
|
||||||
|
|
||||||
|
For Load unpacked — a GUI step (Chrome's `chrome://extensions` is privileged; the
|
||||||
|
CLI can't load an unpacked extension):
|
||||||
|
|
||||||
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked** →
|
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked** →
|
||||||
> select `<repo>/extensions/ab-connect`
|
> select `<repo>/extensions/ab-connect` (it appears in the list as
|
||||||
|
> **agent-browser-stealth**)
|
||||||
|
|
||||||
|
Once loaded, the relay goes live and plain `agent-browser open <url>` connects
|
||||||
|
through it automatically — `auto_connect_cdp` prefers the live extension relay
|
||||||
|
over a raw `--remote-debugging-port`, so Chrome 136+'s "Allow remote debugging?"
|
||||||
|
consent popup never appears. `agent-browser extension connect` is the explicit
|
||||||
|
form of the same path.
|
||||||
|
|
||||||
**You can do this load step yourself with a computer-use / GUI-automation tool**
|
**You can do this load step yourself with a computer-use / GUI-automation tool**
|
||||||
(e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer
|
(e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ Electron apps often have multiple windows or webviews. Use tab commands to list
|
|||||||
# List all available targets (windows, webviews, etc.)
|
# List all available targets (windows, webviews, etc.)
|
||||||
agent-browser tab
|
agent-browser tab
|
||||||
|
|
||||||
# Switch to a specific tab by index
|
# Switch to a specific tab by id (t1, t2, …; integers not accepted)
|
||||||
agent-browser tab 2
|
agent-browser tab t2
|
||||||
|
|
||||||
# Switch by URL pattern
|
# Switch by URL pattern
|
||||||
agent-browser tab --url "*settings*"
|
agent-browser tab --url "*settings*"
|
||||||
@@ -117,7 +117,7 @@ agent-browser tab
|
|||||||
# 1: [webview] Embedded Content https://example.com/widget
|
# 1: [webview] Embedded Content https://example.com/widget
|
||||||
|
|
||||||
# Switch to a webview
|
# Switch to a webview
|
||||||
agent-browser tab 1
|
agent-browser tab t1
|
||||||
|
|
||||||
# Interact with the webview normally
|
# Interact with the webview normally
|
||||||
agent-browser snapshot -i
|
agent-browser snapshot -i
|
||||||
|
|||||||
Reference in New Issue
Block a user