Compare commits

...
47 Commits
Author SHA1 Message Date
leeguooooo ae22bda46c chore(release): bump to 0.16.1-fork.3 2026-03-04 11:35:11 +09:00
leeguooooo d82357fea4 fix(release): enforce bundled binary version checks and bump to 0.16.1-fork.2 2026-03-04 10:25:04 +09:00
leeguooooo 66a39f3c83 feat: add abs alias, refresh extension panel, and bump to 0.16.1-fork.1 2026-03-04 09:57:56 +09:00
leeguooooo eedf824af7 merge: upstream v0.16.1 while preserving fork stealth behaviors 2026-03-04 09:48:58 +09:00
leeguooooo c07eb7ee52 chore: 更新 Cloudflare 及浏览器自动化攻防文章并补发 blog 链接 2026-03-04 09:39:38 +09:00
leeguooooo d32a1d046a Detail agent stealth Cloudflare fix 2026-03-03 18:04:44 +09:00
leeguooooo 0c0ed5e72c chore(release): bump version to 0.15.2-fork.2 2026-03-03 17:13:07 +09:00
leeguooooo 34092ec193 fix: prevent stale native version and skip npm-bin rewrite on pnpm 2026-03-03 17:12:17 +09:00
leeguooooo 726377c4c1 feat: add doctor diagnostics and bump to 0.15.2-fork.1 2026-03-03 17:05:42 +09:00
leeguooooo 8e2e4abce6 feat: complete agent-browser-stealth extension controls 2026-03-03 12:33:46 +09:00
leeguooooo 870895e922 feat: expand agent-browser-stealth extension capabilities 2026-03-03 12:28:34 +09:00
leeguooooo 2a766cfe48 feat: add CDP tab-group plugin handshake with silent fallback 2026-03-03 12:17:14 +09:00
leeguooooo d04cf59238 feat: auto-group agent tabs by default in local Chromium 2026-03-03 11:37:53 +09:00
leeguooooo 0a257ad2c1 merge: sync upstream/main into fork main (v0.15.2) 2026-03-03 09:57:17 +09:00
leeguooooo 44c0361fcd Update readme with stealth FAQ 2026-03-02 18:32:28 +09:00
leeguooooo 907ca8c808 Update agent-browser skill installs 2026-03-02 09:38:48 +09:00
leeguooooo 74fda70b67 fix(release): publish correct native version in fork.7 2026-02-28 11:29:21 +09:00
leeguooooo 2a397de59f merge: sync upstream/main into fork main 2026-02-28 11:19:50 +09:00
leeguooooo bf672ee7f9 fix(stealth): avoid matchMedia Illegal invocation
- bind MediaQueryList methods when proxied for prefers-color-scheme light patch

- add regression test for addEventListener/removeEventListener

- bump version to 0.14.0-fork.6 and sync Cargo metadata
2026-02-28 11:17:02 +09:00
leeguooooo 74910cfef1 Merge tag 'v0.15.0' into codex/sync-v0.15.0
v0.15.0

# Conflicts:
#	CHANGELOG.md
#	README.md
#	cli/Cargo.lock
#	cli/Cargo.toml
#	cli/src/commands.rs
#	cli/src/connection.rs
#	cli/src/flags.rs
#	cli/src/main.rs
#	docs/src/app/commands/page.mdx
#	docs/src/app/configuration/page.mdx
#	package.json
#	src/actions.ts
2026-02-27 10:49:55 +09:00
leeguooooo 41830dff71 fix(cookies): require domain and path together when url is absent 2026-02-27 10:49:12 +09:00
leeguooooo e005c7251b feat(stealth): add risk-mode signals and document stealth architecture 2026-02-25 10:27:45 +09:00
leeguooooo 11eab471f1 chore(release): bump version to 0.14.0-fork.4 2026-02-25 10:10:26 +09:00
leeguooooo aa256e30c7 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	README.md
#	cli/src/connection.rs
#	cli/src/flags.rs
#	cli/src/main.rs
#	src/browser.ts
#	src/protocol.ts
2026-02-25 10:06:20 +09:00
leeguooooo 6f1dd39121 chore(clawhub): 改为本地 pre-push 自动同步 skill 2026-02-24 18:10:05 +09:00
leeguooooo 85d18799a4 feat(skill): 新增 agent-browser-stealth 的 OpenClaw skill 与 ClawHub 自动同步 2026-02-24 18:05:29 +09:00
leeguooooo 43e781a8d3 docs(readme): 精简文档并聚焦反爬能力 2026-02-24 18:00:19 +09:00
leeguooooo 25e8719e51 docs(readme): 补充 --delay 文本转义与 stealth 行为说明 2026-02-24 17:58:13 +09:00
leeguooooo ec011f46ff fix(stealth): 修复 launch 选项与测试基线不一致问题
在 stealth 默认策略下保留自定义 user-agent,不再被 CDP 覆盖。

同步更新 protocol/browser/launch/file-access 相关测试预期,并放宽 browser.test 的 hook 超时以消除偶发超时。
2026-02-24 17:42:12 +09:00
leeguooooo aef8fcc038 ci(release): 修复 OIDC 发布认证链路
移除 setup-node 的 registry-url 注入,避免发布步骤继承无效 NODE_AUTH_TOKEN。

发布前升级 npm 到 v11,使用独立 npmrc 并启用 provenance,以匹配 npm trusted publishing。
2026-02-24 17:24:20 +09:00
leeguooooo 96582b79fd ci(release): 调整 trusted publishing 发布流程
将 changesets/action 改为仅处理 version/PR,不再由其执行 publish。

新增发布前版本检查与独立 pnpm ci:publish 步骤,避免 OIDC 发布在 action 内失败。
2026-02-24 17:20:17 +09:00
leeguooooo 058a286326 chore(release): 发布 0.14.0-fork.3
更新 package.json 版本并写入对应 changelog 条目。
2026-02-24 17:14:06 +09:00
leeguooooo b1f27236d8 fix(cli): 修复 type/keyboard 的 --delay 参数解析
将 --delay <ms> 从输入文本中剥离并写入 delay 字段,避免搜索词混入参数。

同时支持使用 -- 终止参数解析以输入字面量 --delay 文本,并补充回归测试与帮助文档。
2026-02-24 17:13:46 +09:00
leeguooooo a5a9327b7d docs(home): 补充首页能力亮点说明
- 新增自动区域检测能力描述

- 新增验证码自动重试能力描述
2026-02-24 17:06:34 +09:00
leeguooooo 699ccbd3cb feat(cli): 强制使用用户现有浏览器并移除 profile/channel
- 禁用 --profile/AGENT_BROWSER_PROFILE 与 --channel/AGENT_BROWSER_CHANNEL,并给出项目策略提示

- 默认模式强制连接 localhost:9333,连接失败直接报错,不再自动回退新开浏览器

- 同步更新 README、技能文档、docs 与 --help 输出

- 版本升级到 0.14.0-fork.2 并同步 cli/Cargo.toml 与 Cargo.lock
2026-02-24 17:05:11 +09:00
leeguooooo 893ddfd259 feat(cdp): 默认优先连接 9333 常驻 Chrome
- 无显式连接参数时先尝试 CDP 9333,失败后回退本地浏览器启动

- 修复 CDP 页选择稳定性:过滤 omnibox 系统页、无可用页时自动创建 fallback 页

- 调整页面关闭后的 active 索引维护,降低 No page found 问题

- 新增 agent-browser-stealth 二进制入口并保持与 agent-browser 行为一致

- 同步更新 CLI 帮助、README、技能文档与 docs 说明
2026-02-24 16:22:49 +09:00
leeguooooo ea2e93dbba feat(stealth): 优化隐身对抗并引入双版本发布体系
- 将 CreepJS like headless 指标优化到 0%(headless/stealth 维持 0%)

- 新增 ActiveText 与 prefers-color-scheme 探针修复

- 版本号采用 <upstream>-fork.<fork> 格式并在 --version 输出 upstream/fork

- 更新 README、SKILL 与 docs 中的版本体系说明
2026-02-24 15:29:11 +09:00
leeguooooo 4c6afe3e69 docs(config): 移除 --stealth 配置项说明 2026-02-24 14:55:07 +09:00
leeguooooo 9f9a90cf63 docs(cli): 清理过时 stealth 环境变量说明 2026-02-24 14:54:51 +09:00
leeguooooo 0443e4ed7a feat(stealth): 默认开启并收敛 chrome 指纹特征 2026-02-24 14:54:30 +09:00
leeguooooo c5b2292caa feat(stealth): 增强浏览器级 UA 覆盖并修复背景特征 2026-02-24 14:50:03 +09:00
leeguooooo 2ed0c6f8ec feat(stealth): 进一步降低 creepjs like-headless 指标 2026-02-24 14:36:56 +09:00
leeguooooo 3a91aef4c9 feat(stealth): 优化指纹信号并同步文档与包配置 2026-02-24 14:23:59 +09:00
leeguooooo 02ebc9f328 feat(stealth): 增强指纹一致性并新增 creepjs 检测脚本 2026-02-24 14:19:25 +09:00
leeguooooo 955543b757 chore: prepare fork sync and independent release setup 2026-02-24 14:14:50 +09:00
leeguooooo ecad112707 feat(cli): 默认开启 stealth 并支持 wait 区间超时 2026-02-24 12:27:04 +09:00
leeguooooo 8932f28926 fix(stealth): 修复 headed 模式下 stealth 失效并统一策略
- 修复 launch 协议未透传 stealth 导致 --headed 下补丁失效的问题\n- 在 BrowserManager 引入 StealthPolicy,统一 local/CDP/provider 能力决策\n- 增加 launch 返回 stealth 状态并在 --debug 输出连接类型与能力\n- 补充 local/CDP 回归测试与 bot.sannysoft.com 自动检查脚本\n- 同步 README、CLI help、技能文档与 CDP 文档中的 stealth 能力矩阵
2026-02-24 12:18:47 +09:00
57 changed files with 10700 additions and 2939 deletions
+47 -5
View File
@@ -11,6 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
# Build native binaries for all platforms first
@@ -133,8 +134,8 @@ jobs:
needs: build-binaries
runs-on: ubuntu-latest
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
published: ${{ steps.publish_metadata.outputs.published }}
publishedPackages: ${{ steps.publish_metadata.outputs.publishedPackages }}
steps:
- name: Checkout Repo
uses: actions/checkout@v4
@@ -151,7 +152,6 @@ jobs:
with:
node-version: '22'
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
@@ -201,17 +201,59 @@ jobs:
fi
echo "All 5 platform binaries present and valid"
- name: Verify bundled binary versions
run: |
pnpm run verify:bundled-binaries
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
- name: Check if publish is needed
id: publish_check
if: steps.changesets.outputs.hasChangesets == 'false'
run: |
LOCAL_VERSION=$(node -p "require('./package.json').version")
REMOTE_VERSION=$(npm view agent-browser-stealth version 2>/dev/null || echo "")
echo "local_version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
echo "remote_version=$REMOTE_VERSION" >> "$GITHUB_OUTPUT"
if [ "$LOCAL_VERSION" != "$REMOTE_VERSION" ]; then
echo "needs_publish=true" >> "$GITHUB_OUTPUT"
else
echo "needs_publish=false" >> "$GITHUB_OUTPUT"
fi
echo "Local: $LOCAL_VERSION"
echo "Remote: ${REMOTE_VERSION:-<none>}"
- name: Publish to npm (trusted publishing)
id: publish_npm
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
env:
NODE_AUTH_TOKEN: ""
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
NPM_CONFIG_PROVENANCE: "true"
run: |
npm install -g npm@^11
npm --version
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
pnpm ci:publish
- name: Set release outputs
id: publish_metadata
run: |
if [ "${{ steps.publish_npm.outcome }}" = "success" ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
echo "publishedPackages=[{\"name\":\"agent-browser-stealth\",\"version\":\"${{ steps.publish_check.outputs.local_version }}\"}]" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
echo "publishedPackages=[]" >> "$GITHUB_OUTPUT"
fi
# Create GitHub release with binaries after npm publish
github-release:
+8
View File
@@ -0,0 +1,8 @@
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
exit 0
fi
pnpm run clawhub:sync || {
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
}
+11 -20
View File
@@ -1,28 +1,17 @@
# agent-browser
## 0.16.1
## 0.15.2-fork.0
### Patch Changes
- c4180c8: Improved Chrome launch reliability by automatically detecting containerized environments (Docker, Podman, Kubernetes) and enabling --no-sandbox when needed. Added support for discovering Playwright-installed Chromium browsers and enhanced error messages with helpful diagnostics when Chrome fails to launch.
- Merge upstream `v0.15.2` updates, including fixes for cookies clear/tab close output, daemon EPERM liveness checks, unnamed element reference matching, and docs/skills refresh.
## 0.16.0
### Minor Changes
- 05018b3: Added experimental native Rust daemon (`--native` flag, `AGENT_BROWSER_NATIVE=1` env, or `"native": true` in config). The native daemon communicates with Chrome directly via CDP, eliminating Node.js and Playwright dependencies. Supports 150+ commands with full parity to the default Node.js daemon. Includes WebDriver backend for Safari/iOS, CDP protocol codegen, request tracking, frame context management, and comprehensive e2e and parity tests.
## 0.15.3
## 0.15.1-fork.11
### Patch Changes
- 62241b5: Fixed Windows compatibility issues including proper handling of extended-length path prefixes from canonicalize(), prevention of MSYS/Git Bash path translation that could mangle arguments, and improved daemon startup reliability. Also added ARM64 Windows support in postinstall shims and expanded CI testing with a full daemon lifecycle test on Windows.
## 0.15.2
### Patch Changes
- 6aea316: Documentation site improvements and internal tooling updates including enhanced code blocks, mobile navigation, and docs chat components. CLI connection and output handling refinements. Skill creator reference documentation and scripts have been reorganized.
- Auto-attach existing browser more reliably by trying CDP localhost:9333 first, then falling back to auto-discovery before failing.
Align daemon behavior and user-facing docs/skill guidance with the same attachment policy.
## 0.15.1
@@ -32,11 +21,13 @@
## 0.15.0
### Minor Changes
### Patch Changes
- 2e38882: - Added security hardening: authentication vault, content boundary markers, domain allowlist, action policy, action confirmation, and output length limits.
- Added `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory.
- Added `--selector` flag to `scroll` command for scrolling within specific container elements.
- Fix CLI typing delay parsing so `--delay` is treated as an option instead of typed text.
- Add `--delay <ms>` parsing for `type` and `keyboard type`
- Support `--` to type literal `--delay` text
- Add regression tests for parsing and delay behavior
- Update CLI help, README, skills, and docs command references
## 0.14.0
+211 -1133
View File
File diff suppressed because it is too large Load Diff
+37 -32
View File
@@ -44,8 +44,8 @@ dependencies = [
]
[[package]]
name = "agent-browser"
version = "0.16.1"
name = "agent-browser-stealth"
version = "0.16.1-fork.0"
dependencies = [
"aes-gcm",
"async-trait",
@@ -628,20 +628,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -772,7 +772,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
@@ -1043,9 +1043,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.180"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libfuzzer-sys"
@@ -1059,11 +1059,10 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.12"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"libc",
]
@@ -1106,9 +1105,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miniz_oxide"
@@ -1315,9 +1314,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.105"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
@@ -1414,14 +1413,14 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.43"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -1432,6 +1431,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
@@ -1607,7 +1612,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
@@ -1839,9 +1844,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -1949,9 +1954,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -2098,9 +2103,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
@@ -2154,7 +2159,7 @@ version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"getrandom 0.4.1",
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
@@ -2328,14 +2333,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.5",
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
@@ -2779,9 +2784,9 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.12"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
+11 -3
View File
@@ -1,10 +1,18 @@
[package]
name = "agent-browser"
version = "0.16.1"
name = "agent-browser-stealth"
version = "0.16.1-fork.3"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
[[bin]]
name = "agent-browser"
path = "src/main.rs"
[[bin]]
name = "agent-browser-stealth"
path = "src/main_stealth.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+227 -177
View File
@@ -71,6 +71,62 @@ pub fn gen_id() -> String {
)
}
/// Parse free-form text arguments with optional `--delay <ms>`.
///
/// `--` can be used to stop flag parsing if text must include `--delay` literally.
fn parse_text_with_optional_delay(
args: &[&str],
context: &str,
usage: &'static str,
) -> Result<(String, Option<u64>), ParseError> {
let mut text_parts: Vec<&str> = Vec::new();
let mut delay_ms: Option<u64> = None;
let mut parse_flags = true;
let mut i = 0;
while i < args.len() {
let arg = args[i];
if parse_flags && arg == "--" {
parse_flags = false;
i += 1;
continue;
}
if parse_flags && arg == "--delay" {
let raw = args
.get(i + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: format!("{} --delay", context),
usage,
})?;
let parsed = raw.parse::<u64>().map_err(|_| ParseError::InvalidValue {
message: format!(
"Invalid --delay value: {} (must be a non-negative integer in milliseconds)",
raw
),
usage,
})?;
delay_ms = Some(parsed);
i += 2;
continue;
}
text_parts.push(arg);
i += 1;
}
let text = text_parts.join(" ");
if text.is_empty() {
return Err(ParseError::MissingArguments {
context: context.to_string(),
usage,
});
}
Ok((text, delay_ms))
}
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
if args.is_empty() {
return Err(ParseError::MissingArguments {
@@ -92,7 +148,6 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
match cmd {
// === Navigation ===
// Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts
"open" | "goto" | "navigate" => {
let url = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: cmd.to_string(),
@@ -129,6 +184,35 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
nav_cmd["iosDevice"] = json!(device);
}
}
if let Some(ref risk_mode) = flags.risk_mode {
if matches!(risk_mode.as_str(), "off" | "warn" | "block") {
nav_cmd["riskMode"] = json!(risk_mode);
} else {
return Err(ParseError::InvalidValue {
message: format!(
"Invalid --risk-mode value: {} (expected off, warn, or block)",
risk_mode
),
usage: "open <url>",
});
}
}
if let Some(ref wait_until) = flags.wait_until {
if matches!(
wait_until.as_str(),
"load" | "domcontentloaded" | "networkidle"
) {
nav_cmd["waitUntil"] = json!(wait_until);
} else {
return Err(ParseError::InvalidValue {
message: format!(
"Invalid --wait-until value: {} (expected load, domcontentloaded, or networkidle)",
wait_until
),
usage: "open <url>",
});
}
}
Ok(nav_cmd)
}
"back" => Ok(json!({ "id": id, "action": "back" })),
@@ -168,9 +252,18 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"type" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "type".to_string(),
usage: "type <selector> <text>",
usage: "type <selector> <text> [--delay <ms>]",
})?;
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
let (text, delay) = parse_text_with_optional_delay(
&rest[1..],
"type",
"type <selector> <text> [--delay <ms>]",
)?;
let mut cmd = json!({ "id": id, "action": "type", "selector": sel, "text": text });
if let Some(ms) = delay {
cmd["delay"] = json!(ms);
}
Ok(cmd)
}
"hover" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
@@ -275,14 +368,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?;
match *sub {
"type" => {
let text: String = rest[1..].join(" ");
if text.is_empty() {
return Err(ParseError::MissingArguments {
context: "keyboard type".to_string(),
usage: "keyboard type <text>",
});
let (text, delay) = parse_text_with_optional_delay(
&rest[1..],
"keyboard type",
"keyboard type <text> [--delay <ms>]",
)?;
let mut cmd = json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text });
if let Some(ms) = delay {
cmd["delay"] = json!(ms);
}
Ok(json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text }))
Ok(cmd)
}
"inserttext" | "insertText" => {
let text: String = rest[1..].join(" ");
@@ -429,8 +524,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
return Ok(cmd);
}
// Default: selector or timeout
// Default: selector, timeout, or range (e.g. 2000-5000)
if let Some(arg) = rest.first() {
// Check for range syntax: "2000-5000"
if let Some((min_str, max_str)) = arg.split_once('-') {
if let (Ok(min), Ok(max)) = (min_str.parse::<u64>(), max_str.parse::<u64>()) {
return Ok(
json!({ "id": id, "action": "wait", "timeout": min, "timeoutMax": max }),
);
}
}
if let Ok(timeout) = arg.parse::<u64>() {
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
} else {
@@ -439,7 +542,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} else {
Err(ParseError::MissingArguments {
context: "wait".to_string(),
usage: "wait <selector|ms|--url|--load|--fn|--text>",
usage: "wait <selector|ms|min-max|--url|--load|--fn|--text>",
})
}
}
@@ -568,149 +671,15 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Authentication Vault ===
"auth" => {
let sub = rest.first().map(|s| s.as_ref());
match sub {
Some("save") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
})?;
let mut url = None;
let mut username = None;
let mut password = None;
let mut password_stdin = false;
let mut username_selector = None;
let mut password_selector = None;
let mut submit_selector = None;
let mut j = 2;
while j < rest.len() {
match rest[j].as_ref() {
"--url" => {
url = rest.get(j + 1).cloned();
j += 1;
}
"--username" => {
username = rest.get(j + 1).cloned();
j += 1;
}
"--password" => {
password = rest.get(j + 1).cloned();
j += 1;
}
"--password-stdin" => {
password_stdin = true;
}
"--username-selector" => {
username_selector = rest.get(j + 1).cloned();
j += 1;
}
"--password-selector" => {
password_selector = rest.get(j + 1).cloned();
j += 1;
}
"--submit-selector" => {
submit_selector = rest.get(j + 1).cloned();
j += 1;
}
other => {
if other.starts_with("--") {
return Err(ParseError::InvalidValue {
message: format!("unknown flag '{}' for auth save", other),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
});
}
}
}
j += 1;
}
let url_val = url.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
let user_val = username.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
if !password_stdin && password.is_none() {
return Err(ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
});
}
let mut cmd = json!({
"id": id,
"action": "auth_save",
"name": name,
"url": url_val,
"username": user_val,
});
if password_stdin {
cmd["passwordStdin"] = json!(true);
}
if let Some(pass_val) = password {
cmd["password"] = json!(pass_val);
}
if let Some(us) = username_selector {
cmd["usernameSelector"] = json!(us);
}
if let Some(ps) = password_selector {
cmd["passwordSelector"] = json!(ps);
}
if let Some(ss) = submit_selector {
cmd["submitSelector"] = json!(ss);
}
Ok(cmd)
}
Some("login") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth login".to_string(),
usage: "agent-browser auth login <name>",
})?;
Ok(json!({ "id": id, "action": "auth_login", "name": name }))
}
Some("list") => Ok(json!({ "id": id, "action": "auth_list" })),
Some("delete") | Some("remove") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth delete".to_string(),
usage: "agent-browser auth delete <name>",
})?;
Ok(json!({ "id": id, "action": "auth_delete", "name": name }))
}
Some("show") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth show".to_string(),
usage: "agent-browser auth show <name>",
})?;
Ok(json!({ "id": id, "action": "auth_show", "name": name }))
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: sub.unwrap_or("(none)").to_string(),
valid_options: &["save", "login", "list", "delete", "show"],
}),
// === Doctor ===
"doctor" => {
if !rest.is_empty() {
return Err(ParseError::InvalidValue {
message: format!("doctor does not accept arguments: {}", rest.join(" ")),
usage: "doctor",
});
}
}
// === Action Confirmation ===
"confirm" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "confirm".to_string(),
usage: "agent-browser confirm <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid }))
}
"deny" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "deny".to_string(),
usage: "agent-browser deny <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "deny", "confirmationId": cid }))
Ok(json!({ "id": id, "action": "doctor" }))
}
// === Connect (CDP) ===
@@ -888,6 +857,17 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
// Playwright requires either `url` or a complete `domain`+`path` pair.
let has_url = cookie.get("url").is_some();
let has_domain = cookie.get("domain").is_some();
let has_path = cookie.get("path").is_some();
if !has_url && (has_domain != has_path) {
return Err(ParseError::MissingArguments {
context: "cookies set".to_string(),
usage: "When not using --url, you must provide both --domain <domain> and --path <path>",
});
}
Ok(json!({ "id": id, "action": "cookies_set", "cookies": [cookie] }))
}
"clear" => Ok(json!({ "id": id, "action": "cookies_clear" })),
@@ -2077,7 +2057,6 @@ mod tests {
executable_path: None,
extensions: Vec::new(),
cdp: None,
profile: None,
state: None,
proxy: None,
proxy_bypass: None,
@@ -2091,7 +2070,6 @@ mod tests {
session_name: None,
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
cli_state: false,
cli_args: false,
cli_user_agent: false,
@@ -2103,13 +2081,12 @@ mod tests {
annotate: false,
color_scheme: None,
download_path: None,
content_boundaries: false,
max_output: None,
allowed_domains: None,
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
native: false,
tab_group: None,
tab_group_plugin_id: None,
risk_mode: None,
wait_until: None,
cli_tab_group: false,
cli_tab_group_plugin_id: false,
}
}
@@ -2165,28 +2142,34 @@ mod tests {
}
#[test]
fn test_cookies_set_with_domain() {
let cmd = parse_command(
fn test_cookies_set_with_domain_requires_path() {
let result = parse_command(
&args("cookies set mycookie myvalue --domain example.com"),
&default_flags(),
);
assert!(result.is_err());
}
#[test]
fn test_cookies_set_with_path_requires_domain() {
let result = parse_command(
&args("cookies set mycookie myvalue --path /api"),
&default_flags(),
);
assert!(result.is_err());
}
#[test]
fn test_cookies_set_with_domain_and_path() {
let cmd = parse_command(
&args("cookies set mycookie myvalue --domain example.com --path /api"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
assert_eq!(cmd["cookies"][0]["domain"], "example.com");
}
#[test]
fn test_cookies_set_with_path() {
let cmd = parse_command(
&args("cookies set mycookie myvalue --path /api"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "cookies_set");
assert_eq!(cmd["cookies"][0]["name"], "mycookie");
assert_eq!(cmd["cookies"][0]["value"], "myvalue");
assert_eq!(cmd["cookies"][0]["path"], "/api");
}
@@ -2375,6 +2358,22 @@ mod tests {
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
}
#[test]
fn test_navigate_with_risk_mode() {
let mut flags = default_flags();
flags.risk_mode = Some("block".to_string());
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
assert_eq!(cmd["riskMode"], "block");
}
#[test]
fn test_navigate_with_wait_until() {
let mut flags = default_flags();
flags.wait_until = Some("domcontentloaded".to_string());
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
assert_eq!(cmd["waitUntil"], "domcontentloaded");
}
#[test]
fn test_navigate_with_multiple_headers() {
let mut flags = default_flags();
@@ -2505,6 +2504,29 @@ mod tests {
assert_eq!(cmd["text"], "some text");
}
#[test]
fn test_type_command_with_delay() {
let cmd =
parse_command(&args("type #input some text --delay 120"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#input");
assert_eq!(cmd["text"], "some text");
assert_eq!(cmd["delay"], 120);
}
#[test]
fn test_type_command_with_literal_delay_text() {
let cmd = parse_command(
&args("type #input -- --delay 120 should be typed"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#input");
assert_eq!(cmd["text"], "--delay 120 should be typed");
assert!(cmd.get("delay").is_none());
}
#[test]
fn test_select() {
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
@@ -2684,6 +2706,19 @@ mod tests {
assert_eq!(cmd["selector"], "#element");
}
#[test]
fn test_keyboard_type_with_delay() {
let cmd = parse_command(
&args("keyboard type natural typing --delay 90"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "keyboard");
assert_eq!(cmd["subaction"], "type");
assert_eq!(cmd["text"], "natural typing");
assert_eq!(cmd["delay"], 90);
}
#[test]
fn test_wait_timeout() {
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
@@ -2960,6 +2995,21 @@ mod tests {
assert!(err.format().contains("Invalid base64"));
}
#[test]
fn test_doctor() {
let cmd = parse_command(&args("doctor"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "doctor");
}
#[test]
fn test_doctor_rejects_arguments() {
let result = parse_command(&args("doctor extra"), &default_flags());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ParseError::InvalidValue { .. }));
assert!(err.format().contains("doctor does not accept arguments"));
}
#[test]
fn test_unknown_command() {
let result = parse_command(&args("unknowncommand"), &default_flags());
+223 -247
View File
@@ -118,6 +118,12 @@ fn get_pid_path(session: &str) -> PathBuf {
/// Clean up stale socket and PID files for a session
fn cleanup_stale_files(session: &str) {
// Never delete files for a live daemon. A missing PID file can happen in
// race scenarios, but the socket is authoritative for liveness.
if daemon_ready(session) {
return;
}
let pid_path = get_pid_path(session);
let _ = fs::remove_file(&pid_path);
@@ -150,42 +156,6 @@ fn get_port_for_session(session: &str) -> u16 {
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
}
#[cfg(unix)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
unsafe {
if libc::kill(pid, 0) == 0 {
return true;
}
// EPERM means the process exists but we lack permission to
// signal it (e.g. inside a macOS sandbox). Only ESRCH means
// the process is genuinely gone.
return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH);
}
}
}
false
}
#[cfg(windows)]
fn is_daemon_running(session: &str) -> bool {
let pid_path = get_pid_path(session);
if !pid_path.exists() {
return false;
}
let port = get_port_for_session(session);
TcpStream::connect_timeout(
&format!("127.0.0.1:{}", port).parse().unwrap(),
Duration::from_millis(100),
)
.is_ok()
}
fn daemon_ready(session: &str) -> bool {
#[cfg(unix)]
{
@@ -209,95 +179,30 @@ pub struct DaemonResult {
pub already_running: bool,
}
/// Options forwarded to the daemon process as environment variables.
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
/// UX concern (prompting the user on stdin) and not a daemon configuration.
/// The daemon only needs `confirm_actions` to gate action categories.
pub struct DaemonOptions<'a> {
pub headed: bool,
pub executable_path: Option<&'a str>,
pub extensions: &'a [String],
pub args: Option<&'a str>,
pub user_agent: Option<&'a str>,
pub proxy: Option<&'a str>,
pub proxy_bypass: Option<&'a str>,
pub ignore_https_errors: bool,
pub allow_file_access: bool,
pub profile: Option<&'a str>,
pub state: Option<&'a str>,
pub provider: Option<&'a str>,
pub device: Option<&'a str>,
pub session_name: Option<&'a str>,
pub download_path: Option<&'a str>,
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub native: bool,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
cmd.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if opts.headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = opts.executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !opts.extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
}
if let Some(a) = opts.args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = opts.user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = opts.proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = opts.proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if opts.ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if opts.allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = opts.profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = opts.state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = opts.provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = opts.device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = opts.session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(dp) = opts.download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(ad) = opts.allowed_domains {
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
}
if let Some(ap) = opts.action_policy {
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
}
if let Some(ca) = opts.confirm_actions {
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
}
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
#[allow(clippy::too_many_arguments)]
pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
args: Option<&str>,
user_agent: Option<&str>,
proxy: Option<&str>,
proxy_bypass: Option<&str>,
ignore_https_errors: bool,
allow_file_access: bool,
state: Option<&str>,
provider: Option<&str>,
device: Option<&str>,
session_name: Option<&str>,
debug: bool,
download_path: Option<&str>,
tab_group: Option<&str>,
tab_group_plugin_id: Option<&str>,
) -> Result<DaemonResult, String> {
// Socket readiness is the source of truth for a usable daemon.
// PID files can be missing/stale under concurrent start/stop races.
if daemon_ready(session) {
// Double-check it's actually responsive by waiting and checking again
// This handles the race condition where daemon is shutting down
// (daemon has a 100ms shutdown delay, so we wait longer)
@@ -353,124 +258,205 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
// Canonicalize to resolve symlinks (e.g., npm global bin symlink -> actual binary)
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
// On Windows, canonicalize() returns \\?\ prefixed extended-length paths.
// Node.js cannot handle these, so strip the prefix.
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
// Check AGENT_BROWSER_HOME environment variable
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
// Spawn daemon as a fully detached background process
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
}
if let Some(plugin_id) = tab_group_plugin_id {
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
// Create new session (detach from terminal)
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
{
use std::os::windows::process::CommandExt;
if opts.native {
// Native mode: spawn self as daemon (Rust/CDP, no Node.js needed)
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(&exe_path);
cmd.env("AGENT_BROWSER_DAEMON", "1");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start native daemon: {}", e))?;
}
} else {
// Default mode: spawn Node.js daemon (Playwright)
let exe_dir = exe_path.parent().unwrap();
let mut daemon_paths = vec![
exe_dir.join("daemon.js"),
exe_dir.join("../dist/daemon.js"),
PathBuf::from("dist/daemon.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
daemon_paths.insert(0, home_path.join("dist/daemon.js"));
daemon_paths.insert(1, home_path.join("daemon.js"));
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
let daemon_path = daemon_paths
.iter()
.find(|p| p.exists())
.ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Use node.exe explicitly to avoid Git Bash/MSYS2 shell wrapper resolution
let mut cmd = Command::new("node.exe");
cmd.arg(daemon_path)
.env("MSYS_NO_PATHCONV", "1")
.env("MSYS2_ARG_CONV_EXCL", "*");
apply_daemon_env(&mut cmd, session, opts);
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(tg) = tab_group {
cmd.env("AGENT_BROWSER_TAB_GROUP", tg);
}
if let Some(plugin_id) = tab_group_plugin_id {
cmd.env("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", plugin_id);
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
for _ in 0..50 {
@@ -482,20 +468,10 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
thread::sleep(Duration::from_millis(100));
}
#[cfg(unix)]
let endpoint_info = format!(
"socket: {}",
get_socket_dir()
.join(format!("{}.sock", session))
.display()
);
#[cfg(windows)]
let endpoint_info = format!(
"port: 127.0.0.1:{}",
get_port_for_session(session)
);
Err(format!("Daemon failed to start ({})", endpoint_info))
Err(format!(
"Daemon failed to start (socket: {})",
get_socket_dir().join(format!("{}.sock", session)).display()
))
}
fn connect(session: &str) -> Result<Connection, String> {
+245 -124
View File
@@ -1,4 +1,4 @@
use crate::color;
use crate::{color, validation};
use serde::Deserialize;
use std::env;
use std::fs;
@@ -7,6 +7,8 @@ use std::path::{Path, PathBuf};
const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
const DEFAULT_TAB_GROUP: &str = "Agent Browser Stealth";
const DEFAULT_TAB_GROUP_PLUGIN_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -19,7 +21,6 @@ pub struct Config {
pub session_name: Option<String>,
pub executable_path: Option<String>,
pub extensions: Option<Vec<String>>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -35,13 +36,10 @@ pub struct Config {
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
pub native: Option<bool>,
pub tab_group: Option<String>,
pub tab_group_plugin_id: Option<String>,
pub risk_mode: Option<String>,
pub wait_until: Option<String>,
}
impl Config {
@@ -61,7 +59,6 @@ impl Config {
}
(a, b) => b.or(a),
},
profile: other.profile.or(self.profile),
state: other.state.or(self.state),
proxy: other.proxy.or(self.proxy),
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
@@ -77,13 +74,10 @@ impl Config {
annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme),
download_path: other.download_path.or(self.download_path),
content_boundaries: other.content_boundaries.or(self.content_boundaries),
max_output: other.max_output.or(self.max_output),
allowed_domains: other.allowed_domains.or(self.allowed_domains),
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
native: other.native.or(self.native),
tab_group: other.tab_group.or(self.tab_group),
tab_group_plugin_id: other.tab_group_plugin_id.or(self.tab_group_plugin_id),
risk_mode: other.risk_mode.or(self.risk_mode),
wait_until: other.wait_until.or(self.wait_until),
}
}
}
@@ -130,11 +124,6 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
/// Extract --config <path> from args before full flag parsing.
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
/// was the last arg with no value, `None` if --config not present.
///
/// Only flags that consume a following argument need to be listed here.
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
/// intentionally absent -- they don't take a value, so they can't cause
/// the next argument to be mis-consumed.
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
const FLAGS_WITH_VALUE: &[&str] = &[
"--session",
@@ -153,11 +142,12 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--device",
"--session-name",
"--color-scheme",
"--channel",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--tab-group",
"--tab-group-plugin-id",
"--risk-mode",
"--wait-until",
];
let mut i = 0;
while i < args.len() {
@@ -214,7 +204,6 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -225,23 +214,23 @@ pub struct Flags {
pub allow_file_access: bool,
pub device: Option<String>,
pub auto_connect: bool,
pub session_name: Option<String>,
pub session_name: Option<String>, // Defaults to --session when unset
pub annotate: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
pub native: bool,
pub tab_group: Option<String>,
pub tab_group_plugin_id: Option<String>,
/// How verification/captcha detections are handled on navigation:
/// `off` (disable), `warn` (retry and warn), `block` (fail fast).
pub risk_mode: Option<String>,
/// Navigation wait strategy passed to navigate/open commands:
/// `load`, `domcontentloaded`, or `networkidle`.
pub wait_until: Option<String>,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
pub cli_executable_path: bool,
pub cli_extensions: bool,
pub cli_profile: bool,
pub cli_state: bool,
pub cli_args: bool,
pub cli_user_agent: bool,
@@ -250,6 +239,8 @@ pub struct Flags {
pub cli_allow_file_access: bool,
pub cli_annotate: bool,
pub cli_download_path: bool,
pub cli_tab_group: bool,
pub cli_tab_group_plugin_id: bool,
}
pub fn parse_flags(args: &[String]) -> Flags {
@@ -277,7 +268,10 @@ pub fn parse_flags(args: &[String]) -> Flags {
let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
headed: match env::var("AGENT_BROWSER_HEADED") {
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
Err(_) => config.headed.unwrap_or(true),
},
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
session: env::var("AGENT_BROWSER_SESSION")
.ok()
@@ -289,7 +283,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.executable_path),
cdp: config.cdp,
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
@@ -317,33 +310,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH")
.ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT")
tab_group: env::var("AGENT_BROWSER_TAB_GROUP")
.ok()
.and_then(|s| s.parse().ok())
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS")
.or(config.tab_group)
.or_else(|| Some(DEFAULT_TAB_GROUP.to_string())),
tab_group_plugin_id: env::var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID")
.ok()
.map(|s| {
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
})
.or(config.allowed_domains),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY")
.or(config.tab_group_plugin_id)
.or_else(|| Some(DEFAULT_TAB_GROUP_PLUGIN_ID.to_string())),
risk_mode: env::var("AGENT_BROWSER_RISK_MODE")
.ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS")
.ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
native: env_var_is_truthy("AGENT_BROWSER_NATIVE") || config.native.unwrap_or(false),
.or(config.risk_mode)
.map(|s| s.to_ascii_lowercase()),
wait_until: config.wait_until.map(|s| s.to_ascii_lowercase()),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
cli_state: false,
cli_args: false,
cli_user_agent: false,
@@ -352,6 +333,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
cli_allow_file_access: false,
cli_annotate: false,
cli_download_path: false,
cli_tab_group: false,
cli_tab_group_plugin_id: false,
};
let mut i = 0;
@@ -417,13 +400,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--profile" => {
if let Some(s) = args.get(i + 1) {
flags.profile = Some(s.clone());
flags.cli_profile = true;
i += 1;
}
}
"--state" => {
if let Some(s) = args.get(i + 1) {
flags.state = Some(s.clone());
@@ -520,55 +496,29 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed {
i += 1;
}
}
"--max-output" => {
"--tab-group" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<usize>() {
flags.max_output = Some(n);
}
flags.tab_group = Some(s.clone());
flags.cli_tab_group = true;
i += 1;
}
}
"--allowed-domains" => {
"--tab-group-plugin-id" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',')
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect(),
);
flags.tab_group_plugin_id = Some(s.clone());
flags.cli_tab_group_plugin_id = true;
i += 1;
}
}
"--action-policy" => {
"--risk-mode" => {
if let Some(s) = args.get(i + 1) {
flags.action_policy = Some(s.clone());
flags.risk_mode = Some(s.to_ascii_lowercase());
i += 1;
}
}
"--confirm-actions" => {
"--wait-until" => {
if let Some(s) = args.get(i + 1) {
flags.confirm_actions = Some(s.clone());
i += 1;
}
}
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed {
i += 1;
}
}
"--native" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.native = val;
if consumed {
flags.wait_until = Some(s.to_ascii_lowercase());
i += 1;
}
}
@@ -580,6 +530,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
}
i += 1;
}
// Keep auth/state continuity stable by default: if no explicit --session-name
// is provided, derive it from --session (or fall back to "default" when invalid).
if flags.session_name.is_none() {
let derived = if validation::is_valid_session_name(&flags.session) {
flags.session.clone()
} else {
"default".to_string()
};
flags.session_name = Some(derived);
}
flags
}
@@ -597,9 +559,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--allow-file-access",
"--auto-connect",
"--annotate",
"--content-boundaries",
"--confirm-interactive",
"--native",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -608,7 +567,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--executable-path",
"--cdp",
"--extension",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
@@ -620,10 +578,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--tab-group",
"--tab-group-plugin-id",
"--risk-mode",
"--wait-until",
"--config",
];
@@ -658,6 +616,36 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard<'a> {
_lock: MutexGuard<'a, ()>,
vars: Vec<(String, Option<String>)>,
}
impl<'a> EnvGuard<'a> {
fn new(var_names: &[&str]) -> Self {
let lock = ENV_MUTEX.lock().unwrap();
let vars = var_names
.iter()
.map(|&name| (name.to_string(), env::var(name).ok()))
.collect();
Self { _lock: lock, vars }
}
}
impl Drop for EnvGuard<'_> {
fn drop(&mut self) {
for (name, value) in &self.vars {
match value {
Some(v) => env::set_var(name, v),
None => env::remove_var(name),
}
}
}
}
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
@@ -771,6 +759,19 @@ mod tests {
));
assert_eq!(flags.session, "test");
assert_eq!(flags.executable_path, Some("/custom/chrome".to_string()));
assert_eq!(flags.session_name.as_deref(), Some("test"));
}
#[test]
fn test_session_name_defaults_to_session_when_not_provided() {
let flags = parse_flags(&args("--session my-session snapshot"));
assert_eq!(flags.session_name.as_deref(), Some("my-session"));
}
#[test]
fn test_invalid_session_falls_back_to_default_session_name() {
let flags = parse_flags(&args("--session bad/session snapshot"));
assert_eq!(flags.session_name.as_deref(), Some("default"));
}
#[test]
@@ -795,12 +796,6 @@ mod tests {
assert!(flags.cli_extensions);
}
#[test]
fn test_cli_profile_tracking() {
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
assert!(flags.cli_profile);
}
#[test]
fn test_cli_annotate_tracking() {
let flags = parse_flags(&args("--annotate screenshot"));
@@ -827,13 +822,134 @@ mod tests {
assert!(!flags.cli_download_path);
}
#[test]
fn test_default_tab_group_is_enabled() {
let flags = parse_flags(&args("snapshot"));
assert_eq!(flags.tab_group.as_deref(), Some(DEFAULT_TAB_GROUP));
assert!(!flags.cli_tab_group);
}
#[test]
fn test_default_tab_group_plugin_id_is_enabled() {
let flags = parse_flags(&args("snapshot"));
assert_eq!(
flags.tab_group_plugin_id.as_deref(),
Some(DEFAULT_TAB_GROUP_PLUGIN_ID)
);
assert!(!flags.cli_tab_group_plugin_id);
}
#[test]
fn test_parse_tab_group_flag() {
let input = vec![
"--tab-group".to_string(),
"Agent Browser Stealth".to_string(),
"snapshot".to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.tab_group.as_deref(), Some("Agent Browser Stealth"));
assert!(flags.cli_tab_group);
}
#[test]
fn test_clean_args_removes_tab_group() {
let cleaned = clean_args(&args("--tab-group AgentGroup open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_tab_group_plugin_id_flag() {
let input = vec![
"--tab-group-plugin-id".to_string(),
"cli-plugin-id".to_string(),
"snapshot".to_string(),
];
let flags = parse_flags(&input);
assert_eq!(flags.tab_group_plugin_id.as_deref(), Some("cli-plugin-id"));
assert!(flags.cli_tab_group_plugin_id);
}
#[test]
fn test_clean_args_removes_tab_group_plugin_id() {
let cleaned = clean_args(&args(
"--tab-group-plugin-id cli-plugin-id open example.com",
));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_tab_group_plugin_id_precedence_config_env_cli() {
use std::io::Write;
let _guard = EnvGuard::new(&["AGENT_BROWSER_TAB_GROUP_PLUGIN_ID"]);
let dir = std::env::temp_dir().join("ab-test-plugin-id-precedence");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"tabGroupPluginId":"config-plugin-id"}}"#).unwrap();
env::set_var("AGENT_BROWSER_TAB_GROUP_PLUGIN_ID", "env-plugin-id");
let env_args = vec![
"--config".to_string(),
config_path.to_string_lossy().to_string(),
"snapshot".to_string(),
];
let flags_from_env = parse_flags(&env_args);
assert_eq!(
flags_from_env.tab_group_plugin_id.as_deref(),
Some("env-plugin-id")
);
let cli_args = vec![
"--config".to_string(),
config_path.to_string_lossy().to_string(),
"--tab-group-plugin-id".to_string(),
"cli-plugin-id".to_string(),
"snapshot".to_string(),
];
let flags_from_cli = parse_flags(&cli_args);
assert_eq!(
flags_from_cli.tab_group_plugin_id.as_deref(),
Some("cli-plugin-id")
);
assert!(flags_from_cli.cli_tab_group_plugin_id);
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_parse_risk_mode_flag() {
let flags = parse_flags(&args("--risk-mode block open example.com"));
assert_eq!(flags.risk_mode.as_deref(), Some("block"));
}
#[test]
fn test_clean_args_removes_risk_mode() {
let cleaned = clean_args(&args("--risk-mode warn open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_wait_until_flag() {
let flags = parse_flags(&args("--wait-until domcontentloaded open example.com"));
assert_eq!(flags.wait_until.as_deref(), Some("domcontentloaded"));
}
#[test]
fn test_clean_args_removes_wait_until() {
let cleaned = clean_args(&args("--wait-until networkidle open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args(
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
"--executable-path /chrome --proxy http://proxy snapshot",
));
assert!(flags.cli_executable_path);
assert!(flags.cli_profile);
assert!(flags.cli_proxy);
assert!(!flags.cli_extensions);
assert!(!flags.cli_state);
@@ -852,7 +968,6 @@ mod tests {
"sessionName": "my-app",
"executablePath": "/usr/bin/chromium",
"extensions": ["/ext1", "/ext2"],
"profile": "/tmp/profile",
"state": "/tmp/state.json",
"proxy": "http://proxy:8080",
"proxyBypass": "localhost",
@@ -864,7 +979,10 @@ mod tests {
"allowFileAccess": true,
"cdp": "9222",
"autoConnect": true,
"headers": "{\"Auth\":\"token\"}"
"headers": "{\"Auth\":\"token\"}",
"tabGroup": "Agent Browser Stealth",
"tabGroupPluginId": "tab-group-plugin-id",
"riskMode": "block"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.headed, Some(true));
@@ -878,7 +996,6 @@ mod tests {
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
@@ -891,6 +1008,12 @@ mod tests {
assert_eq!(config.cdp.as_deref(), Some("9222"));
assert_eq!(config.auto_connect, Some(true));
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
assert_eq!(config.tab_group.as_deref(), Some("Agent Browser Stealth"));
assert_eq!(
config.tab_group_plugin_id.as_deref(),
Some("tab-group-plugin-id")
);
assert_eq!(config.risk_mode.as_deref(), Some("block"));
}
#[test]
@@ -924,7 +1047,6 @@ mod tests {
let user = Config {
headed: Some(true),
proxy: Some("http://user-proxy:8080".to_string()),
profile: Some("/user/profile".to_string()),
..Config::default()
};
let project = Config {
@@ -935,7 +1057,6 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.headed, Some(true)); // kept from user
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
assert_eq!(merged.debug, Some(true)); // added by project
}
+219 -300
View File
@@ -3,7 +3,6 @@ mod commands;
mod connection;
mod flags;
mod install;
mod native;
mod output;
mod validation;
@@ -18,122 +17,10 @@ use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use connection::{ensure_daemon, get_socket_dir, send_command};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{
print_command_help, print_help, print_response_with_opts, print_version, OutputOptions,
};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js.
/// These commands don't need a browser, so we handle them directly to avoid
/// sending passwords through the daemon's Unix socket channel.
fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
let exe_path = env::current_exe().unwrap_or_default();
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
#[cfg(windows)]
let exe_path = {
let p = exe_path.to_string_lossy();
if let Some(stripped) = p.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
exe_path
}
};
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
let mut script_paths = vec![
exe_dir.join("auth-cli.js"),
exe_dir.join("../dist/auth-cli.js"),
PathBuf::from("dist/auth-cli.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
script_paths.insert(0, home_path.join("dist/auth-cli.js"));
script_paths.insert(1, home_path.join("auth-cli.js"));
}
let script_path = match script_paths.iter().find(|p| p.exists()) {
Some(p) => p.clone(),
None => {
if json_mode {
println!(r#"{{"success":false,"error":"auth-cli.js not found"}}"#);
} else {
eprintln!(
"{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.",
color::error_indicator()
);
}
exit(1);
}
};
let cmd_json = serde_json::to_string(cmd).unwrap_or_default();
match ProcessCommand::new("node")
.arg(&script_path)
.arg(&cmd_json)
.output()
{
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprint!("{}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
if json_mode {
println!(r#"{{"success":false,"error":"No response from auth-cli"}}"#);
} else {
eprintln!("{} No response from auth-cli", color::error_indicator());
}
exit(1);
}
if json_mode {
println!("{}", stdout);
} else {
// Parse the JSON response and use the standard output formatter
match serde_json::from_str::<connection::Response>(stdout) {
Ok(resp) => {
let action = cmd.get("action").and_then(|v| v.as_str());
let opts = OutputOptions {
json: false,
content_boundaries: false,
max_output: None,
};
print_response_with_opts(&resp, action, &opts);
if !resp.success {
exit(1);
}
}
Err(_) => {
println!("{}", stdout);
}
}
}
exit(output.status.code().unwrap_or(0));
}
Err(e) => {
if json_mode {
println!(
r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#,
e
);
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
exit(1);
}
}
}
use output::{print_command_help, print_help, print_response, print_version};
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
@@ -248,21 +135,6 @@ fn main() {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Prevent MSYS/Git Bash path translation from mangling arguments
#[cfg(windows)]
{
env::set_var("MSYS_NO_PATHCONV", "1");
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(native::daemon::run_daemon(&session));
return;
}
let args: Vec<String> = env::args().skip(1).collect();
let flags = parse_flags(&args);
let clean = clean_args(&args);
@@ -285,6 +157,62 @@ fn main() {
return;
}
if let Some(ref risk_mode) = flags.risk_mode {
if !matches!(risk_mode.as_str(), "off" | "warn" | "block") {
let msg = format!(
"Invalid --risk-mode value: {} (expected off, warn, or block)",
risk_mode
);
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
if args.iter().any(|a| a == "--profile") {
let msg =
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if env::var("AGENT_BROWSER_PROFILE").is_ok() {
let msg =
"Project policy: AGENT_BROWSER_PROFILE is forbidden. Remove it and use --session-name.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if args.iter().any(|a| a == "--channel") {
let msg = "Project policy: --channel is forbidden. Browser selection follows your existing browser session.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if env::var("AGENT_BROWSER_CHANNEL").is_ok() {
let msg =
"Project policy: AGENT_BROWSER_CHANNEL is forbidden. Remove it and use your existing browser session.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if clean.is_empty() {
print_help();
return;
@@ -303,7 +231,7 @@ fn main() {
return;
}
let mut cmd = match parse_command(&clean, &flags) {
let cmd = match parse_command(&clean, &flags) {
Ok(c) => c,
Err(e) => {
if flags.json {
@@ -326,48 +254,6 @@ fn main() {
}
};
// Handle --password-stdin for auth save
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
if cmd.get("password").is_some() {
eprintln!(
"{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
color::warning_indicator()
);
}
if cmd
.get("passwordStdin")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!(
"{} Failed to read password from stdin",
color::error_indicator()
);
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
if pass.is_empty() {
eprintln!("{} Password from stdin is empty", color::error_indicator());
exit(1);
}
cmd["password"] = json!(pass);
cmd.as_object_mut().unwrap().remove("passwordStdin");
}
}
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(
action,
"auth_save" | "auth_list" | "auth_show" | "auth_delete"
) {
run_auth_cli(&cmd, flags.json);
}
}
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
@@ -384,28 +270,26 @@ fn main() {
}
}
let daemon_opts = DaemonOptions {
headed: flags.headed,
executable_path: flags.executable_path.as_deref(),
extensions: &flags.extensions,
args: flags.args.as_deref(),
user_agent: flags.user_agent.as_deref(),
proxy: flags.proxy.as_deref(),
proxy_bypass: flags.proxy_bypass.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
profile: flags.profile.as_deref(),
state: flags.state.as_deref(),
provider: flags.provider.as_deref(),
device: flags.device.as_deref(),
session_name: flags.session_name.as_deref(),
download_path: flags.download_path.as_deref(),
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
native: flags.native,
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
let daemon_result = match ensure_daemon(
&flags.session,
flags.headed,
flags.executable_path.as_deref(),
&flags.extensions,
flags.args.as_deref(),
flags.user_agent.as_deref(),
flags.proxy.as_deref(),
flags.proxy_bypass.as_deref(),
flags.ignore_https_errors,
flags.allow_file_access,
flags.state.as_deref(),
flags.provider.as_deref(),
flags.device.as_deref(),
flags.session_name.as_deref(),
flags.debug,
flags.download_path.as_deref(),
flags.tab_group.as_deref(),
flags.tab_group_plugin_id.as_deref(),
) {
Ok(result) => result,
Err(e) => {
if flags.json {
@@ -432,11 +316,6 @@ fn main() {
} else {
None
},
if flags.cli_profile {
Some("--profile")
} else {
None
},
if flags.cli_state {
Some("--state")
} else {
@@ -461,7 +340,10 @@ fn main() {
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_download_path.then_some("--download-path"),
flags.native.then_some("--native"),
flags.cli_tab_group.then_some("--tab-group"),
flags
.cli_tab_group_plugin_id
.then_some("--tab-group-plugin-id"),
]
.into_iter()
.flatten()
@@ -527,6 +409,8 @@ fn main() {
exit(1);
}
let mut attached_to_existing_browser = false;
// Auto-connect to existing browser
if flags.auto_connect {
let mut launch_cmd = json!({
@@ -546,6 +430,12 @@ fn main() {
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
@@ -564,6 +454,8 @@ fn main() {
}
exit(1);
}
attached_to_existing_browser = true;
}
// Connect via CDP if --cdp flag is set
@@ -636,6 +528,12 @@ fn main() {
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
@@ -654,6 +552,8 @@ fn main() {
}
exit(1);
}
attached_to_existing_browser = true;
}
// Launch with cloud provider if -p flag is set
@@ -667,39 +567,121 @@ fn main() {
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "Provider connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
}
match send_command(launch_cmd, &flags.session) {
Ok(resp) => {
if !resp.success {
let msg = resp
.error
.unwrap_or_else(|| "Provider connection failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
}
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
// Project policy: when no explicit connection mode is provided,
// commands should attach to an existing browser.
// Try CDP :9333 first, then fall back to auto-connect discovery.
let can_try_default_cdp = flags.cdp.is_none()
&& !flags.auto_connect
&& flags.provider.is_none()
&& flags.executable_path.is_none()
&& flags.state.is_none()
&& flags.proxy.is_none()
&& flags.args.is_none()
&& flags.user_agent.is_none()
&& !flags.ignore_https_errors
&& !flags.allow_file_access
&& flags.extensions.is_empty();
if can_try_default_cdp {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"cdpPort": 9333
});
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
}
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
attached_to_existing_browser = resp.success;
}
if !attached_to_existing_browser {
let mut auto_connect_cmd = json!({
"id": gen_id(),
"action": "launch",
"autoConnect": true
});
if let Some(ref cs) = flags.color_scheme {
auto_connect_cmd["colorScheme"] = json!(cs);
}
if let Some(ref tg) = flags.tab_group {
auto_connect_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id);
}
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
attached_to_existing_browser = resp.success;
}
}
}
if can_try_default_cdp && !attached_to_existing_browser {
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp <port|url>.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
// Launch headed browser or configure browser options (without CDP or provider)
if (flags.headed
|| flags.executable_path.is_some()
|| flags.profile.is_some()
|| flags.state.is_some()
|| flags.proxy.is_some()
|| flags.args.is_some()
|| flags.user_agent.is_some()
|| flags.ignore_https_errors
|| flags.allow_file_access
|| flags.debug
|| flags.color_scheme.is_some()
|| flags.download_path.is_some())
&& flags.cdp.is_none()
&& flags.provider.is_none()
&& !attached_to_existing_browser
{
let mut launch_cmd = json!({
"id": gen_id(),
@@ -716,11 +698,6 @@ fn main() {
cmd_obj.insert("executablePath".to_string(), json!(exec_path));
}
// Add profile path if specified
if let Some(ref profile_path) = flags.profile {
cmd_obj.insert("profile".to_string(), json!(profile_path));
}
// Add state path if specified
if let Some(ref state_path) = flags.state {
cmd_obj.insert("storageState".to_string(), json!(state_path));
@@ -766,23 +743,27 @@ fn main() {
if let Some(ref dp) = flags.download_path {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref domains) = flags.allowed_domains {
launch_cmd["allowedDomains"] = json!(domains);
if let Some(ref tg) = flags.tab_group {
launch_cmd["tabGroup"] = json!(tg);
}
if let Some(ref plugin_id) = flags.tab_group_plugin_id {
launch_cmd["tabGroupPluginId"] = json!(plugin_id);
}
match send_command(launch_cmd, &flags.session) {
Ok(resp) if !resp.success => {
// Launch command failed (e.g., invalid state file, profile error)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
} else {
eprintln!("{} {}", color::error_indicator(), error_msg);
Ok(resp) => {
if !resp.success {
// Launch command failed (e.g., invalid state file)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
} else {
eprintln!("{} {}", color::error_indicator(), error_msg);
}
exit(1);
}
exit(1);
}
Err(e) => {
if flags.json {
@@ -796,77 +777,15 @@ fn main() {
}
exit(1);
}
Ok(_) => {
// Launch succeeded
}
}
}
let output_opts = OutputOptions {
json: flags.json,
content_boundaries: flags.content_boundaries,
max_output: flags.max_output,
};
match send_command(cmd.clone(), &flags.session) {
Ok(resp) => {
let success = resp.success;
// Handle interactive confirmation
if flags.confirm_interactive {
if let Some(data) = &resp.data {
if data
.get("confirmation_required")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let desc = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("unknown action");
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let cid = data
.get("confirmation_id")
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[agent-browser] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
eprint!(" Allow? [y/N]: ");
let mut input = String::new();
let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
std::io::stdin().read_line(&mut input).is_ok()
&& matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
} else {
false
};
let confirm_cmd = if approved {
json!({ "id": gen_id(), "action": "confirm", "confirmationId": cid })
} else {
json!({ "id": gen_id(), "action": "deny", "confirmationId": cid })
};
match send_command(confirm_cmd, &flags.session) {
Ok(r) => {
if !approved {
eprintln!("{} Action denied", color::error_indicator());
exit(1);
}
print_response_with_opts(&r, None, &output_opts);
}
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
return;
}
}
}
// Extract action for context-specific output handling
let action = cmd.get("action").and_then(|v| v.as_str());
print_response_with_opts(&resp, action, &output_opts);
print_response(&resp, flags.json, action);
if !success {
exit(1);
}
+1
View File
@@ -0,0 +1 @@
include!("main.rs");
+235 -19
View File
@@ -105,6 +105,42 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} {}", color::success_indicator(), color::bold(title));
println!(" {}", color::dim(url));
if let Some(warning) = data.get("warning").and_then(|v| v.as_str()) {
println!("{} {}", color::warning_indicator(), warning);
}
if let Some(risk_signals) = data.get("riskSignals").and_then(|v| v.as_array()) {
for signal in risk_signals {
let code = signal
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("unknown_risk");
let source = signal
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let evidence = signal
.get("evidence")
.and_then(|v| v.as_str())
.unwrap_or("-");
let confidence = signal
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
println!(
"{} risk-signal code={} source={} evidence={} confidence={:.2}",
color::warning_indicator(),
code,
source,
evidence,
confidence
);
}
}
if let Some(warnings) = data.get("warnings").and_then(|v| v.as_array()) {
for warning in warnings.iter().filter_map(|v| v.as_str()) {
println!("{} {}", color::warning_indicator(), warning);
}
}
return;
}
println!("{}", url);
@@ -113,6 +149,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Diff responses -- route by action to avoid fragile shape probing
if let Some(obj) = data.as_object() {
match action {
Some("doctor") => {
print_doctor_report(obj);
return;
}
Some("diff_snapshot") => {
print_snapshot_diff(obj);
return;
@@ -833,10 +873,14 @@ Global Options:
--json Output as JSON
--session <name> Use specific session
--headers <json> Set HTTP headers (scoped to this origin)
--risk-mode <mode> Risk handling for verify/captcha pages: off, warn, block
--wait-until <mode> Navigation wait strategy: load, domcontentloaded, networkidle
--headed Show browser window
Examples:
agent-browser open example.com
agent-browser --risk-mode block open example.com
agent-browser --wait-until domcontentloaded open example.com
agent-browser open https://github.com
agent-browser open localhost:3000
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
@@ -962,10 +1006,11 @@ Examples:
r##"
agent-browser type - Type text into an element
Usage: agent-browser type <selector> <text>
Usage: agent-browser type <selector> <text> [--delay <ms>]
Types text into the specified element character by character.
Unlike fill, this does not clear existing content first.
Use --delay to add per-character delay (milliseconds).
Global Options:
--json Output as JSON
@@ -973,7 +1018,9 @@ Global Options:
Examples:
agent-browser type "#search" "hello"
agent-browser type "#search" "iphone" --delay 120
agent-browser type @e2 "additional text"
agent-browser type @e2 -- "--delay 120 (literal text)"
See Also:
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
@@ -1204,7 +1251,7 @@ the current focus — essential for contenteditable editors like
Lexical, ProseMirror, CodeMirror, and Monaco.
Subcommands:
type <text> Type text character-by-character with real
type <text> [--delay <ms>] Type text character-by-character with real
key events (keydown, keypress, keyup per char)
inserttext <text> Insert text without key events (like paste)
@@ -1217,6 +1264,7 @@ Global Options:
Examples:
agent-browser keyboard type "Hello, World!"
agent-browser keyboard type "human pacing" --delay 90
agent-browser keyboard type "# My Heading"
agent-browser keyboard inserttext "pasted content"
@@ -1282,13 +1330,14 @@ Examples:
r##"
agent-browser wait - Wait for condition
Usage: agent-browser wait <selector|ms|option>
Usage: agent-browser wait <selector|ms|min-max|option>
Waits for an element to appear, a timeout, or other conditions.
Modes:
<selector> Wait for element to appear
<ms> Wait for specified milliseconds
<min>-<max> Wait for random time between min and max ms
--url <pattern> Wait for URL to match pattern
--load <state> Wait for load state (load, domcontentloaded, networkidle)
--fn <expression> Wait for JavaScript expression to be truthy
@@ -1305,6 +1354,7 @@ Global Options:
Examples:
agent-browser wait "#loading-spinner"
agent-browser wait 2000
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
agent-browser wait --url "**/dashboard"
agent-browser wait --load networkidle
agent-browser wait --fn "window.appReady === true"
@@ -1694,8 +1744,8 @@ Operations:
Cookie Set Options:
--url <url> URL for the cookie (allows setting before page load)
--domain <domain> Cookie domain (e.g., ".example.com")
--path <path> Cookie path (e.g., "/api")
--domain <domain> Cookie domain (use with --path, e.g., ".example.com")
--path <path> Cookie path (use with --domain, e.g., "/api")
--httpOnly Set HttpOnly flag (prevents JavaScript access)
--secure Set Secure flag (HTTPS only)
--sameSite <Strict|Lax|None> SameSite policy
@@ -1703,6 +1753,7 @@ Cookie Set Options:
Note: If --url, --domain, and --path are all omitted, the cookie will be set
for the current page URL.
When --url is omitted, --domain and --path must be provided together.
Global Options:
--json Output as JSON
@@ -2072,7 +2123,8 @@ Operations:
clean --older-than <days> Delete expired state files
Automatic State Persistence:
Use --session-name to auto-save/restore state across restarts:
Use --session-name to auto-save/restore state across restarts.
If omitted, it defaults to --session (or "default"):
agent-browser --session-name myapp open https://example.com
Or set AGENT_BROWSER_SESSION_NAME environment variable.
@@ -2180,6 +2232,33 @@ Examples:
agent-browser click @e1
"##
}
"doctor" => {
r##"
agent-browser doctor - Diagnose CDP, sourceURL sanitization, and tab-group plugin health
Usage: agent-browser doctor
Runs a non-destructive health check focused on:
- CDP endpoint reachability (preferred :9333 + common ports)
- DevToolsActivePort discovery from local Chrome profiles
- CDP sourceURL sanitization probe (Runtime.evaluate leakage check)
- Plugin handshake page context suitability (internal page vs http(s))
- Tab-group plugin handshake status (when connected via CDP)
Notes:
- doctor does not accept positional arguments
- If browser is not already connected, doctor will still report CDP probe results
- Plugin handshake requires CDP mode, a normal http(s) page, and the extension installed
Global Options:
--json Output as JSON
--session <name> Use specific session
Examples:
agent-browser doctor
agent-browser --json doctor
"##
}
// === iOS Commands ===
"tap" => {
@@ -2314,15 +2393,16 @@ pub fn print_help() {
agent-browser - fast browser automation CLI for AI agents
Usage: agent-browser <command> [args] [options]
Aliases: agent-browser, agent-browser-stealth, abs
Core Commands:
open <url> Navigate to URL
click <sel> Click element (or @ref)
dblclick <sel> Double-click element
type <sel> <text> Type into element
type <sel> <text> [--delay <ms>] Type into element
fill <sel> <text> Clear and fill
press <key> Press key (Enter, Tab, Control+a)
keyboard type <text> Type text with real keystrokes (no selector)
keyboard type <text> [--delay <ms>] Type text with real keystrokes (no selector)
keyboard inserttext <text> Insert text without key events
hover <sel> Hover element
focus <sel> Focus element
@@ -2334,7 +2414,7 @@ Core Commands:
download <sel> <path> Download file by clicking element
scroll <dir> [px] Scroll (up/down/left/right)
scrollintoview <sel> Scroll element into view
wait <sel|ms> Wait for element or time
wait <sel|ms|min-max> Wait for element, time, or random range
screenshot [path] Take screenshot
pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI)
@@ -2408,6 +2488,7 @@ Sessions:
Setup:
install Install browser binaries
install --with-deps Also install system dependencies (Linux)
doctor Diagnose CDP + sourceURL + plugin health
Snapshot Options:
-i, --interactive Only interactive elements
@@ -2417,7 +2498,6 @@ Snapshot Options:
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
@@ -2439,9 +2519,14 @@ Options:
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--tab-group <name> Base title for agent tab groups (CDP plugin mode; silent no-op if plugin unavailable)
--tab-group-plugin-id <id> Expected Chrome extension ID for tab-group handshake (or AGENT_BROWSER_TAB_GROUP_PLUGIN_ID)
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
--wait-until <mode> Navigation wait strategy for open/navigate: load, domcontentloaded, networkidle
--session-name <name> Auto-save/restore session state (defaults to --session)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
@@ -2451,7 +2536,12 @@ Options:
--native [Experimental] Use native Rust daemon instead of Node.js (or AGENT_BROWSER_NATIVE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
--version, -V Show version (fork builds include upstream/fork info)
Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
@@ -2470,12 +2560,12 @@ Configuration:
Extensions from user and project configs are merged (not replaced).
Example agent-browser.json:
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}}
{{"headed": true, "proxy": "http://localhost:8080", "userAgent": "my-agent/1.0"}}
Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config)
AGENT_BROWSER_SESSION Session name (default: "default")
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name
AGENT_BROWSER_SESSION_NAME Auto-save/restore state persistence name (default: AGENT_BROWSER_SESSION)
AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM state encryption
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete states older than N days (default: 30)
AGENT_BROWSER_EXECUTABLE_PATH Custom browser executable path
@@ -2489,8 +2579,14 @@ Environment:
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_LOCALE Override auto-detected locale (e.g., zh-TW, ja-JP)
AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
AGENT_BROWSER_TAB_GROUP Base title for tab groups (default: "Agent Browser Stealth"; session suffix auto-appended)
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID Expected Chrome extension ID for tab-group handshake (default: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
@@ -2507,11 +2603,11 @@ Environment:
AGENT_BROWSER_NATIVE Use native Rust daemon (experimental, no Node.js/Playwright)
Install (recommended, fastest - native Rust CLI):
npm install -g agent-browser
npm install -g agent-browser-stealth
agent-browser install # Download Chromium (first time)
Try without installing (slower, routes through Node.js):
npx agent-browser open example.com
npx agent-browser-stealth open example.com
Examples:
agent-browser open example.com
@@ -2526,7 +2622,7 @@ Examples:
agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser --color-scheme dark open example.com # Dark mode
agent-browser --profile ~/.myapp open example.com # Persistent profile
agent-browser --risk-mode block open example.com # Block on verification/captcha pages
agent-browser --session-name myapp open example.com # Auto-save/restore state
Command Chaining:
@@ -2546,6 +2642,94 @@ iOS Simulator (requires Xcode and Appium):
);
}
pub fn print_response(resp: &Response, json: bool, action: Option<&str>) {
let opts = OutputOptions {
json,
content_boundaries: false,
max_output: None,
};
print_response_with_opts(resp, action, &opts);
}
fn status_badge(status: &str) -> String {
match status {
"pass" => color::green("PASS"),
"warn" => color::yellow("WARN"),
"fail" => color::red("FAIL"),
"skip" => color::dim("SKIP"),
_ => status.to_string(),
}
}
fn print_doctor_report(data: &serde_json::Map<String, serde_json::Value>) {
let ok = data.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
let summary = if ok {
format!("{} doctor checks passed", color::success_indicator())
} else {
format!("{} doctor found issues", color::error_indicator())
};
println!("{}", summary);
if let Some(context) = data.get("context").and_then(|v| v.as_object()) {
let launched = context
.get("launched")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let connection = context
.get("connectionKind")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let session = context
.get("session")
.and_then(|v| v.as_str())
.unwrap_or("default");
let cdp_endpoint = context
.get("cdpEndpoint")
.and_then(|v| v.as_str())
.unwrap_or("-");
println!(
" context: launched={} connection={} session={} cdp={}",
launched, connection, session, cdp_endpoint
);
}
if let Some(checks) = data.get("checks").and_then(|v| v.as_array()) {
for check in checks {
let Some(obj) = check.as_object() else {
continue;
};
let name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let status = obj
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = obj.get("message").and_then(|v| v.as_str()).unwrap_or("");
println!(" [{}] {} - {}", status_badge(status), name, message);
}
}
if let Some(plugin) = data.get("plugin").and_then(|v| v.as_object()) {
let plugin_status = plugin
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let plugin_message = plugin.get("message").and_then(|v| v.as_str()).unwrap_or("");
let plugin_id = plugin
.get("configuredPluginId")
.and_then(|v| v.as_str())
.unwrap_or("-");
println!(
" plugin: [{}] id={} {}",
status_badge(plugin_status),
plugin_id,
plugin_message
);
}
}
fn print_snapshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
let changed = data
.get("changed")
@@ -2622,6 +2806,38 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
);
}
pub fn print_version() {
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
/// Parse fork version metadata from semver-like strings:
/// <upstream>-fork.<fork>
/// Example:
/// 0.14.0-fork.1 -> (0.14.0, 1)
fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
let (upstream, fork) = version.split_once("-fork.")?;
if upstream.is_empty() || fork.is_empty() {
return None;
}
if !upstream
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
{
return None;
}
if !fork
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return None;
}
Some((upstream, fork))
}
pub fn print_version() {
let version = env!("CARGO_PKG_VERSION");
if let Some((upstream, fork)) = parse_fork_version(version) {
println!(
"agent-browser {} (upstream {}, fork {})",
version, upstream, fork
);
} else {
println!("agent-browser {}", version);
}
}
+272
View File
@@ -0,0 +1,272 @@
# PRD: CLI Web 数据采集体验优化(以小红书场景为例)
- 文档版本: v0.1
- 状态: Draft
- 作者: Codex
- 日期: 2026-03-04
## 1. 背景与问题
在使用 `agent-browser` CLI 执行「小红书宠物博主采集(100 条)」时,当前流程可完成任务,但存在明显的可用性与稳定性痛点:
1. 网络层可观测性不足,响应体抓取不稳定,需注入脚本劫持。
2. 分页采集依赖手工 `scroll down + wait`,重复劳动且易漏数据。
3. 结构化导出缺少一站式命令,需要 `eval` 二次解析。
4. 页面交互依赖文本选择,页面文案变动后脆弱。
5. 反爬失败时缺少可解释的自动回退策略。
6. 用户对“可抓字段”预期不清(例如搜索接口无联系方式)。
7. 长会话缺少快照与断点续抓机制。
## 2. 目标与非目标
## 2.1 目标
1. 将常见采集链路从“脚本拼接”降为“CLI 原生命令组合”。
2. 让关键动作具备可观测性(日志)和可恢复性(快照/续跑)。
3. 降低站点轻微改版、反爬限制带来的失败率。
## 2.2 非目标
1. 不承诺绕过平台强风控或登录体系。
2. 不在本期实现完整通用爬虫 DSL。
3. 不默认抓取平台未公开展示的隐私字段。
## 3. 目标用户与核心场景
1. 增长/运营: 按关键词采集账号基础数据并导出 CSV。
2. 测试/研发: 复现抓取问题,定位请求失败原因。
3. AI Agent 工作流: 在 CLI 内稳定执行“搜索 -> 翻页 -> 提取 -> 导出”。
## 4. 需求范围与优先级
## 4.1 P0
1. `network capture` 增强模式(可过滤、可落盘 response body)。
2. `scroll-collect` 自动滚动采集(按页数或直到无新增)。
3. `extract` / `extract-to` 结构化导出(JSON/CSV)。
## 4.2 P1
1. 语义选择器与 fallback 链(role/aria/data/text)。
2. 401/403/406 智能回退(页面触发 + 回包监听)。
3. 可抓字段矩阵与二段式采集文档提示。
## 4.3 P2
1. `session snapshot` + `crawl resume` 断点续抓。
## 5. CLI 方案设计
## 5.1 网络捕获增强
命令草案:
```bash
agent-browser network capture --match '/api/sns/web/v1/search/usersearch' --save ./out.ndjson
agent-browser network capture --domain edith.xiaohongshu.com --method POST --save ./xhs_usersearch.ndjson
```
参数:
- `--match <regex>`: 按 URL 正则过滤。
- `--domain <host>`: 按域名过滤。
- `--method <GET|POST|...>`: 按方法过滤。
- `--status <code|range>`: 按状态过滤。
- `--save <path>`: NDJSON 输出文件。
- `--include-body <request|response|both>`: 控制 body 输出范围。
- `--max-body-bytes <n>`: 单条 body 截断阈值。
NDJSON 记录结构:
```json
{
"ts": "2026-03-04T10:00:00.123Z",
"session_id": "sess_abc",
"request_id": "req_123",
"method": "POST",
"url": "https://edith.xiaohongshu.com/api/sns/web/v1/search/usersearch",
"status": 200,
"duration_ms": 312,
"request_headers": {"content-type": "application/json"},
"request_body": "{...}",
"response_headers": {"content-type": "application/json"},
"response_body": "{...}",
"truncated": false
}
```
## 5.2 自动滚动采集
命令草案:
```bash
agent-browser scroll-collect --until no-new-items --max-steps 200 --idle-rounds 3
agent-browser scroll-collect --pages 20 --wait-ms 1200
```
行为:
1. 每轮执行滚动与等待。
2. 基于 DOM 项数量或网络新增请求判断“是否有新增”。
3. 达到停止条件后输出结束原因。
输出示例:
```text
step=1 new_items=15 total_items=15
step=2 new_items=15 total_items=30
...
stop_reason=no-new-items idle_rounds=3 total_items=135
```
## 5.3 结构化提取与导出
命令草案:
```bash
agent-browser extract --from network --match usersearch --fields 'name,fans,note_count,red_id'
agent-browser extract-to --from network --match usersearch --fields 'name,fans,note_count,red_id,url' --format csv --out ./users.csv
```
参数:
- `--from <network|dom|eval>`: 数据源。
- `--match <pattern>`: 来源过滤(URL/事件名)。
- `--query <JMESPath|JSONPath>`: 自定义提取表达式。
- `--fields <a,b,c>`: 字段映射快捷写法。
- `--dedupe-by <field>`: 去重键。
- `--limit <n>`: 限制条数。
- `--format <json|ndjson|csv>`: 输出格式。
- `--out <path>`: 文件输出路径。
## 5.4 语义选择器与回退链
命令草案:
```bash
agent-browser click --selector 'role=tab[name="用户"]' --fallback 'aria=用户,text=用户'
agent-browser find --selector 'data-testid=user-tab' --fallback 'role=tab[name="用户"],text=用户'
```
策略:
1. 主选择器失败后按 fallback 顺序重试。
2. 日志打印每次尝试与失败原因。
## 5.5 反爬失败自动回退
命令草案:
```bash
agent-browser request replay --on-status 401,403,406 --fallback page-action
```
策略:
1. 直接请求失败后自动回退到页面行为触发。
2. 自动复用 UA/Referer/Cookie Jar。
3. 捕获最终有效响应并给出“回退成功/失败”日志。
## 5.6 会话快照与断点续抓
命令草案:
```bash
agent-browser session snapshot save ./snapshots/xhs-20260304.json
agent-browser crawl resume --snapshot ./snapshots/xhs-20260304.json --out ./users.csv
```
快照最小字段:
- 当前 URL
- 关键词/筛选参数
- 已抓 user_id 集合摘要(可哈希分片)
- 分页进度(page/scroll step
- 导出配置(fields/format/out
## 6. 错误码设计(草案)
- `AB_NET_CAPTURE_BODY_UNAVAILABLE` (1001): 响应体不可用(被浏览器策略阻断或已释放)。
- `AB_SCROLL_TIMEOUT_NO_PROGRESS` (1101): 滚动超时且无新增。
- `AB_EXTRACT_QUERY_INVALID` (1201): 提取表达式语法错误。
- `AB_EXTRACT_OUTPUT_FAILED` (1202): 导出失败(权限/路径不可写)。
- `AB_SELECTOR_NOT_FOUND` (1301): 主选择器与 fallback 全部失败。
- `AB_REQUEST_BLOCKED_406` (1406): 请求被风控拦截,且回退链路失败。
- `AB_RESUME_SNAPSHOT_INVALID` (1501): 快照损坏或版本不兼容。
要求:
1. CLI 退出码与错误码可映射。
2. 错误输出提供 `hint`(下一步建议命令)。
## 7. 日志与可观测性
默认人类可读,开启 `--log-format json` 输出结构化日志。
JSON 日志字段:
- `ts`
- `level`
- `session_id`
- `command`
- `event`
- `step`
- `url`
- `status`
- `error_code`
- `message`
- `hint`
示例:
```json
{"ts":"2026-03-04T10:11:22.123Z","level":"INFO","command":"scroll-collect","event":"step","step":12,"new_items":15,"total_items":180}
{"ts":"2026-03-04T10:13:01.001Z","level":"WARN","command":"request replay","event":"fallback","status":406,"message":"direct request blocked, fallback to page-action"}
```
## 8. 文档与帮助信息更新要求
当功能落地时,需要同步更新以下位置(按仓库规范):
1. `cli/src/output.rs``--help`、示例、环境变量)
2. `README.md`(命令选项、样例)
3. `skills/agent-browser/SKILL.md`Agent 工作流)
4. `docs/src/app/`(新增/更新 MDX 页面,表格使用 HTML `<table>`
5. 对应源码内联注释
## 9. 验收用例(首批)
1. `network capture` 能稳定保存目标接口完整 request/response body。
2. 设置 `--max-body-bytes` 后被截断记录带 `truncated=true`
3. `scroll-collect --pages 5` 精确执行 5 轮并退出。
4. `scroll-collect --until no-new-items` 在连续空增量 N 轮后退出。
5. `extract-to ... --format csv` 产出可打开 CSV 且列名正确。
6. `extract --dedupe-by user_id` 去重结果稳定。
7. selector 主规则失败时,fallback 生效并成功点击。
8. 对 406 场景触发自动回退并成功捕获有效响应。
9. 回退失败时返回 `AB_REQUEST_BLOCKED_406` 且提供 hint。
10. `session snapshot save/load` 前后任务可恢复。
11. `crawl resume` 不重复导出已抓 ID。
12. `--log-format json` 日志字段完整,便于机器消费。
## 10. 里程碑建议
1. M11 周): `network capture` + `scroll-collect`
2. M21 周): `extract-to` + selector fallback。
3. M31 周): 406 回退链路 + 文档补全。
4. M41 周): snapshot/resume + 稳定性打磨。
## 11. 风险与缓解
1. 平台策略变化导致规则失效。
缓解: 增加站点适配层与策略开关,保留回退日志。
2. 响应体过大带来内存与 IO 压力。
缓解: 流式写入 NDJSON + 截断阈值。
3. 通用提取表达式学习成本高。
缓解: 提供字段模板与场景 presets。
## 12. 开放问题
1. `extract` 表达式标准优先 JSONPath 还是 JMESPath
2. `session snapshot` 是否需要加密(含 cookie 元信息)?
3. 是否提供站点模板(如 `preset xiaohongshu-user-search`)以降低上手成本?
@@ -0,0 +1,192 @@
# 浏览器自动化攻防方案设计:检测模型与分层控制面
本文聚焦浏览器自动化的攻防方案设计,按两个部分组织:
1. **原理**:风险评分系统如何形成结论
2. **控制面**:如何用分层设计降低风险与波动
本文不包含命令行操作与工程实现步骤。
Turnstile 专题内容见:
[Cloudflare Turnstile 攻防方案设计:系统原理与控制面](https://blog.misonote.com/zh/posts/cloudflare-turnstile-stability-principles/)
---
## 一、原理
### 1.1 风险评分不是单点命中
高风控站点的“是否挑战/是否降权”通常来自多维评分,而不是某一条规则的二元判断。
主要输入维度:
1. **一致性**:同一身份在不同表面是否互相矛盾
2. **稀有性**:低频异常组合是否出现
3. **时序性**:行为时间序列是否呈机械统计特征
4. **执行完整性**:关键链路(挑战脚本、跨域资源、worker)是否被破坏
```mermaid
flowchart LR
A["环境与行为"] --> B["一致性评分"]
A --> C["稀有性评分"]
A --> D["时序评分"]
A --> E["执行完整性评分"]
B --> F["综合风险"]
C --> F
D --> F
E --> F
F --> G{"放行/挑战/限流"}
```
### 1.2 一致性:约束集合而非单点修饰
一致性问题的本质是“同一身份在多个观测面上的约束必须同时成立”。
#### 1.2.1 约束集合示意
可以把身份一致性建模为“约束图”:
```mermaid
flowchart TD
UA["UA 字符串"] --> UACH["UA-CH / userAgentMetadata"]
UA --> LangH["Accept-Language"]
LangH --> LangJS["navigator.language(s)"]
LangJS --> Intl["Intl locale/timeZone"]
Plat["platform"] --> Rend["渲染能力/WebGL"]
Rend --> Win["窗口/屏幕参数"]
UACH --> Plat
```
图中每条边表示“两个表面必须相互一致”,否则会形成冲突分值。
#### 1.2.2 典型冲突类型
- UA 显示平台/版本与 UA-CH 不一致
- `Accept-Language``navigator.languages` 不一致
- `Intl` 时区与偏移/地区推断不一致
- 设备声明与渲染能力组合异常
工程含义:
- 修一个点可能打破另一个点
- 设计顺序应是“先定约束集合,再决定每个表面如何满足约束”
### 1.3 稀有性:组合风险而非单值风险
稀有性来自“低频组合”,其危险性来自共现而非单项。
可以将稀有性理解为“联合分布”偏离:
- 单项偏离:可被容忍
- 多项共现偏离:风险迅速累积
工程含义:
- 目标是减少低频组合在同一会话内叠加
- 目标不是拟合某个固定画像
### 1.4 时序性:统计特征而非行为语义
行为检测通常关注统计分布特征:
- 低方差:动作间隔过于稳定
- 强周期:间隔呈固定节奏
- 强同步:不同类型动作间隔一致
工程含义:
- 行为治理的目标是“分布塑形”(variance/jitter/backoff
- 行为治理不是“添加更多动作”
### 1.5 执行完整性:上游条件
执行完整性属于“系统是否能正确运行”的前置条件。
- challenge 脚本、跨域 iframe、跨域 worker 的语义被破坏时,失败率会显著上升
- 此类失败可能与“是否被识别”为不同类别的问题
工程原则:
> 执行链路保护优先于信号修饰。
---
## 二、控制面(分层设计)
### 2.1 控制面总览
攻防方案可以拆为四层控制面:
1. **启动控制**:治理启动早期显式风险
2. **协议控制**:治理协议层身份一致性
3. **运行时控制**:治理页面脚本可观测表面
4. **行为与会话控制**:治理时序分布与上下文漂移
```mermaid
flowchart LR
A["启动控制"] --> B["协议控制"]
B --> C["运行时控制"]
C --> D["行为与会话控制"]
D --> E["一致性与稳定性"]
```
### 2.2 启动控制
目标:降低会话早期显式风险。
设计约束:
- 只处理高置信度自动化标识
- 避免引入与协议层/运行时层不一致的改动
### 2.3 协议控制
目标:将身份约束集合落实到协议层输出。
设计要点:
- 将 UA 与 UA-CH 视为同一约束集合的不同投影
- 覆盖范围需要与目标(页面/worker/子目标)一致
### 2.4 运行时控制
目标:覆盖高频探测面,同时保证不破坏执行语义。
设计要点:
- 优先治理高频、可解释的探测路径
- 对跨域挑战链路对象设置严格注入边界
### 2.5 行为与会话控制
目标:塑形时间分布,减少上下文漂移。
设计要点:
- 行为治理以统计分布为目标(variance/jitter/backoff
- 会话治理以一致上下文为目标(避免身份漂移)
### 2.6 挑战场景控制面摘要(Turnstile)
Turnstile 场景下的关键控制面可抽象为:
1. 能力令牌语义:服务端验证、有限时效、单次消费
2. 作用域收缩:`hostname/action/cdata` 收缩滥用空间
3. 执行链路保护:跨域脚本/iframe/worker 语义保护
4. 摩擦与安全分离:clearance 属于体验层,不替代安全决策层
该摘要用于将 Turnstile 纳入统一控制面框架;细节见专题文章。
---
## 三、方案设计优先级
控制面设计通常按以下优先级推进:
1. 执行完整性(保证链路可运行)
2. 一致性约束集合(消除跨表面矛盾)
3. 稀有性控制(避免低频组合叠加)
4. 时序分布塑形(降低机械统计特征)
5. 体验优化(降低重复挑战摩擦)
该顺序的含义是先保证“系统正确性”,再优化“稳定性与摩擦”。
@@ -0,0 +1,250 @@
# Cloudflare Turnstile 攻防方案设计:系统原理与控制面
本文聚焦 Turnstile 的攻防方案设计:
1. **系统原理**:token 的安全语义、挑战执行链路、风险评分的输入输出
2. **控制面设计**:在不同攻击面下,哪些约束是必要的、哪些约束容易引入副作用
本文不包含命令行操作与工程实现步骤。
---
## 一、系统原理
### 1.1 Turnstile 是“能力令牌”系统
Turnstile 的本质是签发一个短生命周期、单次消费的能力令牌(capability token)。
- **签发端**:浏览器端完成挑战执行后获得 token
- **消费端**:业务服务端通过 Siteverify 验证 token 并决定是否放行
```mermaid
flowchart LR
A["浏览器端挑战执行"] --> B["token"]
B --> C["业务服务端"]
C --> D["Siteverify"]
D --> E{"放行/拒绝"}
```
关键含义:
- 前端任何“通过”状态都不是业务放行条件
- 业务放行条件是“token 被正确消费”
### 1.2 Token 的三条安全语义
token 的安全语义可以抽象为三条约束:
1. **必须服务端验证**:不允许仅以前端回调作为依据
2. **有限时效**token 超过时效窗口即失效
3. **单次消费**:同一 token 重复消费应失败
这三条语义分别封装了三个常见攻击目标:
- 伪通过:绕过服务端验证
- 延迟提交:绕过时效窗口
- 重放/并发:绕过单次消费
### 1.3 挑战执行链路是“跨域执行系统”
Turnstile 的 token 产生依赖多组件协作,且跨域链路占主导:
- `api.js` 脚本
- challenge iframe
- challenge worker
- 跨域资源请求
```mermaid
flowchart TD
A["加载 api.js"] --> B["创建 iframe"]
B --> C["执行 worker"]
C --> D["收集信号 + 风险评估"]
D --> E["签发 token"]
```
该链路的工程含义:
- 任何对跨域脚本/iframe/worker 的语义改写,都可能导致 token 生成失败或质量下降
- token 失败不一定意味着“被识别”,也可能是“链路被破坏”
### 1.4 风险评分:输入不是“真假”,而是“自洽程度”
挑战执行阶段会收集环境与行为信号,形成风险评分。
- **信号输入**:环境一致性(UA/UA-CH、语言/时区、渲染能力、能力暴露)
- **行为输入**:时序分布(方差、周期性、同步性)
风险评分的关键不是“拟合某种固定画像”,而是“同一身份在多表面是否自洽”。
### 1.5 作用域绑定:hostname / action / cdata
服务端校验时提供用于绑定业务语义的字段:
- `hostname`token 允许的站点作用域
- `action`token 允许的动作作用域
- `cdata`token 允许的上下文作用域
这些字段的作用是“收缩 token 可被滥用的范围”,而不是“提高通过率”。
```mermaid
flowchart LR
A["token"] --> B["hostname 作用域"]
A --> C["action 作用域"]
A --> D["cdata 作用域"]
B --> E["降低站外盗用收益"]
C --> F["降低动作错配收益"]
D --> G["降低跨流程重放收益"]
```
### 1.6 Token 状态机(能力令牌视角)
从能力令牌视角,token 生命周期可抽象为:
```mermaid
stateDiagram-v2
[*] --> Issued: challenge ok
Issued --> Consumed: siteverify ok
Issued --> Expired: time window
Issued --> Rejected: binding mismatch
Issued --> Replayed: reused
Replayed --> Rejected
Expired --> Rejected
Consumed --> [*]
```
设计目标是让“非法路径”快速失败,并且失败类型可被服务端语义区分。
### 1.7 攻击树(高层)
Turnstile 的主要攻击目标可以抽象为:
```mermaid
flowchart TD
A["绕过业务动作门禁"] --> B["伪造或跳过服务端验证"]
A --> C["重放 token"]
A --> D["扩大 token 作用域"]
A --> E["破坏挑战执行以制造降级路径"]
C --> C1["并发提交"]
C --> C2["延迟提交"]
D --> D1["Any Hostname"]
D --> D2["action/cdata 缺失"]
```
该攻击树强调设计重点:
- 安全决策必须在服务端闭环
- token 必须被作用域收缩并按语义消费
---
## 二、控制面设计(攻防视角)
### 2.1 控制面分层
Turnstile 防线可以分为四层控制面:
1. **挑战执行控制**:保证脚本/iframe/worker 跨域链路完整
2. **服务端消费控制**:保证 token 的语义被正确消费
3. **作用域控制**:收缩 `hostname/action/cdata` 的可用范围
4. **摩擦控制**:clearance 用于降低挑战摩擦(不作为安全决策依据)
```mermaid
flowchart LR
A["挑战执行控制"] --> E["token 可生成"]
A --> F["token 质量"]
B["服务端消费控制"] --> G["安全决策闭环"]
C["作用域控制"] --> H["滥用收益收缩"]
D["摩擦控制"] --> I["挑战频率下降"]
```
### 2.2 挑战执行控制:跨域语义保护优先
挑战执行链路对跨域执行语义高度敏感。
原则:
- 跨域脚本/iframe/worker 避免语义改写
- 所有指纹修饰必须先满足“不破坏挑战执行”这一硬约束
该原则的工程含义:
- “执行完整性”是上游条件
- “信号修饰”是下游优化
### 2.3 服务端消费控制:把 token 当作能力消费
服务端消费控制的设计关键在于“放行条件定义”,而不是“接口调用细节”。
放行条件应体现三类约束:
- 真实性:校验 `success`
- 作用域:校验 `hostname`
- 语义绑定:校验 `action/cdata`
并且必须贯彻 token 的两个安全语义:
- 时效性:过期拒绝
- 单次性:重放拒绝
从攻防角度,该层解决的是“绕过与重放”。
### 2.4 作用域控制:Hostname Management 与 Any Hostname
Hostname 管理解决“站外盗用”的攻击面。
- 启用 Hostname Management:收缩 token 可用站点范围
- 启用 Any Hostname:扩大 token 可用站点范围
设计结论:
- Any Hostname 不是“更灵活”,而是“扩大攻击面”,必须用更强的服务端约束做补偿控制(来源域白名单 + 业务绑定)。
### 2.5 摩擦控制:Pre-clearance 与 cf_clearance 的边界
Pre-clearance 通过后可产生 clearance,用于后续 WAF 挑战联动。
边界定义:
- clearance 用于体验层(降低重复挑战摩擦)
- Siteverify 用于安全决策层(业务放行依据)
将两者混用会引入“体验信号替代安全信号”的设计缺陷。
### 2.6 高对抗场景:代理池与设备关联
在代理池与分布式滥用场景中,单一 IP 维度约束容易失效。
设计方向是引入更稳定的关联维度(例如设备级 ephemeral id),用于聚类与阈值策略。
该层属于平台能力与业务风控的交界:
- 平台提供关联信号
- 业务定义动作分层、阈值与处置策略
---
## 三、方案设计优先级
Turnstile 攻防设计通常按以下优先级推进:
1. 服务端消费语义闭环(真实性 + 作用域 + 绑定 + 单次性 + 时效性)
2. 挑战执行链路完整性(跨域语义保护)
3. 信号一致性(减少跨字段矛盾)
4. 行为时序(降低机械分布)
5. 体验优化(clearance 等摩擦控制)
该顺序的含义是先定义“正确的安全决策”,再优化“挑战摩擦与通过率波动”。
---
## 官方参考(概念与配置)
- Widgets: <https://developers.cloudflare.com/turnstile/concepts/widget/>
- Widget configurations: <https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/>
- Server-side validation: <https://developers.cloudflare.com/turnstile/get-started/server-side-validation/>
- CSP: <https://developers.cloudflare.com/turnstile/reference/content-security-policy/>
- Hostname management: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/>
- Any Hostname: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/any-hostname/>
- Pre-clearance: <https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/pre-clearance/>
- Cloudflare clearance: <https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/>
- Ephemeral IDs: <https://developers.cloudflare.com/turnstile/additional-configuration/ephemeral-id/>
@@ -0,0 +1,97 @@
# agent-browser 与 agent-browser-stealth:能力差异与选型
本文给出 `agent-browser``agent-browser-stealth` 的技术差异、适用场景和升级验证步骤。
项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser)
---
## 1. 定位差异
- `agent-browser`:标准浏览器自动化能力
- `agent-browser-stealth`:在标准自动化能力基础上,增加反检测与高风控场景稳定性能力
---
## 2. 核心能力对比
| 维度 | agent-browser | agent-browser-stealth |
| --- | --- | --- |
| 自动化基础能力 | 支持 | 支持 |
| 指纹一致性治理 | 基础 | 多层(launch/CDP/init-script |
| 高风控站点稳定性 | 一般 | 更高 |
| 会话连续性(附着现有浏览器) | 支持 | 支持,默认附着策略更明确 |
| Cloudflare/Turnstile 回归工具 | 无专用脚本 | `check:turnstile-testkey` |
---
## 3. Cloudflare/Turnstile 相关能力(v0.15.2-fork.2+
### 3.1 挑战链路保护
- 同源 worker 注入保留
- 跨域 challenge worker 不做注入改写
- 降低 challenge worker 执行异常概率
### 3.2 导航等待策略
`open/navigate` 支持:
- `--wait-until load`
- `--wait-until domcontentloaded`
- `--wait-until networkidle`
挑战页建议优先 `domcontentloaded`,减少 `load` 阶段超时误判。
### 3.3 确定性回归
提供官方 test key 回归脚本:
```bash
pnpm run check:turnstile-testkey
```
通过特征:输出 `XXXX.DUMMY.TOKEN.XXXX`
---
## 4. 适用场景
优先使用 `agent-browser-stealth` 的场景:
1. 目标站点存在挑战页/验证码/限流
2. 自动化链路对稳定性要求高
3. 需要长期回归验证与版本门禁
使用 `agent-browser` 的场景:
1. 低风控站点
2. 以基础自动化能力验证为主
---
## 5. 升级验证步骤
```bash
# 1) 检查版本
agent-browser -V
# 2) 关闭旧 daemon,避免版本漂移
agent-browser --session default close
# 3) 运行确定性回归
pnpm run check:turnstile-testkey
# 4) 可选:真实站点回归
agent-browser --wait-until domcontentloaded open https://www.anyviewer.com/cloudflare.html
```
如果启用域名白名单(`AGENT_BROWSER_ALLOWED_DOMAINS`),需包含 `challenges.cloudflare.com`
---
## 6. 结论
`agent-browser-stealth` 适用于高风控与稳定性敏感场景;`agent-browser` 适用于标准自动化场景。
选型建议按目标站点风控强度与回归要求决定。
+2 -2
View File
@@ -14,9 +14,9 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
GitHub repository: https://github.com/vercel-labs/agent-browser
GitHub repository: https://github.com/leeguooooo/agent-browser
Documentation: https://agent-browser.dev
npm package: agent-browser
npm package: agent-browser-stealth
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
+150 -22
View File
@@ -1,11 +1,18 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from '@/lib/page-metadata';
export const metadata = pageMetadata("cdp-mode")
export const metadata = pageMetadata('cdp-mode');
# CDP Mode
Connect to an existing browser via Chrome DevTools Protocol:
Default behavior in this fork: when `--cdp` is omitted, agent-browser auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed local-launch fallback).
Project policy:
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
@@ -52,7 +59,7 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
Auto-connect discovers Chrome by:
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
2. Falling back to probing common debugging ports (9222, 9229)
2. Falling back to probing common debugging ports (9222, 9229, 9333)
This is useful when:
@@ -75,6 +82,35 @@ Or set it globally via config or environment variable:
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
```
## Stealth behavior
`--stealth` is enabled by default across connection modes, but capabilities depend on how you connect:
<table>
<thead>
<tr>
<th>Connection type</th>
<th>Stealth capabilities</th>
</tr>
</thead>
<tbody>
<tr>
<td>Local launch</td>
<td>Chromium launch args + context init scripts</td>
</tr>
<tr>
<td>CDP / auto-connect</td>
<td>Context init scripts</td>
</tr>
<tr>
<td>Cloud providers</td>
<td>Context init scripts (Kernel may also apply provider-managed stealth)</td>
</tr>
</tbody>
</table>
Use `--debug` to print the active connection type and applied stealth capabilities.
## Use cases
This enables control of:
@@ -89,27 +125,119 @@ This enables control of:
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td><code>--session &lt;name&gt;</code></td><td>Use isolated session</td></tr>
<tr><td><code>--profile &lt;path&gt;</code></td><td>Persistent browser profile directory</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
<tr><td><code>--headers &lt;json&gt;</code></td><td>HTTP headers scoped to origin</td></tr>
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
<tr><td><code>--args &lt;args&gt;</code></td><td>Browser launch args (comma-separated)</td></tr>
<tr><td><code>--user-agent &lt;ua&gt;</code></td><td>Custom User-Agent string</td></tr>
<tr><td><code>--proxy &lt;url&gt;</code></td><td>Proxy server URL</td></tr>
<tr><td><code>--proxy-bypass &lt;hosts&gt;</code></td><td>Hosts to bypass proxy</td></tr>
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
<tr><td><code>{"--cdp <port|url>"}</code></td><td>CDP connection (port or WebSocket URL)</td></tr>
<tr><td><code>--auto-connect</code></td><td>Auto-discover and connect to running Chrome</td></tr>
<tr><td><code>--color-scheme &lt;scheme&gt;</code></td><td>Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
<tr><td><code>--debug</code></td><td>Debug output</td></tr>
<tr>
<td>
<code>--session &lt;name&gt;</code>
</td>
<td>Use isolated session</td>
</tr>
<tr>
<td>
<code>-p &lt;provider&gt;</code>
</td>
<td>
Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>,{' '}
<code>kernel</code>)
</td>
</tr>
<tr>
<td>
<code>--headers &lt;json&gt;</code>
</td>
<td>HTTP headers scoped to origin</td>
</tr>
<tr>
<td>
<code>--executable-path</code>
</td>
<td>Custom browser executable</td>
</tr>
<tr>
<td>
<code>--args &lt;args&gt;</code>
</td>
<td>Browser launch args (comma-separated)</td>
</tr>
<tr>
<td>
<code>--user-agent &lt;ua&gt;</code>
</td>
<td>Custom User-Agent string</td>
</tr>
<tr>
<td>
<code>--proxy &lt;url&gt;</code>
</td>
<td>Proxy server URL</td>
</tr>
<tr>
<td>
<code>--proxy-bypass &lt;hosts&gt;</code>
</td>
<td>Hosts to bypass proxy</td>
</tr>
<tr>
<td>
<code>--json</code>
</td>
<td>JSON output for scripts</td>
</tr>
<tr>
<td>
<code>--full, -f</code>
</td>
<td>Full page screenshot</td>
</tr>
<tr>
<td>
<code>--name, -n</code>
</td>
<td>Locator name filter</td>
</tr>
<tr>
<td>
<code>--exact</code>
</td>
<td>Exact text match</td>
</tr>
<tr>
<td>
<code>--headed</code>
</td>
<td>Show browser window</td>
</tr>
<tr>
<td>
<code>{'--cdp <port|url>'}</code>
</td>
<td>CDP connection (port or WebSocket URL)</td>
</tr>
<tr>
<td>
<code>--auto-connect</code>
</td>
<td>Auto-discover and connect to running Chrome</td>
</tr>
<tr>
<td>
<code>--color-scheme &lt;scheme&gt;</code>
</td>
<td>
Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
</td>
</tr>
<tr>
<td>
<code>--debug</code>
</td>
<td>Debug output</td>
</tr>
</tbody>
</table>
+68 -59
View File
@@ -1,19 +1,22 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from '@/lib/page-metadata';
export const metadata = pageMetadata("commands")
export const metadata = pageMetadata('commands');
# Commands
Executable aliases: `agent-browser`, `agent-browser-stealth`, `abs`.
## Core
```bash
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected
agent-browser click <sel> # Click element (--new-tab to open in new tab)
agent-browser dblclick <sel> # Double-click
agent-browser fill <sel> <text> # Clear and fill
agent-browser type <sel> <text> # Type into element
agent-browser type <sel> <text> [--delay <ms>] # Type into element
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
agent-browser keyboard type <text> # Type at current focus (no selector needed)
agent-browser keyboard type <text> [--delay <ms>] # Type at current focus (no selector needed)
agent-browser keyboard inserttext <text> # Insert text without key events
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
@@ -32,9 +35,17 @@ agent-browser pdf <path> # Save page as PDF
agent-browser snapshot # Accessibility tree with refs
agent-browser eval <js> # Run JavaScript
agent-browser connect <port|url> # Connect to browser via CDP
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
agent-browser --version # Show CLI version
agent-browser close # Close browser (aliases: quit, exit)
```
Fork builds print dual-version metadata with `--version`:
```bash
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
```
## Get info
```bash
@@ -95,6 +106,7 @@ agent-browser find nth 2 ".card" hover
```bash
agent-browser wait <selector> # Wait for element
agent-browser wait <ms> # Wait for time
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
agent-browser wait --text "Welcome" # Wait for text
agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state
@@ -102,6 +114,16 @@ agent-browser wait --fn "condition" # Wait for JS condition
agent-browser wait --download [path] # Wait for download
```
## Risk Mode
Control how `open`/`navigate` handles verification or captcha interstitials:
```bash
agent-browser --risk-mode warn open https://example.com # default: wait for auto-clear, then retry/warn with riskSignals
agent-browser --risk-mode block open https://example.com # fail fast on detection
agent-browser --risk-mode off open https://example.com # disable detection/retry
```
## Downloads
```bash
@@ -111,6 +133,32 @@ agent-browser wait --download [path] # Wait for any download to complete
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
## Tab grouping
```bash
agent-browser open https://example.com
# CDP mode groups tabs when tab-group plugin is installed
# Override the default group title
agent-browser --tab-group "My Agent Group" open https://example.com
```
CDP mode uses a browser extension handshake to group tabs.
- Extension available: tabs are grouped by `session`.
- Extension missing/unavailable: silent no-op (commands still succeed).
- Default titles:
- `default` session: `Agent Browser Stealth`
- non-default: `Agent Browser Stealth • <session>`
- Extension side panel (`agent-browser-stealth`) also provides:
- Session window isolation and deterministic group colors.
- `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions.
- Toggle switches for strict isolation / activation guard / auto-clean.
- Session allowlist editing (domain fallback to `about:blank` when violated).
- Download routing to `agent-browser-stealth/<session>/...`.
- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title.
- Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID.
## Mouse
```bash
@@ -153,6 +201,14 @@ agent-browser storage local clear # Clear all
agent-browser storage session # Same for sessionStorage
```
For `cookies set`, use one of these patterns:
- `--url <url>`
- `--domain <domain> --path <path>`
- omit all three to scope from the current page URL
When `--url` is omitted, `--domain` and `--path` must be provided together.
## Network
```bash
@@ -199,49 +255,8 @@ agent-browser console --clear # Clear console log
agent-browser errors # View page errors
agent-browser errors --clear # Clear error log
agent-browser highlight <sel> # Highlight element
```
## Auth vault
```bash
agent-browser auth save <name> [opts] # Save auth profile
agent-browser auth login <name> # Login using saved credentials
agent-browser auth list # List saved profiles (names and URLs only)
agent-browser auth show <name> # Show profile metadata (no passwords)
agent-browser auth delete <name> # Delete a saved profile
```
Save options:
- `--url <url>` -- login page URL (required)
- `--username <user>` -- username (required)
- `--password <pass>` -- password (required unless `--password-stdin`)
- `--password-stdin` -- read password from stdin (recommended to avoid shell history exposure)
- `--username-selector <sel>` -- custom CSS selector for username field
- `--password-selector <sel>` -- custom CSS selector for password field
- `--submit-selector <sel>` -- custom CSS selector for submit button
```bash
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
agent-browser auth login github
agent-browser auth list
```
## Confirmation
When `--confirm-actions` is set, certain action categories return a `confirmation_required` response instead of executing immediately. Use `confirm` or `deny` to approve or reject the action.
```bash
agent-browser confirm <confirmation-id> # Approve a pending action
agent-browser deny <confirmation-id> # Deny a pending action
```
Pending confirmations auto-deny after 60 seconds.
```bash
agent-browser --confirm-actions eval,download eval "document.title"
# Returns confirmation_required with ID
agent-browser confirm c_8f3a1234
agent-browser doctor # Diagnose CDP + sourceURL + plugin handshake status
pnpm run check:turnstile-testkey # Deterministic Turnstile smoke check (official test key)
```
## State management
@@ -276,8 +291,7 @@ agent-browser reload # Reload page
```bash
--session <name> # Isolated browser session
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
--profile <path> # Persistent browser profile directory
--session-name <name> # Auto-save/restore session state (defaults to --session when omitted)
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
@@ -288,6 +302,7 @@ agent-browser reload # Reload page
--proxy-bypass <hosts> # Hosts to bypass proxy
--ignore-https-errors # Ignore HTTPS certificate errors
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
--stealth # Stealth mode (always on by default)
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
--json # JSON output (for scripts)
@@ -296,16 +311,10 @@ agent-browser reload # Reload page
--headed # Show browser window (not headless)
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
--auto-connect # Auto-discover and connect to running Chrome
--color-scheme <scheme> # Color scheme: dark, light, no-preference
--download-path <path> # Default download directory
--content-boundaries # Wrap page output in boundary markers for LLM safety
--max-output <chars> # Truncate page output to N characters
--allowed-domains <list> # Comma-separated allowed domain patterns
--action-policy <path> # Path to action policy JSON file
--confirm-actions <list> # Action categories requiring confirmation
--confirm-interactive # Interactive confirmation prompts (auto-denies if stdin is not a TTY)
--config <path> # Use a custom config file
--debug # Debug output
--tab-group <name> # Base title for agent tab groups (CDP plugin mode)
--tab-group-plugin-id <id> # Expected extension ID for tab-group handshake
--wait-until <mode> # Navigation wait strategy for open/navigate (load, domcontentloaded, networkidle)
--debug # Debug output (includes stealth connection type + capabilities)
```
## Command chaining
+419 -72
View File
@@ -1,24 +1,52 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from '@/lib/page-metadata';
export const metadata = pageMetadata("configuration")
export const metadata = pageMetadata('configuration');
# Configuration
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
In this fork, default launch behavior auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
## Config File Locations
agent-browser checks two locations, merged in priority order:
<table>
<thead>
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
<tr>
<th>Priority</th>
<th>Location</th>
<th>Scope</th>
</tr>
</thead>
<tbody>
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
<tr>
<td>1 (lowest)</td>
<td>
<code>~/.agent-browser/config.json</code>
</td>
<td>User-level defaults</td>
</tr>
<tr>
<td>2</td>
<td>
<code>./agent-browser.json</code>
</td>
<td>Project-level overrides</td>
</tr>
<tr>
<td>3</td>
<td>
<code>AGENT_BROWSER_*</code> env vars
</td>
<td>Override config values</td>
</tr>
<tr>
<td>4 (highest)</td>
<td>CLI flags</td>
<td>Override everything</td>
</tr>
</tbody>
</table>
@@ -37,7 +65,6 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data",
"userAgent": "my-agent/1.0",
"ignoreHttpsErrors": true
}
@@ -49,42 +76,253 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<table>
<thead>
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
<tr>
<th>Config Key</th>
<th>CLI Flag</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
<tr><td><code>contentBoundaries</code></td><td><code>--content-boundaries</code></td><td>boolean</td></tr>
<tr><td><code>maxOutput</code></td><td><code>--max-output</code></td><td>number</td></tr>
<tr><td><code>allowedDomains</code></td><td><code>--allowed-domains</code></td><td>string[]</td></tr>
<tr><td><code>actionPolicy</code></td><td><code>--action-policy</code></td><td>string</td></tr>
<tr><td><code>confirmActions</code></td><td><code>--confirm-actions</code></td><td>string</td></tr>
<tr><td><code>confirmInteractive</code></td><td><code>--confirm-interactive</code></td><td>boolean</td></tr>
<tr><td><code>native</code></td><td><code>--native</code></td><td>boolean (experimental)</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
<tr>
<td>
<code>headed</code>
</td>
<td>
<code>--headed</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>json</code>
</td>
<td>
<code>--json</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>full</code>
</td>
<td>
<code>--full, -f</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>debug</code>
</td>
<td>
<code>--debug</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>session</code>
</td>
<td>
<code>--session</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>sessionName</code>
</td>
<td>
<code>--session-name</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>executablePath</code>
</td>
<td>
<code>--executable-path</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>extensions</code>
</td>
<td>
<code>--extension</code>
</td>
<td>string[]</td>
</tr>
<tr>
<td>
<code>state</code>
</td>
<td>
<code>--state</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>proxy</code>
</td>
<td>
<code>--proxy</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>proxyBypass</code>
</td>
<td>
<code>--proxy-bypass</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>args</code>
</td>
<td>
<code>--args</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>userAgent</code>
</td>
<td>
<code>--user-agent</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>provider</code>
</td>
<td>
<code>-p, --provider</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>device</code>
</td>
<td>
<code>--device</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>ignoreHttpsErrors</code>
</td>
<td>
<code>--ignore-https-errors</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>allowFileAccess</code>
</td>
<td>
<code>--allow-file-access</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>cdp</code>
</td>
<td>
<code>--cdp</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>autoConnect</code>
</td>
<td>
<code>--auto-connect</code>
</td>
<td>boolean</td>
</tr>
<tr>
<td>
<code>colorScheme</code>
</td>
<td>
<code>--color-scheme</code>
</td>
<td>
string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
</td>
</tr>
<tr>
<td>
<code>downloadPath</code>
</td>
<td>
<code>--download-path</code>
</td>
<td>string</td>
</tr>
<tr>
<td>
<code>tabGroup</code>
</td>
<td>
<code>--tab-group</code>
</td>
<td>string (base title for session tab grouping via CDP plugin handshake)</td>
</tr>
<tr>
<td>
<code>tabGroupPluginId</code>
</td>
<td>
<code>--tab-group-plugin-id</code>
</td>
<td>string (expected extension ID for tab-group plugin handshake)</td>
</tr>
<tr>
<td>
<code>riskMode</code>
</td>
<td>
<code>--risk-mode</code>
</td>
<td>
string (<code>off</code>, <code>warn</code>, <code>block</code>)
</td>
</tr>
<tr>
<td>
<code>headers</code>
</td>
<td>
<code>--headers</code>
</td>
<td>string (JSON)</td>
</tr>
</tbody>
</table>
`riskMode` defaults to `warn` when unset.
For tab grouping in CDP mode, grouping is best-effort through the extension handshake:
extension available => grouped by session; extension missing/unavailable => silent no-op.
With the `agent-browser-stealth` extension installed, the side panel also exposes
session window isolation controls, activation guard toggles, empty-group cleanup, and per-session allowlist policy editing.
## Common Configurations
### Local Development
@@ -92,7 +330,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
```json
{
"headed": true,
"profile": "./browser-data"
"sessionName": "local-dev"
}
```
@@ -124,17 +362,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
}
```
### AI Agent Security
```json
{
"contentBoundaries": true,
"maxOutput": 50000,
"allowedDomains": ["your-app.com", "*.your-app.com"],
"actionPolicy": "./policy.json"
}
```
## Overriding Boolean Options
Boolean flags accept an optional `true`/`false` value to override config settings:
@@ -150,7 +377,7 @@ agent-browser --headed open example.com # same as --headed true
agent-browser --headed true open example.com # explicit
```
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`, `--content-boundaries`, `--confirm-interactive`, `--native`.
This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`.
## Extensions Merging
@@ -164,28 +391,148 @@ These environment variables configure additional daemon and runtime behavior:
<table>
<thead>
<tr><th>Variable</th><th>Description</th><th>Default</th></tr>
<tr>
<th>Variable</th>
<th>Description</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers for LLM safety.</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output (truncates beyond limit).</td><td>(unlimited)</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns (e.g., <code>example.com,*.example.com</code>).</td><td>(unrestricted)</td></tr>
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation.</td><td>(none)</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts (auto-denies if stdin is not a TTY).</td><td>(disabled)</td></tr>
<tr><td><code>AGENT_BROWSER_NATIVE</code></td><td>Use the experimental native Rust daemon instead of Node.js/Playwright.</td><td>(disabled)</td></tr>
<tr>
<td>
<code>AGENT_BROWSER_AUTO_CONNECT</code>
</td>
<td>Auto-discover and connect to a running Chrome instance.</td>
<td>(disabled)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code>
</td>
<td>
Allow <code>file://</code> URLs to access local files.
</td>
<td>(disabled)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_COLOR_SCHEME</code>
</td>
<td>
Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).
</td>
<td>(none)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_DOWNLOAD_PATH</code>
</td>
<td>Default directory for browser downloads.</td>
<td>(temp directory)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_TAB_GROUP</code>
</td>
<td>Base title for tab grouping. Session suffix is appended automatically in CDP mode.</td>
<td>
<code>Agent Browser Stealth</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_TAB_GROUP_PLUGIN_ID</code>
</td>
<td>Expected extension ID for CDP tab-group plugin handshake.</td>
<td>
<code>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_RISK_MODE</code>
</td>
<td>
Verification/captcha handling mode (<code>off</code>, <code>warn</code>, <code>block</code>
).
</td>
<td>
<code>warn</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_DEFAULT_TIMEOUT</code>
</td>
<td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td>
<td>
<code>25000</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_SESSION_NAME</code>
</td>
<td>
Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when
unset).
</td>
<td>
(same as <code>AGENT_BROWSER_SESSION</code>)
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
</td>
<td>Auto-delete saved session states older than N days.</td>
<td>
<code>30</code>
</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
</td>
<td>64-char hex key for AES-256-GCM session encryption.</td>
<td>(none)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_STREAM_PORT</code>
</td>
<td>
Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).
</td>
<td>(disabled)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_IOS_DEVICE</code>
</td>
<td>
Default iOS device name for the <code>ios</code> provider.
</td>
<td>(none)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_IOS_UDID</code>
</td>
<td>
Default iOS device UDID for the <code>ios</code> provider.
</td>
<td>(none)</td>
</tr>
<tr>
<td>
<code>AGENT_BROWSER_DEBUG</code>
</td>
<td>
Enable debug output (<code>1</code> to enable).
</td>
<td>(disabled)</td>
</tr>
</tbody>
</table>
+18 -9
View File
@@ -9,7 +9,7 @@ export const metadata = pageMetadata("installation")
Installs the native Rust binary for maximum performance:
```bash
npm install -g agent-browser
npm install -g agent-browser-stealth
agent-browser install # Download Chromium
```
@@ -20,8 +20,8 @@ This is the fastest option -- commands run through the native Rust CLI directly
Run directly with `npx` if you want to try it without installing globally:
```bash
npx agent-browser install # Download Chromium (first time only)
npx agent-browser open example.com
npx agent-browser-stealth install # Download Chromium (first time only)
npx agent-browser-stealth open example.com
```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
@@ -31,14 +31,14 @@ npx agent-browser open example.com
For projects that want to pin the version in `package.json`:
```bash
npm install agent-browser
npx agent-browser install
npm install agent-browser-stealth
npx agent-browser-stealth install
```
Then use via `npx` or `package.json` scripts:
```bash
npx agent-browser open example.com
npx agent-browser-stealth open example.com
```
## Homebrew (macOS)
@@ -51,7 +51,7 @@ agent-browser install # Download Chromium
## From source
```bash
git clone https://github.com/vercel-labs/agent-browser
git clone https://github.com/leeguooooo/agent-browser
cd agent-browser
pnpm install
pnpm build
@@ -60,6 +60,15 @@ pnpm build:native
pnpm link --global
```
## Fork versioning
Fork releases use a dual-version format:
- `<upstream>-fork.<fork>`
- Example: `0.14.0-fork.1`
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
## Linux dependencies
On Linux, install system dependencies:
@@ -89,7 +98,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```typescript
import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser';
import { BrowserManager } from 'agent-browser-stealth';
export async function handler() {
const browser = new BrowserManager();
@@ -110,7 +119,7 @@ agent-browser works with any AI agent out of the box. For richer context:
Install the skill for your AI coding assistant:
```bash
npx skills add vercel-labs/agent-browser
npx skills add leeguooooo/agent-browser
```
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
+6 -2
View File
@@ -7,13 +7,15 @@ export const metadata = pageMetadata("")
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
```bash
npm install -g agent-browser # all platforms (fastest, native Rust CLI)
npm install -g agent-browser-stealth # all platforms (fastest, native Rust CLI)
brew install agent-browser # macOS
# or try without installing
npx agent-browser open example.com
npx agent-browser-stealth open example.com
```
Executable aliases after install: `agent-browser`, `agent-browser-stealth`, and `abs`.
## Features
- **Agent-first** - Compact text output uses fewer tokens than JSON, designed for AI context efficiency
@@ -22,6 +24,8 @@ npx agent-browser open example.com
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
- **Sessions** - Multiple isolated browser instances with separate auth
- **Cross-platform** - macOS, Linux, Windows with native binaries
- **Auto region detection** - Locale, timezone, and Accept-Language automatically match the target site's TLD
- **Captcha auto-retry** - Detects captcha/verification pages and retries with randomized backoff
## Works with
+2 -23
View File
@@ -34,29 +34,6 @@ Each session has its own:
- Navigation history
- Authentication state
## Persistent profiles
By default, browser state is lost when the browser closes. Use `--profile` to persist state across restarts:
```bash
# Use a persistent profile directory
agent-browser --profile ~/.myapp-profile open myapp.com
# Login once, then reuse the authenticated session
agent-browser --profile ~/.myapp-profile open myapp.com/dashboard
# Or via environment variable
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
```
The profile directory stores:
- Cookies and localStorage
- IndexedDB data
- Service workers
- Browser cache
- Login sessions
## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
@@ -73,6 +50,8 @@ export AGENT_BROWSER_SESSION_NAME=twitter
agent-browser open twitter.com
```
If `--session-name` is omitted, it defaults to `--session` (or `default`).
State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start.
### Session name rules
+1 -1
View File
@@ -176,7 +176,7 @@ Send input events to control the browser remotely.
For advanced use, control streaming directly via the TypeScript API:
```typescript
import { BrowserManager } from 'agent-browser';
import { BrowserManager } from 'agent-browser-stealth';
const browser = new BrowserManager();
await browser.launch({ headless: true });
+2 -2
View File
@@ -53,7 +53,7 @@ export function Header() {
</div>
<nav className="flex items-center gap-4">
<a
href="https://github.com/vercel-labs/agent-browser"
href="https://github.com/leeguooooo/agent-browser"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
@@ -69,7 +69,7 @@ export function Header() {
<span>16k</span>
</a>
<a
href="https://www.npmjs.com/package/agent-browser"
href="https://www.npmjs.com/package/agent-browser-stealth"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:text-neutral-400 dark:hover:text-neutral-100"
@@ -0,0 +1,83 @@
(() => {
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
window.addEventListener('message', (event) => {
if (event.source !== window) {
return;
}
const data = event.data;
if (!data || data.type !== REQUEST_TYPE) {
return;
}
const request = {
type: REQUEST_TYPE,
nonce: data.nonce,
session: data.session,
groupTitle: data.groupTitle,
pluginId: data.pluginId,
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
};
try {
chrome.runtime.sendMessage(request, (response) => {
const lastError = chrome.runtime.lastError;
if (lastError) {
window.postMessage(
{
type: RESPONSE_TYPE,
nonce: request.nonce,
ok: false,
error: lastError.message,
},
'*'
);
return;
}
const payload = response && typeof response === 'object' ? response : { ok: false };
window.postMessage(
{
type: RESPONSE_TYPE,
nonce: request.nonce,
ok: payload.ok === true,
extensionId:
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
? payload.extensionId
: chrome.runtime.id,
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
color: typeof payload.color === 'string' ? payload.color : undefined,
collapsed: payload.collapsed === true,
policy:
payload.policy && typeof payload.policy === 'object'
? {
enforced: payload.policy.enforced === true,
blocked: payload.policy.blocked === true,
reason:
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
}
: undefined,
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
error: typeof payload.error === 'string' ? payload.error : undefined,
},
'*'
);
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
window.postMessage(
{
type: RESPONSE_TYPE,
nonce: request.nonce,
ok: false,
error: errorMessage,
},
'*'
);
}
});
})();
+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "agent-browser-stealth",
"version": "0.2.0",
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
"permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "service-worker.js"
},
"action": {
"default_title": "agent-browser-stealth"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"run_at": "document_start",
"match_about_blank": true
}
]
}
+879
View File
@@ -0,0 +1,879 @@
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
const PANEL_GET_STATE = 'AB_PANEL_GET_STATE';
const PANEL_CLOSE_OTHER_TABS = 'AB_PANEL_CLOSE_OTHER_SESSION_TABS';
const PANEL_FOCUS_SESSION = 'AB_PANEL_FOCUS_SESSION';
const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS';
const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY';
const PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS';
const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth';
const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth';
const STORAGE_POLICY_KEY = 'abSessionPoliciesV1';
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
const CLEANUP_ALARM_NAME = 'ab-clean-empty-groups';
const GROUP_COLORS = ['blue', 'green', 'pink', 'orange', 'purple', 'cyan', 'red', 'yellow'];
const RISKY_TLDS = new Set(['zip', 'mov', 'click', 'top', 'gq', 'tk', 'country']);
const RISKY_HOST_KEYWORDS = ['secure-login', 'account-verify', 'wallet-verify', 'airdrop-claim'];
const DEFAULT_EXTENSION_OPTIONS = {
strictWindowIsolation: true,
suppressCrossWindowActivation: true,
autoCleanEmptyGroups: true,
};
const sessionGroupCache = new Map();
const sessionWindowMap = new Map();
const tabSessionMap = new Map();
const tabMetaById = new Map();
const downloadEvents = [];
const sessionPolicies = new Map();
let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
let bootstrapPromise = bootstrapState();
function normalizeSession(session) {
if (typeof session !== 'string') return 'default';
const trimmed = session.trim();
return trimmed.length > 0 ? trimmed.slice(0, 64) : 'default';
}
function normalizeGroupTitle(title) {
if (typeof title !== 'string') return DEFAULT_GROUP_TITLE;
const trimmed = title.trim();
return trimmed.length > 0 ? trimmed.slice(0, 80) : DEFAULT_GROUP_TITLE;
}
function normalizeAllowedDomains(domains) {
if (!Array.isArray(domains)) return [];
return domains
.map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : ''))
.filter((item) => item.length > 0)
.slice(0, 256);
}
function parseHostname(rawUrl) {
if (typeof rawUrl !== 'string' || rawUrl.length === 0) return null;
try {
const parsed = new URL(rawUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
return parsed.hostname.toLowerCase();
} catch {
return null;
}
}
function domainMatches(hostname, pattern) {
if (!hostname || !pattern) return false;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(2);
return hostname === suffix || hostname.endsWith(`.${suffix}`);
}
if (pattern.startsWith('.')) {
const suffix = pattern.slice(1);
return hostname === suffix || hostname.endsWith(pattern);
}
return hostname === pattern || hostname.endsWith(`.${pattern}`);
}
function isDomainAllowed(hostname, patterns) {
if (!hostname) return true;
if (!patterns || patterns.length === 0) return true;
return patterns.some((pattern) => domainMatches(hostname, pattern));
}
function collectRiskHints(rawUrl, allowedDomains) {
const hints = [];
const hostname = parseHostname(rawUrl);
if (!hostname) return hints;
if (allowedDomains.length > 0 && !isDomainAllowed(hostname, allowedDomains)) {
hints.push(`domain-not-allowed:${hostname}`);
}
const tld = hostname.split('.').pop();
if (tld && RISKY_TLDS.has(tld)) {
hints.push(`high-risk-tld:.${tld}`);
}
for (const keyword of RISKY_HOST_KEYWORDS) {
if (hostname.includes(keyword)) {
hints.push(`suspicious-host-keyword:${keyword}`);
}
}
return [...new Set(hints)].slice(0, 10);
}
function cacheKey(windowId, session) {
return `${windowId}:${session}`;
}
function sanitizeSegment(input, fallback = 'default') {
const raw = typeof input === 'string' ? input : '';
const cleaned = raw
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-')
.replace(/\s+/g, '_')
.replace(/\.+/g, '.')
.trim();
if (!cleaned) return fallback;
return cleaned.slice(0, 80);
}
function sanitizeFilename(filename, fallback = 'download.bin') {
const name = typeof filename === 'string' ? filename.split('/').pop() : '';
return sanitizeSegment(name, fallback);
}
function pickColorForSession(session) {
let hash = 0;
for (let i = 0; i < session.length; i += 1) {
hash = (hash * 31 + session.charCodeAt(i)) >>> 0;
}
return GROUP_COLORS[hash % GROUP_COLORS.length];
}
function shouldCollapseGroup(session) {
return session !== 'default';
}
async function loadPolicies() {
try {
const result = await chrome.storage.local.get([STORAGE_POLICY_KEY]);
const entries = result?.[STORAGE_POLICY_KEY];
if (!entries || typeof entries !== 'object') return;
for (const [session, domains] of Object.entries(entries)) {
const normalizedSession = normalizeSession(session);
sessionPolicies.set(normalizedSession, normalizeAllowedDomains(domains));
}
} catch {
// Ignore storage load failures.
}
}
function normalizeOptions(raw) {
if (!raw || typeof raw !== 'object') {
return { ...DEFAULT_EXTENSION_OPTIONS };
}
return {
strictWindowIsolation: raw.strictWindowIsolation !== false,
suppressCrossWindowActivation: raw.suppressCrossWindowActivation !== false,
autoCleanEmptyGroups: raw.autoCleanEmptyGroups !== false,
};
}
async function loadOptions() {
try {
const result = await chrome.storage.local.get([STORAGE_OPTIONS_KEY]);
extensionOptions = normalizeOptions(result?.[STORAGE_OPTIONS_KEY]);
} catch {
extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS };
}
}
async function persistOptions() {
await chrome.storage.local.set({ [STORAGE_OPTIONS_KEY]: extensionOptions });
}
async function setExtensionOptions(nextOptions) {
extensionOptions = {
...extensionOptions,
...normalizeOptions(nextOptions),
};
await persistOptions();
await syncCleanupAlarm();
return extensionOptions;
}
async function bootstrapState() {
await loadPolicies();
await loadOptions();
await syncCleanupAlarm();
}
async function persistPolicies() {
const serialized = {};
for (const [session, domains] of sessionPolicies.entries()) {
serialized[session] = [...domains];
}
await chrome.storage.local.set({ [STORAGE_POLICY_KEY]: serialized });
}
async function setSessionPolicy(session, allowedDomains) {
const normalizedSession = normalizeSession(session);
const normalizedDomains = normalizeAllowedDomains(allowedDomains);
sessionPolicies.set(normalizedSession, normalizedDomains);
await persistPolicies();
}
function getSessionPolicy(session) {
const normalizedSession = normalizeSession(session);
return sessionPolicies.get(normalizedSession) ?? [];
}
function updateTabMeta(tab) {
if (!tab || typeof tab.id !== 'number') return;
tabMetaById.set(tab.id, {
id: tab.id,
windowId: typeof tab.windowId === 'number' ? tab.windowId : -1,
url: typeof tab.url === 'string' ? tab.url : '',
title: typeof tab.title === 'string' ? tab.title : '',
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
active: tab.active === true,
lastSeenAt: Date.now(),
});
}
function pruneDownloadEvents() {
const maxSize = 100;
if (downloadEvents.length > maxSize) {
downloadEvents.splice(0, downloadEvents.length - maxSize);
}
}
function recordDownloadEvent(event) {
downloadEvents.push({ ...event, timestamp: Date.now() });
pruneDownloadEvents();
}
function removeWindowCaches(windowId) {
for (const key of [...sessionGroupCache.keys()]) {
if (key.startsWith(`${windowId}:`)) {
sessionGroupCache.delete(key);
}
}
for (const [session, mappedWindowId] of [...sessionWindowMap.entries()]) {
if (mappedWindowId === windowId) {
sessionWindowMap.delete(session);
}
}
}
async function ensureSessionWindow(tabId, currentWindowId, session) {
if (!extensionOptions.strictWindowIsolation) {
sessionWindowMap.set(session, currentWindowId);
return currentWindowId;
}
let targetWindowId = sessionWindowMap.get(session);
if (typeof targetWindowId === 'number') {
try {
await chrome.windows.get(targetWindowId);
} catch {
sessionWindowMap.delete(session);
targetWindowId = undefined;
}
}
if (typeof targetWindowId !== 'number') {
sessionWindowMap.set(session, currentWindowId);
return currentWindowId;
}
if (targetWindowId === currentWindowId) {
return targetWindowId;
}
await chrome.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
return targetWindowId;
}
async function findExistingGroup(windowId, groupTitle) {
const tabs = await chrome.tabs.query({ windowId });
const checked = new Set();
for (const tab of tabs) {
if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checked.has(tab.groupId)) {
continue;
}
checked.add(tab.groupId);
try {
const group = await chrome.tabGroups.get(tab.groupId);
if (group.title === groupTitle) {
return tab.groupId;
}
} catch {
// Ignore stale group references.
}
}
return null;
}
async function ensureSessionGroup(tabId, windowId, session, groupTitle) {
const targetWindowId = await ensureSessionWindow(tabId, windowId, session);
const key = cacheKey(targetWindowId, session);
let groupId = sessionGroupCache.get(key);
if (typeof groupId === 'number') {
try {
await chrome.tabGroups.get(groupId);
} catch {
groupId = undefined;
}
}
if (typeof groupId !== 'number') {
const existing = await findExistingGroup(targetWindowId, groupTitle);
if (typeof existing === 'number') {
groupId = existing;
}
}
if (typeof groupId === 'number') {
await chrome.tabs.group({ groupId, tabIds: [tabId] });
} else {
groupId = await chrome.tabs.group({
tabIds: [tabId],
createProperties: { windowId: targetWindowId },
});
}
const color = pickColorForSession(session);
const collapsed = shouldCollapseGroup(session);
await chrome.tabGroups.update(groupId, {
title: groupTitle,
color,
collapsed,
});
sessionGroupCache.set(key, groupId);
sessionWindowMap.set(session, targetWindowId);
return {
groupId,
windowId: targetWindowId,
color,
collapsed,
};
}
async function applySessionDomainFallback(tabId, session) {
const allowedDomains = getSessionPolicy(session);
if (allowedDomains.length === 0) {
return { enforced: false, blocked: false };
}
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch {
return { enforced: true, blocked: false };
}
const hostname = parseHostname(tab.url);
if (!hostname) {
return { enforced: true, blocked: false };
}
if (isDomainAllowed(hostname, allowedDomains)) {
return { enforced: true, blocked: false };
}
await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {});
return {
enforced: true,
blocked: true,
reason: `${hostname} is not in allowed domains`,
};
}
function getManagedSessionForTab(tabId) {
if (typeof tabId !== 'number') return undefined;
return tabSessionMap.get(tabId);
}
function collectSessionTabIds(session) {
const result = [];
for (const [tabId, tabSession] of tabSessionMap.entries()) {
if (tabSession === session) {
result.push(tabId);
}
}
return result;
}
async function closeOtherSessionTabs(session) {
const normalized = normalizeSession(session);
const closeIds = [];
for (const [tabId, tabSession] of tabSessionMap.entries()) {
if (tabSession !== normalized) {
closeIds.push(tabId);
}
}
if (closeIds.length > 0) {
await chrome.tabs.remove(closeIds);
}
return { closed: closeIds.length };
}
async function focusSession(session) {
const normalized = normalizeSession(session);
const tabIds = collectSessionTabIds(normalized);
if (tabIds.length === 0) {
return { focused: false };
}
let tab;
try {
tab = await chrome.tabs.get(tabIds[0]);
} catch {
return { focused: false };
}
if (typeof tab.windowId === 'number') {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
}
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
return { focused: true, tabId: tab.id };
}
async function cleanEmptyGroups() {
let removedGroups = 0;
let removedWindows = 0;
for (const [key, groupId] of [...sessionGroupCache.entries()]) {
const [windowIdRaw] = key.split(':');
const windowId = Number(windowIdRaw);
let groupExists = true;
try {
await chrome.tabGroups.get(groupId);
} catch {
groupExists = false;
}
if (!groupExists) {
sessionGroupCache.delete(key);
removedGroups += 1;
continue;
}
const tabs = await chrome.tabs.query({ windowId }).catch(() => []);
const hasMembers = tabs.some((tab) => tab.groupId === groupId);
if (!hasMembers) {
sessionGroupCache.delete(key);
removedGroups += 1;
}
}
for (const [session, windowId] of [...sessionWindowMap.entries()]) {
try {
await chrome.windows.get(windowId);
} catch {
sessionWindowMap.delete(session);
removedWindows += 1;
}
}
return { removedGroups, removedWindows };
}
async function syncCleanupAlarm() {
try {
await chrome.alarms.clear(CLEANUP_ALARM_NAME);
if (extensionOptions.autoCleanEmptyGroups) {
await chrome.alarms.create(CLEANUP_ALARM_NAME, { periodInMinutes: 1 });
}
} catch {
// Ignore alarms API failures.
}
}
async function enforceSessionWindowAffinity(tabId) {
if (!extensionOptions.suppressCrossWindowActivation) return { moved: false };
const session = getManagedSessionForTab(tabId);
if (!session) return { moved: false };
if (!extensionOptions.strictWindowIsolation) return { moved: false };
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch {
return { moved: false };
}
const mappedWindowId = sessionWindowMap.get(session);
if (typeof mappedWindowId !== 'number' || mappedWindowId === tab.windowId) {
if (typeof tab.windowId === 'number') {
sessionWindowMap.set(session, tab.windowId);
}
return { moved: false };
}
try {
await chrome.tabs.move(tabId, { windowId: mappedWindowId, index: -1 });
await chrome.tabs.update(tabId, { active: false }).catch(() => {});
return { moved: true, toWindowId: mappedWindowId };
} catch {
return { moved: false };
}
}
async function updateRiskBadge(tabId) {
let text = '';
let title = 'agent-browser-stealth';
const session = getManagedSessionForTab(tabId);
if (session) {
let tab;
try {
tab = await chrome.tabs.get(tabId);
} catch {
tab = undefined;
}
if (tab) {
const hints = collectRiskHints(tab.url, getSessionPolicy(session));
if (hints.length > 0) {
text = '!';
title = `Risk hints (${hints.length}): ${hints.join(', ')}`;
}
}
}
await chrome.action.setBadgeText({ text }).catch(() => {});
await chrome.action.setBadgeBackgroundColor({ color: '#dc2626' }).catch(() => {});
await chrome.action.setTitle({ title }).catch(() => {});
}
async function buildPanelState() {
const allTabs = await chrome.tabs.query({});
for (const tab of allTabs) {
updateTabMeta(tab);
}
const sessionMap = new Map();
for (const tab of allTabs) {
if (typeof tab.id !== 'number') continue;
const session = getManagedSessionForTab(tab.id);
if (!session) continue;
if (!sessionMap.has(session)) {
sessionMap.set(session, {
session,
windowId: sessionWindowMap.get(session) ?? tab.windowId,
allowedDomains: getSessionPolicy(session),
tabs: [],
riskHints: [],
});
}
const entry = sessionMap.get(session);
entry.tabs.push({
id: tab.id,
windowId: tab.windowId,
title: tab.title ?? '',
url: tab.url ?? '',
active: tab.active === true,
groupId: typeof tab.groupId === 'number' ? tab.groupId : -1,
});
const hints = collectRiskHints(tab.url, entry.allowedDomains);
for (const hint of hints) {
if (!entry.riskHints.includes(hint)) {
entry.riskHints.push(hint);
}
}
}
const sessions = [];
for (const sessionEntry of sessionMap.values()) {
sessionEntry.tabs.sort((a, b) => Number(b.active) - Number(a.active));
const key = cacheKey(sessionEntry.windowId, sessionEntry.session);
const cachedGroupId = sessionGroupCache.get(key);
let group;
if (typeof cachedGroupId === 'number') {
try {
const groupInfo = await chrome.tabGroups.get(cachedGroupId);
group = {
id: cachedGroupId,
title: groupInfo.title,
color: groupInfo.color,
collapsed: groupInfo.collapsed,
};
} catch {
// Group may no longer exist.
}
}
sessions.push({
...sessionEntry,
group,
});
}
sessions.sort((a, b) => a.session.localeCompare(b.session));
return {
extensionId: chrome.runtime.id,
options: { ...extensionOptions },
totals: {
sessions: sessions.length,
tabs: sessions.reduce((sum, session) => sum + session.tabs.length, 0),
},
sessions,
downloads: downloadEvents.slice(-25).reverse(),
};
}
async function handleTabGroupRequest(message, sender) {
await bootstrapPromise;
const tabId = sender.tab?.id;
const windowId = sender.tab?.windowId;
const nonce = typeof message.nonce === 'string' ? message.nonce : undefined;
if (typeof tabId !== 'number' || typeof windowId !== 'number') {
return {
ok: false,
error: 'missing-tab-context',
extensionId: chrome.runtime.id,
nonce,
};
}
if (typeof message.pluginId === 'string' && message.pluginId !== chrome.runtime.id) {
return {
ok: false,
error: 'plugin-id-mismatch',
extensionId: chrome.runtime.id,
nonce,
};
}
const session = normalizeSession(message.session);
const groupTitle = normalizeGroupTitle(message.groupTitle);
const allowedDomains = normalizeAllowedDomains(message.allowedDomains);
if (allowedDomains.length > 0) {
await setSessionPolicy(session, allowedDomains);
}
tabSessionMap.set(tabId, session);
updateTabMeta(sender.tab);
const grouping = await ensureSessionGroup(tabId, windowId, session, groupTitle);
const policy = await applySessionDomainFallback(tabId, session);
const riskHints = collectRiskHints(sender.tab?.url, getSessionPolicy(session));
if (policy.blocked && policy.reason) {
riskHints.push(`policy-blocked:${policy.reason}`);
}
return {
ok: true,
extensionId: chrome.runtime.id,
nonce,
...grouping,
policy,
riskHints: [...new Set(riskHints)].slice(0, 10),
};
}
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
bootstrapPromise = bootstrapState();
});
chrome.runtime.onStartup.addListener(() => {
bootstrapPromise = bootstrapState();
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || typeof message !== 'object') {
return;
}
const type = message.type;
if (type === REQUEST_TYPE) {
handleTabGroupRequest(message, sender)
.then((response) => sendResponse(response))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({
ok: false,
error: errorMessage,
extensionId: chrome.runtime.id,
nonce: typeof message.nonce === 'string' ? message.nonce : undefined,
});
});
return true;
}
if (type === PANEL_GET_STATE) {
bootstrapPromise
.then(() => buildPanelState())
.then((state) => sendResponse({ ok: true, state }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
if (type === PANEL_CLOSE_OTHER_TABS) {
closeOtherSessionTabs(message.session)
.then((result) => sendResponse({ ok: true, result }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
if (type === PANEL_FOCUS_SESSION) {
focusSession(message.session)
.then((result) => sendResponse({ ok: true, result }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
if (type === PANEL_CLEAN_EMPTY_GROUPS) {
cleanEmptyGroups()
.then((result) => sendResponse({ ok: true, result }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
if (type === PANEL_SET_POLICY) {
bootstrapPromise
.then(() => setSessionPolicy(message.session, message.allowedDomains))
.then(() => sendResponse({ ok: true }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
if (type === PANEL_SET_OPTIONS) {
bootstrapPromise
.then(() => setExtensionOptions(message.options))
.then((options) => sendResponse({ ok: true, options }))
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
sendResponse({ ok: false, error: errorMessage });
});
return true;
}
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
updateTabMeta(tab);
const session = getManagedSessionForTab(tabId);
if (!session) {
if (changeInfo.status === 'complete' && tab.active === true) {
updateRiskBadge(tabId).catch(() => {});
}
return;
}
if (typeof tab.windowId === 'number') {
sessionWindowMap.set(session, tab.windowId);
}
if (changeInfo.status === 'complete') {
applySessionDomainFallback(tabId, session).catch(() => {});
if (tab.active === true) {
updateRiskBadge(tabId).catch(() => {});
}
}
});
chrome.tabs.onActivated.addListener((activeInfo) => {
enforceSessionWindowAffinity(activeInfo.tabId).catch(() => {});
updateRiskBadge(activeInfo.tabId).catch(() => {});
});
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
const session = getManagedSessionForTab(tabId);
tabSessionMap.delete(tabId);
tabMetaById.delete(tabId);
if (removeInfo.isWindowClosing) {
removeWindowCaches(removeInfo.windowId);
return;
}
if (!session) return;
const remaining = collectSessionTabIds(session);
if (remaining.length === 0) {
sessionWindowMap.delete(session);
}
cleanEmptyGroups().catch(() => {});
});
chrome.tabs.onDetached.addListener((tabId) => {
const session = getManagedSessionForTab(tabId);
if (!session) return;
tabMetaById.delete(tabId);
});
chrome.tabs.onAttached.addListener((tabId, attachInfo) => {
const session = getManagedSessionForTab(tabId);
if (!session) return;
sessionWindowMap.set(session, attachInfo.newWindowId);
});
chrome.windows.onRemoved.addListener((windowId) => {
removeWindowCaches(windowId);
cleanEmptyGroups().catch(() => {});
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm?.name !== CLEANUP_ALARM_NAME) return;
cleanEmptyGroups().catch(() => {});
});
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
const session = getManagedSessionForTab(item.tabId);
if (!session) {
suggest();
return;
}
const safeSession = sanitizeSegment(session, 'default');
const safeFilename = sanitizeFilename(item.filename, `download-${item.id}.bin`);
const filename = `${DOWNLOAD_ARCHIVE_ROOT}/${safeSession}/${safeFilename}`;
recordDownloadEvent({
id: item.id,
tabId: item.tabId,
session,
state: 'routing',
filename,
});
suggest({
filename,
conflictAction: 'uniquify',
});
});
chrome.downloads.onChanged.addListener((delta) => {
if (!delta || typeof delta.id !== 'number') return;
const state = delta.state?.current;
if (!state) return;
recordDownloadEvent({
id: delta.id,
state,
filename: delta.filename?.current,
});
});
+239
View File
@@ -0,0 +1,239 @@
:root {
--bg: #0d1117;
--panel: #161b22;
--item-bg: rgba(255, 255, 255, 0.03);
--item-hover: rgba(255, 255, 255, 0.06);
--border: #30363d;
--border-focus: #484f58;
--text-main: #e6edf3;
--text-muted: #848d97;
--accent: #2f81f7;
--accent-soft: rgba(47, 129, 247, 0.1);
--success: #3fb950;
--warning: #d29922;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
* {
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
padding: 20px;
background-color: var(--bg);
color: var(--text-main);
font-family: var(--font-family);
font-size: 13px;
line-height: 1.5;
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-focus); }
header {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
h1::before {
content: "";
display: block;
width: 12px;
height: 12px;
background: var(--accent);
border-radius: 3px;
box-shadow: 0 0 10px var(--accent);
}
.actions {
display: flex;
gap: 8px;
}
button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-main);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
button:hover {
background: var(--item-hover);
border-color: var(--border-focus);
}
#refresh-btn {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
#refresh-btn:hover {
background: #4493f8;
border-color: #4493f8;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
overflow: hidden;
}
h3 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 12px 0;
font-weight: 600;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.session-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.session-title h4 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: var(--accent);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0;
}
.tag {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid transparent;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.list {
display: flex;
flex-direction: column;
gap: 1px; /* Divider effect */
background: var(--border);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
margin-top: 12px;
}
.item {
background: var(--panel);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
transition: background 0.1s ease;
}
.item:hover {
background: var(--item-hover);
}
.item-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-main);
}
.item-url {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-actions {
display: flex;
gap: 4px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.row-actions button {
padding: 4px 8px;
font-size: 11px;
background: transparent;
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 40px 0;
font-size: 12px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: rgba(110, 118, 129, 0.4);
padding: 2px 5px;
border-radius: 4px;
font-size: 11px;
}
#summary code {
color: var(--warning);
}
/* Summary Grid */
#summary .tags {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
#summary .tag {
text-align: center;
padding: 6px;
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agent-browser-stealth panel</title>
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<header>
<h1>agent-browser-stealth</h1>
<div class="actions">
<button id="refresh-btn" type="button">Refresh</button>
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
</div>
</header>
<section id="summary" class="card"></section>
<section id="sessions" class="stack"></section>
<section id="downloads" class="card"></section>
<script src="sidepanel.js"></script>
</body>
</html>
+246
View File
@@ -0,0 +1,246 @@
const summaryEl = document.getElementById('summary');
const sessionsEl = document.getElementById('sessions');
const downloadsEl = document.getElementById('downloads');
const refreshBtn = document.getElementById('refresh-btn');
const cleanupBtn = document.getElementById('cleanup-btn');
async function send(message) {
return chrome.runtime.sendMessage(message);
}
function createTag(text) {
const span = document.createElement('span');
span.className = 'tag';
span.textContent = text;
return span;
}
function renderSummary(state) {
summaryEl.innerHTML = '<h3>Overview</h3>';
const options = state.options || {};
const idInfo = document.createElement('div');
idInfo.style.marginBottom = '12px';
idInfo.style.fontSize = '11px';
idInfo.style.color = 'var(--text-muted)';
idInfo.innerHTML = `Extension ID: <code>${state.extensionId}</code>`;
summaryEl.appendChild(idInfo);
const tags = document.createElement('div');
tags.className = 'tags';
tags.appendChild(createTag(`Sessions: ${state.totals.sessions}`));
tags.appendChild(createTag(`Tabs: ${state.totals.tabs}`));
tags.appendChild(
createTag(`Isolation: ${options.strictWindowIsolation === false ? 'Off' : 'On'}`)
);
tags.appendChild(
createTag(`Guard: ${options.suppressCrossWindowActivation === false ? 'Off' : 'On'}`)
);
tags.appendChild(
createTag(`Auto-Clean: ${options.autoCleanEmptyGroups === false ? 'Off' : 'On'}`)
);
const optionActions = document.createElement('div');
optionActions.className = 'row-actions';
const createOptionBtn = (text, active, onClick) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = text;
if (active) btn.style.borderColor = 'var(--accent)';
btn.addEventListener('click', onClick);
return btn;
};
optionActions.appendChild(
createOptionBtn('Strict Isolation', options.strictWindowIsolation !== false, async () => {
await send({
type: 'AB_PANEL_SET_OPTIONS',
options: { ...options, strictWindowIsolation: options.strictWindowIsolation === false },
});
await refresh();
})
);
optionActions.appendChild(
createOptionBtn('Activation Guard', options.suppressCrossWindowActivation !== false, async () => {
await send({
type: 'AB_PANEL_SET_OPTIONS',
options: {
...options,
suppressCrossWindowActivation: options.suppressCrossWindowActivation === false,
},
});
await refresh();
})
);
optionActions.appendChild(
createOptionBtn('Auto-Clean', options.autoCleanEmptyGroups !== false, async () => {
await send({
type: 'AB_PANEL_SET_OPTIONS',
options: {
...options,
autoCleanEmptyGroups: options.autoCleanEmptyGroups === false,
},
});
await refresh();
})
);
summaryEl.appendChild(tags);
summaryEl.appendChild(optionActions);
}
function renderSessions(state) {
sessionsEl.innerHTML = '<h3>Active Sessions</h3>';
if (!state.sessions || state.sessions.length === 0) {
const empty = document.createElement('div');
empty.className = 'card empty';
empty.textContent = 'No active sessions monitored.';
sessionsEl.appendChild(empty);
return;
}
for (const session of state.sessions) {
const card = document.createElement('article');
card.className = 'card';
const titleRow = document.createElement('div');
titleRow.className = 'session-title';
const titleLeft = document.createElement('h4');
titleLeft.textContent = session.session;
const focusBtn = document.createElement('button');
focusBtn.textContent = 'Focus';
focusBtn.addEventListener('click', async () => {
await send({ type: 'AB_PANEL_FOCUS_SESSION', session: session.session });
await refresh();
});
titleRow.appendChild(titleLeft);
titleRow.appendChild(focusBtn);
const tags = document.createElement('div');
tags.className = 'tags';
tags.appendChild(createTag(`Window: ${session.windowId ?? 'N/A'}`));
tags.appendChild(createTag(`Tabs: ${session.tabs.length}`));
if (session.group) {
tags.appendChild(createTag(`G: ${session.group.title || 'Untitled'}`));
}
if (session.allowedDomains && session.allowedDomains.length > 0) {
tags.appendChild(createTag(`Allowlist: ${session.allowedDomains.length} domains`));
}
const list = document.createElement('div');
list.className = 'list';
for (const tab of session.tabs.slice(0, 10)) {
const item = document.createElement('div');
item.className = 'item';
const t = document.createElement('div');
t.className = 'item-title';
if (tab.active) {
const dot = document.createElement('span');
dot.textContent = '●';
dot.style.color = 'var(--success)';
dot.style.marginRight = '6px';
dot.style.fontSize = '10px';
t.appendChild(dot);
}
t.appendChild(document.createTextNode(tab.title || '(Untitled)'));
const u = document.createElement('div');
u.className = 'item-url';
u.textContent = tab.url || 'about:blank';
item.appendChild(t);
item.appendChild(u);
list.appendChild(item);
}
const footerActions = document.createElement('div');
footerActions.className = 'row-actions';
const keepBtn = document.createElement('button');
keepBtn.textContent = 'Isolate Session';
keepBtn.addEventListener('click', async () => {
await send({ type: 'AB_PANEL_CLOSE_OTHER_SESSION_TABS', session: session.session });
await refresh();
});
const policyBtn = document.createElement('button');
policyBtn.textContent = 'Config Policy';
policyBtn.addEventListener('click', async () => {
const current = (session.allowedDomains || []).join(',');
const input = window.prompt('Allowed domains (comma-separated)', current);
if (input === null) return;
const allowedDomains = input
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
await send({ type: 'AB_PANEL_SET_POLICY', session: session.session, allowedDomains });
await refresh();
});
footerActions.appendChild(keepBtn);
footerActions.appendChild(policyBtn);
card.appendChild(titleRow);
card.appendChild(tags);
card.appendChild(list);
card.appendChild(footerActions);
sessionsEl.appendChild(card);
}
}
function renderDownloads(state) {
downloadsEl.innerHTML = '<h3>Recent Downloads</h3>';
const list = document.createElement('div');
list.className = 'list';
const entries = state.downloads || [];
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'No download events yet.';
list.appendChild(empty);
} else {
for (const entry of entries.slice(0, 8)) {
const item = document.createElement('div');
item.className = 'item';
item.innerHTML = `<div class="item-title">#${entry.id} · ${entry.state || 'updated'}</div><div class="item-url">${entry.filename || ''}</div>`;
list.appendChild(item);
}
}
downloadsEl.appendChild(list);
}
async function refresh() {
const response = await send({ type: 'AB_PANEL_GET_STATE' });
if (!response || response.ok !== true || !response.state) {
summaryEl.textContent = response?.error || 'Failed to load extension state.';
sessionsEl.innerHTML = '';
downloadsEl.innerHTML = '';
return;
}
renderSummary(response.state);
renderSessions(response.state);
renderDownloads(response.state);
}
refreshBtn.addEventListener('click', refresh);
cleanupBtn.addEventListener('click', async () => {
await send({ type: 'AB_PANEL_CLEAN_EMPTY_GROUPS' });
await refresh();
});
refresh();
setInterval(refresh, 5000);
+25 -11
View File
@@ -1,7 +1,7 @@
{
"name": "agent-browser",
"version": "0.16.1",
"description": "Headless browser automation CLI for AI agents",
"name": "agent-browser-stealth",
"version": "0.16.1-fork.3",
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module",
"main": "dist/daemon.js",
"files": [
@@ -11,20 +11,23 @@
"skills"
],
"bin": {
"agent-browser": "./bin/agent-browser.js"
"agent-browser-stealth": "./bin/agent-browser.js",
"agent-browser": "./bin/agent-browser.js",
"abs": "./bin/agent-browser.js"
},
"scripts": {
"prepare": "husky",
"version:sync": "node scripts/sync-version.js",
"version": "npm run version:sync && git add cli/Cargo.toml",
"native:clean": "cargo clean --manifest-path cli/Cargo.toml -p agent-browser-stealth",
"build": "tsc",
"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 && npm run native:clean && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:macos": "npm run version:sync && npm run native:clean && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm run verify:bundled-binaries && npm run verify:native-version && npm publish",
"start": "node dist/daemon.js",
"dev": "tsx src/daemon.ts",
"typecheck": "tsc --noEmit",
@@ -33,15 +36,26 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
"check:daemon-pid-recovery": "node scripts/check-daemon-pid-recovery.js",
"check:stealth-regression": "node scripts/check-stealth-regression.js",
"check:turnstile-testkey": "pnpm exec tsx scripts/check-turnstile-testkey.ts",
"postinstall": "node scripts/postinstall.js",
"verify:native-version": "node scripts/verify-native-version.js",
"verify:bundled-binaries": "node scripts/verify-bundled-binaries.js",
"clawhub:sync": "bash scripts/clawhub-sync.sh",
"sync:upstream": "bash scripts/sync-upstream.sh",
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
"changeset": "changeset",
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:bundled-binaries && pnpm run verify:native-version && changeset publish"
},
"keywords": [
"browser",
"automation",
"headless",
"stealth",
"anti-bot",
"anti-detection",
"playwright",
"cli",
"agent"
@@ -49,12 +63,12 @@
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/agent-browser.git"
"url": "git+https://github.com/leeguooooo/agent-browser.git"
},
"bugs": {
"url": "https://github.com/vercel-labs/agent-browser/issues"
"url": "https://github.com/leeguooooo/agent-browser/issues"
},
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
"homepage": "https://github.com/leeguooooo/agent-browser#readme",
"dependencies": {
"node-simctl": "^7.4.0",
"playwright-core": "^1.57.0",
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* End-to-end check for CreepJS headless/stealth indicators.
*
* Usage:
* node scripts/check-creepjs-headless.js
* node scripts/check-creepjs-headless.js --compare-stealth
* node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
*/
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const args = process.argv.slice(2);
const getArgValue = (name, fallback) => {
const index = args.indexOf(name);
if (index === -1 || index + 1 >= args.length) return fallback;
return args[index + 1];
};
const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser'));
const sessionPrefix = getArgValue('--session-prefix', 'creepjs-e2e');
const compareStealth = args.includes('--compare-stealth');
const targetUrl = getArgValue('--url', 'https://abrahamjuliot.github.io/creepjs/');
const extractionScript = `(() => {
const headless = globalThis.Fingerprint?.headless ?? null;
const toNumber = (value) => (typeof value === 'number' ? value : null);
return {
found: !!headless,
metrics: headless ? {
chromium: !!headless.chromium,
likeHeadless: toNumber(headless.likeHeadlessRating),
headless: toNumber(headless.headlessRating),
stealth: toNumber(headless.stealthRating),
raw: headless,
} : null,
navigator: {
userAgent: navigator.userAgent,
userAgentData: navigator.userAgentData ? navigator.userAgentData.toJSON?.() ?? null : null,
language: navigator.language,
languages: navigator.languages,
platform: navigator.platform,
webdriver: navigator.webdriver,
webdriverInNavigator: ('webdriver' in navigator),
},
window: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
screenX: window.screenX,
screenY: window.screenY,
},
intl: {
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
};
})()`;
function runCommand(commandArgs, options = {}) {
const result = spawnSync(binary, commandArgs, { encoding: 'utf8' });
if (result.status !== 0 && !options.allowFailure) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
throw new Error(
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
`${stderr || stdout || `exit code ${result.status}`}`
);
}
return result;
}
function withSessionArgs(session, stealth) {
const base = ['--session', session];
if (stealth === false) {
base.push('--stealth', 'false');
}
return base;
}
function runSingleCheck({ stealth, runId }) {
const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`;
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
try {
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
runCommand([
...withSessionArgs(session, stealth),
'wait',
'--fn',
'!!(window.Fingerprint && window.Fingerprint.headless)',
]);
runCommand([...withSessionArgs(session, stealth), 'wait', '2000']);
const evalResult = runCommand([
...withSessionArgs(session, stealth),
'eval',
'--json',
extractionScript,
]);
const payload = JSON.parse(evalResult.stdout);
return {
session,
stealth,
url: targetUrl,
extracted: payload?.data?.result ?? null,
};
} finally {
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
}
}
function main() {
const runId = Date.now();
const checks = compareStealth ? [true, false] : [true];
const results = checks.map((stealth) => runSingleCheck({ stealth, runId }));
const output = {
binary,
compareStealth,
timestamp: new Date().toISOString(),
results,
};
console.log(JSON.stringify(output, null, 2));
}
main();
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* Regression check for daemon liveness when <session>.pid is missing.
*
* What it verifies:
* 1) A daemon session is reachable.
* 2) Deleting <session>.pid does not break the next command.
* 3) The session socket is not recreated (inode unchanged on Unix),
* meaning we reused the live daemon instead of tearing it down.
*
* Usage:
* node scripts/check-daemon-pid-recovery.js
* node scripts/check-daemon-pid-recovery.js --session default
* node scripts/check-daemon-pid-recovery.js --binary ./bin/agent-browser-darwin-arm64
*/
import fs from 'node:fs';
import os from 'node:os';
import path, { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const args = process.argv.slice(2);
const getArgValue = (name, fallback) => {
const index = args.indexOf(name);
if (index === -1 || index + 1 >= args.length) return fallback;
return args[index + 1];
};
function resolveSocketDir() {
if (process.env.AGENT_BROWSER_SOCKET_DIR && process.env.AGENT_BROWSER_SOCKET_DIR.length > 0) {
return process.env.AGENT_BROWSER_SOCKET_DIR;
}
if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) {
return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser');
}
return path.join(os.homedir(), '.agent-browser');
}
function resolveDefaultBinary() {
const osKey = os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux';
const archKey = os.arch() === 'arm64' ? 'arm64' : 'x64';
const ext = os.platform() === 'win32' ? '.exe' : '';
const candidates = [
join(rootDir, 'bin', `agent-browser-${osKey}-${archKey}${ext}`),
join(rootDir, 'cli', 'target', 'release', `agent-browser${ext}`),
join(rootDir, 'bin', `agent-browser-local${ext}`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
}
function runCommand(binary, commandArgs, allowFailure = false) {
const result = spawnSync(binary, commandArgs, {
encoding: 'utf8',
env: process.env,
});
if (result.status !== 0 && !allowFailure) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
throw new Error(
`Command failed: ${binary} ${commandArgs.join(' ')}\n${stderr || stdout || `exit code ${result.status}`}`
);
}
return result;
}
function main() {
const session = getArgValue('--session', 'default');
const binary = getArgValue('--binary', resolveDefaultBinary());
const socketDir = resolveSocketDir();
const isWindows = os.platform() === 'win32';
const pidPath = join(socketDir, `${session}.pid`);
const socketPath = isWindows ? null : join(socketDir, `${session}.sock`);
const portPath = isWindows ? join(socketDir, `${session}.port`) : null;
if (!fs.existsSync(binary)) {
throw new Error(`Binary not found: ${binary}`);
}
// Ensure daemon/session is live before we simulate pid loss.
runCommand(binary, ['--session', session, 'get', 'url']);
let socketInodeBefore = null;
if (!isWindows) {
if (!socketPath || !fs.existsSync(socketPath)) {
throw new Error(`Socket file not found: ${socketPath}`);
}
socketInodeBefore = fs.statSync(socketPath).ino;
}
const pidExistedBefore = fs.existsSync(pidPath);
if (pidExistedBefore) {
fs.unlinkSync(pidPath);
}
// This is the critical step: should still work even though pid file is gone.
const second = runCommand(binary, ['--session', session, 'get', 'title']);
const secondOutput = (second.stdout || '').trim();
let socketInodeAfter = null;
let socketUnchanged = true;
if (!isWindows) {
if (!socketPath || !fs.existsSync(socketPath)) {
throw new Error(`Socket file missing after pid removal: ${socketPath}`);
}
socketInodeAfter = fs.statSync(socketPath).ino;
socketUnchanged = socketInodeBefore === socketInodeAfter;
} else if (portPath && !fs.existsSync(portPath)) {
throw new Error(`Port file missing after pid removal: ${portPath}`);
}
const pidExistsAfter = fs.existsSync(pidPath);
const passed = socketUnchanged;
const report = {
passed,
session,
binary,
socketDir,
pidPath,
pidExistedBefore,
pidExistsAfter,
socketPath,
socketInodeBefore,
socketInodeAfter,
socketUnchanged,
secondCommandOutput: secondOutput,
timestamp: new Date().toISOString(),
};
console.log(JSON.stringify(report, null, 2));
if (!passed) {
process.exit(1);
}
}
main();
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* End-to-end check for bot.sannysoft.com WebDriver (New) result.
*
* Usage:
* node scripts/check-sannysoft-webdriver.js
* node scripts/check-sannysoft-webdriver.js --compare-stealth
* node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-browser
*/
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const args = process.argv.slice(2);
const getArgValue = (name, fallback) => {
const index = args.indexOf(name);
if (index === -1 || index + 1 >= args.length) return fallback;
return args[index + 1];
};
const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser'));
const sessionPrefix = getArgValue('--session-prefix', 'botcheck-e2e');
const compareStealth = args.includes('--compare-stealth');
const targetUrl = getArgValue('--url', 'https://bot.sannysoft.com');
const extractionScript = `(() => {
const normalize = (s) => (s || '').replace(/\\s+/g, ' ').trim();
const rows = Array.from(document.querySelectorAll('tr'));
const exact = rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase() === 'webdriver (new)');
const fallback = exact || rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase().includes('webdriver'));
return {
found: !!fallback,
label: fallback ? normalize(fallback.cells?.[0]?.textContent) : null,
valueText: fallback ? normalize(fallback.cells?.[1]?.textContent) : null,
statusText: fallback ? normalize(fallback.textContent) : null,
navigatorWebdriver: navigator.webdriver,
webdriverInNavigator: ('webdriver' in navigator),
};
})()`;
function runCommand(commandArgs, options = {}) {
const result = spawnSync(binary, commandArgs, { encoding: 'utf8' });
if (result.status !== 0 && !options.allowFailure) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
throw new Error(
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
`${stderr || stdout || `exit code ${result.status}`}`
);
}
return result;
}
function withSessionArgs(session, stealth) {
const base = ['--session', session];
if (stealth === false) {
base.push('--stealth', 'false');
}
return base;
}
function runSingleCheck({ stealth, runId }) {
const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`;
// Best-effort cleanup in case previous run left state behind.
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
try {
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
runCommand([...withSessionArgs(session, stealth), 'wait', '--load', 'networkidle']);
runCommand([...withSessionArgs(session, stealth), 'wait', '5000']);
const evalResult = runCommand([
...withSessionArgs(session, stealth),
'eval',
'--json',
extractionScript,
]);
const payload = JSON.parse(evalResult.stdout);
return {
session,
stealth,
url: targetUrl,
extracted: payload?.data?.result ?? null,
};
} finally {
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
}
}
function main() {
const runId = Date.now();
const checks = compareStealth ? [true, false] : [true];
const results = checks.map((stealth) => runSingleCheck({ stealth, runId }));
const output = {
binary,
compareStealth,
timestamp: new Date().toISOString(),
results,
};
console.log(JSON.stringify(output, null, 2));
}
main();
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* End-to-end stealth regression check across key anti-bot targets.
*
* Usage:
* node scripts/check-stealth-regression.js
* node scripts/check-stealth-regression.js --binary ./cli/target/release/agent-browser
* node scripts/check-stealth-regression.js --session-name stealth-regression
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const args = process.argv.slice(2);
const getArgValue = (name, fallback) => {
const index = args.indexOf(name);
if (index === -1 || index + 1 >= args.length) return fallback;
return args[index + 1];
};
const sessionName = getArgValue('--session-name', 'stealth-regression');
const screenshotDir = getArgValue('--screenshot-dir', join('/tmp', 'agent-browser-stealth-regression'));
const binaryArg = getArgValue('--binary', '');
const candidates = [
binaryArg,
join(rootDir, 'cli', 'target', 'release', 'agent-browser'),
join(rootDir, 'bin', 'agent-browser.js'),
'agent-browser-stealth',
'agent-browser',
].filter(Boolean);
function tryResolveBinary() {
for (const candidate of candidates) {
if (candidate.includes('/') && !existsSync(candidate)) continue;
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8' });
if (probe.status === 0) return candidate;
}
throw new Error(
`Unable to find a runnable agent-browser binary. Tried: ${candidates.join(', ')}`
);
}
const binary = tryResolveBinary();
const sessionArgs = ['--session', sessionName, '--session-name', sessionName];
function runBinary(commandArgs, { allowFailure = false } = {}) {
const result = spawnSync(binary, commandArgs, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.status !== 0 && !allowFailure) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
throw new Error(
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
`${stderr || stdout || `exit code ${result.status}`}`
);
}
return result;
}
function runJson(actionArgs, options = {}) {
const result = runBinary([...sessionArgs, '--json', ...actionArgs], options);
const output = (result.stdout || '').trim();
if (!output) return null;
try {
return JSON.parse(output);
} catch {
throw new Error(`Expected JSON output, got:\n${output}`);
}
}
const genericRiskScript = `(() => {
const lowerTitle = String(document.title || '').toLowerCase();
const lowerBody = String(document.body?.innerText || '').toLowerCase();
const hasCloudflare =
lowerTitle.includes('just a moment') ||
lowerTitle.includes('performing security verification') ||
lowerBody.includes('performing security verification') ||
lowerBody.includes('checking your browser') ||
lowerBody.includes('cloudflare');
const hasCaptcha =
lowerBody.includes('captcha') ||
lowerBody.includes('recaptcha') ||
lowerBody.includes('hcaptcha') ||
lowerBody.includes('turnstile');
return {
title: document.title || '',
url: location.href,
hasCloudflare,
hasCaptcha,
hasTurnstile:
!!document.querySelector('.cf-turnstile, iframe[src*="challenges.cloudflare.com"], [name="cf-turnstile-response"]'),
bodySample: lowerBody.slice(0, 600),
};
})()`;
const sannysoftScript = `(() => {
const normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
const rows = Array.from(document.querySelectorAll('tr'));
const failed = rows.filter((row) => /failed|fail/i.test(normalize(row.innerText)));
return {
failedCount: failed.length,
failedRows: failed.map((row) => normalize(row.innerText)),
navigatorWebdriver: navigator.webdriver,
navigatorVendor: navigator.vendor,
};
})()`;
function sanitizeFileSegment(input) {
return input.replace(/[^a-zA-Z0-9._-]+/g, '-');
}
function main() {
mkdirSync(screenshotDir, { recursive: true });
const timestamp = new Date().toISOString();
const targets = [
'https://bot.sannysoft.com/',
'https://chatgpt.com/',
'https://super86.cc/login',
];
runBinary([...sessionArgs, 'close'], { allowFailure: true });
const report = {
binary,
sessionName,
timestamp,
screenshotDir,
doctor: null,
targets: [],
ok: true,
};
try {
const doctorResp = runJson(['doctor']);
report.doctor = doctorResp?.data ?? null;
for (const target of targets) {
const entry = {
target,
open: null,
risk: null,
sannysoft: null,
screenshot: null,
ok: true,
error: null,
};
try {
const openResp = runJson(['open', target]);
entry.open = openResp?.data ?? null;
runJson(['wait', '3000'], { allowFailure: true });
const riskResp = runJson(['eval', genericRiskScript]);
entry.risk = riskResp?.data?.result ?? null;
if (target.includes('bot.sannysoft.com')) {
const sannysoftResp = runJson(['eval', sannysoftScript]);
entry.sannysoft = sannysoftResp?.data?.result ?? null;
if ((entry.sannysoft?.failedCount ?? 1) > 0) {
entry.ok = false;
}
} else if (entry.risk?.hasCloudflare || entry.risk?.hasCaptcha) {
entry.ok = false;
}
const host = sanitizeFileSegment(new URL(target).host);
const shotPath = join(
screenshotDir,
`${host}-${Date.now().toString(36)}.png`
);
runJson(['screenshot', '--full', shotPath], { allowFailure: true });
entry.screenshot = shotPath;
} catch (error) {
entry.ok = false;
entry.error = error instanceof Error ? error.message : String(error);
}
if (!entry.ok) report.ok = false;
report.targets.push(entry);
}
} finally {
runBinary([...sessionArgs, 'close'], { allowFailure: true });
}
console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);
}
main();
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env tsx
/**
* Deterministic Turnstile check using Cloudflare official testing sitekey.
*
* Usage:
* pnpm run check:turnstile-testkey
* pnpm run check:turnstile-testkey -- --headed
*/
import http from 'node:http';
import { BrowserManager } from '../src/browser.js';
const args = process.argv.slice(2);
const headed = args.includes('--headed');
const waitMsRaw = args.includes('--wait-ms')
? args[args.indexOf('--wait-ms') + 1]
: undefined;
const waitMs = Number.isFinite(Number(waitMsRaw)) ? Number(waitMsRaw) : 9000;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>agent-browser turnstile testkey</title>
</head>
<body>
<h1>Turnstile Testkey Probe</h1>
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-callback="onTurnstileToken"></div>
<script>
window.__turnstileToken = '';
function onTurnstileToken(token) {
window.__turnstileToken = token || '';
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</body>
</html>`;
function createServer(): Promise<http.Server> {
const server = http.createServer((_, res) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(html);
});
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()));
}
async function main(): Promise<void> {
const server = await createServer();
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Unable to start local HTTP server for Turnstile probe');
}
const localUrl = `http://127.0.0.1:${address.port}/`;
const browser = new BrowserManager();
try {
await browser.launch({
id: 'turnstile-testkey',
action: 'launch',
browser: 'chromium',
stealth: true,
headless: !headed,
});
const page = browser.getPage();
await page.goto(localUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
await page.waitForTimeout(waitMs);
const result = await page.evaluate(() => {
const hidden = document.querySelector('input[name="cf-turnstile-response"]') as
| HTMLInputElement
| null;
const hiddenValue = hidden?.value || '';
const callbackValue =
typeof (window as any).__turnstileToken === 'string'
? (window as any).__turnstileToken
: '';
const token = hiddenValue || callbackValue || '';
return {
url: location.href,
title: document.title || '',
tokenLength: token.length,
tokenSample: token.slice(0, 40),
isDummyToken: token.includes('DUMMY'),
hiddenFieldLength: hiddenValue.length,
callbackLength: callbackValue.length,
widgetCount: document.querySelectorAll('.cf-turnstile').length,
};
});
const report = {
timestamp: new Date().toISOString(),
headed,
waitMs,
ok: result.isDummyToken,
result,
};
console.log(JSON.stringify(report, null, 2));
process.exit(result.isDummyToken ? 0 : 1);
} finally {
await browser.close().catch(() => {});
await closeServer(server);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
SKILL_NAME="agent-browser-stealth"
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm is required for ClawHub sync"
exit 1
fi
if [ ! -f "skills/${SKILL_NAME}/SKILL.md" ]; then
echo "Missing skill file: skills/${SKILL_NAME}/SKILL.md"
exit 1
fi
# Sync only this fork-owned skill to avoid permission errors on other skills.
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
mkdir -p "$TMP_DIR/skills"
cp -R "skills/${SKILL_NAME}" "$TMP_DIR/skills/${SKILL_NAME}"
echo "Syncing local skill '${SKILL_NAME}' to ClawHub..."
cd "$TMP_DIR"
pnpm dlx clawhub@latest sync --all --root ./skills
echo "ClawHub sync completed."
+107 -44
View File
@@ -9,7 +9,7 @@
* - Mac/Linux: Replaces symlink to point to native binary
*/
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync } from 'fs';
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync, readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { platform, arch } from 'os';
@@ -27,15 +27,41 @@ const binaryName = `agent-browser-${platformKey}${ext}`;
const binaryPath = join(binDir, binaryName);
// Package info
const packageJson = JSON.parse(
(await import('fs')).readFileSync(join(projectRoot, 'package.json'), 'utf8')
);
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
const version = packageJson.version;
const packageName = packageJson.name;
const binCommands = getBinCommands(packageJson);
// GitHub release URL
const GITHUB_REPO = 'vercel-labs/agent-browser';
const GITHUB_REPO = getGitHubRepoFromPackage(packageJson);
const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
function getGitHubRepoFromPackage(pkg) {
const repo = pkg?.repository;
const repoUrl = typeof repo === 'string' ? repo : repo?.url;
if (typeof repoUrl === 'string') {
const match = repoUrl.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/i);
if (match?.[1]) {
return match[1];
}
}
// Fallback for legacy package metadata
return 'vercel-labs/agent-browser';
}
function getBinCommands(pkg) {
const bin = pkg?.bin;
if (typeof bin === 'string') {
return [pkg.name.replace(/^@[^/]+\//, '')];
}
if (bin && typeof bin === 'object') {
return Object.keys(bin);
}
return ['agent-browser'];
}
async function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const file = createWriteStream(dest);
@@ -107,7 +133,7 @@ async function main() {
console.log('');
console.log('To build the native binary locally:');
console.log(' 1. Install Rust: https://rustup.rs');
console.log(' 2. Run: npm run build:native');
console.log(' 2. Run: pnpm run build:native');
}
// On global installs, fix npm's bin entry to use native binary directly
@@ -135,7 +161,19 @@ function showPlaywrightReminder() {
* Fix npm's bin entry on global installs to use the native binary directly.
* This provides zero-overhead CLI execution for global installs.
*/
function isPnpmGlobalInstall() {
const ua = process.env.npm_config_user_agent || '';
return ua.includes('pnpm/');
}
async function fixGlobalInstallBin() {
// pnpm already manages global shims in its own bin dir.
// Rewriting links via `npm prefix -g` can create stale links in unrelated paths
// (e.g. /opt/homebrew/bin), which then shadow pnpm's up-to-date shims.
if (isPnpmGlobalInstall()) {
return;
}
if (platform() === 'win32') {
await fixWindowsShims();
} else {
@@ -157,27 +195,34 @@ async function fixUnixSymlink() {
return; // npm not available
}
const symlinkPath = join(npmBinDir, 'agent-browser');
let optimized = false;
for (const commandName of binCommands) {
const symlinkPath = join(npmBinDir, commandName);
// Check if symlink exists (indicates global install)
try {
const stat = lstatSync(symlinkPath);
if (!stat.isSymbolicLink()) {
return; // Not a symlink, don't touch it
// Check if symlink exists (indicates global install)
try {
const stat = lstatSync(symlinkPath);
if (!stat.isSymbolicLink()) {
continue; // Not a symlink, don't touch it
}
} catch {
continue; // Symlink doesn't exist, not a global install
}
// Replace symlink to point directly to native binary
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
optimized = true;
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize symlink (${commandName}): ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
}
} catch {
return; // Symlink doesn't exist, not a global install
}
// Replace symlink to point directly to native binary
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
if (optimized) {
console.log('✓ Optimized: symlink points to native binary (zero overhead)');
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize symlink: ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
}
}
@@ -194,37 +239,55 @@ async function fixWindowsShims() {
return;
}
const cmdShim = join(npmBinDir, 'agent-browser.cmd');
const ps1Shim = join(npmBinDir, 'agent-browser.ps1');
// Shims may not exist yet during postinstall (npm creates them after
// lifecycle scripts). If missing, fall back: the JS wrapper at
// bin/agent-browser.js handles Windows correctly via child_process.spawn.
if (!existsSync(cmdShim)) {
return;
}
// Detect architecture so ARM64 Windows is handled correctly
const cpuArch = arch() === 'arm64' ? 'arm64' : 'x64';
const relativeBinaryPath = `node_modules\\agent-browser\\bin\\agent-browser-win32-${cpuArch}.exe`;
// Path to native binary relative to npm prefix
const packagePath = packageName.replace(/\//g, '\\');
const relativeBinaryPath = `node_modules\\${packagePath}\\bin\\${binaryName}`;
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
// Only rewrite shims if the native binary actually exists
// npm may create shims after lifecycle scripts, and binary may be absent
// when running with JS fallback; skip rewriting in those cases.
if (!existsSync(absoluteBinaryPath)) {
return;
}
try {
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
writeFileSync(cmdShim, cmdContent);
let optimized = false;
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`;
writeFileSync(ps1Shim, ps1Content);
for (const commandName of binCommands) {
// The shims are in the npm prefix directory (not prefix/bin on Windows)
const cmdShim = join(npmBinDir, `${commandName}.cmd`);
const ps1Shim = join(npmBinDir, `${commandName}.ps1`);
// Only fix if shims exist (indicates global install)
if (!existsSync(cmdShim)) {
continue;
}
try {
// Overwrite .cmd shim
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
writeFileSync(cmdShim, cmdContent);
// Overwrite .ps1 shim
const ps1Content = `#!/usr/bin/env pwsh
$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe = ""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
$exe = ".exe"
}
& "$basedir/${relativeBinaryPath.replace(/\\/g, '/')}" $args
exit $LASTEXITCODE
`;
writeFileSync(ps1Shim, ps1Content);
optimized = true;
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize shims (${commandName}): ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
}
}
if (optimized) {
console.log('✓ Optimized: shims point to native binary (zero overhead)');
} catch (err) {
console.log(`⚠ Could not optimize shims: ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
}
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
UPSTREAM_REMOTE="upstream"
UPSTREAM_BRANCH="main"
BASE_BRANCH="main"
TRACK_BRANCH="upstream-main"
SYNC_BRANCH=""
PUSH_BRANCH=false
usage() {
cat <<'EOF'
Synchronize upstream changes into a dedicated sync branch.
Usage:
./scripts/sync-upstream.sh [options]
Options:
--push Push the created sync branch to origin
--upstream-remote <name> Upstream remote name (default: upstream)
--upstream-branch <name> Upstream branch to sync from (default: main)
--base-branch <name> Local base branch for sync branch (default: main)
--track-branch <name> Local branch tracking upstream (default: upstream-main)
--sync-branch <name> Explicit sync branch name (default: sync/YYYY-MM-DD)
-h, --help Show this help message
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--push)
PUSH_BRANCH=true
shift
;;
--upstream-remote)
UPSTREAM_REMOTE="${2:-}"
shift 2
;;
--upstream-branch)
UPSTREAM_BRANCH="${2:-}"
shift 2
;;
--base-branch)
BASE_BRANCH="${2:-}"
shift 2
;;
--track-branch)
TRACK_BRANCH="${2:-}"
shift 2
;;
--sync-branch)
SYNC_BRANCH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
for var_name in UPSTREAM_REMOTE UPSTREAM_BRANCH BASE_BRANCH TRACK_BRANCH; do
if [[ -z "${!var_name}" ]]; then
echo "Error: ${var_name} cannot be empty." >&2
exit 1
fi
done
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash changes first." >&2
exit 1
fi
if ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
echo "Error: remote '$UPSTREAM_REMOTE' does not exist." >&2
exit 1
fi
echo "Fetching upstream branch: ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
if git show-ref --verify --quiet "refs/heads/$TRACK_BRANCH"; then
echo "Updating local track branch: $TRACK_BRANCH"
git switch "$TRACK_BRANCH" >/dev/null
git merge --ff-only "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
else
echo "Creating local track branch: $TRACK_BRANCH"
git branch "$TRACK_BRANCH" "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
fi
echo "Switching to base branch: $BASE_BRANCH"
git switch "$BASE_BRANCH" >/dev/null
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
echo "Fast-forwarding ${BASE_BRANCH} from origin/${BASE_BRANCH}"
git fetch origin "$BASE_BRANCH"
git merge --ff-only "origin/${BASE_BRANCH}"
fi
if [[ -z "$SYNC_BRANCH" ]]; then
SYNC_BRANCH="sync/$(date +%F)"
fi
if git show-ref --verify --quiet "refs/heads/$SYNC_BRANCH"; then
suffix=1
while git show-ref --verify --quiet "refs/heads/${SYNC_BRANCH}-${suffix}"; do
suffix=$((suffix + 1))
done
SYNC_BRANCH="${SYNC_BRANCH}-${suffix}"
fi
echo "Creating sync branch: $SYNC_BRANCH"
git switch -c "$SYNC_BRANCH" "$BASE_BRANCH" >/dev/null
merge_message="chore(sync): merge ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH} into ${BASE_BRANCH}"
echo "Merging $TRACK_BRANCH into $SYNC_BRANCH"
if ! git merge --no-ff "$TRACK_BRANCH" -m "$merge_message"; then
echo ""
echo "Merge conflict detected. Resolve conflicts, then run:"
echo " git add <resolved-files>"
echo " git commit"
if [[ "$PUSH_BRANCH" == true ]]; then
echo " git push -u origin $SYNC_BRANCH"
fi
exit 1
fi
echo "Upstream merge completed on branch: $SYNC_BRANCH"
if [[ "$PUSH_BRANCH" == true ]]; then
echo "Pushing branch to origin: $SYNC_BRANCH"
git push -u origin "$SYNC_BRANCH"
echo "Done. Open a PR: ${SYNC_BRANCH} -> ${BASE_BRANCH}"
else
echo "Branch is local only. Push when ready:"
echo " git push -u origin $SYNC_BRANCH"
fi
+21 -3
View File
@@ -20,13 +20,31 @@ const packageJson = JSON.parse(
);
const version = packageJson.version;
console.log(`Syncing version ${version} to all config files...`);
function parseForkVersion(raw) {
const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/);
if (!match) return null;
return {
upstream: match[1],
fork: match[2],
};
}
const forkVersion = parseForkVersion(version);
if (forkVersion) {
console.log(
`Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...`
);
} else {
console.log(`Syncing version ${version} to all config files...`);
}
// Update Cargo.toml
const cargoTomlPath = join(cliDir, "Cargo.toml");
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
const newCargoVersion = `version = "${version}"`;
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
let cargoTomlUpdated = false;
if (cargoVersionRegex.test(cargoToml)) {
@@ -47,7 +65,7 @@ if (cargoVersionRegex.test(cargoToml)) {
// Update Cargo.lock to match Cargo.toml
if (cargoTomlUpdated) {
try {
execSync("cargo update -p agent-browser --offline", {
execSync(`cargo update -p ${cargoPackageName} --offline`, {
cwd: cliDir,
stdio: "pipe",
});
@@ -55,7 +73,7 @@ if (cargoTomlUpdated) {
} catch {
// --offline may fail if package not in cache, try without it
try {
execSync("cargo update -p agent-browser", {
execSync(`cargo update -p ${cargoPackageName}`, {
cwd: cliDir,
stdio: "pipe",
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Verifies that all bundled platform binaries are present and embed the
* package.json version string. This catches stale binary bundles where the
* package version is bumped but one or more binaries were not rebuilt.
*/
import { existsSync, readFileSync, statSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
const expectedVersion = String(pkg.version || "").trim();
if (!expectedVersion) {
console.error("Error: package.json version is empty");
process.exit(1);
}
const expectedBinaries = [
"agent-browser-linux-x64",
"agent-browser-linux-arm64",
"agent-browser-win32-x64.exe",
"agent-browser-darwin-x64",
"agent-browser-darwin-arm64",
];
const minSizeBytes = 100_000;
const versionBytes = Buffer.from(expectedVersion, "utf8");
let errors = 0;
for (const name of expectedBinaries) {
const binaryPath = join(rootDir, "bin", name);
if (!existsSync(binaryPath)) {
console.error(`ERROR: missing binary: bin/${name}`);
errors += 1;
continue;
}
const size = statSync(binaryPath).size;
if (size < minSizeBytes) {
console.error(
`ERROR: binary too small: bin/${name} (${size} bytes, expected >= ${minSizeBytes})`
);
errors += 1;
continue;
}
const bytes = readFileSync(binaryPath);
if (!bytes.includes(versionBytes)) {
console.error(
`ERROR: stale binary version: bin/${name} does not contain "${expectedVersion}"`
);
errors += 1;
continue;
}
console.log(`OK: bin/${name} matches version ${expectedVersion}`);
}
if (errors > 0) {
console.error(`\nFound ${errors} binary validation issue(s).`);
process.exit(1);
}
console.log(`\nAll bundled binaries match package.json version ${expectedVersion}.`);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Verifies that the bundled native binary version matches package.json version.
* This prevents publishing npm tarballs where package version and native binary
* version drift (e.g. package is fork.8 but binary still reports fork.7).
*/
import { existsSync, readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { arch, platform } from 'os';
import { execFileSync } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
const expectedVersion = pkg.version;
const ext = platform() === 'win32' ? '.exe' : '';
const platformBinary = join(projectRoot, 'bin', `agent-browser-${platform()}-${arch()}${ext}`);
if (!existsSync(platformBinary)) {
console.error(`Error: native binary not found for current platform: ${platformBinary}`);
console.error('Run `pnpm run build:native` before publishing.');
process.exit(1);
}
let versionOutput = '';
try {
versionOutput = execFileSync(platformBinary, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: failed to execute native binary --version: ${message}`);
process.exit(1);
}
if (!versionOutput.includes(expectedVersion)) {
console.error(`Version mismatch: package.json=${expectedVersion}, native='${versionOutput}'.`);
console.error('Run `pnpm run build:native` and retry publishing.');
process.exit(1);
}
console.log(`✓ Native binary version matches package.json (${expectedVersion})`);
+127
View File
@@ -0,0 +1,127 @@
---
name: agent-browser-stealth
description: Stealth-first browser automation for OpenClaw using agent-browser-stealth. Use when tasks involve bot-protected websites, anti-fingerprint evasion, captcha-prone flows, login persistence, region-sensitive targets (e.g., Shopee/TikTok/e-commerce), or any request to automate web actions with lower detection risk.
homepage: https://github.com/leeguooooo/agent-browser
---
# agent-browser-stealth for OpenClaw
Use this skill when the task needs web automation and anti-bot stability.
## What this skill prioritizes
- Use `agent-browser` CLI from `agent-browser-stealth` package
- Prefer stealth-safe interaction patterns over brittle one-shot scripts
- Keep command flow deterministic: `open -> snapshot -> act -> re-snapshot`
- Minimize bot signals with humanized pacing and stable session reuse
## Install and baseline
```bash
pnpm add -g agent-browser-stealth
agent-browser install
agent-browser --version
```
If default CDP mode is used in your environment, the CLI first tries `localhost:9333` and then auto-discovery. You can still pass `--cdp` / `--auto-connect` explicitly when needed.
## Standard execution workflow
```bash
agent-browser open <url>
agent-browser wait --load networkidle
agent-browser snapshot -i
# choose refs (@e1, @e2, ...)
agent-browser click @eN
agent-browser fill @eM "..."
agent-browser snapshot -i
```
Use refs (`@e1`) from snapshot output whenever possible.
## Anti-bot operating rules
1. Prefer headed mode for sensitive targets:
```bash
agent-browser --headed --session-name shop open https://example.com
```
2. Reuse session state to avoid repeated cold-start fingerprints:
```bash
agent-browser --session-name shop open https://example.com
```
3. Keep interactions human-like:
```bash
agent-browser type @e2 "query" --delay 120
agent-browser wait 1200-2600
```
4. For contenteditable editors, use keyboard mode:
```bash
agent-browser click "[contenteditable='true']"
agent-browser keyboard type "Hello world" --delay 90
```
5. If text must literally include `--delay`, stop arg parsing with `--`:
```bash
agent-browser type @e2 -- "--delay 120"
agent-browser keyboard type -- "--delay 120"
```
## Region-sensitive websites
For region-bound sites, open target domain directly and let locale/timezone alignment apply.
```bash
agent-browser open https://shopee.tw
```
Only override locale/timezone when explicitly required by the task.
## Recovery patterns
If blocked or unstable:
1. Retry with `--headed`.
2. Reuse `--session-name`.
3. Slow down action cadence (`wait`, `type --delay`).
4. Re-open page and regenerate refs with `snapshot -i`.
## Minimal recipes
Login flow:
```bash
agent-browser --session-name account open https://example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
```
Search and capture:
```bash
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser type @e2 "iphone" --delay 120
agent-browser press Enter
agent-browser wait --load networkidle
agent-browser screenshot result.png
```
## Output expectations for OpenClaw
When using this skill, return:
- Exact commands executed
- Key page state changes (URL/title/important element text)
- Any anti-bot signal encountered and mitigation used
- Next safe action
+141 -19
View File
@@ -1,11 +1,13 @@
---
name: agent-browser
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*), Bash(abs:*)
---
# Browser Automation with agent-browser
Install package: `pnpm add -g agent-browser-stealth` (CLI commands: `agent-browser`, `agent-browser-stealth`, and short alias `abs`). If global install is unavailable in your environment, use `pnpm dlx agent-browser-stealth <command>` for one-off runs.
## Core Workflow
Every browser automation follows this pattern:
@@ -49,7 +51,10 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
agent-browser doctor # Diagnose CDP + sourceURL + tab-group plugin health
agent-browser close # Close browser
agent-browser --version # Show CLI version (fork builds include upstream/fork)
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
@@ -60,11 +65,11 @@ agent-browser snapshot -s "#selector" # Scope to CSS selector
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser type @e2 "text" --delay 120 # Type without clearing (human-like pacing)
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard type "text" --delay 90 # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
@@ -79,11 +84,13 @@ agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory
agent-browser --tab-group "My Agent Group" open <url> # Override default tab-group base title
# Capture
agent-browser screenshot # Screenshot to temp dir
@@ -148,10 +155,25 @@ agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
### Cookie Injection for Auth Callbacks
```bash
# Before navigation: set by URL
agent-browser cookies set session_id "abc123" --url https://app.example.com/api/auth/sso/callback
# Explicit domain/path pair (must be provided together)
agent-browser cookies set auth_token "xyz789" --domain .example.com --path /api
# Or navigate first and rely on current URL
agent-browser open https://app.example.com/api/auth/sso/callback
agent-browser cookies set callback_token "token123"
```
### Session Persistence
```bash
# Auto-save/restore cookies and localStorage across browser restarts
# If --session-name is omitted, it defaults to --session (or "default")
agent-browser --session-name myapp open https://app.example.com/login
# ... login flow ...
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
@@ -197,6 +219,12 @@ agent-browser session list
### Connect to Existing Chrome
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
1. Try CDP at `localhost:9333`
2. If unavailable, fall back to `--auto-connect`-style discovery
3. If both fail, exit with guidance (no automatic managed local browser launch on this path)
```bash
# Auto-discover running Chrome with remote debugging enabled
agent-browser --auto-connect open https://example.com
@@ -204,6 +232,18 @@ agent-browser --auto-connect snapshot
# Or with explicit CDP port
agent-browser --cdp 9222 snapshot
# Debug auto-attach behavior
agent-browser --debug snapshot
# Diagnose CDP + sourceURL + plugin handshake status
agent-browser doctor
# Avoid `open` timeout on challenge-heavy pages
agent-browser --wait-until domcontentloaded open https://example.com
# Deterministic Turnstile smoke check (official test key)
pnpm run check:turnstile-testkey
```
### Color Scheme (Dark Mode)
@@ -219,6 +259,37 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
agent-browser set media dark
```
### Tab Grouping
```bash
# CDP mode groups tabs when tab-group extension is installed
agent-browser open https://example.com
# Override the default group title
agent-browser --tab-group "My Agent Group" open https://example.com
# Or via environment variable
AGENT_BROWSER_TAB_GROUP="My Agent Group" agent-browser open https://example.com
# Override expected extension ID if needed
AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="<extension-id>" agent-browser open https://example.com
```
Notes:
- Works in CDP mode via extension handshake.
- Extension package name in Chrome: `agent-browser-stealth`.
- Extension installed and reachable: tabs are grouped by session.
- Extension missing/unavailable: silent no-op (no warning/error unless debug mode).
- Default titles:
- `default` session: `Agent Browser Stealth`
- non-default session: `Agent Browser Stealth • <session>`
- Additional extension-side capabilities:
- Session window isolation + deterministic group colors.
- Side panel controls: Focus / Keep Only This / Clean Empty Groups + isolation/auto-clean toggles.
- Session allowlist policy editing and fallback blocking (`about:blank`).
- Download auto-routing to `agent-browser-stealth/<session>/...`.
### Visual Browser (Debugging)
```bash
@@ -238,6 +309,42 @@ agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png
```
### Project Policy
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
- Use existing browser sessions (default attach path: CDP `localhost:9333` then auto-discovery) or pass `--cdp` explicitly
### Stealth Mode (Always On)
Stealth is always active -- no flags needed. All sessions automatically apply anti-detection patches (navigator.webdriver removal, UA override, plugin injection, WebGL masking, humanized interactions, etc.).
Chromium launches in managed mode use Chrome channel by default for a genuine browser binary fingerprint.
For best results against strong bot detection, use `--headed` and keep one stable `--session-name`.
### Auto Region Detection
The browser automatically detects the target site's region from the URL TLD and sets matching locale, timezone, and Accept-Language headers. For example, navigating to `shopee.tw` sets locale `zh-TW` and timezone `Asia/Taipei`. This reduces server-side risk scoring from region-signal mismatches.
Override: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE` env vars.
### Captcha Detection & Auto-Retry
When a navigation lands on a captcha/verification page, behavior is controlled by `--risk-mode` (or `AGENT_BROWSER_RISK_MODE`):
- `warn` (default): wait for auto-clear first, then retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals`
- `block`: fail fast once a risk interstitial is detected
- `off`: disable this detection/retry path
Examples:
```bash
agent-browser --risk-mode warn open https://example.com
agent-browser --risk-mode block open https://example.com
AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
```
### iOS Simulator (Mobile Safari)
```bash
@@ -300,8 +407,9 @@ export AGENT_BROWSER_ACTION_POLICY=./policy.json
```
Example `policy.json`:
```json
{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]}
{ "default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] }
```
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
@@ -359,10 +467,23 @@ agent-browser wait --fn "document.readyState === 'complete'"
# Wait a fixed duration (milliseconds) as a last resort
agent-browser wait 5000
# Random wait between 2-5 seconds (useful for anti-detection)
agent-browser wait 2000-5000
```
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
### Humanized Interactions
agent-browser automatically humanizes interactions to avoid behavioral detection:
- **Randomized typing**: `type --delay` varies each keystroke delay by +-40%
- **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range
- **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve
These behaviors are always active. For sensitive sites, combine with `--headed` and a stable `--session-name` for best results.
## Session Management and Cleanup
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
@@ -413,6 +534,7 @@ agent-browser click @e2 # Click using ref from annotated screenshot
```
Use annotated screenshots when:
- The page has unlabeled icon buttons or visual-only elements
- You need to verify visual layout or styling
- Canvas or chart elements are present (invisible to text snapshots)
@@ -455,6 +577,7 @@ agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
**Rules of thumb:**
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
- Programmatic/generated scripts -> use `eval -b` with base64
@@ -466,8 +589,7 @@ Create `agent-browser.json` in the project root for persistent settings:
```json
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data"
"proxy": "http://localhost:8080"
}
```
@@ -475,15 +597,15 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
## Deep-Dive Documentation
| Reference | When to Use |
|-----------|-------------|
| [references/commands.md](references/commands.md) | Full command reference with all options |
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
| Reference | When to Use |
| -------------------------------------------------------------------- | --------------------------------------------------------- |
| [references/commands.md](references/commands.md) | Full command reference with all options |
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
## Experimental: Native Mode
@@ -502,11 +624,11 @@ The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebK
## Ready-to-Use Templates
| Template | Description |
|----------|-------------|
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
| Template | Description |
| ------------------------------------------------------------------------ | ----------------------------------- |
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
```bash
./templates/form-automation.sh https://example.com/form
+146 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { toAIFriendlyError } from './actions.js';
import { describe, it, expect, vi } from 'vitest';
import { detectRiskSignals, executeCommand, toAIFriendlyError } from './actions.js';
describe('toAIFriendlyError', () => {
describe('element blocked by overlay', () => {
@@ -37,3 +37,147 @@ describe('toAIFriendlyError', () => {
});
});
});
describe('detectRiskSignals', () => {
it('should detect verification patterns from URL and title', () => {
const signals = detectRiskSignals(
'https://example.com/verify/captcha?scene=anti_bot',
'Just a moment...'
);
expect(signals.length).toBeGreaterThan(0);
expect(signals.some((s) => s.source === 'url' && s.code === 'captcha_interstitial')).toBe(true);
expect(
signals.some((s) => s.source === 'title' && s.code === 'verification_interstitial')
).toBe(true);
});
it('should return empty array for normal pages', () => {
const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard');
expect(signals).toEqual([]);
});
it('should detect cloudflare security verification text', () => {
const signals = detectRiskSignals(
'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
'dash.cloudflare.com',
'Performing security verification Verifying... This website uses a security service to protect against malicious bots.'
);
expect(signals.some((s) => s.code === 'verification_interstitial')).toBe(true);
expect(signals.some((s) => s.code === 'bot_challenge')).toBe(true);
});
});
describe('tab grouping fallback', () => {
it('should keep navigate successful when tab grouping trigger throws', async () => {
const page = {
waitForTimeout: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
url: vi.fn().mockReturnValue('https://example.com/'),
title: vi.fn().mockResolvedValue('Example Domain'),
};
const browser = {
getPage: vi.fn().mockReturnValue(page),
setTargetUrl: vi.fn().mockResolvedValue(undefined),
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
throw new Error('plugin-unavailable');
}),
};
const response = await executeCommand(
{ id: 'n1', action: 'navigate', url: 'https://example.com', riskMode: 'off' },
browser as any
);
expect(response.success).toBe(true);
expect(page.goto).toHaveBeenCalledTimes(1);
});
it('should keep tab_new successful when tab grouping trigger throws after navigation', async () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
};
const browser = {
newTab: vi.fn().mockResolvedValue({ index: 1, total: 2 }),
getPage: vi.fn().mockReturnValue(page),
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
throw new Error('plugin-unavailable');
}),
};
const response = await executeCommand(
{ id: 't1', action: 'tab_new', url: 'https://example.com' },
browser as any
);
expect(response.success).toBe(true);
expect(browser.newTab).toHaveBeenCalledTimes(1);
expect(page.goto).toHaveBeenCalledTimes(1);
});
});
describe('risk interstitial recovery', () => {
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
const challengeClearMs = 10_000;
let challengeElapsed = 0;
let currentUrl = 'https://dash.cloudflare.com/challenge';
let currentTitle = 'Just a moment...';
const syncChallengeState = () => {
if (challengeElapsed >= challengeClearMs) {
currentUrl = 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm';
currentTitle = 'Cloudflare Dashboard';
} else {
currentUrl = 'https://dash.cloudflare.com/challenge';
currentTitle = 'Just a moment...';
}
};
const page = {
waitForTimeout: vi.fn().mockImplementation(async (ms: number) => {
challengeElapsed += Number(ms) || 0;
syncChallengeState();
}),
goto: vi.fn().mockImplementation(async () => {
// Refreshing during verification resets challenge progress.
if (challengeElapsed < challengeClearMs) {
challengeElapsed = 0;
}
syncChallengeState();
}),
url: vi.fn().mockImplementation(() => currentUrl),
title: vi.fn().mockImplementation(async () => currentTitle),
evaluate: vi.fn().mockImplementation(async () => {
if (currentTitle === 'Just a moment...') {
return 'Performing security verification Verifying... This website uses a security service to protect against malicious bots.';
}
return 'Dashboard content';
}),
};
const browser = {
getPage: vi.fn().mockReturnValue(page),
setTargetUrl: vi.fn().mockResolvedValue(undefined),
triggerTabGroupingForActivePage: vi.fn(),
};
const response = await executeCommand(
{
id: 'cf1',
action: 'navigate',
url: 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
riskMode: 'warn',
},
browser as any
);
expect(response.success).toBe(true);
if (response.success) {
expect(response.data.title).toBe('Cloudflare Dashboard');
expect(response.data.warning).toContain('cleared after wait');
expect(response.data.riskSignals?.length ?? 0).toBeGreaterThan(0);
}
expect(page.goto).toHaveBeenCalledTimes(1);
});
});
+600 -523
View File
File diff suppressed because it is too large Load Diff
+347 -6
View File
@@ -9,11 +9,11 @@ describe('BrowserManager', () => {
beforeAll(async () => {
browser = new BrowserManager();
await browser.launch({ headless: true });
});
}, 30000);
afterAll(async () => {
await browser.close();
});
}, 30000);
describe('launch and close', () => {
it('should report as launched', () => {
@@ -53,6 +53,287 @@ describe('BrowserManager', () => {
expect(newBrowser.getBrowser()).toBeNull();
await newBrowser.close();
});
it('should switch from local session when auto-connect is explicitly requested', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ id: 'test', action: 'launch', headless: true });
const closeSpy = vi.spyOn(testBrowser, 'close');
const autoConnectSpy = vi
.spyOn(testBrowser as any, 'autoConnectViaCDP')
.mockResolvedValue(undefined);
await testBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(autoConnectSpy).toHaveBeenCalledTimes(1);
autoConnectSpy.mockRestore();
closeSpy.mockRestore();
await testBrowser.close();
});
it('should not relaunch when already connected via healthy CDP and auto-connect is requested', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ id: 'test', action: 'launch', cdpPort: 9222 });
expect(connectSpy).toHaveBeenCalledTimes(1);
const closeSpy = vi.spyOn(cdpBrowser, 'close');
await cdpBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).not.toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalledTimes(1);
closeSpy.mockRestore();
await cdpBrowser.close();
connectSpy.mockRestore();
});
it('should report local stealth policy capabilities', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ headless: true });
const status = testBrowser.getStealthStatus('chromium');
expect(status.enabled).toBe(true);
expect(status.connectionKind).toBe('local');
expect(status.capabilities).toContain('chromium-launch-args');
expect(status.capabilities).toContain('context-init-scripts');
await testBrowser.close();
});
it('should apply init-script stealth policy for CDP connections', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(addInitScript).toHaveBeenCalledTimes(1);
const status = cdpBrowser.getStealthStatus();
expect(status.enabled).toBe(true);
expect(status.connectionKind).toBe('cdp');
expect(status.capabilities).toContain('context-init-scripts');
expect(status.capabilities).not.toContain('chromium-launch-args');
await cdpBrowser.close();
spy.mockRestore();
});
it('should reject CDP endpoints with only blank pages when meaningful tabs are required', async () => {
const mockPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await expect(
(cdpBrowser as any).connectViaCDP('9222', {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
})
).rejects.toThrow('No existing user tabs found on this CDP endpoint.');
expect(mockBrowser.close).toHaveBeenCalledTimes(1);
connectSpy.mockRestore();
});
it('should skip auto-connect candidates without user tabs and continue discovery', async () => {
const cdpBrowser = new BrowserManager();
const dirsSpy = vi
.spyOn(cdpBrowser as any, 'getChromeUserDataDirs')
.mockReturnValue(['/tmp/chrome-a', '/tmp/chrome-b']);
const activePortSpy = vi.spyOn(cdpBrowser as any, 'readDevToolsActivePort');
activePortSpy
.mockReturnValueOnce({ port: 9222, wsPath: '/devtools/browser/a' })
.mockReturnValueOnce({ port: 9333, wsPath: '/devtools/browser/b' });
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy
.mockResolvedValueOnce('ws://127.0.0.1:9222/devtools/browser/a')
.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/b');
const connectViaCDPSpy = vi.spyOn(cdpBrowser as any, 'connectViaCDP');
connectViaCDPSpy
.mockRejectedValueOnce(new Error('No existing user tabs found on this CDP endpoint.'))
.mockResolvedValueOnce(undefined);
await (cdpBrowser as any).autoConnectViaCDP();
expect(connectViaCDPSpy).toHaveBeenCalledTimes(2);
expect(connectViaCDPSpy.mock.calls[0][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
expect(connectViaCDPSpy.mock.calls[1][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
dirsSpy.mockRestore();
activePortSpy.mockRestore();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
});
it('should prefer port 9333 before DevToolsActivePort discovery in auto-connect', async () => {
const cdpBrowser = new BrowserManager();
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/preferred');
const connectViaCDPSpy = vi
.spyOn(cdpBrowser as any, 'connectViaCDP')
.mockResolvedValue(undefined);
const dirsSpy = vi.spyOn(cdpBrowser as any, 'getChromeUserDataDirs');
await (cdpBrowser as any).autoConnectViaCDP();
expect(probeSpy).toHaveBeenCalledWith(9333);
expect(connectViaCDPSpy).toHaveBeenCalledTimes(1);
expect(connectViaCDPSpy.mock.calls[0][0]).toContain('9333');
expect(dirsSpy).not.toHaveBeenCalled();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
dirsSpy.mockRestore();
});
it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222, stealth: false });
expect(addInitScript).toHaveBeenCalledTimes(1);
const status = cdpBrowser.getStealthStatus();
expect(status.enabled).toBe(true);
expect(status.connectionKind).toBe('cdp');
expect(status.capabilities).toContain('context-init-scripts');
expect(status.capabilities).not.toContain('chromium-launch-args');
await cdpBrowser.close();
spy.mockRestore();
});
});
describe('tab-group plugin handshake', () => {
it('should mark plugin capability as available after successful handshake', async () => {
const manager = new BrowserManager() as any;
manager.tabGroupIntent = {
session: 'default',
groupTitle: 'Agent Browser Stealth',
pluginId: 'plugin-123',
};
manager.stealthConnectionKind = 'cdp';
const page = {
isClosed: () => false,
url: () => 'https://example.com',
};
const requestSpy = vi
.spyOn(manager, 'requestTabGroupPlugin')
.mockResolvedValue({ ok: true, extensionId: 'plugin-123' });
await manager.tryApplyTabGrouping(page, 'test');
expect(manager.getTabGroupCapability('default')).toBe('available');
expect(requestSpy).toHaveBeenCalledTimes(1);
requestSpy.mockRestore();
});
it('should silently mark capability unavailable on timeout response', async () => {
const manager = new BrowserManager() as any;
manager.tabGroupIntent = {
session: 'default',
groupTitle: 'Agent Browser Stealth',
pluginId: 'plugin-123',
};
manager.stealthConnectionKind = 'cdp';
const page = {
isClosed: () => false,
url: () => 'https://example.com',
};
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
await manager.tryApplyTabGrouping(page, 'test-timeout');
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
expect(requestSpy).toHaveBeenCalledTimes(1);
requestSpy.mockRestore();
});
it('should stop retrying handshake once capability is unavailable', async () => {
const manager = new BrowserManager() as any;
manager.tabGroupIntent = {
session: 'default',
groupTitle: 'Agent Browser Stealth',
pluginId: 'plugin-123',
};
manager.stealthConnectionKind = 'cdp';
const page = {
isClosed: () => false,
url: () => 'https://example.com',
};
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
await manager.tryApplyTabGrouping(page, 'first-attempt');
await manager.tryApplyTabGrouping(page, 'second-attempt');
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
expect(requestSpy).toHaveBeenCalledTimes(1);
requestSpy.mockRestore();
});
});
describe('stale session recovery (all pages closed)', () => {
@@ -878,15 +1159,16 @@ describe('BrowserManager', () => {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
{ url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
@@ -902,6 +1184,65 @@ describe('BrowserManager', () => {
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
it('should ignore omnibox popup pages during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/',
on: vi.fn(),
isClosed: () => false,
},
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('http://example.com');
spy.mockRestore();
});
it('should create a fallback page when CDP has only internal pages', async () => {
const newPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const context = {
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/omnibox_popup_aim.html',
on: vi.fn(),
isClosed: () => false,
},
],
newPage: vi.fn().mockResolvedValue(newPage),
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [context],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(context.newPage).toHaveBeenCalledTimes(1);
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('about:blank');
spy.mockRestore();
});
});
describe('screencast', () => {
+1315 -59
View File
File diff suppressed because it is too large Load Diff
+122 -9
View File
@@ -5,7 +5,7 @@ import * as os from 'os';
import { BrowserManager } from './browser.js';
import { IOSManager } from './ios-manager.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand, initActionPolicy } from './actions.js';
import { executeCommand } from './actions.js';
import { executeIOSCommand } from './ios-actions.js';
import { StreamServer } from './stream-server.js';
import {
@@ -338,9 +338,6 @@ export async function startDaemon(options?: {
// Clean up expired state files on startup
runCleanupExpiredStates();
// Initialize action policy enforcement
initActionPolicy();
// Determine provider from options or environment
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
const isIOS = provider === 'ios';
@@ -413,12 +410,15 @@ export async function startDaemon(options?: {
continue;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: attach to an existing browser only.
const isDoctor = parseResult.command.action === 'doctor';
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close' &&
parseResult.command.action !== 'state_load'
parseResult.command.action !== 'state_load' &&
parseResult.command.action !== 'doctor'
) {
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
@@ -458,29 +458,142 @@ export async function startDaemon(options?: {
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
// Stealth is always enabled in agent-browser-stealth
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme =
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
await manager.launch({
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
const launchOptions = {
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess,
colorScheme,
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
tabGroupPluginId:
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
autoStateFilePath: getSessionAutoStatePath(),
};
let attachedToExistingBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause false-negative attach failures.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
cdpPort: 9333,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
};
await manager.launch({
...cdpLaunchOptions,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
}
}
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
tabGroup: launchOptions.tabGroup,
tabGroupPluginId: launchOptions.tabGroupPluginId,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
);
}
}
}
// For doctor, attempt the same default attach flow but do not fail hard if attach is unavailable.
// This keeps diagnostics actionable even when CDP is down.
if (!manager.isLaunched() && isDoctor && manager instanceof BrowserManager) {
try {
await manager.launch({
id: 'doctor-cdp',
action: 'launch',
cdpPort: 9333,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as 'dark' | 'light' | 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
try {
await manager.launch({
id: 'doctor-auto-connect',
action: 'launch',
autoConnect: true,
ignoreHTTPSErrors: process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1',
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
colorScheme:
process.env.AGENT_BROWSER_COLOR_SCHEME === 'dark' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'light' ||
process.env.AGENT_BROWSER_COLOR_SCHEME === 'no-preference'
? (process.env.AGENT_BROWSER_COLOR_SCHEME as
| 'dark'
| 'light'
| 'no-preference')
: undefined,
tabGroup: process.env.AGENT_BROWSER_TAB_GROUP?.trim() || undefined,
tabGroupPluginId:
process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim() || undefined,
});
} catch {
// Keep running: doctor should report failures instead of exiting early.
}
}
}
+59
View File
@@ -5,6 +5,30 @@ import { parseCommand } from './protocol.js';
const cmd = (obj: object) => JSON.stringify(obj);
describe('parseCommand', () => {
describe('launch', () => {
it('should parse launch command and ignore legacy stealth flag', () => {
const result = parseCommand(
cmd({ id: '1', action: 'launch', headless: false, stealth: true })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('launch');
expect((result.command as any).stealth).toBeUndefined();
}
});
it('should parse launch command with tabGroup', () => {
const result = parseCommand(
cmd({ id: '1', action: 'launch', headless: false, tabGroup: 'Agent Browser Stealth' })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('launch');
expect(result.command.tabGroup).toBe('Agent Browser Stealth');
}
});
});
describe('navigation', () => {
it('should parse navigate command', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' }));
@@ -31,11 +55,38 @@ describe('parseCommand', () => {
}
});
it('should parse navigate with riskMode', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'navigate',
url: 'https://example.com',
riskMode: 'block',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.riskMode).toBe('block');
}
});
it('should reject navigate without url', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
expect(result.success).toBe(false);
});
it('should reject navigate with invalid riskMode', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'navigate',
url: 'https://example.com',
riskMode: 'invalid',
})
);
expect(result.success).toBe(false);
});
it('should parse back command', () => {
const result = parseCommand(cmd({ id: '1', action: 'back' }));
expect(result.success).toBe(true);
@@ -1413,6 +1464,14 @@ describe('parseCommand', () => {
});
describe('invalid commands', () => {
it('should parse doctor command', () => {
const result = parseCommand(cmd({ id: '1', action: 'doctor' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('doctor');
}
});
it('should reject unknown action', () => {
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
expect(result.success).toBe(false);
+9 -1
View File
@@ -51,7 +51,8 @@ const launchSchema = baseCommandSchema.extend({
allowFileAccess: z.boolean().optional(),
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
downloadPath: z.string().optional(),
profile: z.string().optional(),
tabGroup: z.string().min(1).optional(),
tabGroupPluginId: z.string().min(1).optional(),
storageState: z.string().optional(),
allowedDomains: z.array(z.string()).optional(),
actionPolicy: z.string().optional(),
@@ -63,6 +64,7 @@ const navigateSchema = baseCommandSchema.extend({
url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
headers: z.record(z.string()).optional(),
riskMode: z.enum(['off', 'warn', 'block']).optional(),
});
const clickSchema = baseCommandSchema.extend({
@@ -813,6 +815,7 @@ const waitSchema = baseCommandSchema.extend({
action: z.literal('wait'),
selector: z.string().min(1).optional(),
timeout: z.number().positive().optional(),
timeoutMax: z.number().positive().optional(),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
});
@@ -845,6 +848,10 @@ const closeSchema = baseCommandSchema.extend({
action: z.literal('close'),
});
const doctorSchema = baseCommandSchema.extend({
action: z.literal('doctor'),
});
// Tab/Window schemas
const tabNewSchema = baseCommandSchema.extend({
action: z.literal('tab_new'),
@@ -952,6 +959,7 @@ const commandSchema = z.discriminatedUnion('action', [
hoverSchema,
contentSchema,
closeSchema,
doctorSchema,
tabNewSchema,
tabListSchema,
tabSwitchSchema,
+412
View File
@@ -0,0 +1,412 @@
import { afterEach, describe, expect, it } from 'vitest';
import { BrowserManager } from './browser.js';
async function readWebdriverSignals(browser: BrowserManager): Promise<{
value: boolean | undefined;
inNavigator: boolean;
ownNavigator: boolean;
ownPrototype: boolean;
}> {
const page = browser.getPage();
await page.goto('about:blank');
return page.evaluate(() => {
const prototype = Object.getPrototypeOf(navigator);
return {
value: navigator.webdriver,
inNavigator: 'webdriver' in navigator,
ownNavigator: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'),
ownPrototype: Object.prototype.hasOwnProperty.call(prototype, 'webdriver'),
};
});
}
describe('Stealth mode', () => {
let browser: BrowserManager;
afterEach(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
it('removes navigator.webdriver when stealth is enabled', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await readWebdriverSignals(browser);
expect(signals.value).toBeUndefined();
expect(signals.inNavigator).toBe(false);
expect(signals.ownNavigator).toBe(false);
expect(signals.ownPrototype).toBe(false);
});
it('applies stealth patches to contexts created by newWindow', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
await browser.newWindow();
const signals = await readWebdriverSignals(browser);
expect(signals.value).toBeUndefined();
expect(signals.inNavigator).toBe(false);
expect(signals.ownNavigator).toBe(false);
expect(signals.ownPrototype).toBe(false);
});
it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => {
const previousLocale = process.env.AGENT_BROWSER_LOCALE;
process.env.AGENT_BROWSER_LOCALE = 'fr-FR';
try {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const languageSignals = await browser.getPage().evaluate(() => ({
language: navigator.language,
languages: navigator.languages,
}));
expect(languageSignals.language).toBe('fr-FR');
expect(languageSignals.languages).toEqual(['fr-FR', 'fr']);
} finally {
if (previousLocale === undefined) {
delete process.env.AGENT_BROWSER_LOCALE;
} else {
process.env.AGENT_BROWSER_LOCALE = previousLocale;
}
}
});
it('keeps navigator.vendor aligned with Chrome', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const vendorSignals = await browser.getPage().evaluate(() => ({
userAgent: navigator.userAgent,
vendor: navigator.vendor,
}));
if (vendorSignals.userAgent.includes('Chrome/')) {
expect(vendorSignals.vendor).toBe('Google Inc.');
}
});
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const userAgentSignals = await browser.getPage().evaluate(async () => {
const pageUA = navigator.userAgent;
const workerUA = await new Promise<string>((resolve) => {
const source = 'postMessage(navigator.userAgent);';
const blob = new Blob([source], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = (event) => resolve(String(event.data));
});
return { pageUA, workerUA };
});
expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome');
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
});
it('neutralizes the css webdriver heuristic probe', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
probe: CSS.supports('border-end-end-radius: initial'),
baseline: CSS.supports('display: block'),
webdriver: navigator.webdriver,
inNavigator: 'webdriver' in navigator,
}));
expect(signals.probe).toBe(false);
expect(signals.baseline).toBe(true);
expect(signals.webdriver).toBeUndefined();
expect(signals.inNavigator).toBe(false);
});
it('neutralizes creepjs prefers-color-scheme light probe', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => {
const node = document.createElement('div');
node.setAttribute('style', 'background-color: ActiveText');
document.body.appendChild(node);
const activeTextColor = getComputedStyle(node).backgroundColor;
node.remove();
return {
activeTextColor,
prefersLight: matchMedia('(prefers-color-scheme: light)').matches,
prefersDark: matchMedia('(prefers-color-scheme: dark)').matches,
lightListenerCalls: (() => {
try {
const mql = matchMedia('(prefers-color-scheme: light)');
const handler = () => {};
mql.addEventListener('change', handler);
mql.removeEventListener('change', handler);
return 'ok';
} catch (error) {
return String(error);
}
})(),
};
});
expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)');
expect(signals.prefersLight).toBe(false);
expect(typeof signals.prefersDark).toBe('boolean');
expect(signals.lightListenerCalls).toBe('ok');
});
it('exposes realistic mimeTypes/pdf/share signals', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
mimeTypesLength: navigator.mimeTypes ? navigator.mimeTypes.length : 0,
pdfViewerEnabled: navigator.pdfViewerEnabled,
hasShare: typeof navigator.share === 'function',
hasCanShare: typeof navigator.canShare === 'function',
hasConnectionDownlinkMax:
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
hasConnectionDownlinkMaxOnProto:
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
}));
expect(signals.mimeTypesLength).toBeGreaterThan(0);
expect(signals.pdfViewerEnabled).toBe(true);
expect(signals.hasShare).toBe(true);
expect(signals.hasCanShare).toBe(true);
expect(signals.hasConnectionDownlinkMax).toBe(true);
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
});
it('exposes legacy chrome.app/csi/loadTimes APIs', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => {
const chromeObj = (window as any).chrome;
const csi = chromeObj && typeof chromeObj.csi === 'function' ? chromeObj.csi() : null;
const loadTimes =
chromeObj && typeof chromeObj.loadTimes === 'function' ? chromeObj.loadTimes() : null;
return {
hasChrome: !!chromeObj,
hasApp: !!(chromeObj && chromeObj.app),
appInstalled: chromeObj?.app?.isInstalled,
appRunningState: chromeObj?.app?.runningState?.(),
hasCsi: typeof chromeObj?.csi === 'function',
hasLoadTimes: typeof chromeObj?.loadTimes === 'function',
csiHasOnloadT: csi && typeof csi.onloadT === 'number',
csiHasPageT: csi && typeof csi.pageT === 'number',
loadTimesHasRequestTime: loadTimes && typeof loadTimes.requestTime === 'number',
loadTimesHasConnectionInfo: loadTimes && typeof loadTimes.connectionInfo === 'string',
};
});
expect(signals.hasChrome).toBe(true);
expect(signals.hasApp).toBe(true);
expect(signals.appInstalled).toBe(false);
expect(signals.appRunningState).toBe('cannot_run');
expect(signals.hasCsi).toBe(true);
expect(signals.hasLoadTimes).toBe(true);
expect(signals.csiHasOnloadT).toBe(true);
expect(signals.csiHasPageT).toBe(true);
expect(signals.loadTimesHasRequestTime).toBe(true);
expect(signals.loadTimesHasConnectionInfo).toBe(true);
});
it('spoofs high-signal media codec probes', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const codecs = await browser.getPage().evaluate(() => {
const video = document.createElement('video');
const audio = document.createElement('audio');
return {
mp4Avc: video.canPlayType('video/mp4; codecs="avc1.42E01E"'),
xM4a: audio.canPlayType('audio/x-m4a;'),
aac: audio.canPlayType('audio/aac'),
};
});
expect(codecs.mp4Avc).toBe('probably');
expect(codecs.xM4a).toBe('maybe');
expect(codecs.aac).toBe('probably');
});
it('patches srcdoc iframe.contentWindow probes', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const iframeSignals = await browser.getPage().evaluate(() => {
const iframe = document.createElement('iframe');
iframe.srcdoc = '<!doctype html><html><body>ok</body></html>';
const win = iframe.contentWindow;
return {
hasContentWindow: !!win,
selfEqualsWindow: win ? win.self === win : false,
selfEqualsTop: win ? win.self === window.top : null,
frameElementMatches: win ? win.frameElement === iframe : false,
zeroSlotType: typeof (win as any)?.[0],
};
});
expect(iframeSignals.hasContentWindow).toBe(true);
expect(iframeSignals.selfEqualsWindow).toBe(true);
expect(iframeSignals.selfEqualsTop).toBe(false);
expect(iframeSignals.frameElementMatches).toBe(true);
expect(iframeSignals.zeroSlotType).toBe('undefined');
});
it('sanitizes Playwright sourceURL markers in error stacks', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const stacks = await browser.getPage().evaluate(() => {
const explicitEvalStack = eval(
`(() => { try { throw new Error('explicit'); } catch (error) { return String(error.stack || ''); } })()\n//# sourceURL=__playwright_evaluation_script__`
);
let directStack = '';
try {
throw new Error('direct');
} catch (error) {
directStack = String((error as Error).stack || '');
}
return { explicitEvalStack, directStack };
});
expect(stacks.explicitEvalStack).not.toContain('__playwright_evaluation_script__');
expect(stacks.explicitEvalStack).not.toContain('__puppeteer_evaluation_script__');
expect(stacks.explicitEvalStack).not.toContain('sourceURL=');
expect(stacks.directStack).not.toContain('__playwright_evaluation_script__');
expect(stacks.directStack).not.toContain('__puppeteer_evaluation_script__');
});
it('sanitizes sourceURL markers in direct CDP Runtime.evaluate payloads', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const cdp = await browser.getCDPSession();
const response = await cdp.send('Runtime.evaluate', {
expression:
"(() => { throw new Error('cdp'); })()\\n//# sourceURL=__playwright_evaluation_script__",
returnByValue: true,
});
const raw = JSON.stringify(response);
expect(raw).not.toContain('__playwright_evaluation_script__');
expect(raw).not.toContain('__puppeteer_evaluation_script__');
expect(raw).not.toContain('sourceURL=');
});
it('doctor reports CDP sourceURL probe as pass in launched chromium sessions', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const report = await browser.runDoctor();
const check = report.checks.find((entry) => entry.name === 'cdp:sourceurl-sanitized');
expect(check).toBeDefined();
expect(check?.status).toBe('pass');
});
it('doctor marks plugin handshake context as skip outside CDP mode', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const report = await browser.runDoctor();
const check = report.checks.find((entry) => entry.name === 'plugin:handshake-context');
expect(check).toBeDefined();
expect(check?.status).toBe('skip');
expect(check?.message).toContain('only applies to CDP');
});
it('exposes contacts manager and content index APIs', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
hasContacts: 'contacts' in navigator,
contactsManagerCtor: typeof (window as any).ContactsManager === 'function',
hasContentIndexCtor: typeof (window as any).ContentIndex === 'function',
hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined',
hasContentIndexOnSWR:
typeof ServiceWorkerRegistration !== 'undefined' &&
('contentIndex' in ServiceWorkerRegistration.prototype ||
'index' in ServiceWorkerRegistration.prototype),
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null,
screenMatchesViewport:
screen.width === window.innerWidth && screen.height === window.innerHeight,
}));
expect(signals.hasContacts).toBe(true);
expect(signals.contactsManagerCtor).toBe(true);
expect(signals.hasContentIndexCtor).toBe(true);
if (signals.hasServiceWorkerRegistration) {
expect(signals.hasContentIndexOnSWR).toBe(true);
}
expect(signals.notificationPermission).toBe('default');
expect(signals.screenMatchesViewport).toBe(false);
});
it('exposes downlinkMax inside dedicated workers', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const workerSignals = await browser.getPage().evaluate(async () => {
return new Promise<{
hasConnection: boolean;
hasDownlinkMax: boolean;
hasDownlinkMaxOnProto: boolean;
downlinkMax: unknown;
}>((resolve) => {
const source =
"postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});";
const blob = new Blob([source], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = (event) => resolve(event.data);
});
});
expect(workerSignals.hasConnection).toBe(true);
expect(workerSignals.hasDownlinkMax).toBe(true);
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
expect(typeof workerSignals.downlinkMax).toBe('number');
});
it('skips worker wrapping for cross-origin blob URLs', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => {
const nativeCreateObjectURL = URL.createObjectURL;
const nativeRevokeObjectURL = URL.revokeObjectURL;
let createCalls = 0;
let revokeCalls = 0;
(URL as any).createObjectURL = (...args: unknown[]) => {
createCalls += 1;
return nativeCreateObjectURL.apply(URL, args as [Blob | MediaSource]);
};
(URL as any).revokeObjectURL = (...args: unknown[]) => {
revokeCalls += 1;
return nativeRevokeObjectURL.apply(URL, args as [string]);
};
try {
new Worker('blob:https://challenges.cloudflare.com/11111111-1111-1111-1111-111111111111');
} catch {}
(URL as any).createObjectURL = nativeCreateObjectURL;
(URL as any).revokeObjectURL = nativeRevokeObjectURL;
return { createCalls, revokeCalls };
});
expect(signals.createCalls).toBe(0);
expect(signals.revokeCalls).toBe(0);
});
});
+1736
View File
File diff suppressed because it is too large Load Diff
+65 -1
View File
@@ -6,6 +6,15 @@ export interface BaseCommand {
action: string;
}
export type RiskMode = 'off' | 'warn' | 'block';
export interface RiskSignal {
code: string;
source: 'url' | 'title';
evidence: string;
confidence: number;
}
// Action-specific command types
export interface LaunchCommand extends BaseCommand {
action: 'launch';
@@ -18,7 +27,6 @@ export interface LaunchCommand extends BaseCommand {
cdpUrl?: string;
autoConnect?: boolean; // Auto-discover and connect to running Chrome via DevToolsActivePort
extensions?: string[];
profile?: string; // Path to persistent browser profile directory
storageState?: string; // Path to storage state JSON file
proxy?: {
server: string;
@@ -33,6 +41,8 @@ export interface LaunchCommand extends BaseCommand {
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
tabGroup?: string; // Base tab-group title (session suffix is appended automatically)
tabGroupPluginId?: string; // Expected Chrome extension ID for CDP tab-group handshake
allowedDomains?: string[];
actionPolicy?: string;
confirmActions?: string[];
@@ -45,6 +55,8 @@ export interface NavigateCommand extends BaseCommand {
url: string;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
headers?: Record<string, string>;
// off: skip detection/retry, warn: retry then return warning+riskSignals, block: fail fast
riskMode?: RiskMode;
}
export interface ClickCommand extends BaseCommand {
@@ -832,6 +844,7 @@ export interface WaitCommand extends BaseCommand {
action: 'wait';
selector?: string;
timeout?: number;
timeoutMax?: number; // When set with timeout, waits a random duration in [timeout, timeoutMax]
state?: 'attached' | 'detached' | 'visible' | 'hidden';
}
@@ -864,6 +877,10 @@ export interface CloseCommand extends BaseCommand {
action: 'close';
}
export interface DoctorCommand extends BaseCommand {
action: 'doctor';
}
// Tab/Window commands
export interface TabNewCommand extends BaseCommand {
action: 'tab_new';
@@ -918,6 +935,7 @@ export type Command =
| HoverCommand
| ContentCommand
| CloseCommand
| DoctorCommand
| TabNewCommand
| TabListCommand
| TabSwitchCommand
@@ -1123,6 +1141,9 @@ export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
export interface NavigateData {
url: string;
title: string;
warning?: string;
// Structured evidence emitted when verification/captcha patterns are detected.
riskSignals?: RiskSignal[];
}
export interface Annotation {
@@ -1281,6 +1302,49 @@ export interface DiffUrlData {
screenshot?: DiffScreenshotData;
}
export type DoctorCheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
export interface DoctorCheck {
name: string;
status: DoctorCheckStatus;
message: string;
details?: Record<string, unknown>;
}
export interface DoctorData {
ok: boolean;
checks: DoctorCheck[];
context: {
launched: boolean;
connectionKind: string;
cdpEndpoint?: string | null;
session: string;
};
cdp: {
preferredPort: number;
discovered: Array<{
port: number;
status: DoctorCheckStatus;
wsUrl?: string | null;
source: 'preferred-port' | 'common-port' | 'devtools-active-port';
note?: string;
}>;
devToolsActivePort: Array<{
userDataDir: string;
status: DoctorCheckStatus;
port?: number;
wsPath?: string;
}>;
};
plugin: {
configuredPluginId: string;
status: DoctorCheckStatus;
mode: 'cdp' | 'non-cdp' | 'not-launched';
message: string;
extensionId?: string;
};
}
// Browser state
export interface BrowserState {
browser: Browser | null;
+2 -2
View File
@@ -146,9 +146,9 @@ describe('File Access (Issue #345)', () => {
const content = await page.locator('h1').textContent();
expect(content).toBe('Test File Access');
// Verify webdriver is hidden (from custom arg)
// Verify webdriver is hidden under stealth defaults
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
expect(webdriver).toBeUndefined();
});
});
});
+7 -7
View File
@@ -11,7 +11,7 @@ describe('Launch Options', () => {
});
describe('browser args', () => {
it('should launch with custom args to disable webdriver detection', async () => {
it('should keep webdriver undefined with custom args under stealth defaults', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
@@ -21,9 +21,9 @@ describe('Launch Options', () => {
const page = browser.getPage();
await page.goto('about:blank');
// Check that navigator.webdriver is false
// Under stealth defaults, webdriver is hidden (undefined)
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
expect(webdriver).toBeUndefined();
});
it('should launch with multiple args', async () => {
@@ -39,7 +39,7 @@ describe('Launch Options', () => {
expect(browser.isLaunched()).toBe(true);
});
it('should launch without args (default behavior)', async () => {
it('should launch without args and keep webdriver hidden by default', async () => {
browser = new BrowserManager();
await browser.launch({
headless: true,
@@ -48,9 +48,9 @@ describe('Launch Options', () => {
const page = browser.getPage();
await page.goto('about:blank');
// Default Playwright behavior - webdriver is true
// Stealth default behavior - webdriver is hidden
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(true);
expect(webdriver).toBeUndefined();
});
});
@@ -152,7 +152,7 @@ describe('Launch Options', () => {
// Verify webdriver is hidden
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
expect(webdriver).toBeUndefined();
});
});
});