Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2e594003 | ||
|
|
9bd6587278 | ||
|
|
df53b1a70e | ||
|
|
a6f0193779 | ||
|
|
bab58991fe | ||
|
|
c5d4c8908d | ||
|
|
9ac8bae981 | ||
|
|
3302762a32 | ||
|
|
73cf32edc8 | ||
|
|
85fd019f62 | ||
|
|
9b4d924e48 | ||
|
|
9ad011d93c | ||
|
|
ebd220274b | ||
|
|
7a4559ac96 | ||
|
|
bc9622994e | ||
|
|
5d202c06a6 | ||
|
|
0966c630a7 | ||
|
|
d1f574013d | ||
|
|
c3b8855252 | ||
|
|
a9ff0a3fea | ||
|
|
af50605a3b | ||
|
|
9b1f98b966 | ||
|
|
cf4c27d13d | ||
|
|
372eaf2ef6 | ||
|
|
dcefc729e8 | ||
|
|
f4a8f79a22 | ||
|
|
6cf74817d8 | ||
|
|
14ffd30417 | ||
|
|
17686fdbf8 | ||
|
|
22532d756c | ||
|
|
68e2e351b1 | ||
|
|
d95d32831e | ||
|
|
1a4c440d9e | ||
|
|
d1fbdaadeb | ||
|
|
839aaa5586 | ||
|
|
a7f9c24fdb | ||
|
|
42ade7b4e8 | ||
|
|
2dabed973e | ||
|
|
dd2deff06c | ||
|
|
340886293a | ||
|
|
fc1699a526 | ||
|
|
4bcfe74514 | ||
|
|
bb41c24c08 | ||
|
|
75bd1d21a7 | ||
|
|
06c75af46a | ||
|
|
312bb0d65b | ||
|
|
cff003c333 | ||
|
|
f2b0c2ea9b | ||
|
|
ea58bce19e | ||
|
|
afb68ded93 | ||
|
|
a6631cd7d8 | ||
|
|
4f630e29ad | ||
|
|
d232763ff7 | ||
|
|
85f4635358 | ||
|
|
726e9d4ea3 | ||
|
|
ff8a340269 | ||
|
|
c1fa237183 | ||
|
|
f6b21461e9 | ||
|
|
e8ef57bf00 | ||
|
|
9efcb56651 | ||
|
|
091a4ec02e | ||
|
|
6c0f5cbaa1 | ||
|
|
0d72e0d889 | ||
|
|
528de4230f | ||
|
|
7f672494c1 | ||
|
|
8a8106ad75 | ||
|
|
f9cc31d003 | ||
|
|
a7a3f924b0 | ||
|
|
7572c34229 | ||
|
|
06f5f9e8f1 | ||
|
|
5f50ca075c | ||
|
|
e7548c3eb5 | ||
|
|
b77a1e4568 | ||
|
|
7c499885e5 | ||
|
|
fc2621559b | ||
|
|
8b55c553e6 | ||
|
|
6b99d304b1 | ||
|
|
900a5b5cde | ||
|
|
ab9b8d96ca | ||
|
|
5c734c51b6 | ||
|
|
dc54855784 | ||
|
|
ed61be3359 | ||
|
|
54b61f4375 | ||
|
|
9ae82d620e | ||
|
|
8f67cff3e1 | ||
|
|
e70d841a94 | ||
|
|
6032deabd5 | ||
|
|
27dff19105 | ||
|
|
21d591ee65 | ||
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 | ||
|
|
6f4e63ba91 | ||
|
|
98622a7415 | ||
|
|
3d032f9e88 | ||
|
|
d027659571 | ||
|
|
44b6218ef9 | ||
|
|
e93acc68f8 | ||
|
|
d2a33cc005 | ||
|
|
c26afbaba6 | ||
|
|
ffb386e3af | ||
|
|
947d150561 | ||
|
|
06a29251a2 | ||
|
|
0eacec9b9f | ||
|
|
7159012173 | ||
|
|
1b3d41e579 | ||
|
|
dbf272ced7 |
@@ -15,6 +15,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Check version sync
|
||||
run: node scripts/check-version-sync.js
|
||||
|
||||
@@ -44,35 +49,12 @@ jobs:
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
|
||||
dashboard:
|
||||
name: Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter dashboard
|
||||
working-directory: packages/dashboard
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm build
|
||||
working-directory: packages/dashboard
|
||||
|
||||
rust-cross:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Fail fast on a hung test instead of running to GitHub's 6h default.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -105,6 +87,13 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
# Fail fast on a hung e2e test instead of GitHub's 6h default.
|
||||
timeout-minutes: 30
|
||||
# This fork forbids headless by default (always-headed for stealth), but CI
|
||||
# runners have no display. Opt into the documented display-less escape so
|
||||
# launched Chrome can start; e2e tests exercise functionality, not stealth.
|
||||
env:
|
||||
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -132,6 +121,10 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
# Headless-forbidden fork on a headless CI runner — opt into the escape so
|
||||
# `agent-browser open` can launch Chrome.
|
||||
env:
|
||||
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -171,7 +164,10 @@ jobs:
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe open https://example.com
|
||||
# --launch: spawn a standalone browser. Without it, `open` defaults to
|
||||
# auto-connect and looks for an existing Chrome on a debug port — which
|
||||
# a fresh CI runner doesn't have, so it errors "Could not connect".
|
||||
bin/agent-browser-win32-x64.exe --launch open https://example.com
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||
Write-Host "--- Taking snapshot ---"
|
||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||
@@ -209,7 +205,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -253,17 +249,23 @@ jobs:
|
||||
echo "Symlink correctly points to native binary"
|
||||
shell: bash
|
||||
|
||||
- name: Verify shim points to native binary (Windows)
|
||||
- name: Verify CLI works (and prefers the native shim) (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
$shimPath = "$(npm prefix -g)\agent-browser.cmd"
|
||||
$content = Get-Content $shimPath -Raw
|
||||
echo "Shim path: $shimPath"
|
||||
# The CLI must work. The native-shim rewrite is a best-effort speedup
|
||||
# (npm often creates the .cmd AFTER postinstall runs, so the rewrite
|
||||
# can't happen and the JS wrapper — which spawns the native binary — is
|
||||
# the valid fallback). Require functionality; prefer, but don't require,
|
||||
# the native shim.
|
||||
$ver = agent-browser --version
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "agent-browser --version failed"; exit 1 }
|
||||
echo "CLI version: $ver"
|
||||
$content = Get-Content "$(npm prefix -g)\agent-browser.cmd" -Raw
|
||||
echo "Shim content:"
|
||||
echo $content
|
||||
if ($content -notmatch "agent-browser-win32-x64\.exe") {
|
||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
||||
exit 1
|
||||
if ($content -match "agent-browser-win32-x64\.exe") {
|
||||
echo "OK: shim points directly to the native binary (zero overhead)"
|
||||
} else {
|
||||
echo "INFO: shim uses the JS wrapper fallback (functional; native-shim optimization not applied)"
|
||||
}
|
||||
echo "Shim correctly points to native binary"
|
||||
shell: pwsh
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Release binaries
|
||||
|
||||
# Build per-platform binaries and attach them to the GitHub Release for the
|
||||
# pushed tag. No npm, no tokens — only the built-in GITHUB_TOKEN. Consumers
|
||||
# install with: curl -fsSL .../install.sh | sh
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing tag to (re)build binaries for, e.g. v0.27.0-fork.12'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency: release-binaries-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: Linux x64, os: ubuntu-latest, target: x86_64-unknown-linux-gnu, asset: agent-browser-linux-x64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-gnu, asset: agent-browser-linux-arm64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux musl x64, os: ubuntu-latest, target: x86_64-unknown-linux-musl, asset: agent-browser-linux-musl-x64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux musl ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-musl, asset: agent-browser-linux-musl-arm64, use_zigbuild: true, ext: '' }
|
||||
- { name: Windows x64, os: ubuntu-latest, target: x86_64-pc-windows-gnu, asset: agent-browser-win32-x64, use_zigbuild: false, ext: '.exe' }
|
||||
- { name: macOS x64, os: macos-latest, target: x86_64-apple-darwin, asset: agent-browser-darwin-x64, use_zigbuild: false, ext: '' }
|
||||
- { name: macOS ARM64, os: macos-latest, target: aarch64-apple-darwin, asset: agent-browser-darwin-arm64, use_zigbuild: false, ext: '' }
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu mingw-w64
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: |
|
||||
pip3 install ziglang
|
||||
cargo install cargo-zigbuild
|
||||
|
||||
- name: Configure Rust linkers
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
mkdir -p ~/.cargo
|
||||
cat >> ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build (zigbuild)
|
||||
if: matrix.use_zigbuild
|
||||
run: cargo zigbuild --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Build (cargo)
|
||||
if: '!matrix.use_zigbuild'
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (.tar.gz + .sha256)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
src="cli/target/${{ matrix.target }}/release/agent-browser${{ matrix.ext }}"
|
||||
# The binary inside every archive is named `agent-browser` (or .exe);
|
||||
# install.sh extracts that fixed name regardless of platform.
|
||||
cp "$src" "dist/agent-browser${{ matrix.ext }}"
|
||||
chmod +x "dist/agent-browser${{ matrix.ext }}" || true
|
||||
( cd dist
|
||||
tar czf "${{ matrix.asset }}.tar.gz" "agent-browser${{ matrix.ext }}"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
|
||||
else
|
||||
shasum -a 256 "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}.tar.gz*
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
name: Attach binaries to GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: List assets
|
||||
run: ls -la dist
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.tar.gz.sha256
|
||||
fail_on_unmatched_files: true
|
||||
# keep existing release notes if the release was created beforehand
|
||||
append_body: false
|
||||
@@ -1,331 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
needs_github_release: ${{ steps.check.outputs.needs_github_release }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compare package.json version to npm and check GitHub release
|
||||
id: check
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0")
|
||||
echo "npm version: $NPM_VERSION"
|
||||
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_github_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version unchanged on npm, skipping build and publish"
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Check if GitHub release exists; it may be missing if a prior run
|
||||
# published to npm but failed before creating the release.
|
||||
TAG="v$LOCAL_VERSION"
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "GitHub release $TAG exists"
|
||||
echo "needs_github_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "GitHub release $TAG is missing, will rebuild and create it"
|
||||
echo "needs_github_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true' || needs.check-release.outputs.needs_github_release == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Linux x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-arm64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-arm64
|
||||
use_zigbuild: true
|
||||
- name: Windows x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
binary: agent-browser-win32-x64.exe
|
||||
use_zigbuild: false
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: agent-browser-darwin-x64
|
||||
use_zigbuild: false
|
||||
- name: macOS ARM64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
use_zigbuild: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Sync version
|
||||
run: pnpm run version:sync
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm --filter dashboard build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu mingw-w64
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: |
|
||||
pip3 install ziglang
|
||||
cargo install cargo-zigbuild
|
||||
|
||||
- name: Configure Rust linkers
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
mkdir -p ~/.cargo
|
||||
cat >> ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build with zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: cargo zigbuild --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Build with cargo
|
||||
if: '!matrix.use_zigbuild'
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Copy binary
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser.exe artifacts/${{ matrix.binary }}
|
||||
else
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser artifacts/${{ matrix.binary }}
|
||||
chmod +x artifacts/${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.binary }}
|
||||
path: artifacts/${{ matrix.binary }}
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build-binaries]
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: Release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download all binary artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
echo "Binaries in bin/:"
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify all binaries exist
|
||||
run: |
|
||||
EXPECTED_BINARIES=(
|
||||
"agent-browser-linux-x64"
|
||||
"agent-browser-linux-arm64"
|
||||
"agent-browser-linux-musl-x64"
|
||||
"agent-browser-linux-musl-arm64"
|
||||
"agent-browser-win32-x64.exe"
|
||||
"agent-browser-darwin-x64"
|
||||
"agent-browser-darwin-arm64"
|
||||
)
|
||||
MIN_SIZE=100000
|
||||
ERRORS=0
|
||||
for binary in "${EXPECTED_BINARIES[@]}"; do
|
||||
if [ ! -f "bin/$binary" ]; then
|
||||
echo "ERROR: Missing bin/$binary"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
SIZE=$(stat -c%s "bin/$binary" 2>/dev/null || stat -f%z "bin/$binary")
|
||||
if [ "$SIZE" -lt "$MIN_SIZE" ]; then
|
||||
echo "ERROR: bin/$binary is too small ($SIZE bytes, expected >= $MIN_SIZE)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "OK: bin/$binary ($SIZE bytes)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "Error: $ERRORS binary issues found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All 7 platform binaries present and valid"
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [check-release, build-binaries, publish]
|
||||
if: always() && needs.build-binaries.result == 'success' && needs.check-release.outputs.needs_github_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify binaries exist
|
||||
run: |
|
||||
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
|
||||
if [ "$BINARY_COUNT" -lt 7 ]; then
|
||||
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "Found $BINARY_COUNT binaries"
|
||||
|
||||
- name: Extract changelog entry
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted release notes for $VERSION ($LINES lines)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "Release $TAG already exists, uploading assets..."
|
||||
gh release upload "$TAG" bin/agent-browser-* --clobber
|
||||
else
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
bin/agent-browser-*
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -38,6 +38,10 @@ __pycache__/
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# ...but these are real repo assets, not test artifacts — keep them tracked
|
||||
!assets/*.png
|
||||
!extensions/ab-connect/icons/*.png
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -67,3 +71,7 @@ docs/package-lock.json
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
# extension signing key (never commit) + local-only id record
|
||||
.secrets/
|
||||
*.pem
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -1,11 +1,46 @@
|
||||
# agent-browser-stealth
|
||||
|
||||
**English** · [简体中文](README.zh.md)
|
||||
|
||||

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

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

|
||||
|
||||
Each `--session` gets its **own colored Chrome tab group**, so multiple agents
|
||||
can share one real browser concurrently without stepping on each other — or your
|
||||
own tabs.
|
||||
|
||||
## Why the extension (not a raw debug port)
|
||||
|
||||
Other local tools drive Chrome over a raw `--remote-debugging-port` (CDP). Since
|
||||
**Chrome 136**, every such connection pops a blocking **"Allow remote debugging?"**
|
||||
consent dialog — and the port has to be enabled up front. Our extension uses
|
||||
native messaging instead: **install once, then zero per-use confirmation.**
|
||||
|
||||
| | **agent-browser-stealth** (this extension) | web-access (raw CDP port) | Claude in Chrome (chrome.debugger) |
|
||||
|---|---|---|---|
|
||||
| Connect method | native messaging — no port, no token | `--remote-debugging-port` | `chrome.debugger` |
|
||||
| **"Allow remote debugging?" popup** | **never** ✅ | **every connection** 🔴 | no |
|
||||
| Uses your real login | yes | yes | yes |
|
||||
| `Runtime.enable` (CDP) leak¹ | **off by default → clean** ✅ | domain enabled | n/a |
|
||||
| CreepJS stealth score² | **0% stealth · 0% headless** ✅ | real Chrome | real Chrome |
|
||||
| Per-session tab groups / concurrent agents | **yes** ✅ | no | no |
|
||||
| Built for the agent-browser CLI | yes | a separate proxy | a single-app assistant |
|
||||
|
||||
> ¹ Verified against [rebrowser-bot-detector](https://bot-detector.rebrowser.net/):
|
||||
> our relay reports `runtimeEnableLeak: 🟢 No leak` and `navigatorWebdriver: 🟢`.
|
||||
> ² Verified against [CreepJS](https://abrahamjuliot.github.io/creepjs/) on the
|
||||
> connected real-Chrome path — see [Anti-detection](#anti-detection).
|
||||
>
|
||||
> The consent dialog isn't hypothetical: a raw-port tool pops it on **every**
|
||||
> attach (Chrome 136+ security). The extension path never does.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||
```
|
||||
|
||||
Downloads the prebuilt binary for your platform from the latest [GitHub Release](https://github.com/leeguooooo/agent-browser-stealth/releases) and installs `agent-browser` (+ the `abs` alias). No npm, no tokens.
|
||||
|
||||
<details>
|
||||
<summary>Other ways to install</summary>
|
||||
|
||||
- **Pin a version:** `AGENT_BROWSER_VERSION=v0.27.0-fork.12 curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh`
|
||||
- **Custom location:** `AGENT_BROWSER_BIN_DIR=$HOME/bin curl -fsSL … | sh`
|
||||
- **Windows:** download `agent-browser-win32-x64.tar.gz` from the [Releases page](https://github.com/leeguooooo/agent-browser-stealth/releases) and put `agent-browser.exe` on your PATH.
|
||||
- **npm (legacy):** `npm install -g agent-browser-stealth` — still published, but GitHub Releases is the primary channel now.
|
||||
</details>
|
||||
|
||||
### Install the AI agent skills
|
||||
|
||||
The repo ships SKILL.md files for Claude Code, Cursor, etc. Pull them into the current project with [skills.sh](https://skills.sh):
|
||||
@@ -34,14 +121,54 @@ npx skills add leeguooooo/agent-browser-stealth
|
||||
|
||||
This drops `skills/agent-browser` (and the specialized `skill-data/{core,electron,slack,dogfood,agentcore,vercel-sandbox}`) into your project so your AI agent gets the right usage patterns and pre-approved bash permissions for `agent-browser`, `agent-browser-stealth`, and `abs`.
|
||||
|
||||
## Setup (one time)
|
||||
## Command names
|
||||
|
||||
Enable Chrome DevTools Protocol in your Chrome:
|
||||
`agent-browser`, `agent-browser-stealth`, and `abs` are **the same binary** —
|
||||
`abs` is just a short alias. There is no separate "stealth executable"; stealth
|
||||
is a runtime behavior (see [Anti-detection](#anti-detection) below), applied
|
||||
automatically based on whether you attach to your real Chrome or `--launch` a
|
||||
fresh one.
|
||||
|
||||
1. Open `chrome://inspect/#remote-debugging` in Chrome
|
||||
2. Toggle the switch on
|
||||
## Setup: connect to your Chrome
|
||||
|
||||
That's it. This setting persists across Chrome restarts.
|
||||
**Recommended — the browser extension (one click, no popups).** Install the
|
||||
[**agent-browser-stealth** extension from the Chrome Web Store](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),
|
||||
then register the local bridge once:
|
||||
|
||||
```bash
|
||||
agent-browser extension install # register the native-messaging host (one-time)
|
||||
agent-browser open https://x.com/home
|
||||
```
|
||||
|
||||
`agent-browser open` then drives your real, logged-in Chrome over **native
|
||||
messaging** — no debug port, no token, and **no "Allow remote debugging?" dialog,
|
||||
ever**. The extension auto-updates and survives Chrome restarts, so it stays
|
||||
connected with zero per-use confirmation (ideal for unattended/agent use).
|
||||
|
||||
<details>
|
||||
<summary>Alternative — raw remote-debugging port (pops a consent dialog)</summary>
|
||||
|
||||
Without the extension, agent-browser attaches over the Chrome DevTools Protocol,
|
||||
which Chrome only exposes when **launched with a remote-debugging port** (a
|
||||
startup flag — the `chrome://inspect` toggle alone is not enough):
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open -a "Google Chrome" --args --remote-debugging-port=9222
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222
|
||||
# Windows: add --remote-debugging-port=9222 to your Chrome shortcut's target
|
||||
```
|
||||
|
||||
Then `agent-browser open <url>` auto-discovers the port. On first attach,
|
||||
**Chrome 136+ shows an "Allow remote debugging?" dialog** — click Allow once (it
|
||||
persists for that Chrome session). The extension above avoids this entirely.
|
||||
</details>
|
||||
|
||||
**No setup / don't want to touch your real Chrome?** Use
|
||||
`agent-browser --launch open <url>` to spawn a fresh isolated stealth browser
|
||||
(full anti-detection patches applied; see below). This always works without any
|
||||
port setup and is what CI uses automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -57,31 +184,71 @@ agent-browser screenshot ./page.png
|
||||
|
||||
The agent operates in your Chrome — you'll see tabs opening, pages loading, clicks happening in real time. You can take over at any point (e.g. solve a CAPTCHA), then let the agent continue.
|
||||
|
||||
### Standalone mode
|
||||
### Standalone mode (`--launch`)
|
||||
|
||||
If you need a separate browser (CI, testing, etc.):
|
||||
Spawn a separate browser instead of attaching to your running Chrome:
|
||||
|
||||
```bash
|
||||
# Throwaway: fresh, EMPTY profile — no cookies, no login (good for CI/testing)
|
||||
agent-browser --launch open https://example.com
|
||||
|
||||
# Keep your login: launch with your real Chrome profile (cookies/sessions intact)
|
||||
agent-browser --launch --profile auto open https://x.com/home
|
||||
# or name it explicitly: --profile Default / --profile "Profile 1"
|
||||
```
|
||||
|
||||
> ⚠️ Plain `--launch` (no `--profile`) uses a **temporary empty profile** — you will
|
||||
> NOT be logged into anything. For logged-in sites use `--profile auto` (picks the
|
||||
> Chrome profile you used most recently) or `--profile <name>`. agent-browser prints
|
||||
> a warning when you `--launch` without a profile.
|
||||
|
||||
In CI environments, standalone mode is used automatically.
|
||||
|
||||
## Anti-detection
|
||||
|
||||
When connected to your real Chrome, we inject **zero** JavaScript patches. Your browser's fingerprint is completely genuine.
|
||||
<img src="assets/shield.png" alt="stealth shield" width="320" align="right" />
|
||||
|
||||
The only thing we do is call `Emulation.setAutomationOverride` via CDP to set `navigator.webdriver = false` at the native Chrome level — undetectable by lie-detection systems like CreepJS.
|
||||
When connected to your real Chrome, we inject **zero** JavaScript patches. Your browser's fingerprint is completely genuine. The guiding rule is **native CDP/Chrome overrides over JS lies** — a re-defined getter is itself detectable; a native override isn't.
|
||||
|
||||
- `navigator.webdriver = false` via `Emulation.setAutomationOverride` (native, undetectable by CreepJS-style lie tests).
|
||||
- **`Runtime.enable` is left OFF by default.** A live `Runtime` domain is a detectable CDP signal (the patchright/rebrowser "runtime leak") — even when attached to your real Chrome. We only enable it when you opt into console/error capture (see below). `click`, `fill`, `eval`, etc. work without it.
|
||||
|
||||
**Test results (connected to real Chrome):**
|
||||
|
||||
| Test site | Result |
|
||||
|---|---|
|
||||
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | 0% stealth, 0% headless |
|
||||
| [bot.sannysoft.com](https://bot.sannysoft.com) | All green |
|
||||
| [Cloudflare Turnstile](https://nowsecure.nl) | Passed |
|
||||
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | **0% stealth · 0% headless** (no override traces at all) |
|
||||
| [bot.incolumitas.com](https://bot.incolumitas.com/) | all checks OK — `overflowTest`, `overrideTest`, `puppeteerExtraStealthUsed`, worker consistency |
|
||||
| [bot.sannysoft.com](https://bot.sannysoft.com) | all green |
|
||||
| [BrowserScan](https://www.browserscan.net/bot-detection) | Webdriver · User-Agent · CDP all clean |
|
||||
| [Cloudflare Turnstile](https://nowsecure.nl) | passed |
|
||||
|
||||
When using `--launch` mode (standalone browser), a full suite of 32 stealth patches is applied for headless Chrome.
|
||||
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
|
||||
|
||||
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
||||
|
||||
### Verify it yourself
|
||||
|
||||
Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
|
||||
|
||||
- **[CreepJS](https://abrahamjuliot.github.io/creepjs/)** — the most thorough fingerprint / lie detector
|
||||
- **[bot.incolumitas.com](https://bot.incolumitas.com/)** — behavioral + fingerprint scoring with a public methodology
|
||||
- **[BrowserScan](https://www.browserscan.net/bot-detection)** — Webdriver / User-Agent / CDP / Navigator
|
||||
- **[bot.sannysoft.com](https://bot.sannysoft.com)** — the classic automation-marker checklist
|
||||
- **[pixelscan.net](https://pixelscan.net/)** · **[iphey.com](https://iphey.com/)** — consistency & identity
|
||||
|
||||
We deliberately **don't ship our own bot detector** — the strongest, most honest benchmark is the market's best detectors run against your real browser.
|
||||
|
||||
### Tuning knobs (environment variables)
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_CAPTURE_CONSOLE` | off | Enable `Runtime` domain so `console` / `errors` capture page output. Off keeps the stealthiest profile. |
|
||||
| `AGENT_BROWSER_TIMEZONE` | unset | `--launch` only. An IANA id (e.g. `Asia/Tokyo`) sets the timezone natively (Intl + Date follow, no JS lie) to match a proxy; `auto` derives one from the locale. |
|
||||
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | `--launch` only. Auto-forces WebRTC through the proxy when one is set (no real-IP leak). `1` hides the local IP without a proxy; `0` opts out. |
|
||||
| `AGENT_BROWSER_HIDE_CANVAS` | off | `--launch` only. Adds session-stable canvas/audio fingerprint noise. Off by default (noise is itself a "lie"). |
|
||||
| `AGENT_BROWSER_ADAPTIVE_REF` | on | When a saved `@ref` moves and the role/name re-query fails, relocate it by fingerprint similarity (high score + clear margin required, else it fails loudly). `0` disables. |
|
||||
| `AGENT_BROWSER_CLICK_MODE` | _(auto)_ | Click strategy. Default scrolls the target into view, dispatches a coordinate click, and falls back to a DOM `.click()` if a floating layer occludes the point. `dom` always uses `.click()` (best for autocomplete/menu items that close on blur); `coord` is strict coordinate-only (hard-fail on occlusion). |
|
||||
|
||||
## Differences from upstream
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# agent-browser-stealth
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||

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

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

|
||||
|
||||
每个 `--session` 拿到**自己的彩色标签组**,多个 agent 共用同一个真实浏览器、互不干扰,也不动你自己的标签页。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||
```
|
||||
|
||||
从最新的 [GitHub Release](https://github.com/leeguooooo/agent-browser-stealth/releases) 下载对应平台的预编译二进制,安装 `agent-browser`(以及 `abs` 别名)。无需 npm,无需 token。
|
||||
|
||||
### 安装 AI agent skills
|
||||
|
||||
```bash
|
||||
npx skills add leeguooooo/agent-browser-stealth
|
||||
```
|
||||
|
||||
把 `skills/agent-browser` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
|
||||
|
||||
## 连接你的 Chrome
|
||||
|
||||
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**agent-browser-stealth** 扩展](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
|
||||
|
||||
```bash
|
||||
agent-browser extension install # 注册原生消息 host(一次性)
|
||||
agent-browser open https://x.com/home
|
||||
```
|
||||
|
||||
之后 `agent-browser open` 就通过**原生消息**驱动你真实、已登录的 Chrome —— 无调试端口、无 token、**永远不弹 "Allow remote debugging?"**。扩展自动更新、重启不掉,零确认(适合无人值守 / agent 场景)。
|
||||
|
||||
<details>
|
||||
<summary>备选 —— 裸 remote-debugging 端口(会弹同意框)</summary>
|
||||
|
||||
不装扩展时,agent-browser 退回用 CDP 连接,而 Chrome 只在带 remote-debugging 端口启动时才暴露它:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open -a "Google Chrome" --args --remote-debugging-port=9222
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222
|
||||
# Windows: 给 Chrome 快捷方式 target 加 --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
然后 `agent-browser open <url>` 自动发现端口。首次连接 **Chrome 136+ 会弹 "Allow remote debugging?"** —— 点一次 Allow(该 Chrome 会话内持续有效)。上面的扩展则完全避开这个框。
|
||||
</details>
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 连接你的 Chrome 并导航
|
||||
agent-browser open https://example.com
|
||||
|
||||
# 一切都在你已登录的浏览器里进行
|
||||
agent-browser click "Post"
|
||||
agent-browser fill "Title" "Hello World"
|
||||
agent-browser screenshot ./page.png
|
||||
```
|
||||
|
||||
Agent 在你的 Chrome 里操作 —— 你能实时看到开标签、加载、点击。任意时刻都能接管(比如手动过验证码),然后让 agent 继续。
|
||||
|
||||
### 独立模式(`--launch`)
|
||||
|
||||
```bash
|
||||
# 临时:全新空 profile —— 无 cookie 无登录(适合 CI / 测试)
|
||||
agent-browser --launch open https://example.com
|
||||
|
||||
# 保留登录:用你真实的 Chrome profile 启动
|
||||
agent-browser --launch --profile auto open https://x.com/home
|
||||
```
|
||||
|
||||
## 反检测
|
||||
|
||||
连接你真实 Chrome 时,我们**零** JS 注入 —— 浏览器指纹完全是真的。指导原则是 **native CDP/Chrome 覆盖优先于 JS 谎言**:被重定义的 getter 本身可被检测,原生覆盖则不会。
|
||||
|
||||
- `navigator.webdriver = false` 走 `Emulation.setAutomationOverride`(原生,CreepJS 类说谎检测查不出)。
|
||||
- **`Runtime.enable` 默认关闭** —— 活着的 `Runtime` 域是可被检测的 CDP 信号(patchright/rebrowser 的 "runtime leak"),即便连的是你真实 Chrome。只在你主动开启 console/错误捕获时才启用。
|
||||
|
||||
**实测结果(连接真实 Chrome,中继路径):**
|
||||
|
||||
| 检测站 | 结果 |
|
||||
|---|---|
|
||||
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | **0% stealth · 0% headless**(零 override 痕迹) |
|
||||
| [bot.incolumitas.com](https://bot.incolumitas.com/) | 全部 OK(overflowTest / overrideTest / puppeteerExtraStealth / worker 一致性) |
|
||||
| [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) | `runtimeEnableLeak` 🟢 · `pwInitScripts` 🟢 |
|
||||
| [bot.sannysoft.com](https://bot.sannysoft.com) | 全绿 |
|
||||
|
||||
`--launch` 独立模式下会改用一整套隐身补丁,同样过上述检测。
|
||||
|
||||
## 与上游的差异
|
||||
|
||||
基于 [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser):
|
||||
|
||||
- **默认 auto-connect** —— `agent-browser open` 连你的 Chrome 而非启新的
|
||||
- **CDP 原生隐身** —— `Emulation.setAutomationOverride` 而非 JS 补丁
|
||||
- **双隐身模式** —— 真实 Chrome 零补丁,`--launch` 全补丁
|
||||
- **`--launch` / `--new`** —— 显式启动独立浏览器
|
||||
- **CI 自动检测** —— 设了 `CI` 环境变量时走独立模式
|
||||
|
||||
所有上游功能(命令、快照、截图、录制、标签、会话等)保持一致。
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0(与上游一致)
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.5"
|
||||
version = "0.27.0-fork.37"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
@@ -57,6 +57,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"include_dir",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
@@ -1048,6 +1049,25 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.5"
|
||||
version = "0.27.0-fork.37"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
@@ -19,9 +19,10 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
include_dir = "0.7"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "time", "sync", "signal", "process"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
|
||||
@@ -540,7 +540,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
|
||||
// === Wait ===
|
||||
"wait" => {
|
||||
// Check for --url flag: wait --url "**/dashboard"
|
||||
// Check for --url flag: wait --url "**/dashboard" [--timeout ms]
|
||||
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
||||
let url = rest
|
||||
.get(idx + 1)
|
||||
@@ -548,7 +548,23 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
context: "wait --url".to_string(),
|
||||
usage: "wait --url <pattern>",
|
||||
})?;
|
||||
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
|
||||
if url.is_empty() {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: "wait --url needs a non-empty pattern (an empty pattern would \
|
||||
match any URL)."
|
||||
.to_string(),
|
||||
usage: "wait --url <pattern>",
|
||||
});
|
||||
}
|
||||
let mut cmd = json!({ "id": id, "action": "waitforurl", "url": url });
|
||||
// Parse --timeout (without it the default applies — and a
|
||||
// non-matching pattern would otherwise wait the full default).
|
||||
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
|
||||
if let Some(ms) = rest.get(t_idx + 1).and_then(|s| s.parse::<u64>().ok()) {
|
||||
cmd["timeout"] = json!(ms);
|
||||
}
|
||||
}
|
||||
return Ok(cmd);
|
||||
}
|
||||
|
||||
// Check for --load flag: wait --load networkidle
|
||||
@@ -620,7 +636,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// racing into a half-rendered UI.
|
||||
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
||||
Some("detached")
|
||||
} else if rest.iter().any(|&s| s == "--hidden") {
|
||||
} else if rest.contains(&"--hidden") {
|
||||
Some("hidden")
|
||||
} else {
|
||||
None
|
||||
@@ -1066,6 +1082,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// === Get ===
|
||||
"get" => parse_get(&rest, &id),
|
||||
|
||||
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
||||
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value" | "count" | "box"
|
||||
| "styles" | "attr" => {
|
||||
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
||||
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||
get_args.push(sub);
|
||||
get_args.extend_from_slice(&rest);
|
||||
parse_get(&get_args, &id)
|
||||
}
|
||||
|
||||
// === Is (state checks) ===
|
||||
"is" => parse_is(&rest, &id),
|
||||
|
||||
@@ -2751,6 +2779,7 @@ mod tests {
|
||||
provider: None,
|
||||
ignore_https_errors: false,
|
||||
allow_file_access: false,
|
||||
hide_scrollbars: true,
|
||||
device: None,
|
||||
auto_connect: false,
|
||||
force_launch: false,
|
||||
@@ -2766,6 +2795,7 @@ mod tests {
|
||||
cli_proxy: false,
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_hide_scrollbars: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
@@ -3789,6 +3819,29 @@ mod tests {
|
||||
assert_eq!(cmd["url"], "**/dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_url_empty_pattern_rejected() {
|
||||
// An empty pattern would match any URL — reject it rather than silently
|
||||
// always-match. (Build argv directly: split_whitespace can't yield "".)
|
||||
let argv = vec!["wait".to_string(), "--url".to_string(), String::new()];
|
||||
let err = parse_command(&argv, &default_flags());
|
||||
assert!(err.is_err(), "empty --url pattern should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_url_with_timeout() {
|
||||
// --timeout must be parsed for the --url path; without it a non-matching
|
||||
// pattern waits the full default (and could wedge the daemon).
|
||||
let cmd = parse_command(
|
||||
&args("wait --url **/dashboard --timeout 3000"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "waitforurl");
|
||||
assert_eq!(cmd["url"], "**/dashboard");
|
||||
assert_eq!(cmd["timeout"], 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_load() {
|
||||
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
||||
@@ -5163,11 +5216,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_find_role_missing_action_verb_with_name_flag() {
|
||||
let err = parse_command(
|
||||
&args("find role button --name Submit"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err =
|
||||
parse_command(&args("find role button --name Submit"), &default_flags()).unwrap_err();
|
||||
let msg = err.format();
|
||||
assert!(
|
||||
msg.contains("Missing action verb"),
|
||||
@@ -5185,11 +5235,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_find_testid_missing_action_verb_with_exact_flag() {
|
||||
let err = parse_command(
|
||||
&args("find testid foo --exact"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = parse_command(&args("find testid foo --exact"), &default_flags()).unwrap_err();
|
||||
assert!(err.format().contains("Missing action verb"));
|
||||
}
|
||||
|
||||
@@ -5238,11 +5284,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_wait_gone_with_timeout() {
|
||||
let cmd = parse_command(
|
||||
&args("wait .modal --gone --timeout 2000"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd =
|
||||
parse_command(&args("wait .modal --gone --timeout 2000"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["selector"], ".modal");
|
||||
assert_eq!(cmd["state"], "detached");
|
||||
assert_eq!(cmd["timeout"], 2000);
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
//! `agent-browser connect` — zero-confirmation control of the user's real,
|
||||
//! logged-in Chrome via the `ab-connect` MV3 extension over Chrome **native
|
||||
//! messaging** (no localhost port, no token; Chrome authenticates the extension
|
||||
//! to this host by id).
|
||||
//!
|
||||
//! Two pieces live here:
|
||||
//! - `run_connect` — `--install` writes the native-messaging host manifest (and
|
||||
//! a tiny launcher) so Chrome will spawn us; with no flag it reports status.
|
||||
//! - `run_nm_host` — the hidden `__nm-host` mode Chrome launches: it speaks the
|
||||
//! native-messaging stdio framing (4-byte little-endian length + JSON).
|
||||
//!
|
||||
//! This step wires the transport end-to-end (Chrome ⇄ host). Bridging the host
|
||||
//! to the daemon's relay + CdpClient is layered on next.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
|
||||
/// manifest filename.
|
||||
pub const HOST_NAME: &str = "com.agent_browser.connect";
|
||||
|
||||
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
||||
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
|
||||
/// that extension talk to this host, and the force-install policy references it.
|
||||
pub const EXTENSION_ID: &str = "ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||
|
||||
/// The Chrome Web Store assigns its own id (the manifest "key" is stripped from
|
||||
/// store uploads), so the published build has a different origin than the local
|
||||
/// Load-unpacked one. Allow both to talk to the native-messaging host.
|
||||
pub const STORE_EXTENSION_ID: &str = "knfcmbamhjmaonkfnjhldjedeobeafmk";
|
||||
|
||||
/// Update URL the force-install policy points at. MUST be the Chrome Web Store
|
||||
/// endpoint: Chrome 149 tags any **off-Web-Store** force-installed extension
|
||||
/// `[BLOCKED]` on an unmanaged browser (verified on macOS — chrome://policy shows
|
||||
/// `[BLOCKED]…` / "Error, Warning"). Self-hosting a `.crx` therefore does NOT
|
||||
/// work on consumer Chrome; the extension must be published to the Web Store, and
|
||||
/// then this policy force-installs it silently (Web Store extensions are allowed).
|
||||
pub const UPDATE_URL: &str = "https://clients2.google.com/service/update2/crx";
|
||||
|
||||
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
|
||||
/// and the fallback when the force-install profile can't be approved headlessly.
|
||||
pub const STORE_URL: &str =
|
||||
"https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||
|
||||
/// Stable identifiers for the generated Chrome configuration profile, so a
|
||||
/// re-install replaces (rather than duplicates) it in System Settings.
|
||||
const PROFILE_ID: &str = "work.pwtk.agent-browser.ab-connect";
|
||||
const PROFILE_UUID: &str = "A1B2C3D4-AB00-4CCE-9E10-AAAABBBBCCCC";
|
||||
const PROFILE_PAYLOAD_UUID: &str = "A1B2C3D4-AB01-4CCE-9E10-DDDDEEEEFFFF";
|
||||
|
||||
/// `agent-browser extension <install|uninstall|status>` (local; no daemon).
|
||||
/// `args` is the cleaned argv including the leading "extension".
|
||||
pub fn run_connect(args: &[String], json: bool) {
|
||||
let install = args.iter().any(|a| a == "--install" || a == "install");
|
||||
let uninstall = args.iter().any(|a| a == "--uninstall" || a == "uninstall");
|
||||
|
||||
if uninstall {
|
||||
let removed = remove_host_manifests();
|
||||
let profile_removed = remove_force_install_profile();
|
||||
if json {
|
||||
report(
|
||||
json,
|
||||
true,
|
||||
&format!("removed {removed} native-host manifest(s)"),
|
||||
);
|
||||
} else {
|
||||
println!("✓ removed {removed} native-host manifest(s).");
|
||||
if profile_removed {
|
||||
println!("✓ removed ~/.agent-browser/ab-connect.mobileconfig");
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
println!(
|
||||
" To fully remove the extension, delete the \"agent-browser connect\" profile\n\
|
||||
in System Settings → Profiles (or run: profiles remove -identifier {PROFILE_ID})."
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if install {
|
||||
let no_open = args.iter().any(|a| a == "--no-open");
|
||||
match install_native_host() {
|
||||
Ok(paths) => {
|
||||
let profile = install_force_install_profile(no_open);
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"installed": paths,
|
||||
"extensionId": EXTENSION_ID,
|
||||
"profile": profile.as_ref().ok().map(|p| p.display().to_string()),
|
||||
"profileError": profile.as_ref().err(),
|
||||
"updateUrl": UPDATE_URL,
|
||||
}
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("✓ native-messaging host installed:");
|
||||
for p in &paths {
|
||||
println!(" {p}");
|
||||
}
|
||||
match profile {
|
||||
Ok(path) => {
|
||||
println!(
|
||||
"\n✓ Chrome force-install profile written:\n {}",
|
||||
path.display()
|
||||
);
|
||||
if cfg!(target_os = "macos") {
|
||||
println!(
|
||||
"\nGet the extension into Chrome (one-time). Either:\n\
|
||||
A) One click: open {STORE_URL}\n and press \"Add to Chrome\".\n\
|
||||
B) Silent: approve the profile, then restart Chrome —\n \
|
||||
System Settings → General → Device Management → double-click\n \
|
||||
\"agent-browser connect\" → Install. Chrome then force-installs +\n \
|
||||
auto-updates it (no token, no per-use confirmation).\n\
|
||||
Both need the extension published to the Web Store; until then use\n \
|
||||
chrome://extensions → Developer mode → Load unpacked → extensions/ab-connect."
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("\n! could not write the force-install profile: {e}");
|
||||
println!(
|
||||
" Fallback: load extensions/ab-connect via chrome://extensions →\n\
|
||||
Developer mode → Load unpacked."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => report(json, false, &format!("install failed: {e}")),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Status.
|
||||
let manifest = host_manifest_path_for_chrome();
|
||||
let installed = manifest.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"installed": installed,
|
||||
"manifest": manifest.as_ref().map(|p| p.display().to_string()),
|
||||
"extensionId": EXTENSION_ID,
|
||||
}
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else if installed {
|
||||
println!("✓ native-messaging host installed ({HOST_NAME}).");
|
||||
println!(" Load the ab-connect extension and it connects automatically.");
|
||||
} else {
|
||||
println!("✗ not installed. Run: agent-browser connect --install");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the launcher script + native-messaging host manifest(s).
|
||||
fn install_native_host() -> Result<Vec<String>, String> {
|
||||
let home = dirs::home_dir().ok_or("no home dir")?;
|
||||
let ab_dir = home.join(".agent-browser");
|
||||
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
// Chrome execs the manifest `path` directly with the calling extension's
|
||||
// origin as argv[1]; a launcher lets us run the binary in __nm-host mode
|
||||
// regardless of how/where agent-browser is installed.
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let launcher = ab_dir.join("nm-host.sh");
|
||||
let script = format!(
|
||||
"#!/bin/sh\n# agent-browser native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
|
||||
exe.display()
|
||||
);
|
||||
std::fs::write(&launcher, script).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"name": HOST_NAME,
|
||||
"description": "agent-browser connect — native messaging host",
|
||||
"path": launcher.display().to_string(),
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
format!("chrome-extension://{EXTENSION_ID}/"),
|
||||
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
|
||||
],
|
||||
});
|
||||
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut written = Vec::new();
|
||||
for dir in native_messaging_dirs() {
|
||||
if let Some(parent) = dir.parent() {
|
||||
if !parent.exists() {
|
||||
continue; // that browser isn't installed
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
||||
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
|
||||
written.push(path.display().to_string());
|
||||
}
|
||||
if written.is_empty() {
|
||||
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Write a Chrome configuration profile that force-installs `ab-connect` from
|
||||
/// [`UPDATE_URL`], and (unless `no_open`) `open` it so the user approves it once
|
||||
/// in System Settings. Returns the profile path. macOS only — elsewhere it
|
||||
/// returns an error and the caller prints the manual fallback.
|
||||
fn install_force_install_profile(no_open: bool) -> Result<PathBuf, String> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err("force-install profile is macOS-only; on Linux set Chrome's \
|
||||
ExtensionInstallForcelist policy JSON, or Load unpacked from chrome://extensions"
|
||||
.into());
|
||||
}
|
||||
let home = dirs::home_dir().ok_or("no home dir")?;
|
||||
let ab_dir = home.join(".agent-browser");
|
||||
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
|
||||
let path = ab_dir.join("ab-connect.mobileconfig");
|
||||
std::fs::write(&path, force_install_mobileconfig()).map_err(|e| e.to_string())?;
|
||||
if !no_open {
|
||||
// `open` queues the profile in System Settings for one-time approval.
|
||||
let _ = std::process::Command::new("open").arg(&path).status();
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The `.mobileconfig` payload: a user-scope Chrome policy that force-installs
|
||||
/// the extension from the Chrome Web Store. User scope installs without admin —
|
||||
/// just a one-time approval click. Must use the STORE id (the Web Store update
|
||||
/// server serves the published extension under the id it assigned, not the local
|
||||
/// Load-unpacked id).
|
||||
fn force_install_mobileconfig() -> String {
|
||||
let forcelist = format!("{STORE_EXTENSION_ID};{UPDATE_URL}");
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.google.Chrome</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}.chrome</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_PAYLOAD_UUID}</string>
|
||||
<key>PayloadEnabled</key><true/>
|
||||
<key>PayloadDisplayName</key><string>agent-browser connect (Chrome)</string>
|
||||
<key>ExtensionInstallForcelist</key>
|
||||
<array>
|
||||
<string>{forcelist}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_UUID}</string>
|
||||
<key>PayloadDisplayName</key><string>agent-browser connect</string>
|
||||
<key>PayloadDescription</key><string>Force-installs the agent-browser connect extension so agent-browser can drive your logged-in Chrome. No token, no per-use confirmation.</string>
|
||||
<key>PayloadOrganization</key><string>agent-browser-stealth</string>
|
||||
<key>PayloadScope</key><string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove the generated `.mobileconfig` file (the profile itself is removed by
|
||||
/// the user from System Settings, or via `profiles remove`).
|
||||
fn remove_force_install_profile() -> bool {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".agent-browser").join("ab-connect.mobileconfig"))
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| std::fs::remove_file(&p).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn remove_host_manifests() -> usize {
|
||||
let mut n = 0;
|
||||
for dir in native_messaging_dirs() {
|
||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Per-OS NativeMessagingHosts directories for Chrome + Chromium-family browsers.
|
||||
fn native_messaging_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs_out = Vec::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_support) = dirs::config_dir() {
|
||||
for sub in [
|
||||
"Google/Chrome",
|
||||
"Google/Chrome Beta",
|
||||
"Google/Chrome Canary",
|
||||
"Chromium",
|
||||
"Microsoft Edge",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs_out.push(app_support.join(sub).join("NativeMessagingHosts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
if let Some(config) = dirs::config_dir() {
|
||||
for sub in [
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
"microsoft-edge",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs_out
|
||||
}
|
||||
|
||||
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
||||
native_messaging_dirs()
|
||||
.into_iter()
|
||||
.map(|d| d.join(format!("{HOST_NAME}.json")))
|
||||
.find(|p| p.exists())
|
||||
.or_else(|| {
|
||||
native_messaging_dirs()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|d| d.join(format!("{HOST_NAME}.json")))
|
||||
})
|
||||
}
|
||||
|
||||
fn report(json: bool, ok: bool, msg: &str) {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({ "success": ok, "error": if ok { serde_json::Value::Null } else { serde_json::json!(msg) }, "message": msg }))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else if ok {
|
||||
println!("✓ {msg}");
|
||||
} else {
|
||||
eprintln!("✗ {msg}");
|
||||
}
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- native messaging host (`__nm-host`) ----------------------------------
|
||||
|
||||
fn nm_log(line: &str) {
|
||||
let path = dirs::home_dir()
|
||||
.map(|h| h.join(".agent-browser").join("nm-host.log"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-nm-host.log"));
|
||||
if let Some(p) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn random_guid() -> String {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = getrandom::getrandom(&mut b);
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
|
||||
fn relay_url_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".agent-browser").join("relay-cdp-url"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-relay-cdp-url"))
|
||||
}
|
||||
|
||||
/// The live relay CDP WebSocket URL, if the native-messaging host is running
|
||||
/// (it writes the file on connect and removes it on exit). Used by
|
||||
/// `agent-browser extension connect` to attach without the user copying a URL.
|
||||
pub fn relay_url() -> Option<String> {
|
||||
let s = std::fs::read_to_string(relay_url_path()).ok()?;
|
||||
let s = s.trim().to_string();
|
||||
if s.starts_with("ws://") {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
|
||||
///
|
||||
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
|
||||
/// **CDP WebSocket endpoint** that agent-browser connects to like any Chrome.
|
||||
/// `relay::RelayState` translates envelope ⇄ raw CDP and emulates browser-level
|
||||
/// Target discovery. The ws URL carries an unguessable guid (written to a 600
|
||||
/// file) so only this user's agent-browser — not arbitrary local processes —
|
||||
/// can drive the browser. No token, no user interaction.
|
||||
pub fn run_nm_host() {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
nm_log(&format!("[nm-host] runtime build failed: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(nm_host_main());
|
||||
}
|
||||
|
||||
async fn nm_host_main() {
|
||||
use crate::native::relay::{RelayOut, RelayState};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
/// client_id -> unbounded sender feeding that client's ws writer.
|
||||
type ClientMap = Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<String>>>>;
|
||||
|
||||
nm_log(&format!(
|
||||
"[nm-host] start argv={:?}",
|
||||
std::env::args().skip(1).collect::<Vec<_>>()
|
||||
));
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
nm_log(&format!("[nm-host] bind failed: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||
let guid = random_guid();
|
||||
let url = format!("ws://127.0.0.1:{port}/{guid}");
|
||||
let url_path = relay_url_path();
|
||||
if let Some(p) = url_path.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
if std::fs::write(&url_path, &url).is_ok() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&url_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
nm_log(&format!("[nm-host] cdp endpoint {url}"));
|
||||
|
||||
let state = Arc::new(Mutex::new(RelayState::new()));
|
||||
let clients: ClientMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let next_client_id = Arc::new(AtomicU64::new(1));
|
||||
let (to_ext, mut to_ext_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||
|
||||
// Single writer to Chrome (extension) over stdout, native-messaging framed.
|
||||
tokio::spawn(async move {
|
||||
let mut out = tokio::io::stdout();
|
||||
while let Some(frame) = to_ext_rx.recv().await {
|
||||
let len = (frame.len() as u32).to_ne_bytes();
|
||||
if out.write_all(&len).await.is_err() || out.write_all(&frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = out.flush().await;
|
||||
}
|
||||
});
|
||||
|
||||
// Accept agent-browser CDP clients on the guid-scoped ws endpoint.
|
||||
{
|
||||
let state = state.clone();
|
||||
let clients = clients.clone();
|
||||
let next_client_id = next_client_id.clone();
|
||||
let to_ext = to_ext.clone();
|
||||
let guid = guid.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(x) => x,
|
||||
Err(_) => break,
|
||||
};
|
||||
let st = state.clone();
|
||||
let client_id = next_client_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (ctx, crx) = mpsc::unbounded_channel::<String>();
|
||||
clients.lock().await.insert(client_id, ctx);
|
||||
let tx = to_ext.clone();
|
||||
let g = guid.clone();
|
||||
let cls = clients.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_cdp_client(stream, g, st, client_id, crx, tx, cls).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extension → host frames.
|
||||
let mut stdin = tokio::io::stdin();
|
||||
loop {
|
||||
let mut len_buf = [0u8; 4];
|
||||
if stdin.read_exact(&mut len_buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let len = u32::from_ne_bytes(len_buf) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
if stdin.read_exact(&mut buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let outs = {
|
||||
let mut s = state.lock().await;
|
||||
s.handle_ext_message(&v, "")
|
||||
};
|
||||
for o in outs {
|
||||
match o {
|
||||
RelayOut::ToClient { to, msg } => {
|
||||
let text = msg.to_string();
|
||||
let cls = clients.lock().await;
|
||||
match to {
|
||||
// Command reply → only the client that issued it.
|
||||
Some(cid) => {
|
||||
if let Some(tx) = cls.get(&cid) {
|
||||
let _ = tx.send(text);
|
||||
}
|
||||
}
|
||||
// CDP event → fan out to every connected client.
|
||||
None => {
|
||||
for tx in cls.values() {
|
||||
let _ = tx.send(text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayOut::ToExt(m) => {
|
||||
let _ = to_ext.send(m.to_string().into_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nm_log("[nm-host] stdin EOF — Chrome closed the port");
|
||||
let _ = std::fs::remove_file(relay_url_path());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
// The handshake-callback Result type is dictated by tokio-tungstenite's
|
||||
// accept_hdr_async contract; its Err variant (an http Response) can't be shrunk.
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn handle_cdp_client(
|
||||
stream: tokio::net::TcpStream,
|
||||
guid: String,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<crate::native::relay::RelayState>>,
|
||||
client_id: u64,
|
||||
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
clients: std::sync::Arc<
|
||||
tokio::sync::Mutex<
|
||||
std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>,
|
||||
>,
|
||||
>,
|
||||
) {
|
||||
use crate::native::relay::ClientRoute;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
let want_path = format!("/{guid}");
|
||||
let cb = |req: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
if req.uri().path() == want_path {
|
||||
Ok(resp)
|
||||
} else {
|
||||
let mut reject = tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(
|
||||
Some("forbidden".to_string()),
|
||||
);
|
||||
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
|
||||
Err(reject)
|
||||
}
|
||||
};
|
||||
let ws = match tokio_tungstenite::accept_hdr_async(stream, cb).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
nm_log("[nm-host] cdp client connected");
|
||||
// Ask the extension to (re)attach + announce every tab so this client
|
||||
// discovers the user's existing tabs instead of racing an empty list.
|
||||
let _ = to_ext.send(br#"{"method":"attachAll"}"#.to_vec()).await;
|
||||
let (mut tx, mut rx) = ws.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
relayed = from_relay.recv() => match relayed {
|
||||
Some(text) => { if tx.send(Message::Text(text)).await.is_err() { break } }
|
||||
None => break,
|
||||
},
|
||||
incoming = rx.next() => match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let v: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let route = { state.lock().await.route_client_command(client_id, &v) };
|
||||
match route {
|
||||
ClientRoute::Local(reply) => {
|
||||
if tx.send(Message::Text(reply.to_string())).await.is_err() { break }
|
||||
}
|
||||
ClientRoute::Forward(env) => {
|
||||
let _ = to_ext.send(env.to_string().into_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Unregister and forget this client's in-flight commands.
|
||||
clients.lock().await.remove(&client_id);
|
||||
state.lock().await.drop_client(client_id);
|
||||
nm_log("[nm-host] cdp client disconnected");
|
||||
}
|
||||
@@ -412,6 +412,7 @@ pub struct DaemonOptions<'a> {
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub hide_scrollbars: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
@@ -476,6 +477,10 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
cmd.env(
|
||||
"AGENT_BROWSER_HIDE_SCROLLBARS",
|
||||
if opts.hide_scrollbars { "1" } else { "0" },
|
||||
);
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
proxy_password: None,
|
||||
ignore_https_errors: false,
|
||||
allow_file_access: false,
|
||||
hide_scrollbars: true,
|
||||
profile: None,
|
||||
state: None,
|
||||
provider: None,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! `find-url` — search the user's local Chrome/Edge **bookmarks** for pages they
|
||||
//! saved, by keyword. Borrowed from web-access's `find-url.mjs`; lets an agent
|
||||
//! locate an internal system or a previously-saved page that public search
|
||||
//! can't reach, without opening a browser.
|
||||
//!
|
||||
//! v1 covers bookmarks only (a zero-dependency JSON read). Visited-history lives
|
||||
//! in a locked SQLite DB and would need a SQLite dependency — not included yet.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::color;
|
||||
|
||||
struct Hit {
|
||||
name: String,
|
||||
url: String,
|
||||
folder: String,
|
||||
date_added: i64,
|
||||
}
|
||||
|
||||
/// Entry point for the `find-url` subcommand. `args` is the full cleaned argv
|
||||
/// (including the leading "find-url").
|
||||
pub fn run_find_url(args: &[String], json: bool) {
|
||||
// Parse flags out of args[1..]; everything else is a keyword.
|
||||
let mut browser = "chrome".to_string();
|
||||
let mut profile = "Default".to_string();
|
||||
let mut limit: usize = 20;
|
||||
let mut keywords: Vec<String> = Vec::new();
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--browser" => {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
browser = v.to_lowercase();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--profile" => {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
profile = v.clone();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--limit" => {
|
||||
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<usize>().ok()) {
|
||||
limit = v;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--json" => {}
|
||||
other if other.starts_with("--") => {}
|
||||
other => keywords.push(other.to_lowercase()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let path = match bookmarks_path(&browser, &profile) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
emit_error(
|
||||
json,
|
||||
&format!("Could not locate {browser} bookmarks for profile '{profile}'"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let raw = match std::fs::read_to_string(&path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
emit_error(json, &format!("Failed to read {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let root: Value = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
emit_error(json, &format!("Failed to parse bookmarks JSON: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
if let Some(roots) = root.get("roots").and_then(|r| r.as_object()) {
|
||||
for node in roots.values() {
|
||||
walk(node, "", &keywords, &mut hits);
|
||||
}
|
||||
}
|
||||
|
||||
// Most-recently-added first (date_added is microseconds since 1601).
|
||||
hits.sort_by_key(|b| std::cmp::Reverse(b.date_added));
|
||||
hits.truncate(limit);
|
||||
|
||||
if json {
|
||||
let arr: Vec<Value> = hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
serde_json::json!({
|
||||
"name": h.name,
|
||||
"url": h.url,
|
||||
"folder": h.folder,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": { "results": arr, "count": hits.len() },
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if hits.is_empty() {
|
||||
let kw = if keywords.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" matching {:?}", keywords.join(" "))
|
||||
};
|
||||
println!("No {browser} bookmarks found{kw}.");
|
||||
return;
|
||||
}
|
||||
for h in &hits {
|
||||
if h.folder.is_empty() {
|
||||
println!("{}\n {}", h.name, h.url);
|
||||
} else {
|
||||
println!("{} ({})\n {}", h.name, h.folder, h.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively walk a bookmark node, collecting URL entries that match every
|
||||
/// keyword (in name or url). Empty keyword list matches everything.
|
||||
fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
|
||||
match node.get("type").and_then(|t| t.as_str()) {
|
||||
Some("url") => {
|
||||
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
||||
// URIs aren't pages you can visit, and their bodies can be huge.
|
||||
if url.is_empty() || url.starts_with("javascript:") || url.starts_with("data:") {
|
||||
return;
|
||||
}
|
||||
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
||||
if keywords.iter().all(|k| hay.contains(k.as_str())) {
|
||||
let date_added = node
|
||||
.get("date_added")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
out.push(Hit {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
folder: folder.to_string(),
|
||||
date_added,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("folder") => {
|
||||
let fname = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let child_folder = if folder.is_empty() {
|
||||
fname.to_string()
|
||||
} else {
|
||||
format!("{folder}/{fname}")
|
||||
};
|
||||
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
|
||||
for child in children {
|
||||
walk(child, &child_folder, keywords, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Bookmarks file path for a browser + profile across platforms.
|
||||
fn bookmarks_path(browser: &str, profile: &str) -> Option<PathBuf> {
|
||||
let base = browser_user_data_dir(browser)?;
|
||||
let path = base.join(profile).join("Bookmarks");
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The "User Data" directory that holds per-profile folders, per OS/browser.
|
||||
fn browser_user_data_dir(browser: &str) -> Option<PathBuf> {
|
||||
let is_edge = browser == "edge" || browser == "msedge";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app_support = dirs::config_dir()?; // ~/Library/Application Support
|
||||
let sub = if is_edge {
|
||||
"Microsoft Edge"
|
||||
} else {
|
||||
"Google/Chrome"
|
||||
};
|
||||
Some(app_support.join(sub))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local = dirs::data_local_dir()?; // %LOCALAPPDATA%
|
||||
let sub = if is_edge {
|
||||
"Microsoft/Edge/User Data"
|
||||
} else {
|
||||
"Google/Chrome/User Data"
|
||||
};
|
||||
Some(local.join(sub))
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let config = dirs::config_dir()?; // ~/.config
|
||||
let sub = if is_edge {
|
||||
"microsoft-edge"
|
||||
} else {
|
||||
"google-chrome"
|
||||
};
|
||||
Some(config.join(sub))
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(json: bool, msg: &str) {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": false,
|
||||
"error": msg,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {msg}", color::error_indicator());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ pub struct Config {
|
||||
pub user_agent: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub hide_scrollbars: Option<bool>,
|
||||
pub ignore_https_errors: Option<bool>,
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
@@ -131,6 +132,7 @@ impl Config {
|
||||
user_agent: other.user_agent.or(self.user_agent),
|
||||
provider: other.provider.or(self.provider),
|
||||
device: other.device.or(self.device),
|
||||
hide_scrollbars: other.hide_scrollbars.or(self.hide_scrollbars),
|
||||
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
@@ -187,6 +189,12 @@ fn env_var_is_truthy(name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var_bool(name: &str) -> Option<bool> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""))
|
||||
}
|
||||
|
||||
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
||||
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
||||
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||
@@ -240,6 +248,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
"--model",
|
||||
"--humanize",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -306,6 +315,7 @@ pub struct Flags {
|
||||
pub provider: Option<String>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub hide_scrollbars: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
@@ -343,6 +353,7 @@ pub struct Flags {
|
||||
pub cli_proxy: bool,
|
||||
pub cli_proxy_bypass: bool,
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_hide_scrollbars: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_headed: bool,
|
||||
@@ -443,12 +454,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
|| config.ignore_https_errors.unwrap_or(false),
|
||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||
|| config.allow_file_access.unwrap_or(false),
|
||||
hide_scrollbars: env_var_bool("AGENT_BROWSER_HIDE_SCROLLBARS")
|
||||
.or(config.hide_scrollbars)
|
||||
.unwrap_or(true),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(true)),
|
||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH")
|
||||
|| env::var("CI").is_ok(),
|
||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH") || env::var("CI").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -518,6 +531,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_proxy: false,
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_hide_scrollbars: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
@@ -677,6 +691,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--hide-scrollbars" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.hide_scrollbars = val;
|
||||
flags.cli_hide_scrollbars = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--device" => {
|
||||
if let Some(d) = args.get(i + 1) {
|
||||
flags.device = Some(d.clone());
|
||||
@@ -775,6 +797,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--humanize" => {
|
||||
// Human-like input motion level (off|fast|human). Surface it as
|
||||
// AGENT_BROWSER_HUMANIZE so the daemon — spawned as a child that
|
||||
// inherits this process's env — picks it up and it overrides the
|
||||
// adaptive detector. Applies when the session's daemon launches.
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
match crate::native::humanize::HumanizeLevel::parse(s) {
|
||||
Some(_) => std::env::set_var("AGENT_BROWSER_HUMANIZE", s),
|
||||
None => eprintln!(
|
||||
"warning: --humanize must be off|fast|human, got {s:?} (ignored)"
|
||||
),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-dir" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.screenshot_dir = Some(s.clone());
|
||||
@@ -852,6 +889,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--debug",
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--hide-scrollbars",
|
||||
"--auto-connect",
|
||||
"--launch",
|
||||
"--new",
|
||||
@@ -900,6 +938,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
"--model",
|
||||
"--humanize",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -933,6 +972,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(String::from).collect()
|
||||
@@ -1176,6 +1216,7 @@ mod tests {
|
||||
"userAgent": "test-agent",
|
||||
"provider": "ios",
|
||||
"device": "iPhone 15",
|
||||
"hideScrollbars": false,
|
||||
"ignoreHttpsErrors": true,
|
||||
"allowFileAccess": true,
|
||||
"cdp": "9222",
|
||||
@@ -1201,6 +1242,7 @@ mod tests {
|
||||
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
||||
assert_eq!(config.provider.as_deref(), Some("ios"));
|
||||
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
||||
assert_eq!(config.hide_scrollbars, Some(false));
|
||||
assert_eq!(config.ignore_https_errors, Some(true));
|
||||
assert_eq!(config.allow_file_access, Some(true));
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
@@ -1454,6 +1496,33 @@ mod tests {
|
||||
assert!(flags.cli_allow_file_access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_default_true() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.hide_scrollbars);
|
||||
assert!(!flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_false() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("--hide-scrollbars false open"));
|
||||
assert!(!flags.hide_scrollbars);
|
||||
assert!(flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_bare_defaults_true() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("--hide-scrollbars open"));
|
||||
assert!(flags.hide_scrollbars);
|
||||
assert!(flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_connect_false() {
|
||||
let flags = parse_flags(&args("--auto-connect false open"));
|
||||
@@ -1462,7 +1531,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bool_flag_with_value() {
|
||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
||||
let cleaned = clean_args(&args(
|
||||
"--headed false --debug true --hide-scrollbars false open example.com",
|
||||
));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
mod chat;
|
||||
mod color;
|
||||
mod commands;
|
||||
mod connect;
|
||||
mod connection;
|
||||
mod doctor;
|
||||
mod findurl;
|
||||
mod flags;
|
||||
mod install;
|
||||
mod native;
|
||||
@@ -60,6 +62,23 @@ fn print_json_error_with_type(message: impl AsRef<str>, error_type: &str) {
|
||||
}));
|
||||
}
|
||||
|
||||
fn should_send_hide_scrollbars_launch_option(
|
||||
cli_hide_scrollbars: bool,
|
||||
hide_scrollbars: bool,
|
||||
) -> bool {
|
||||
cli_hide_scrollbars || !hide_scrollbars
|
||||
}
|
||||
|
||||
fn apply_hide_scrollbars_launch_option(
|
||||
launch_cmd: &mut serde_json::Value,
|
||||
cli_hide_scrollbars: bool,
|
||||
hide_scrollbars: bool,
|
||||
) {
|
||||
if should_send_hide_scrollbars_launch_option(cli_hide_scrollbars, hide_scrollbars) {
|
||||
launch_cmd["hideScrollbars"] = json!(hide_scrollbars);
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedProxy {
|
||||
server: String,
|
||||
username: Option<String>,
|
||||
@@ -485,6 +504,14 @@ fn main() {
|
||||
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
|
||||
}
|
||||
|
||||
// Native-messaging host mode: Chrome launches `agent-browser __nm-host
|
||||
// <extension-origin> [...]` for the ab-connect extension. Must run before
|
||||
// ANY stdout write — stdout is the Chrome native-messaging channel.
|
||||
if env::args().nth(1).as_deref() == Some("__nm-host") {
|
||||
connect::run_nm_host();
|
||||
return;
|
||||
}
|
||||
|
||||
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
|
||||
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
|
||||
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
|
||||
@@ -512,7 +539,19 @@ fn main() {
|
||||
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let mut flags = parse_flags(&args);
|
||||
let clean = clean_args(&args);
|
||||
let mut clean = clean_args(&args);
|
||||
|
||||
// Loudly warn when launching a fresh browser with no profile: it gets a
|
||||
// temporary EMPTY profile (no cookies / no login). For logged-in sites the
|
||||
// user almost always wants --profile auto (their real Chrome profile).
|
||||
// Skipped under CI (force_launch is implicit there and login isn't expected).
|
||||
if flags.force_launch && flags.profile.is_none() && env::var("CI").is_err() {
|
||||
eprintln!(
|
||||
"⚠ --launch uses a temporary EMPTY browser profile (no cookies, no login). \
|
||||
For logged-in sites, add `--profile auto` (or `--profile Default`) to reuse \
|
||||
your real Chrome session."
|
||||
);
|
||||
}
|
||||
|
||||
let has_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
|
||||
@@ -602,6 +641,47 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle find-url (doesn't need daemon): search local bookmarks
|
||||
if matches!(
|
||||
clean.first().map(|s| s.as_str()),
|
||||
Some("find-url") | Some("findurl")
|
||||
) {
|
||||
findurl::run_find_url(&clean, flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle extension: native-messaging host install/status, and
|
||||
// `extension connect` which attaches to the live relay (auto-discovers the
|
||||
// CDP url the host wrote) by rewriting into the normal `connect <url>` flow.
|
||||
// (`connect <port>` stays the plain CDP-attach command.)
|
||||
if clean.first().map(|s| s.as_str()) == Some("extension") {
|
||||
if clean.get(1).map(|s| s.as_str()) == Some("connect") {
|
||||
match connect::relay_url() {
|
||||
Some(url) => {
|
||||
// The connect path reads `flags.cdp` (parsed from the original
|
||||
// argv, which was `extension connect` → None), NOT `clean`.
|
||||
// Without this the relay URL is dropped and we fall through to
|
||||
// auto-connect, grabbing some other Chrome (stale :9222) or
|
||||
// popping the remote-debug prompt. Point the daemon at the
|
||||
// relay explicitly.
|
||||
flags.cdp = Some(url.clone());
|
||||
flags.auto_connect = false;
|
||||
clean = vec!["connect".to_string(), url];
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
"{} extension not connected. Run `agent-browser extension install`, load the\n ab-connect extension in Chrome (chrome://extensions → Developer mode →\n Load unpacked → extensions/ab-connect), then retry.",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connect::run_connect(&clean, flags.json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session separately (doesn't need daemon)
|
||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||
run_session(&clean, &flags.session, flags.json);
|
||||
@@ -741,6 +821,7 @@ fn main() {
|
||||
proxy_password: proxy_password.as_deref(),
|
||||
ignore_https_errors: flags.ignore_https_errors,
|
||||
allow_file_access: flags.allow_file_access,
|
||||
hide_scrollbars: flags.hide_scrollbars,
|
||||
profile: flags.profile.as_deref(),
|
||||
state: flags.state.as_deref(),
|
||||
provider: flags.provider.as_deref(),
|
||||
@@ -814,6 +895,7 @@ fn main() {
|
||||
},
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_hide_scrollbars.then_some("--hide-scrollbars"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
flags.cli_headed.then_some("--headed"),
|
||||
]
|
||||
@@ -1062,6 +1144,10 @@ fn main() {
|
||||
|| flags.args.is_some()
|
||||
|| flags.user_agent.is_some()
|
||||
|| flags.allow_file_access
|
||||
|| should_send_hide_scrollbars_launch_option(
|
||||
flags.cli_hide_scrollbars,
|
||||
flags.hide_scrollbars,
|
||||
)
|
||||
|| flags.color_scheme.is_some()
|
||||
|| flags.download_path.is_some()
|
||||
|| flags.engine.is_some()
|
||||
@@ -1136,6 +1222,12 @@ fn main() {
|
||||
launch_cmd["allowFileAccess"] = json!(true);
|
||||
}
|
||||
|
||||
apply_hide_scrollbars_launch_option(
|
||||
&mut launch_cmd,
|
||||
flags.cli_hide_scrollbars,
|
||||
flags.hide_scrollbars,
|
||||
);
|
||||
|
||||
if let Some(ref cs) = flags.color_scheme {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
@@ -1488,4 +1580,23 @@ mod tests {
|
||||
"Daemon process exited during startup:\nline \"quoted\"\u{001b}[2mansi\u{001b}[22m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_launch_option_serialization() {
|
||||
assert!(!should_send_hide_scrollbars_launch_option(false, true));
|
||||
assert!(should_send_hide_scrollbars_launch_option(false, false));
|
||||
assert!(should_send_hide_scrollbars_launch_option(true, true));
|
||||
|
||||
let mut default_cmd = json!({ "action": "launch" });
|
||||
apply_hide_scrollbars_launch_option(&mut default_cmd, false, true);
|
||||
assert!(default_cmd.get("hideScrollbars").is_none());
|
||||
|
||||
let mut config_false_cmd = json!({ "action": "launch" });
|
||||
apply_hide_scrollbars_launch_option(&mut config_false_cmd, false, false);
|
||||
assert_eq!(config_false_cmd["hideScrollbars"], false);
|
||||
|
||||
let mut cli_true_cmd = json!({ "action": "launch" });
|
||||
apply_hide_scrollbars_launch_option(&mut cli_true_cmd, true, true);
|
||||
assert_eq!(cli_true_cmd["hideScrollbars"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::cdp::types::{
|
||||
use super::cookies;
|
||||
use super::diff;
|
||||
use super::element::RefMap;
|
||||
use super::humanize;
|
||||
use super::inspect_server::InspectServer;
|
||||
use super::interaction;
|
||||
use super::network::{self, DomainFilter, EventTracker};
|
||||
@@ -197,6 +198,7 @@ fn launch_hash(opts: &LaunchOptions) -> u64 {
|
||||
opts.proxy_password.hash(&mut h);
|
||||
opts.user_agent.hash(&mut h);
|
||||
opts.allow_file_access.hash(&mut h);
|
||||
opts.hide_scrollbars.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -632,6 +634,8 @@ impl DaemonState {
|
||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||
.await;
|
||||
}
|
||||
// Hide automation markers in this cross-origin iframe session too.
|
||||
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
|
||||
}
|
||||
}
|
||||
for sid in &drained.detached_iframe_sessions {
|
||||
@@ -669,7 +673,10 @@ impl DaemonState {
|
||||
}
|
||||
|
||||
let tab_id = mgr.assign_tab_id();
|
||||
mgr.add_page(super::browser::PageInfo {
|
||||
// Passively discovered (event-driven) — must NOT steal the
|
||||
// active tab, or a foreign/user/other-session tab opening
|
||||
// hijacks this session's eval/screenshot target.
|
||||
mgr.add_background_page(super::browser::PageInfo {
|
||||
tab_id,
|
||||
label: None,
|
||||
target_id: te.target_info.target_id.clone(),
|
||||
@@ -1519,10 +1526,14 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||
// about:blank. Failing here lets the caller surface the real error.
|
||||
if let Err(e) = mgr
|
||||
.client
|
||||
.send_command("Runtime.evaluate", Some(serde_json::json!({
|
||||
"expression": "1",
|
||||
"returnByValue": true,
|
||||
})), Some(&session_id))
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(serde_json::json!({
|
||||
"expression": "1",
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(&session_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(format!(
|
||||
@@ -1604,11 +1615,18 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
// Return a helpful error guiding the user to enable it.
|
||||
return Err(format!(
|
||||
"Could not connect to your Chrome browser.\n\n\
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||
{}\n\n\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
|
||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
||||
Or launch a separate browser that KEEPS your login state:\n \
|
||||
agent-browser --launch --profile auto open <url>\n\
|
||||
(plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
|
||||
no logged-in sessions.)\n\n\
|
||||
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||
does NOT expose the CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -1756,45 +1774,66 @@ fn chrome_relaunch_hint() -> &'static str {
|
||||
/// Called after every successful launch / CDP connect / auto-connect.
|
||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
||||
return; // Explicitly disabled
|
||||
}
|
||||
let Some(ref mgr) = state.browser else {
|
||||
return;
|
||||
};
|
||||
let Ok(session_id) = mgr.active_session_id() else {
|
||||
return;
|
||||
};
|
||||
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||
fn stealth_enabled() -> bool {
|
||||
!env::var("AGENT_BROWSER_STEALTH")
|
||||
.map(|v| v == "0")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
||||
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||
///
|
||||
/// Stealth scripts are registered per-session via
|
||||
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
|
||||
/// tabs or cross-origin iframe sessions created after the initial page. We must
|
||||
/// re-apply to every session the user can touch, otherwise automation markers
|
||||
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
|
||||
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
|
||||
if !stealth_enabled() {
|
||||
return;
|
||||
}
|
||||
// Determine mode: an external attach uses minimal patches (the user's real
|
||||
// Chrome already has a genuine fingerprint — heavy patches create detectable
|
||||
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
|
||||
let mode = if mgr.is_cdp_connection() {
|
||||
stealth::StealthMode::CdpAttach
|
||||
} else {
|
||||
stealth::StealthMode::FullLaunch
|
||||
};
|
||||
|
||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||
if let Err(e) = stealth::apply_stealth(
|
||||
&mgr.client,
|
||||
session_id,
|
||||
mode,
|
||||
locale.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
||||
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
|
||||
}
|
||||
// Also inject into the current page (already loaded before our init script)
|
||||
// Also inject into the current page (already loaded before our init script).
|
||||
if let Err(e) =
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
|
||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
||||
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth to a specific session of the active browser (no-op if no
|
||||
/// browser or stealth disabled).
|
||||
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
|
||||
if let Some(ref mgr) = state.browser {
|
||||
apply_stealth_via_mgr(mgr, session_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply stealth to the active page session (initial connect/launch).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
let session_id = match state
|
||||
.browser
|
||||
.as_ref()
|
||||
.and_then(|m| m.active_session_id().ok())
|
||||
{
|
||||
Some(sid) => sid.to_string(),
|
||||
None => return,
|
||||
};
|
||||
apply_stealth_to_session(state, &session_id).await;
|
||||
}
|
||||
|
||||
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
||||
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
||||
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
||||
@@ -1875,11 +1914,24 @@ fn launch_options_from_env() -> LaunchOptions {
|
||||
.unwrap_or(false),
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
|
||||
hide_scrollbars: hide_scrollbars_from_env(),
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_scrollbars_from_env() -> bool {
|
||||
env::var("AGENT_BROWSER_HIDE_SCROLLBARS")
|
||||
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | ""))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn hide_scrollbars_from_launch_cmd(cmd: &Value) -> bool {
|
||||
cmd.get("hideScrollbars")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or_else(hide_scrollbars_from_env)
|
||||
}
|
||||
|
||||
async fn try_auto_restore_state(state: &mut DaemonState) {
|
||||
let session_name = match state.session_name.as_deref() {
|
||||
Some(n) if !n.is_empty() => n.to_string(),
|
||||
@@ -2043,6 +2095,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("downloadPath")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
hide_scrollbars: hide_scrollbars_from_launch_cmd(cmd),
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
};
|
||||
@@ -2132,11 +2185,18 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
Err(_e) => {
|
||||
return Err(format!(
|
||||
"Could not connect to your Chrome browser.\n\n\
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||
{}\n\n\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
|
||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
||||
Or launch a separate browser that KEEPS your login state:\n \
|
||||
agent-browser --launch --profile auto open <url>\n\
|
||||
(plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
|
||||
no logged-in sessions.)\n\n\
|
||||
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||
does NOT expose the CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -2276,6 +2336,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
|
||||
apply_launch_init_scripts(state).await;
|
||||
// Apply stealth patches (the 32 JS patches + HeadlessChrome UA strip in
|
||||
// FullLaunch mode). The fresh-launch path was missing this — only the launch
|
||||
// FLAGS (e.g. --disable-blink-features) were applied, so the JS patches never
|
||||
// ran and navigator.userAgent kept the HeadlessChrome marker.
|
||||
apply_stealth_to_browser(state).await;
|
||||
|
||||
Ok(json!({ "launched": true }))
|
||||
}
|
||||
@@ -2448,7 +2513,49 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
state.ref_map.clear();
|
||||
state.iframe_sessions.clear();
|
||||
state.active_frame_id = None;
|
||||
mgr.navigate(url, wait_until).await
|
||||
let result = mgr.navigate(url, wait_until).await?;
|
||||
// Adaptive humanize: sample the freshly loaded page for known behavioural
|
||||
// anti-bot vendors and escalate this session to Human if any are present.
|
||||
detect_and_set_humanize(mgr).await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// After navigation, probe the page for known anti-bot vendor fingerprints
|
||||
/// (cookies / script URLs / `window` globals) and set this session's humanize
|
||||
/// level accordingly — `Human` when a vendor is detected, else the `Off`
|
||||
/// baseline. Best-effort: any failure leaves the level unchanged. Skipped when
|
||||
/// `AGENT_BROWSER_HUMANIZE` is set, since the override always wins and the probe
|
||||
/// would be wasted work.
|
||||
async fn detect_and_set_humanize(mgr: &BrowserManager) {
|
||||
if std::env::var("AGENT_BROWSER_HUMANIZE").is_ok() {
|
||||
return;
|
||||
}
|
||||
let js = r#"(() => { try {
|
||||
const cookies = document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean);
|
||||
const scripts = Array.from(document.scripts, s => s.src || '').filter(Boolean);
|
||||
const re = /_px|bmak|_abck|datadome|reese84|kpsdk|incap_ses|visid_incap|akam/i;
|
||||
const globals = Object.getOwnPropertyNames(window).filter(k => re.test(k));
|
||||
return { cookies, scripts, globals };
|
||||
} catch (e) { return {}; } })()"#;
|
||||
let Ok(val) = mgr.evaluate(js, None).await else {
|
||||
return;
|
||||
};
|
||||
let to_strings = |v: Option<&Value>| -> Vec<String> {
|
||||
v.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let signals = humanize::DetectSignals {
|
||||
cookie_names: to_strings(val.get("cookies")),
|
||||
script_urls: to_strings(val.get("scripts")),
|
||||
window_globals: to_strings(val.get("globals")),
|
||||
};
|
||||
let level = humanize::detect_level(&signals, humanize::HumanizeLevel::Off);
|
||||
humanize::set_detected_level(level);
|
||||
}
|
||||
|
||||
async fn handle_url(state: &DaemonState) -> Result<Value, String> {
|
||||
@@ -3430,16 +3537,53 @@ async fn wait_for_selector(
|
||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||
}
|
||||
|
||||
/// Convert a URL glob (Playwright-style: `*` matches within a path segment,
|
||||
/// `**` matches across segments, `?` matches one char) to an anchored regex.
|
||||
fn url_glob_to_regex(glob: &str) -> String {
|
||||
let mut re = String::from("^");
|
||||
let mut chars = glob.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'*' => {
|
||||
if chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
re.push_str(".*"); // ** — any chars incl. '/'
|
||||
} else {
|
||||
re.push_str("[^/]*"); // * — any chars except '/'
|
||||
}
|
||||
}
|
||||
'?' => re.push('.'),
|
||||
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
|
||||
re.push('\\');
|
||||
re.push(c);
|
||||
}
|
||||
_ => re.push(c),
|
||||
}
|
||||
}
|
||||
re.push('$');
|
||||
re
|
||||
}
|
||||
|
||||
async fn wait_for_url(
|
||||
client: &super::cdp::client::CdpClient,
|
||||
session_id: &str,
|
||||
pattern: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let check_fn = format!(
|
||||
"location.href.includes({})",
|
||||
serde_json::to_string(pattern).unwrap_or_default()
|
||||
);
|
||||
// A pattern with glob metacharacters is matched as a glob (the core skill
|
||||
// documents `wait --url "**/dashboard"`); otherwise it's a plain substring
|
||||
// so exact / partial URLs keep working.
|
||||
let check_fn = if pattern.contains('*') || pattern.contains('?') {
|
||||
format!(
|
||||
"(()=>{{try{{return new RegExp({}).test(location.href)}}catch(e){{return false}}}})()",
|
||||
serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"location.href.includes({})",
|
||||
serde_json::to_string(pattern).unwrap_or_default()
|
||||
)
|
||||
};
|
||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||
}
|
||||
|
||||
@@ -3475,8 +3619,19 @@ async fn poll_until_true(
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
let result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||
}
|
||||
|
||||
// Bound each probe. A `Runtime.evaluate` issued while the page is
|
||||
// navigating can hang (the execution context is being torn down); without
|
||||
// a cap the `.await` would block past the deadline forever and wedge the
|
||||
// daemon's request loop. Cap at the remaining budget (max 2s per probe).
|
||||
let probe_cap = remaining.min(tokio::time::Duration::from_secs(2));
|
||||
let probe = tokio::time::timeout(
|
||||
probe_cap,
|
||||
client.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&super::cdp::types::EvaluateParams {
|
||||
expression: expression.to_string(),
|
||||
@@ -3484,24 +3639,31 @@ async fn poll_until_true(
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
if result
|
||||
.result
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
// A probe that timed out or errored (e.g. the execution context was
|
||||
// replaced mid-navigation) is transient — keep polling until the
|
||||
// deadline rather than failing or hanging.
|
||||
if let Ok(Ok(result)) = probe {
|
||||
if result
|
||||
.result
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let nap = tokio::time::Duration::from_millis(100)
|
||||
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||
if nap.is_zero() {
|
||||
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
tokio::time::sleep(nap).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3637,13 +3799,38 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
||||
state.event_tracker.clear_console();
|
||||
Ok(json!({ "cleared": true }))
|
||||
} else {
|
||||
let result = state.event_tracker.get_console_json();
|
||||
let mut result = state.event_tracker.get_console_json();
|
||||
if !console_capture_active(state) {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active browser session has the CDP `Runtime` domain enabled
|
||||
/// (required to receive console/error events). OFF by default for stealth.
|
||||
fn console_capture_active(state: &DaemonState) -> bool {
|
||||
state
|
||||
.browser
|
||||
.as_ref()
|
||||
.map(|b| b.capture_console)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const CONSOLE_DISABLED_HINT: &str =
|
||||
"console/error capture is disabled for stealth (Runtime.enable is a detectable CDP \
|
||||
signal). Restart the session with AGENT_BROWSER_CAPTURE_CONSOLE=1 to capture page output.";
|
||||
|
||||
async fn handle_errors(state: &DaemonState) -> Result<Value, String> {
|
||||
Ok(state.event_tracker.get_errors_json())
|
||||
let mut result = state.event_tracker.get_errors_json();
|
||||
if !console_capture_active(state) {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
@@ -3931,13 +4118,26 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
let url = cmd.get("url").and_then(|v| v.as_str());
|
||||
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||
state.ref_map.clear();
|
||||
state.iframe_sessions.clear();
|
||||
state.active_frame_id = None;
|
||||
mgr.tab_new(url, label).await
|
||||
let result = {
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
mgr.tab_new(url, label).await?
|
||||
};
|
||||
// A new tab is a new CDP session; stealth scripts registered on the prior
|
||||
// session don't carry over, so patch the new tab too.
|
||||
if let Some(sid) = state
|
||||
.browser
|
||||
.as_ref()
|
||||
.and_then(|m| m.active_session_id().ok())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
apply_stealth_to_session(state, &sid).await;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
@@ -5313,19 +5513,29 @@ async fn handle_wheel(cmd: &Value, state: &DaemonState) -> Result<Value, String>
|
||||
let delta_x = cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let delta_y = cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
mgr.client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
Some(json!({
|
||||
"type": "mouseWheel",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"deltaX": delta_x,
|
||||
"deltaY": delta_y,
|
||||
})),
|
||||
Some(&session_id),
|
||||
)
|
||||
.await?;
|
||||
// Humanize: at Off this is one instant wheel event (unchanged); at
|
||||
// Fast/Human the scroll is split into eased, slightly-jittered segments so
|
||||
// it ramps and settles like a real wheel/trackpad flick.
|
||||
let level = humanize::active_level();
|
||||
let seed = humanize::next_seed();
|
||||
for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
|
||||
mgr.client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
Some(json!({
|
||||
"type": "mouseWheel",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"deltaX": dx,
|
||||
"deltaY": dy,
|
||||
})),
|
||||
Some(&session_id),
|
||||
)
|
||||
.await?;
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "scrolled": true, "deltaX": delta_x, "deltaY": delta_y }))
|
||||
}
|
||||
@@ -5902,6 +6112,54 @@ async fn execute_subaction(
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS selector that matches an ARIA `role` — both an explicit `role="X"`
|
||||
/// attribute AND the HTML elements that carry that role *implicitly*. The naive
|
||||
/// `[role="X"], X` form fails for every role whose implicit element has a
|
||||
/// different tag than the role name (e.g. role `link` ⇒ `<a href>`, not `<link>`;
|
||||
/// role `heading` ⇒ `<h1>`..`<h6>`), which made `find role link/heading` never
|
||||
/// match real elements.
|
||||
fn role_to_query(role: &str) -> String {
|
||||
let implicit = match role {
|
||||
"link" => "a[href], area[href]",
|
||||
"button" => "button, input[type=button], input[type=submit], input[type=reset], summary",
|
||||
"heading" => "h1, h2, h3, h4, h5, h6",
|
||||
"textbox" => {
|
||||
"input[type=text], input[type=search], input[type=email], input[type=url], \
|
||||
input[type=tel], input[type=password], input:not([type]), textarea"
|
||||
}
|
||||
"searchbox" => "input[type=search]",
|
||||
"checkbox" => "input[type=checkbox]",
|
||||
"radio" => "input[type=radio]",
|
||||
"combobox" => "select",
|
||||
"listbox" => "select[multiple]",
|
||||
"slider" => "input[type=range]",
|
||||
"spinbutton" => "input[type=number]",
|
||||
"img" => "img",
|
||||
"list" => "ul, ol",
|
||||
"listitem" => "li",
|
||||
"table" => "table",
|
||||
"row" => "tr",
|
||||
"cell" | "gridcell" => "td",
|
||||
"columnheader" | "rowheader" => "th",
|
||||
"article" => "article",
|
||||
"navigation" => "nav",
|
||||
"main" => "main",
|
||||
"banner" => "header",
|
||||
"contentinfo" => "footer",
|
||||
"complementary" => "aside",
|
||||
"figure" => "figure",
|
||||
"separator" => "hr",
|
||||
"progressbar" => "progress",
|
||||
"group" => "fieldset",
|
||||
_ => "",
|
||||
};
|
||||
if implicit.is_empty() {
|
||||
format!("[role=\"{role}\"], {role}")
|
||||
} else {
|
||||
format!("[role=\"{role}\"], {implicit}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
|
||||
match name {
|
||||
Some(n) => {
|
||||
@@ -5922,27 +6180,25 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
let name = cmd.get("name").and_then(|v| v.as_str());
|
||||
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Accessible-name approximation: aria-label, then title/alt/value, then the
|
||||
// element's text. Covers links (text), input buttons (value), images (alt).
|
||||
let name_match = name
|
||||
.map(|n| {
|
||||
let nj = serde_json::to_string(n).unwrap_or_default();
|
||||
if exact {
|
||||
format!(
|
||||
"el.getAttribute('aria-label') === {} || el.textContent.trim() === {}",
|
||||
serde_json::to_string(n).unwrap_or_default(),
|
||||
serde_json::to_string(n).unwrap_or_default()
|
||||
)
|
||||
format!("__an === {nj}")
|
||||
} else {
|
||||
format!(
|
||||
"(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})",
|
||||
n = serde_json::to_string(n).unwrap_or_default()
|
||||
)
|
||||
format!("__an.includes({nj})")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "true".to_string());
|
||||
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const els = document.querySelectorAll('[role="{role}"], {role}');
|
||||
const els = document.querySelectorAll({selector});
|
||||
for (const el of els) {{
|
||||
const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
|
||||
|| el.getAttribute('alt') || el.value || el.textContent || '').trim();
|
||||
if ({name_match}) {{
|
||||
el.setAttribute('data-agent-browser-located', 'true');
|
||||
return true;
|
||||
@@ -5950,7 +6206,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
}}
|
||||
return false;
|
||||
}})()"#,
|
||||
role = role,
|
||||
selector = serde_json::to_string(&role_to_query(role)).unwrap_or_default(),
|
||||
name_match = name_match,
|
||||
);
|
||||
|
||||
@@ -6276,7 +6532,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'target' parameter")?;
|
||||
|
||||
let (sx, sy, source_session_id) = super::element::resolve_element_center(
|
||||
let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
@@ -6284,7 +6540,7 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
&state.iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
let (tx, ty, target_session_id) = super::element::resolve_element_center(
|
||||
let (tx, ty, _, _, target_session_id) = super::element::resolve_element_center(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
@@ -6309,12 +6565,26 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Move in steps to target, keeping the left button held (buttons: 1) so
|
||||
// that the browser sees a drag rather than a plain pointer move.
|
||||
let steps = 10;
|
||||
for i in 1..=steps {
|
||||
let cx = sx + (tx - sx) * (i as f64) / (steps as f64);
|
||||
let cy = sy + (ty - sy) * (i as f64) / (steps as f64);
|
||||
// Move to the target with the left button held (buttons: 1) so the browser
|
||||
// sees a drag. At Off this is the original linear 10-step path; at
|
||||
// Fast/Human it follows humanize's curved, decelerating trajectory.
|
||||
let level = humanize::active_level();
|
||||
let drag_path: Vec<(f64, f64, std::time::Duration)> =
|
||||
if matches!(level, humanize::HumanizeLevel::Off) {
|
||||
(1..=10)
|
||||
.map(|i| {
|
||||
let cx = sx + (tx - sx) * (i as f64) / 10.0;
|
||||
let cy = sy + (ty - sy) * (i as f64) / 10.0;
|
||||
(cx, cy, std::time::Duration::from_millis(10))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
humanize::move_path((sx, sy), (tx, ty), level, humanize::next_seed())
|
||||
.into_iter()
|
||||
.map(|s| (s.x, s.y, s.delay))
|
||||
.collect()
|
||||
};
|
||||
for (cx, cy, delay) in drag_path {
|
||||
mgr.client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
@@ -6322,7 +6592,9 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
Some(&target_session_id),
|
||||
)
|
||||
.await?;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse up at target
|
||||
@@ -8328,6 +8600,44 @@ mod tests {
|
||||
use crate::test_utils::EnvGuard;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_url_glob_to_regex() {
|
||||
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
|
||||
assert_eq!(url_glob_to_regex("**iana**"), "^.*iana.*$");
|
||||
assert_eq!(
|
||||
url_glob_to_regex("https://x.com/**"),
|
||||
"^https://x\\.com/.*$"
|
||||
);
|
||||
// single * stays within a path segment
|
||||
assert_eq!(url_glob_to_regex("/a/*/c"), "^/a/[^/]*/c$");
|
||||
assert_eq!(url_glob_to_regex("/p?ge"), "^/p.ge$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_glob_regex_matches() {
|
||||
let re = regex_lite::Regex::new(&url_glob_to_regex("**/help/**")).unwrap();
|
||||
assert!(re.is_match("https://www.iana.org/help/example-domains"));
|
||||
assert!(!re.is_match("https://www.iana.org/about"));
|
||||
let re2 = regex_lite::Regex::new(&url_glob_to_regex("https://www.iana.org/**")).unwrap();
|
||||
assert!(re2.is_match("https://www.iana.org/help/example-domains"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_to_query_implicit_elements() {
|
||||
// links are <a href>, not <link>; headings are h1..h6
|
||||
assert_eq!(
|
||||
role_to_query("link"),
|
||||
"[role=\"link\"], a[href], area[href]"
|
||||
);
|
||||
assert_eq!(
|
||||
role_to_query("heading"),
|
||||
"[role=\"heading\"], h1, h2, h3, h4, h5, h6"
|
||||
);
|
||||
assert!(role_to_query("button").contains("button"));
|
||||
// unknown/custom roles fall back to the attribute + literal tag
|
||||
assert_eq!(role_to_query("tablist"), "[role=\"tablist\"], tablist");
|
||||
}
|
||||
|
||||
fn unique_socket_dir(label: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -8644,17 +8954,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_launch_options_from_env_defaults() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HEADED");
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(opts.headless);
|
||||
assert!(opts.args.is_empty());
|
||||
assert!(!opts.allow_file_access);
|
||||
assert!(opts.hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_options_from_env_headed_flag() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]);
|
||||
_guard.set("AGENT_BROWSER_HEADED", "1");
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.set("AGENT_BROWSER_HEADED", "1");
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(
|
||||
!opts.headless,
|
||||
@@ -8662,6 +8976,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_options_from_env_hide_scrollbars_false() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||
let opts = launch_options_from_env();
|
||||
assert!(!opts.hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_cmd_hide_scrollbars_missing_uses_env_default() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||
|
||||
assert!(!hide_scrollbars_from_launch_cmd(&json!({
|
||||
"action": "launch"
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_cmd_hide_scrollbars_explicit_overrides_env_default() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.set("AGENT_BROWSER_HIDE_SCROLLBARS", "false");
|
||||
|
||||
assert!(hide_scrollbars_from_launch_cmd(&json!({
|
||||
"action": "launch",
|
||||
"hideScrollbars": true
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_har_entry_to_json_enriches_request_and_response() {
|
||||
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Adaptive @ref relocation.
|
||||
//!
|
||||
//! When a saved `@ref`'s DOM node is gone (stale `backendNodeId`) and the
|
||||
//! role/name/nth re-query also fails, we score the current page's candidate
|
||||
//! elements against the ref's stored [`ElementFingerprint`] and relocate to the
|
||||
//! best match — but ONLY when confident: the best candidate must clear a high
|
||||
//! absolute threshold AND beat the runner-up by a clear margin. This matches the
|
||||
//! project's "fail loudly rather than mis-click" posture (see the identity and
|
||||
//! occlusion guards in `element.rs`).
|
||||
//!
|
||||
//! Everything in this module is pure and browser-free so the scoring can be
|
||||
//! unit-tested directly.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Minimum absolute similarity (0..1) for a relocation candidate to be accepted.
|
||||
pub const ADAPTIVE_THRESHOLD: f64 = 0.70;
|
||||
/// Minimum gap between the best and second-best candidate to avoid ambiguity.
|
||||
pub const ADAPTIVE_MARGIN: f64 = 0.15;
|
||||
|
||||
/// A structural/semantic fingerprint of an element, captured at snapshot time so
|
||||
/// a moved element can be re-identified after the page mutates.
|
||||
///
|
||||
/// Populated purely from the accessibility tree we already walk (`TreeNode`), so
|
||||
/// capturing it costs no extra CDP round-trips — `TreeNode` has no DOM tag or
|
||||
/// attributes (those would need an N×`DOM.describeNode` storm per snapshot), so
|
||||
/// `tag` holds the AX **role** and `attrs` holds discriminating AX properties
|
||||
/// (value/url/level/checked), not DOM `id`/`class`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ElementFingerprint {
|
||||
/// AX role, e.g. "button" (used where a DOM tag would otherwise go).
|
||||
pub tag: String,
|
||||
/// Accessible name / visible text — the dominant identity signal.
|
||||
pub text: String,
|
||||
/// Discriminating AX properties: value, url, level, checked. Keyed by name.
|
||||
pub attrs: BTreeMap<String, String>,
|
||||
/// Ancestor role signatures from nearest to farthest, e.g. "form" / "list".
|
||||
pub ancestors: Vec<String>,
|
||||
/// Parent role.
|
||||
pub parent_tag: String,
|
||||
/// Parent accessible name / text.
|
||||
pub parent_text: String,
|
||||
/// Index among same-role siblings.
|
||||
pub sibling_index: u32,
|
||||
/// Count of same-role siblings.
|
||||
pub sibling_count: u32,
|
||||
}
|
||||
|
||||
/// Component weights. They sum to 1.0 so the total score lands in 0..1.
|
||||
/// Tuned for AX-derived fingerprints: the accessible name dominates, with role
|
||||
/// and tree structure carrying disambiguation when the name has changed (which
|
||||
/// is exactly when the exact role+name+nth fallback failed and we got here).
|
||||
const W_TAG: f64 = 0.20;
|
||||
const W_TEXT: f64 = 0.40;
|
||||
const W_ATTRS: f64 = 0.10;
|
||||
const W_ANCESTORS: f64 = 0.20;
|
||||
const W_PARENT_SIBLING: f64 = 0.10;
|
||||
|
||||
/// Per-attribute importance for the attribute-overlap score. Strong identity
|
||||
/// signals (a link's url) outweigh weak ones (heading level).
|
||||
fn attr_weight(name: &str) -> f64 {
|
||||
match name {
|
||||
"url" | "value" => 3.0,
|
||||
"checked" => 2.0,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Levenshtein-based string similarity in 0..1 (1.0 = identical). Two empty
|
||||
/// strings are treated as a perfect match (consistent absence of text).
|
||||
pub fn string_similarity(a: &str, b: &str) -> f64 {
|
||||
if a == b {
|
||||
return 1.0;
|
||||
}
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let max_len = a.len().max(b.len());
|
||||
if max_len == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = levenshtein(&a, &b);
|
||||
1.0 - (dist as f64 / max_len as f64)
|
||||
}
|
||||
|
||||
fn levenshtein(a: &[char], b: &[char]) -> usize {
|
||||
if a.is_empty() {
|
||||
return b.len();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.len();
|
||||
}
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
for (i, &ca) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, &cb) in b.iter().enumerate() {
|
||||
let cost = if ca == cb { 0 } else { 1 };
|
||||
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
/// Jaccard similarity over whitespace-separated tokens (used for `class`).
|
||||
fn token_jaccard(a: &str, b: &str) -> f64 {
|
||||
let sa: std::collections::BTreeSet<&str> = a.split_whitespace().collect();
|
||||
let sb: std::collections::BTreeSet<&str> = b.split_whitespace().collect();
|
||||
if sa.is_empty() && sb.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
let inter = sa.intersection(&sb).count() as f64;
|
||||
let union = sa.union(&sb).count() as f64;
|
||||
if union == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
inter / union
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-ratio of the longest common subsequence over two ancestor sequences.
|
||||
fn lcs_ratio(a: &[String], b: &[String]) -> f64 {
|
||||
if a.is_empty() && b.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
|
||||
for i in 0..a.len() {
|
||||
for j in 0..b.len() {
|
||||
dp[i + 1][j + 1] = if a[i] == b[j] {
|
||||
dp[i][j] + 1
|
||||
} else {
|
||||
dp[i][j + 1].max(dp[i + 1][j])
|
||||
};
|
||||
}
|
||||
}
|
||||
let lcs = dp[a.len()][b.len()] as f64;
|
||||
(2.0 * lcs) / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
fn attr_score(base: &BTreeMap<String, String>, cand: &BTreeMap<String, String>) -> f64 {
|
||||
let mut names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
names.extend(base.keys().map(|s| s.as_str()));
|
||||
names.extend(cand.keys().map(|s| s.as_str()));
|
||||
if names.is_empty() {
|
||||
return 1.0; // no attributes on either side — neutral
|
||||
}
|
||||
let mut total = 0.0;
|
||||
let mut got = 0.0;
|
||||
for name in names {
|
||||
let w = attr_weight(name);
|
||||
total += w;
|
||||
// present on only one side → no credit
|
||||
if let (Some(a), Some(b)) = (base.get(name), cand.get(name)) {
|
||||
if name == "class" {
|
||||
got += w * token_jaccard(a, b);
|
||||
} else if a == b {
|
||||
got += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
got / total
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_sibling_score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||||
// Split the 0.10 budget: parent tag 0.4, parent text 0.3, sibling pos 0.3.
|
||||
let parent_tag = if base.parent_tag == cand.parent_tag {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let parent_text = string_similarity(&base.parent_text, &cand.parent_text);
|
||||
let span = base.sibling_count.max(1) as f64;
|
||||
let delta = (base.sibling_index as i64 - cand.sibling_index as i64).unsigned_abs() as f64;
|
||||
let sibling = 1.0 - (delta / span).min(1.0);
|
||||
0.4 * parent_tag + 0.3 * parent_text + 0.3 * sibling
|
||||
}
|
||||
|
||||
/// Similarity score in 0..1 between a stored baseline and a candidate element.
|
||||
pub fn score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||||
let tag = if base.tag == cand.tag { 1.0 } else { 0.0 };
|
||||
let text = string_similarity(&base.text, &cand.text);
|
||||
let attrs = attr_score(&base.attrs, &cand.attrs);
|
||||
let ancestors = lcs_ratio(&base.ancestors, &cand.ancestors);
|
||||
let parent_sibling = parent_sibling_score(base, cand);
|
||||
|
||||
W_TAG * tag
|
||||
+ W_TEXT * text
|
||||
+ W_ATTRS * attrs
|
||||
+ W_ANCESTORS * ancestors
|
||||
+ W_PARENT_SIBLING * parent_sibling
|
||||
}
|
||||
|
||||
/// Why a relocation was rejected.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RejectReason {
|
||||
/// No candidates to score.
|
||||
NoCandidates,
|
||||
/// Best score below [`ADAPTIVE_THRESHOLD`].
|
||||
LowScore { best: f64 },
|
||||
/// Best score too close to the runner-up (below [`ADAPTIVE_MARGIN`]).
|
||||
Ambiguous { best: f64, second: f64 },
|
||||
}
|
||||
|
||||
/// A successful relocation decision.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Relocation {
|
||||
/// Chosen candidate's backend node id.
|
||||
pub backend_node_id: i64,
|
||||
/// Winning score.
|
||||
pub score: f64,
|
||||
/// Runner-up score (0.0 when there was only one candidate).
|
||||
pub second_score: f64,
|
||||
}
|
||||
|
||||
/// Pick the best candidate, accepting only when confident. `candidates` is a
|
||||
/// list of `(backend_node_id, fingerprint)` for the current page.
|
||||
pub fn pick_best(
|
||||
base: &ElementFingerprint,
|
||||
candidates: &[(i64, ElementFingerprint)],
|
||||
threshold: f64,
|
||||
margin: f64,
|
||||
) -> Result<Relocation, RejectReason> {
|
||||
if candidates.is_empty() {
|
||||
return Err(RejectReason::NoCandidates);
|
||||
}
|
||||
let mut scored: Vec<(i64, f64)> = candidates
|
||||
.iter()
|
||||
.map(|(id, fp)| (*id, score(base, fp)))
|
||||
.collect();
|
||||
// Highest score first; stable enough for deterministic ties.
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let (best_id, best) = scored[0];
|
||||
let second = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
|
||||
|
||||
if best < threshold {
|
||||
return Err(RejectReason::LowScore { best });
|
||||
}
|
||||
if best - second < margin {
|
||||
return Err(RejectReason::Ambiguous { best, second });
|
||||
}
|
||||
Ok(Relocation {
|
||||
backend_node_id: best_id,
|
||||
score: best,
|
||||
second_score: second,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fp(tag: &str, text: &str, attrs: &[(&str, &str)]) -> ElementFingerprint {
|
||||
ElementFingerprint {
|
||||
tag: tag.to_string(),
|
||||
text: text.to_string(),
|
||||
attrs: attrs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_fingerprints_score_one() {
|
||||
let a = fp(
|
||||
"button",
|
||||
"Submit",
|
||||
&[("id", "go"), ("class", "btn primary")],
|
||||
);
|
||||
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_tag_caps_score_below_threshold() {
|
||||
let a = fp("button", "Submit", &[("id", "go")]);
|
||||
let b = fp("a", "Submit", &[("id", "go")]);
|
||||
// Same text + same attrs but different role: must lose the role weight
|
||||
// (W_TAG = 0.20), landing around 0.80 and below a perfect match.
|
||||
let s = score(&a, &b);
|
||||
assert!(s < 0.85 && s > 0.75, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_similarity_basics() {
|
||||
assert_eq!(string_similarity("abc", "abc"), 1.0);
|
||||
assert_eq!(string_similarity("", ""), 1.0);
|
||||
assert!(string_similarity("Submit", "Submit now") > 0.5);
|
||||
assert!(string_similarity("Add post", "Post all") < 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_uses_token_overlap() {
|
||||
let a = fp("div", "", &[("class", "card primary big")]);
|
||||
let b = fp("div", "", &[("class", "card primary")]);
|
||||
// partial class overlap should still score high (tag+text match, attrs partial)
|
||||
let s = score(&a, &b);
|
||||
assert!(s > 0.85, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_lcs() {
|
||||
let mut a = fp("button", "OK", &[]);
|
||||
let mut b = fp("button", "OK", &[]);
|
||||
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
|
||||
// b wrapped in an extra div — DOM path changed but mostly preserved
|
||||
b.ancestors = vec![
|
||||
"form#f".into(),
|
||||
"div.wrap".into(),
|
||||
"div.col".into(),
|
||||
"body".into(),
|
||||
];
|
||||
let s = score(&a, &b);
|
||||
assert!(s > 0.85, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_accepts_clear_winner() {
|
||||
let base = fp("button", "Submit", &[("id", "go")]);
|
||||
let winner = fp("button", "Submit", &[("id", "go")]);
|
||||
let other = fp("a", "Home", &[("href", "/")]);
|
||||
let out = pick_best(
|
||||
&base,
|
||||
&[(10, other), (20, winner)],
|
||||
ADAPTIVE_THRESHOLD,
|
||||
ADAPTIVE_MARGIN,
|
||||
)
|
||||
.expect("should accept");
|
||||
assert_eq!(out.backend_node_id, 20);
|
||||
assert!(out.score > out.second_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_rejects_ambiguous_twins() {
|
||||
let base = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
// Two near-identical delete buttons — must refuse to guess.
|
||||
let twin_a = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
let twin_b = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
let err = pick_best(
|
||||
&base,
|
||||
&[(1, twin_a), (2, twin_b)],
|
||||
ADAPTIVE_THRESHOLD,
|
||||
ADAPTIVE_MARGIN,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, RejectReason::Ambiguous { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_rejects_low_score() {
|
||||
let base = fp("button", "Submit order", &[("id", "checkout")]);
|
||||
let junk = fp("span", "unrelated footer text", &[("class", "muted")]);
|
||||
let err = pick_best(&base, &[(1, junk)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err();
|
||||
assert!(matches!(err, RejectReason::LowScore { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_no_candidates() {
|
||||
let base = fp("button", "x", &[]);
|
||||
assert_eq!(
|
||||
pick_best(&base, &[], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(),
|
||||
RejectReason::NoCandidates
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ use super::cdp::lightpanda::{launch_lightpanda, LightpandaLaunchOptions, Lightpa
|
||||
use super::cdp::types::*;
|
||||
use super::element::{resolve_element_object_id, RefMap};
|
||||
|
||||
/// The daemon's session name, set once at daemon start. Names the Chrome tab
|
||||
/// group that abs-created tabs land in when driving the user's real Chrome via
|
||||
/// the `ab-connect` extension, so each agent/session gets its own group.
|
||||
pub static DAEMON_SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch validation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -305,12 +310,64 @@ pub struct BrowserManager {
|
||||
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
||||
visited_origins: HashSet<String>,
|
||||
next_tab_id: u32,
|
||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||
/// (the patchright / rebrowser "runtime leak") — even when attached to the user's
|
||||
/// real Chrome. Opt in via `AGENT_BROWSER_CAPTURE_CONSOLE=1` when you need the
|
||||
/// `console` / `errors` commands to return page output.
|
||||
pub capture_console: bool,
|
||||
}
|
||||
|
||||
/// Whether console/error capture (and thus `Runtime.enable`) is opted into for this
|
||||
/// daemon. Defaults to `false` so the common automation path leaves no Runtime-domain
|
||||
/// fingerprint. Set `AGENT_BROWSER_CAPTURE_CONSOLE=1` (or `true`) to turn it on.
|
||||
pub fn console_capture_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_CAPTURE_CONSOLE")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const LIGHTPANDA_TARGET_INIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Outcome of a single `Browser.getVersion` liveness probe.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LivenessProbe {
|
||||
/// Chrome answered — the connection is definitely alive.
|
||||
Responded,
|
||||
/// The CDP transport errored (WebSocket closed/reset) — the socket is gone.
|
||||
TransportError,
|
||||
/// The probe timed out with no response.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Decide whether a CDP connection should be considered alive from one probe.
|
||||
///
|
||||
/// The subtle case is [`LivenessProbe::TimedOut`]. For a browser we launched
|
||||
/// ourselves (`is_external_attach == false`) a hung CDP socket is a real
|
||||
/// problem and the daemon should reconnect. But for an *externally attached*
|
||||
/// browser — the stealth fork's default, where we attach to the user's real
|
||||
/// Chrome — a slow/no response is almost always Chrome being briefly busy or,
|
||||
/// critically, showing the Chrome 136+ "Allow remote debugging?" consent modal,
|
||||
/// which blocks CDP responses until the user clicks Allow.
|
||||
///
|
||||
/// Treating that timeout as "dead" tears down the already-consented connection
|
||||
/// and forces a reconnect, which re-pops the consent prompt; repeated on every
|
||||
/// command it produces an endless prompt loop and a connection storm that can
|
||||
/// freeze Chrome. So for external attaches we keep the connection alive on
|
||||
/// timeout. A genuinely dead external socket instead surfaces as
|
||||
/// [`LivenessProbe::TransportError`] (and Chrome being closed by the user is a
|
||||
/// transport error, not a timeout), so zombie-socket detection is preserved.
|
||||
fn connection_alive_from_probe(probe: LivenessProbe, is_external_attach: bool) -> bool {
|
||||
match probe {
|
||||
LivenessProbe::Responded => true,
|
||||
LivenessProbe::TransportError => false,
|
||||
LivenessProbe::TimedOut => is_external_attach,
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserManager {
|
||||
pub async fn launch(options: LaunchOptions, engine: Option<&str>) -> Result<Self, String> {
|
||||
let engine = engine.unwrap_or("chrome");
|
||||
@@ -377,6 +434,7 @@ impl BrowserManager {
|
||||
ignore_https_errors,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
manager.discover_and_attach_targets().await?;
|
||||
manager
|
||||
@@ -466,6 +524,7 @@ impl BrowserManager {
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
if direct_page {
|
||||
@@ -514,12 +573,14 @@ impl BrowserManager {
|
||||
|
||||
if page_targets.is_empty() {
|
||||
// Create a new tab
|
||||
let agent_group = self.agent_group();
|
||||
let result: CreateTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
agent_group,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -593,9 +654,14 @@ impl BrowserManager {
|
||||
self.client
|
||||
.send_command_no_params("Page.enable", Some(session_id))
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", Some(session_id))
|
||||
.await?;
|
||||
// `Runtime.enable` leaves a detectable CDP signal (the patchright/rebrowser
|
||||
// "runtime leak"), so only enable it when console/error capture is opted in.
|
||||
// `Runtime.evaluate` / `Runtime.callFunctionOn` work fine without it.
|
||||
if self.capture_console {
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", Some(session_id))
|
||||
.await?;
|
||||
}
|
||||
// Resume the target if it is paused waiting for the debugger.
|
||||
// This is needed for real browser sessions (Chrome 144+) where targets
|
||||
// are paused after attach until explicitly resumed. No-op otherwise.
|
||||
@@ -629,9 +695,12 @@ impl BrowserManager {
|
||||
self.client
|
||||
.send_command_no_params("Page.enable", None)
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", None)
|
||||
.await?;
|
||||
// See `enable_domains`: `Runtime.enable` is a CDP fingerprint, gated on opt-in.
|
||||
if self.capture_console {
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", None)
|
||||
.await?;
|
||||
}
|
||||
let _ = self
|
||||
.client
|
||||
.send_command_no_params("Runtime.runIfWaitingForDebugger", None)
|
||||
@@ -829,21 +898,27 @@ impl BrowserManager {
|
||||
self.default_timeout_ms
|
||||
}
|
||||
|
||||
/// Checks if the CDP connection is alive by sending a simple command.
|
||||
/// Returns false if the command times out or fails.
|
||||
/// Checks if the CDP connection is alive by sending a `Browser.getVersion`
|
||||
/// probe. See [`connection_alive_from_probe`] for how the outcome maps to a
|
||||
/// liveness verdict — in particular why a timeout does NOT tear down an
|
||||
/// externally-attached browser.
|
||||
pub async fn is_connection_alive(&self) -> bool {
|
||||
let timeout = tokio::time::Duration::from_secs(3);
|
||||
let result = tokio::time::timeout(
|
||||
let probe = match tokio::time::timeout(
|
||||
timeout,
|
||||
self.client
|
||||
.send_command_no_params("Browser.getVersion", None),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(_)) => true,
|
||||
Ok(Err(_)) | Err(_) => false,
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => LivenessProbe::Responded,
|
||||
Ok(Err(_)) => LivenessProbe::TransportError,
|
||||
Err(_) => LivenessProbe::TimedOut,
|
||||
};
|
||||
// No child process => we attached to an external browser (the user's
|
||||
// real Chrome — the stealth fork's default).
|
||||
let is_external_attach = self.browser_process.is_none();
|
||||
connection_alive_from_probe(probe, is_external_attach)
|
||||
}
|
||||
|
||||
/// Non-blocking check whether the locally-launched browser process has exited
|
||||
@@ -890,12 +965,14 @@ impl BrowserManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let agent_group = self.agent_group();
|
||||
let result: CreateTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
agent_group,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -1004,6 +1081,30 @@ impl BrowserManager {
|
||||
self.pages.iter().any(|p| p.label.as_deref() == Some(label))
|
||||
}
|
||||
|
||||
/// Chrome tab-group name for tabs this manager creates, or `None` when not
|
||||
/// driving the user's real Chrome via the `ab-connect` extension relay.
|
||||
///
|
||||
/// Grouping only makes sense on the shared real browser (one Chrome, many
|
||||
/// agents): each session's tabs go into its own group. On a launched / direct
|
||||
/// CDP browser the endpoint is strict, so we must NOT send the custom param —
|
||||
/// hence `None` there. We detect the relay by matching our `ws_url` against
|
||||
/// the live relay URL the native-messaging host published.
|
||||
fn agent_group(&self) -> Option<String> {
|
||||
let via_relay = crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str());
|
||||
if !via_relay {
|
||||
return None;
|
||||
}
|
||||
let name = DAEMON_SESSION
|
||||
.get()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("default");
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn tab_new(
|
||||
&mut self,
|
||||
url: Option<&str>,
|
||||
@@ -1028,12 +1129,14 @@ impl BrowserManager {
|
||||
|
||||
let target_url = url.unwrap_or("about:blank");
|
||||
|
||||
let agent_group = self.agent_group();
|
||||
let result: CreateTargetResult = self
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: target_url.to_string(),
|
||||
agent_group,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -1432,6 +1535,21 @@ impl BrowserManager {
|
||||
self.active_page_index = index;
|
||||
}
|
||||
|
||||
/// Add a passively-discovered page WITHOUT changing the active tab.
|
||||
///
|
||||
/// On a shared browser (ab-connect), `Target.targetCreated` events stream in
|
||||
/// for tabs the user or OTHER agent sessions open. Those are drained on every
|
||||
/// command; routing them through `add_page` made the active tab silently jump
|
||||
/// to a foreign tab, so the session's own `eval`/`get title`/`screenshot`
|
||||
/// landed on the wrong page. Passively-tracked pages must not steal focus —
|
||||
/// only explicit opens (`tab new`, switch) set the active tab.
|
||||
pub fn add_background_page(&mut self, page: PageInfo) {
|
||||
if self.pages.iter().any(|p| p.target_id == page.target_id) {
|
||||
return;
|
||||
}
|
||||
self.pages.push(page);
|
||||
}
|
||||
|
||||
pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
|
||||
update_page_target_info_in_pages(&mut self.pages, target)
|
||||
}
|
||||
@@ -1617,6 +1735,7 @@ async fn initialize_lightpanda_manager(
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
|
||||
@@ -1728,6 +1847,41 @@ mod tests {
|
||||
assert_eq!(format_tab_id(42), "t42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_responded_is_alive_for_both_kinds() {
|
||||
assert!(connection_alive_from_probe(LivenessProbe::Responded, true));
|
||||
assert!(connection_alive_from_probe(LivenessProbe::Responded, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_transport_error_is_dead_for_both_kinds() {
|
||||
// A closed/reset WebSocket is a genuine death — reconnect in both cases.
|
||||
assert!(!connection_alive_from_probe(
|
||||
LivenessProbe::TransportError,
|
||||
true
|
||||
));
|
||||
assert!(!connection_alive_from_probe(
|
||||
LivenessProbe::TransportError,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_timeout_keeps_external_attach_alive() {
|
||||
// Regression guard for the remote-debugging consent storm: a timed-out
|
||||
// probe must NOT tear down an externally-attached browser, otherwise the
|
||||
// daemon reconnects and re-pops Chrome's "Allow remote debugging?" modal
|
||||
// on every command (endless prompts + browser freeze).
|
||||
assert!(connection_alive_from_probe(LivenessProbe::TimedOut, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_timeout_marks_launched_browser_dead() {
|
||||
// A browser we launched that stops responding is a real problem worth a
|
||||
// reconnect (and has no consent modal to worry about).
|
||||
assert!(!connection_alive_from_probe(LivenessProbe::TimedOut, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_id() {
|
||||
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
|
||||
|
||||
@@ -103,6 +103,9 @@ pub struct LaunchOptions {
|
||||
pub ignore_https_errors: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
/// Hide native scrollbars in headless Chromium screenshots by launching
|
||||
/// Chrome with `--hide-scrollbars`.
|
||||
pub hide_scrollbars: bool,
|
||||
/// Initial viewport dimensions used for `--window-size` so the content
|
||||
/// area matches the desired viewport from the start.
|
||||
pub viewport_size: Option<(u32, u32)>,
|
||||
@@ -130,6 +133,7 @@ impl Default for LaunchOptions {
|
||||
ignore_https_errors: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
hide_scrollbars: true,
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
}
|
||||
@@ -142,6 +146,40 @@ struct ChromeArgs {
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Whether to launch Chrome headless. The stealth fork FORBIDS headless (it's a
|
||||
/// bot-detection tell), so this is `false` unless an operator explicitly opts in
|
||||
/// via `AGENT_BROWSER_ALLOW_HEADLESS=1` for a display-less server. The `headless`
|
||||
/// LaunchOption is intentionally ignored — headed is non-negotiable for stealth.
|
||||
fn launch_headless() -> bool {
|
||||
std::env::var("AGENT_BROWSER_ALLOW_HEADLESS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
|
||||
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
|
||||
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
|
||||
let opt_in = std::env::var("AGENT_BROWSER_BLOCK_WEBRTC").ok();
|
||||
let explicitly_off = opt_in
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "0" || v.eq_ignore_ascii_case("false"));
|
||||
if explicitly_off {
|
||||
return None;
|
||||
}
|
||||
let explicitly_on = opt_in
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
|
||||
if has_proxy {
|
||||
// Force all WebRTC UDP through the proxy so the real IP can't leak.
|
||||
Some("disable_non_proxied_udp")
|
||||
} else if explicitly_on {
|
||||
// No proxy, but the user asked to hide the local network IP.
|
||||
Some("default_public_interface_only")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
@@ -174,10 +212,21 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
.as_ref()
|
||||
.is_some_and(|exts| !exts.is_empty());
|
||||
|
||||
// Extensions require headed mode in native Chrome (content scripts are not
|
||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
||||
if options.headless && !has_extensions {
|
||||
// Stealth fork: NEVER launch headless. Headless Chrome is a detectable tell
|
||||
// (creepjs scores ~33% headless even with new-headless; a real GPU and a
|
||||
// headed window score 0%). So we always launch headed and ignore the
|
||||
// `headless` option. The only escape is an explicit AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||
// for genuinely display-less servers (discouraged — it forfeits stealth).
|
||||
// Extensions also require headed mode (content scripts aren't injected headless).
|
||||
if launch_headless() && !has_extensions {
|
||||
args.push("--headless=new".to_string());
|
||||
// Linux paints native scrollbars into viewport screenshots unless
|
||||
// Chrome is launched with this flag. `--hide-scrollbars` is
|
||||
// presence-based, so agent-browser exposes --hide-scrollbars false
|
||||
// as the public opt-out instead of forwarding a fake inverse switch.
|
||||
if options.hide_scrollbars {
|
||||
args.push("--hide-scrollbars".to_string());
|
||||
}
|
||||
// Enable SwiftShader software rendering in headless mode. This
|
||||
// prevents silent crashes in environments where GPU drivers are
|
||||
// missing or restricted (VMs, containers, some cloud machines)
|
||||
@@ -193,6 +242,20 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
// WebRTC IP-leak handling. WebRTC enumerates ICE candidates that can expose
|
||||
// the machine's real local/public IP even when HTTP traffic goes through a
|
||||
// proxy — defeating the proxy. `--force-webrtc-ip-handling-policy` is a real
|
||||
// Chrome privacy switch (no detectable JS lie), applied here for launched
|
||||
// Chrome only (an attached real Chrome keeps the user's own flags).
|
||||
// - proxy set -> `disable_non_proxied_udp`: force WebRTC through
|
||||
// the proxy so the real IP can't leak.
|
||||
// - AGENT_BROWSER_BLOCK_WEBRTC=1 (no proxy) -> `default_public_interface_only`:
|
||||
// hide the local network IP (Brave/uBlock default).
|
||||
// Opt out entirely with AGENT_BROWSER_BLOCK_WEBRTC=0.
|
||||
if let Some(policy) = webrtc_ip_handling_policy(options.proxy.is_some()) {
|
||||
args.push(format!("--force-webrtc-ip-handling-policy={}", policy));
|
||||
}
|
||||
|
||||
let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
let dir = PathBuf::from(&expanded);
|
||||
@@ -229,7 +292,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
.iter()
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless && !has_extensions {
|
||||
if !has_window_size && launch_headless() && !has_extensions {
|
||||
let (w, h) = options.viewport_size.unwrap_or((1280, 720));
|
||||
args.push(format!("--window-size={},{}", w, h));
|
||||
}
|
||||
@@ -653,7 +716,83 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
||||
Some((port, ws_path))
|
||||
}
|
||||
|
||||
/// Remove leftover Chrome temp profile directories from daemons that were
|
||||
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
|
||||
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
|
||||
/// per session under the system temp dir. On daemon startup we sweep them — but
|
||||
/// ONLY dirs that no running process still references as `--user-data-dir`, so
|
||||
/// a profile in active use is never deleted.
|
||||
pub fn cleanup_orphaned_chrome_profiles() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&tmp) else {
|
||||
return;
|
||||
};
|
||||
// Snapshot live process command lines once. If we can't determine them,
|
||||
// skip cleanup entirely rather than risk deleting an in-use profile.
|
||||
let Some(live_cmdlines) = running_process_cmdlines() else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let path_str = path.to_string_lossy();
|
||||
let in_use = live_cmdlines
|
||||
.iter()
|
||||
.any(|cmd| cmd.contains(path_str.as_ref()));
|
||||
if !in_use {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-axww", "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|l| l.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||
// Best-effort: skip cleanup where we can't cheaply enumerate full process
|
||||
// command lines, to avoid deleting a profile that is still in use.
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
// Prefer the dialog-free `ab-connect` extension relay when it is live.
|
||||
// The relay drives the user's REAL Chrome via the extension's
|
||||
// `chrome.debugger` permission, which — unlike a raw `--remote-debugging-port`
|
||||
// CDP attach — never triggers Chrome 136+'s per-connection
|
||||
// "Allow remote debugging?" consent modal. The native-messaging host writes
|
||||
// ~/.agent-browser/relay-cdp-url while connected and removes it on exit, so a
|
||||
// present URL means the relay is up. This must win over the DevToolsActivePort
|
||||
// / :9222 probes below: if the user's Chrome happens to also be listening on a
|
||||
// debug port, attaching there would pop the consent dialog and defeat the
|
||||
// whole zero-interaction extension path.
|
||||
if let Some(relay) = crate::connect::relay_url() {
|
||||
// The relay is a local CDP-over-WS endpoint we connect to like Chrome.
|
||||
// A bare TCP liveness check (no WS upgrade) confirms it is actually
|
||||
// accepting before we commit, mirroring the consent-free probe used for
|
||||
// DevToolsActivePort.
|
||||
if relay_is_live(&relay).await {
|
||||
return Ok(relay);
|
||||
}
|
||||
}
|
||||
|
||||
let user_data_dirs = get_chrome_user_data_dirs();
|
||||
|
||||
for dir in &user_data_dirs {
|
||||
@@ -674,57 +813,75 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
||||
Err(
|
||||
"No running Chrome with remote debugging found. Remote debugging is a \
|
||||
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
||||
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
||||
--cdp <port>/--launch."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||
///
|
||||
/// Tries the exact WebSocket path from DevToolsActivePort first (single
|
||||
/// prompt on M144+), then falls back to legacy HTTP discovery for older
|
||||
/// Chrome versions. This order avoids triggering duplicate remote-debugging
|
||||
/// permission prompts (#1210, #1206).
|
||||
/// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
|
||||
/// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
|
||||
/// same port for older Chrome layouts.
|
||||
///
|
||||
/// Crucially, this does NOT open a throwaway verification WebSocket. On
|
||||
/// Chrome 136+ the "Allow remote debugging?" consent is granted *per
|
||||
/// connection*: a probe WebSocket we then close would consume the user's one
|
||||
/// Allow click, leaving the real connection (opened afterwards) unconsented —
|
||||
/// which manifests as an endless prompt loop or a hung command. By skipping the
|
||||
/// probe, the real connection is the single WebSocket the user consents to.
|
||||
/// (Background: #1210, #1206 duplicate-prompt reports.)
|
||||
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
|
||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
||||
if verify_ws_endpoint(&ws_url).await {
|
||||
return Ok(ws_url);
|
||||
// Consent-free liveness: a bare TCP connect does not trigger the
|
||||
// remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
|
||||
// so we can tell "Chrome is listening" from "stale DevToolsActivePort"
|
||||
// without burning a prompt.
|
||||
if tcp_port_alive(port).await {
|
||||
return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path));
|
||||
}
|
||||
|
||||
// Pre-M144 fallback: HTTP endpoints (/json/version, /json/list, etc.)
|
||||
// Port isn't accepting connections (stale file / different layout). Fall
|
||||
// back to HTTP discovery for older Chrome before giving up.
|
||||
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
||||
return Ok(ws_url);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Cannot connect to Chrome on port {}: both direct WebSocket and HTTP discovery failed",
|
||||
"Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed",
|
||||
port
|
||||
))
|
||||
}
|
||||
|
||||
/// Verify that a WebSocket endpoint is a live CDP server by sending
|
||||
/// `Browser.getVersion` and checking for a valid response.
|
||||
async fn verify_ws_endpoint(ws_url: &str) -> bool {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
/// Consent-free check that something is accepting TCP connections on
|
||||
/// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
|
||||
/// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
|
||||
/// use for liveness before handing the URL to the single real connection.
|
||||
async fn tcp_port_alive(port: u16) -> bool {
|
||||
let timeout = Duration::from_secs(1);
|
||||
matches!(
|
||||
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(("127.0.0.1", port)),).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(2);
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?;
|
||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
||||
ws.send(Message::Text(cmd.into())).await.ok()?;
|
||||
while let Some(Ok(msg)) = ws.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if v.get("id").and_then(|id| id.as_u64()) == Some(1) {
|
||||
let _ = ws.close(None).await;
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
matches!(result, Ok(Some(())))
|
||||
/// Consent-free liveness for the `ab-connect` relay ws URL (`ws://127.0.0.1:<port>/…`).
|
||||
/// Parses the port and does a bare TCP connect — a stale relay-cdp-url file
|
||||
/// (host exited without cleanup) must not divert auto-connect away from the
|
||||
/// working port path.
|
||||
async fn relay_is_live(ws_url: &str) -> bool {
|
||||
let port = ws_url
|
||||
.strip_prefix("ws://")
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.and_then(|hostport| hostport.rsplit(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok());
|
||||
match port {
|
||||
Some(p) => tcp_port_alive(p).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default Chrome user-data directory paths for the current platform.
|
||||
@@ -846,6 +1003,18 @@ pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec<ChromeProfile> {
|
||||
/// 3. Case-insensitive directory name match
|
||||
///
|
||||
/// Returns the resolved directory name, or an error with available profiles.
|
||||
/// Read `profile.last_used` (the directory name of the profile Chrome opened
|
||||
/// most recently) from a user-data dir's `Local State`. Used to resolve
|
||||
/// `--profile auto`.
|
||||
fn read_last_used_profile(user_data_dir: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(user_data_dir.join("Local State")).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
json.get("profile")?
|
||||
.get("last_used")?
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result<String, String> {
|
||||
let profiles = list_chrome_profiles(user_data_dir);
|
||||
|
||||
@@ -857,6 +1026,21 @@ pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result<Strin
|
||||
));
|
||||
}
|
||||
|
||||
// "auto": pick the profile Chrome last used (else "Default", else the first
|
||||
// one), so `--profile auto` reuses the real logged-in profile without the
|
||||
// user having to name it explicitly.
|
||||
if input.eq_ignore_ascii_case("auto") {
|
||||
if let Some(lu) = read_last_used_profile(user_data_dir) {
|
||||
if let Some(p) = profiles.iter().find(|p| p.directory == lu) {
|
||||
return Ok(p.directory.clone());
|
||||
}
|
||||
}
|
||||
if let Some(p) = profiles.iter().find(|p| p.directory == "Default") {
|
||||
return Ok(p.directory.clone());
|
||||
}
|
||||
return Ok(profiles[0].directory.clone());
|
||||
}
|
||||
|
||||
// Tier 1: exact directory name match
|
||||
if let Some(p) = profiles.iter().find(|p| p.directory == input) {
|
||||
return Ok(p.directory.clone());
|
||||
@@ -1245,6 +1429,37 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn webrtc_policy_forces_proxy_when_proxy_set() {
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
|
||||
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
|
||||
// Proxy set, no env: always force WebRTC through the proxy.
|
||||
assert_eq!(
|
||||
webrtc_ip_handling_policy(true),
|
||||
Some("disable_non_proxied_udp")
|
||||
);
|
||||
// No proxy, no env: leave WebRTC at Chrome's default.
|
||||
assert_eq!(webrtc_ip_handling_policy(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webrtc_policy_opt_in_and_opt_out() {
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
|
||||
|
||||
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "1");
|
||||
assert_eq!(
|
||||
webrtc_ip_handling_policy(false),
|
||||
Some("default_public_interface_only")
|
||||
);
|
||||
|
||||
// Explicit opt-out wins even when a proxy is set.
|
||||
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "0");
|
||||
assert_eq!(webrtc_ip_handling_policy(true), None);
|
||||
assert_eq!(webrtc_ip_handling_policy(false), None);
|
||||
|
||||
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_noop_child() -> Child {
|
||||
Command::new("/bin/sh")
|
||||
@@ -1353,23 +1568,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_headless_includes_headless_flag() {
|
||||
fn test_build_args_forbids_headless_by_default() {
|
||||
// Stealth fork: headless is FORBIDDEN. `headless: true` is ignored — the
|
||||
// launch is always headed (no --headless / swiftshader / forced size).
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
|
||||
g.remove("AGENT_BROWSER_ALLOW_HEADLESS");
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless must be forbidden even when the headless option is true"
|
||||
);
|
||||
assert!(!result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--enable-unsafe-swiftshader"));
|
||||
if let Some(dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_allow_headless_escape() {
|
||||
// The only way back to headless: an explicit opt-in for display-less servers.
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
|
||||
g.set("AGENT_BROWSER_ALLOW_HEADLESS", "1");
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||
assert!(result
|
||||
.args
|
||||
.iter()
|
||||
.any(|a| a == "--enable-unsafe-swiftshader"));
|
||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||
// Temp dir created when no profile
|
||||
assert!(result.temp_user_data_dir.is_some());
|
||||
let dir = result.temp_user_data_dir.unwrap();
|
||||
assert!(dir.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if let Some(dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1380,6 +1616,7 @@ mod tests {
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(!result.args.iter().any(|a| a.contains("--headless")));
|
||||
assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
|
||||
assert!(!result
|
||||
.args
|
||||
.iter()
|
||||
@@ -1434,6 +1671,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
|
||||
let opts = LaunchOptions {
|
||||
headless: true,
|
||||
hide_scrollbars: false,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_chrome_args(&opts).unwrap();
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a == "--hide-scrollbars"),
|
||||
"--hide-scrollbars false should suppress agent-browser's default hide switch"
|
||||
);
|
||||
if let Some(ref dir) = result.temp_user_data_dir {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_args_start_maximized_suppresses_default_window_size() {
|
||||
let opts = LaunchOptions {
|
||||
@@ -1474,6 +1728,10 @@ mod tests {
|
||||
!result.args.iter().any(|a| a.contains("--headless")),
|
||||
"headless flag should be omitted when extensions are present"
|
||||
);
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a == "--hide-scrollbars"),
|
||||
"scrollbars should remain visible when extensions force headed mode"
|
||||
);
|
||||
assert!(
|
||||
!result.args.iter().any(|a| a.contains("--window-size")),
|
||||
"window-size should be omitted when extensions force headed mode"
|
||||
@@ -1579,6 +1837,44 @@ mod tests {
|
||||
assert!(!is_chrome_profile_name("relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_chrome_profile_auto_prefers_last_used() {
|
||||
let tmp = std::env::temp_dir().join("ab-auto-lastused-test");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let local_state = serde_json::json!({
|
||||
"profile": {
|
||||
"last_used": "Profile 2",
|
||||
"info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} }
|
||||
}
|
||||
});
|
||||
std::fs::write(
|
||||
tmp.join("Local State"),
|
||||
serde_json::to_string(&local_state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Profile 2");
|
||||
assert_eq!(resolve_chrome_profile(&tmp, "AUTO").unwrap(), "Profile 2");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_chrome_profile_auto_falls_back_to_default() {
|
||||
let tmp = std::env::temp_dir().join("ab-auto-default-test");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let local_state = serde_json::json!({
|
||||
"profile": { "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} } }
|
||||
});
|
||||
std::fs::write(
|
||||
tmp.join("Local State"),
|
||||
serde_json::to_string(&local_state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Default");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// Helper to create a fake Chrome user-data dir with a `Local State` file.
|
||||
fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) {
|
||||
let mut info_cache = serde_json::Map::new();
|
||||
@@ -1870,83 +2166,61 @@ mod tests {
|
||||
// auto_connect_cdp discovery-order tests (#1210, #1206)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// When DevToolsActivePort provides a ws_path and the port is reachable,
|
||||
/// `resolve_cdp_from_active_port` should return the exact ws_path URL
|
||||
/// WITHOUT calling HTTP discovery first.
|
||||
/// When the port is live, `resolve_cdp_from_active_port` returns the exact
|
||||
/// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
|
||||
/// probe with a verification WebSocket (which would burn Chrome 136+'s
|
||||
/// per-connection remote-debugging consent on a throwaway socket).
|
||||
#[tokio::test]
|
||||
async fn test_resolve_cdp_from_active_port_prefers_ws_path() {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||
|
||||
async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
|
||||
// A bound listener makes the port TCP-reachable. We do NOT accept/serve
|
||||
// any WebSocket — resolve must succeed from the bare TCP check alone.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let ws_path = "/devtools/browser/test-uuid-1234".to_string();
|
||||
let ws_path = "/devtools/browser/test-uuid-1234";
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// accept: verify_ws_endpoint() WebSocket handshake
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
if let Some(Ok(WsMsg::Text(text))) = ws.next().await {
|
||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req.get("id").unwrap();
|
||||
let reply = format!(
|
||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/147"}}}}"#,
|
||||
id
|
||||
);
|
||||
ws.send(WsMsg::Text(reply)).await.unwrap();
|
||||
}
|
||||
let _ = ws.close(None).await;
|
||||
});
|
||||
|
||||
let result = resolve_cdp_from_active_port(port, &ws_path).await;
|
||||
assert!(result.is_ok(), "should succeed: {:?}", result);
|
||||
let url = result.unwrap();
|
||||
let result = resolve_cdp_from_active_port(port, ws_path).await;
|
||||
assert!(
|
||||
url.contains("test-uuid-1234"),
|
||||
"should use exact ws_path from DevToolsActivePort, got: {}",
|
||||
url
|
||||
result.is_ok(),
|
||||
"should succeed when port is live: {:?}",
|
||||
result
|
||||
);
|
||||
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path));
|
||||
server.await.unwrap();
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("ws://127.0.0.1:{}{}", port, ws_path),
|
||||
"should return the exact DevToolsActivePort URL untouched"
|
||||
);
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port`
|
||||
/// should fall back to HTTP discovery.
|
||||
/// Regression guard for the consent storm: resolving the URL must only do a
|
||||
/// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a
|
||||
/// handshake on a throwaway socket consumes the user's one "Allow remote
|
||||
/// debugging?" click, leaving the real connection unconsented (endless
|
||||
/// prompts / hang).
|
||||
#[tokio::test]
|
||||
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close)
|
||||
let (s1, _) = listener.accept().await.unwrap();
|
||||
drop(s1);
|
||||
|
||||
// 2nd accept: HTTP /json/version from discover_cdp_url()
|
||||
let (mut s2, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = s2.read(&mut buf).await;
|
||||
let body = format!(
|
||||
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#,
|
||||
port
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
s2.write_all(resp.as_bytes()).await.unwrap();
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
// The liveness check connects then drops without writing anything.
|
||||
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
|
||||
let mut buf = [0u8; 128];
|
||||
let read =
|
||||
tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await;
|
||||
match read {
|
||||
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
|
||||
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
|
||||
}
|
||||
});
|
||||
|
||||
let result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await;
|
||||
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result);
|
||||
let url = result.unwrap();
|
||||
assert!(
|
||||
url.contains("fallback-uuid"),
|
||||
"should use HTTP discovery fallback, got: {}",
|
||||
url
|
||||
let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
@@ -1961,4 +2235,35 @@ mod tests {
|
||||
let result = resolve_cdp_from_active_port(port, "/devtools/browser/dead").await;
|
||||
assert!(result.is_err(), "should fail when nothing is listening");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_relay_is_live_true_when_listening() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let url = format!("ws://127.0.0.1:{}/abc-guid", port);
|
||||
assert!(
|
||||
relay_is_live(&url).await,
|
||||
"relay_is_live should be true while the port is accepting"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_relay_is_live_false_when_dead() {
|
||||
// Bind to grab a free port, then drop so nothing is listening.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let url = format!("ws://127.0.0.1:{}/abc-guid", port);
|
||||
assert!(
|
||||
!relay_is_live(&url).await,
|
||||
"relay_is_live must be false for a stale relay-cdp-url (host exited)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_relay_is_live_false_on_malformed_url() {
|
||||
assert!(!relay_is_live("not-a-ws-url").await);
|
||||
assert!(!relay_is_live("ws://127.0.0.1/no-port").await);
|
||||
assert!(!relay_is_live("ws://127.0.0.1:notaport/x").await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
"All CDP discovery methods failed for {host}:{port}. \
|
||||
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
|
||||
(/json/version, /json/list), so `--cdp <port>` cannot find the target — \
|
||||
use the default auto-connect (just `agent-browser open <url>`), which reads \
|
||||
DevToolsActivePort and attaches over WebSocket. \
|
||||
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,11 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
// Spawns a real child process and binds a TCP server with timing-based
|
||||
// readiness assumptions; flaky under CI load (intermittent "exited before
|
||||
// CDP became ready" / connection-refused races). Run locally with
|
||||
// `--ignored` when touching lightpanda startup.
|
||||
#[ignore = "process spawn + socket timing race, flaky in CI"]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
|
||||
@@ -106,7 +106,13 @@ pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
// Tolerate minimal targetInfo: the ab-connect relay's synthesized
|
||||
// Target.attachedToTarget (re-announce path) omits title/url, and real CDP
|
||||
// occasionally omits them too. Default to empty rather than fail the whole
|
||||
// Target.getTargets deserialize.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
@@ -141,6 +147,12 @@ pub struct SetDiscoverTargetsParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
/// Non-CDP hint consumed only by the `ab-connect` extension: the Chrome
|
||||
/// tab-group name to drop the new tab into (per-session grouping on the
|
||||
/// shared real Chrome). `None` on the normal CDP path so a strict real-Chrome
|
||||
/// endpoint never receives an unknown parameter.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -17,6 +17,10 @@ use super::state;
|
||||
use super::stream::StreamServer;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
// Record this daemon's session so tabs it opens on the shared real Chrome
|
||||
// (via the ab-connect extension) land in a per-session Chrome tab group.
|
||||
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
|
||||
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
@@ -59,6 +63,10 @@ pub async fn run_daemon(session: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
|
||||
// run on kill -9). Only removes dirs no live process references.
|
||||
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
|
||||
@@ -94,6 +94,61 @@ async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
async fn send_raw_http_request(port: u64, request: &str) -> String {
|
||||
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.expect("HTTP client should connect to stream server");
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.expect("HTTP request should be written");
|
||||
stream
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("HTTP client write side should shut down");
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.expect("HTTP response should be read");
|
||||
String::from_utf8(response).expect("HTTP response should be utf-8")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn spawn_fake_daemon_socket(
|
||||
socket_dir: &std::path::Path,
|
||||
session_name: &str,
|
||||
) -> tokio::sync::oneshot::Receiver<String> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let socket_path = socket_dir.join(format!("{session_name}.sock"));
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let listener =
|
||||
tokio::net::UnixListener::bind(&socket_path).expect("fake daemon socket should bind");
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut reader = tokio::io::BufReader::new(stream);
|
||||
let mut command = String::new();
|
||||
if reader.read_line(&mut command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
let _ = stream
|
||||
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
|
||||
.await;
|
||||
let _ = stream.write_all(b"\n").await;
|
||||
let _ = tx.send(command);
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core: launch, navigate, evaluate, url, title, close
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -363,6 +418,98 @@ async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() {
|
||||
let _ = std::fs::remove_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_stream_command_requires_same_origin_before_daemon_relay() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
|
||||
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("t");
|
||||
std::fs::create_dir_all(&temp_parent).expect("socket temp parent should be created");
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("ab-e2e-")
|
||||
.tempdir_in(temp_parent)
|
||||
.expect("socket dir should be created");
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir
|
||||
.path()
|
||||
.to_str()
|
||||
.expect("socket dir should be utf-8"),
|
||||
);
|
||||
guard.set("AGENT_BROWSER_SESSION", "x");
|
||||
|
||||
let mut state = DaemonState::new();
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "stream_enable", "port": 0 }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let port = get_data(&resp)["port"]
|
||||
.as_u64()
|
||||
.expect("stream enable should report the bound port");
|
||||
|
||||
let mut daemon_command = spawn_fake_daemon_socket(socket_dir.path(), "x").await;
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let cross_origin_request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_raw_http_request(port, &cross_origin_request).await;
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected cross-origin response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), &mut daemon_command)
|
||||
.await
|
||||
.is_err(),
|
||||
"cross-origin command request reached daemon relay"
|
||||
);
|
||||
|
||||
let same_origin_request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let response = send_raw_http_request(port, &same_origin_request).await;
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 200 OK"),
|
||||
"unexpected same-origin response: {response}"
|
||||
);
|
||||
assert!(
|
||||
response.contains(&format!(
|
||||
"Access-Control-Allow-Origin: http://localhost:{port}"
|
||||
)),
|
||||
"same-origin command response did not reflect origin: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"same-origin command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
|
||||
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
|
||||
.await
|
||||
.expect("same-origin request should reach fake daemon")
|
||||
.expect("fake daemon should return relayed command");
|
||||
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "stream_disable" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot with refs and ref-based click
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2105,9 +2252,13 @@ async fn e2e_save_state_cross_domain() {
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Navigate to domain A and set cookie + localStorage
|
||||
// Navigate to domain A and set cookie + localStorage. Use example.org (a
|
||||
// stable IANA-reserved domain, like example.com below) rather than an
|
||||
// external service such as httpbin.org — cookie/localStorage are set
|
||||
// client-side via CDP, so the only requirement is that the page loads
|
||||
// reliably. A flaky external domain made this test intermittently fail in CI.
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://httpbin.org/html" }),
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.org/" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
@@ -2116,7 +2267,7 @@ async fn e2e_save_state_cross_domain() {
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "3", "action": "cookies_set",
|
||||
"name": "domainA_cookie", "value": "from_httpbin"
|
||||
"name": "domainA_cookie", "value": "from_example_org"
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
@@ -2183,7 +2334,7 @@ async fn e2e_save_state_cross_domain() {
|
||||
let has_domain_b = cookies.iter().any(|c| c["name"] == "domainB_cookie");
|
||||
assert!(
|
||||
has_domain_a,
|
||||
"Should include cross-domain cookie from httpbin.org: {:?}",
|
||||
"Should include cross-domain cookie from example.org: {:?}",
|
||||
cookies
|
||||
);
|
||||
assert!(
|
||||
@@ -2194,21 +2345,26 @@ async fn e2e_save_state_cross_domain() {
|
||||
|
||||
// Verify BOTH origins' localStorage are present
|
||||
let origins = state_data["origins"].as_array().unwrap();
|
||||
// Match full hostnames so the two example.* origins don't alias each other.
|
||||
let has_origin_a = origins.iter().any(|o| {
|
||||
o["origin"].as_str().is_some_and(|s| s.contains("httpbin"))
|
||||
o["origin"]
|
||||
.as_str()
|
||||
.is_some_and(|s| s.contains("example.org"))
|
||||
&& o["localStorage"]
|
||||
.as_array()
|
||||
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainA_key"))
|
||||
});
|
||||
let has_origin_b = origins.iter().any(|o| {
|
||||
o["origin"].as_str().is_some_and(|s| s.contains("example"))
|
||||
o["origin"]
|
||||
.as_str()
|
||||
.is_some_and(|s| s.contains("example.com"))
|
||||
&& o["localStorage"]
|
||||
.as_array()
|
||||
.is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainB_key"))
|
||||
});
|
||||
assert!(
|
||||
has_origin_a,
|
||||
"Should include localStorage from httpbin.org origin: {:?}",
|
||||
"Should include localStorage from example.org origin: {:?}",
|
||||
origins
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::adaptive::{self, ElementFingerprint};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
|
||||
@@ -13,6 +14,9 @@ pub struct RefEntry {
|
||||
pub nth: Option<usize>,
|
||||
pub selector: Option<String>,
|
||||
pub frame_id: Option<String>,
|
||||
/// AX fingerprint captured at snapshot time, used by adaptive relocation when
|
||||
/// the node is gone and the role/name/nth re-query also fails.
|
||||
pub fingerprint: Option<ElementFingerprint>,
|
||||
}
|
||||
|
||||
pub struct RefMap {
|
||||
@@ -57,10 +61,19 @@ impl RefMap {
|
||||
nth,
|
||||
selector: None,
|
||||
frame_id: frame_id.map(|s| s.to_string()),
|
||||
fingerprint: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Attach an AX fingerprint to an existing ref (set during snapshot, used by
|
||||
/// adaptive relocation). No-op if the ref is unknown.
|
||||
pub fn set_fingerprint(&mut self, ref_id: &str, fingerprint: ElementFingerprint) {
|
||||
if let Some(entry) = self.map.get_mut(ref_id) {
|
||||
entry.fingerprint = Some(fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_selector(
|
||||
&mut self,
|
||||
ref_id: String,
|
||||
@@ -78,6 +91,7 @@ impl RefMap {
|
||||
nth,
|
||||
selector: Some(selector),
|
||||
frame_id: None,
|
||||
fingerprint: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -146,13 +160,57 @@ pub fn parse_ref(input: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// When a saved `@ref`'s node is gone and the role/name/nth re-query also failed,
|
||||
/// try to relocate the element by AX fingerprint similarity. Returns the chosen
|
||||
/// backend node id only when confident (high score + clear margin over the
|
||||
/// runner-up). Opt out with `AGENT_BROWSER_ADAPTIVE_REF=0`.
|
||||
async fn relocate_stale_ref(
|
||||
client: &CdpClient,
|
||||
ref_id: &str,
|
||||
entry: &RefEntry,
|
||||
session_id: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Option<i64> {
|
||||
if std::env::var("AGENT_BROWSER_ADAPTIVE_REF").as_deref() == Ok("0") {
|
||||
return None;
|
||||
}
|
||||
let baseline = entry.fingerprint.as_ref()?;
|
||||
let candidates = super::snapshot::collect_current_fingerprints(
|
||||
client,
|
||||
session_id,
|
||||
entry.frame_id.as_deref(),
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
match adaptive::pick_best(
|
||||
baseline,
|
||||
&candidates,
|
||||
adaptive::ADAPTIVE_THRESHOLD,
|
||||
adaptive::ADAPTIVE_MARGIN,
|
||||
) {
|
||||
Ok(reloc) => {
|
||||
eprintln!(
|
||||
"[adaptive] relocated {ref_id} ({} \"{}\") score={:.2} second={:.2} -> backendNodeId {}",
|
||||
entry.role, entry.name, reloc.score, reloc.second_score, reloc.backend_node_id
|
||||
);
|
||||
Some(reloc.backend_node_id)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `@ref` or CSS selector to a click point. Returns
|
||||
/// `(centre_x, centre_y, width, height, session_id)`. Width/height come from the
|
||||
/// element's box model and feed humanize's in-bounds landing jitter; the CSS
|
||||
/// selector path returns zero size (→ land on centre, no jitter).
|
||||
pub async fn resolve_element_center(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(f64, f64, String), String> {
|
||||
) -> Result<(f64, f64, f64, f64, String), String> {
|
||||
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
||||
let entry = ref_map
|
||||
.get(&ref_id)
|
||||
@@ -163,15 +221,19 @@ pub async fn resolve_element_center(
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let mut active_id = backend_node_id;
|
||||
// Identity check: React often re-uses the same DOM node when
|
||||
// re-rendering — backendNodeId stays the same but accessibleName
|
||||
// / role changes. Without this verification, `click @e20` (saved
|
||||
// when the button said "Add post") happily clicks the *same*
|
||||
// node that now says "Post all", silently submitting the thread.
|
||||
//
|
||||
// Set AGENT_BROWSER_VERIFY_REF=0 to skip (saves one CDP
|
||||
// roundtrip per ref-based interaction; only safe if you know
|
||||
// the page is static between snapshot and click).
|
||||
// On mismatch, try adaptive fingerprint relocation before failing:
|
||||
// a confident high-score/high-margin match is a stronger identity
|
||||
// signal than role+name, and lets a moved+renamed element still
|
||||
// resolve. If relocation isn't confident, surface the original
|
||||
// identity error. Set AGENT_BROWSER_VERIFY_REF=0 to skip the check
|
||||
// (and thus relocation) entirely.
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||
if let Err(e) = verify_ref_identity(
|
||||
client,
|
||||
@@ -183,7 +245,12 @@ pub async fn resolve_element_center(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e);
|
||||
match relocate_stale_ref(client, &ref_id, entry, session_id, iframe_sessions)
|
||||
.await
|
||||
{
|
||||
Some(id) => active_id = id,
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +258,7 @@ pub async fn resolve_element_center(
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
&DomGetBoxModelParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
backend_node_id: Some(active_id),
|
||||
node_id: None,
|
||||
object_id: None,
|
||||
},
|
||||
@@ -200,14 +267,30 @@ pub async fn resolve_element_center(
|
||||
.await;
|
||||
|
||||
if let Ok(r) = result {
|
||||
let (x, y) = box_model_center(&r.model);
|
||||
return Ok((x, y, effective_session_id.to_string()));
|
||||
let (x, y, w, h) = box_model_dims(&r.model);
|
||||
// Occlusion check: a transient overlay (X.com's "click
|
||||
// outside to close" mask, modal backdrop, sticky banner,
|
||||
// etc.) can land on top of our target between snapshot
|
||||
// and click. Coordinates are correct, but
|
||||
// `document.elementFromPoint(x, y)` returns the overlay
|
||||
// — and the click goes to the overlay's handler, not
|
||||
// ours. Catch it here so the user gets "occluded by
|
||||
// DIV[testid=mask]" instead of "modal silently closed +
|
||||
// thread submitted by accident".
|
||||
//
|
||||
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
||||
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
|
||||
.await?;
|
||||
}
|
||||
return Ok((x, y, w, h, effective_session_id.to_string()));
|
||||
}
|
||||
// backend_node_id is stale; re-query the accessibility tree below
|
||||
}
|
||||
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||
let fresh_id = find_node_id_by_role_name(
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name.
|
||||
// If that fails, try adaptive fingerprint relocation before giving up.
|
||||
let fresh_id = match find_node_id_by_role_name(
|
||||
client,
|
||||
session_id,
|
||||
&entry.role,
|
||||
@@ -216,7 +299,16 @@ pub async fn resolve_element_center(
|
||||
entry.frame_id.as_deref(),
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => match relocate_stale_ref(client, &ref_id, entry, session_id, iframe_sessions)
|
||||
.await
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Err(e),
|
||||
},
|
||||
};
|
||||
let result: DomGetBoxModelResult = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
@@ -228,13 +320,14 @@ pub async fn resolve_element_center(
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
let (x, y) = box_model_center(&result.model);
|
||||
return Ok((x, y, effective_session_id.to_string()));
|
||||
let (x, y, w, h) = box_model_dims(&result.model);
|
||||
return Ok((x, y, w, h, effective_session_id.to_string()));
|
||||
}
|
||||
|
||||
// CSS selector
|
||||
let (x, y) = resolve_by_selector(client, session_id, selector_or_ref).await?;
|
||||
Ok((x, y, session_id.to_string()))
|
||||
// No box model on the CSS-selector fast path → zero size → land on centre.
|
||||
Ok((x, y, 0.0, 0.0, session_id.to_string()))
|
||||
}
|
||||
|
||||
pub async fn resolve_element_object_id(
|
||||
@@ -254,9 +347,11 @@ pub async fn resolve_element_object_id(
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
let mut active_id = backend_node_id;
|
||||
// Same identity guard as resolve_element_center — see that
|
||||
// function for why React DOM-node-reuse breaks ref-based
|
||||
// interactions if we skip this.
|
||||
// interactions if we skip this, and why a confident adaptive
|
||||
// relocation is allowed to override an identity mismatch.
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||
if let Err(e) = verify_ref_identity(
|
||||
client,
|
||||
@@ -268,7 +363,12 @@ pub async fn resolve_element_object_id(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e);
|
||||
match relocate_stale_ref(client, &ref_id, entry, session_id, iframe_sessions)
|
||||
.await
|
||||
{
|
||||
Some(id) => active_id = id,
|
||||
None => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +376,7 @@ pub async fn resolve_element_object_id(
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
&DomResolveNodeParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
backend_node_id: Some(active_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser".to_string()),
|
||||
},
|
||||
@@ -292,8 +392,9 @@ pub async fn resolve_element_object_id(
|
||||
// backend_node_id is stale; re-query the accessibility tree below
|
||||
}
|
||||
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||
let fresh_id = find_node_id_by_role_name(
|
||||
// Fallback: re-query the accessibility tree to find a fresh node by role/name.
|
||||
// If that fails, try adaptive fingerprint relocation before giving up.
|
||||
let fresh_id = match find_node_id_by_role_name(
|
||||
client,
|
||||
session_id,
|
||||
&entry.role,
|
||||
@@ -302,7 +403,16 @@ pub async fn resolve_element_object_id(
|
||||
entry.frame_id.as_deref(),
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => match relocate_stale_ref(client, &ref_id, entry, session_id, iframe_sessions)
|
||||
.await
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Err(e),
|
||||
},
|
||||
};
|
||||
let result: DomResolveNodeResult = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
@@ -335,6 +445,18 @@ pub async fn resolve_element_object_id(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A syntactically-invalid selector makes `document.querySelector` THROW.
|
||||
// With returnByValue:false, Runtime.evaluate then returns the thrown
|
||||
// DOMException as a remote object *with* an objectId — which would otherwise
|
||||
// be mistaken for "the element" and silently no-op a `.click()` on it. Treat
|
||||
// any thrown exception as a hard error so a typo'd selector fails loudly.
|
||||
if let Some(ex) = result.exception_details {
|
||||
return Err(format!(
|
||||
"Invalid selector '{}': {}",
|
||||
selector_or_ref, ex.text
|
||||
));
|
||||
}
|
||||
|
||||
let object_id = result
|
||||
.result
|
||||
.object_id
|
||||
@@ -396,9 +518,23 @@ async fn verify_ref_identity(
|
||||
"backendNodeId": backend_node_id,
|
||||
"fetchRelatives": false,
|
||||
});
|
||||
let resp: Result<GetFullAXTreeResult, String> = client
|
||||
.send_command_typed("Accessibility.getPartialAXTree", ¶ms, Some(session_id))
|
||||
.await;
|
||||
// Tight 1s timeout: this is a defensive guard, not a critical path.
|
||||
// The default 30s CDP timeout was the dominant factor in the
|
||||
// "click hangs 5+ minutes" report — three CDP calls (verify +
|
||||
// resolveNode + paint-settle) at 30s each, multiplied by parallel
|
||||
// click invocations queueing on the daemon, totalled multi-minute
|
||||
// user-visible hangs. Cap our own helper so a stuck AX query
|
||||
// doesn't make `click` worse than the no-guard version was.
|
||||
let resp: Result<GetFullAXTreeResult, String> = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
client.send_command_typed("Accessibility.getPartialAXTree", ¶ms, Some(session_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
// Timeout: skip identity verification rather than block the click.
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let Ok(tree) = resp else {
|
||||
// Node likely gone; let the box-model call fail and trigger fallback.
|
||||
return Ok(());
|
||||
@@ -426,6 +562,170 @@ async fn verify_ref_identity(
|
||||
))
|
||||
}
|
||||
|
||||
/// At the moment we'd dispatch the click, ask the page itself which element
|
||||
/// occupies (x, y). If it's not our target (and not a descendant or
|
||||
/// ancestor), an overlay has appeared between snapshot and click — we'd
|
||||
/// silently click the overlay otherwise. Returns Err with details about
|
||||
/// the occluding element so the caller can wait + re-snapshot.
|
||||
///
|
||||
/// Implemented as a single Runtime.callFunctionOn: resolve the cached
|
||||
/// backendNodeId to a remote object, then run a function on it that
|
||||
/// compares with elementFromPoint. The function returns null when the
|
||||
/// click is safe and a JSON string with diagnostic info when it isn't.
|
||||
async fn verify_click_target(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
backend_node_id: i64,
|
||||
ref_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<(), String> {
|
||||
use serde::Deserialize;
|
||||
|
||||
// Resolve once. backendNodeId is stable across renders; only the
|
||||
// element under (x, y) is what changes when an overlay flickers.
|
||||
let resolve_params = DomResolveNodeParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser-occlusion".to_string()),
|
||||
};
|
||||
let resolve_fut = client.send_command_typed::<_, serde_json::Value>(
|
||||
"DOM.resolveNode",
|
||||
&resolve_params,
|
||||
Some(session_id),
|
||||
);
|
||||
let Ok(resolve_resp) =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), resolve_fut).await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(resolved) = resolve_resp else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(object_id) = resolved
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Auto-retry on transient occlusion. Many real-world overlays
|
||||
// (modal backdrops, focus rings, click-outside masks) blink in for
|
||||
// a frame or two during state transitions and clear on their own.
|
||||
// Without retries the user gets an "occluded" error and has to
|
||||
// wrap every click in their own retry loop. With retries the
|
||||
// common case is invisible — only persistent overlays surface.
|
||||
//
|
||||
// AGENT_BROWSER_OCCLUSION_RETRIES (default 3, 0 disables)
|
||||
// AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS (default 200)
|
||||
let max_retries: u32 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRIES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3);
|
||||
let retry_delay_ms: u64 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Occluder {
|
||||
tag: Option<String>,
|
||||
testid: Option<String>,
|
||||
role: Option<String>,
|
||||
#[serde(rename = "ariaLabel")]
|
||||
aria_label: Option<String>,
|
||||
text: Option<String>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
// function(x, y) { ... } where `this` is the target element.
|
||||
// Return null → click is safe.
|
||||
// Return JSON → describes the occluding element.
|
||||
let function_decl = "function(x, y) { \
|
||||
const at = document.elementFromPoint(x, y); \
|
||||
if (!at) return JSON.stringify({reason:'no-element-at-point'}); \
|
||||
if (at === this || this.contains(at) || at.contains(this)) return null; \
|
||||
return JSON.stringify({ \
|
||||
tag: at.tagName, \
|
||||
testid: (at.dataset && at.dataset.testid) || null, \
|
||||
role: at.getAttribute('role'), \
|
||||
ariaLabel: at.getAttribute('aria-label'), \
|
||||
text: ((at.textContent||'').trim().slice(0, 60)) \
|
||||
}); \
|
||||
}";
|
||||
|
||||
let mut last_occ: Option<Occluder> = None;
|
||||
for attempt in 0..=max_retries {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await;
|
||||
}
|
||||
let call_params = serde_json::json!({
|
||||
"objectId": object_id,
|
||||
"functionDeclaration": function_decl,
|
||||
"arguments": [{"value": x}, {"value": y}],
|
||||
"returnByValue": true,
|
||||
});
|
||||
let call_fut = client.send_command_typed::<_, serde_json::Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&call_params,
|
||||
Some(session_id),
|
||||
);
|
||||
let Ok(call_resp) =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), call_fut).await
|
||||
else {
|
||||
return Ok(()); // probe itself stalled — fall through to click
|
||||
};
|
||||
let Ok(call_result) = call_resp else {
|
||||
return Ok(());
|
||||
};
|
||||
let value = call_result.get("result").and_then(|r| r.get("value"));
|
||||
let json_str = match value {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
// null / undefined → element at point IS our target. Safe.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let occ: Occluder = match serde_json::from_str(&json_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
last_occ = Some(occ);
|
||||
}
|
||||
|
||||
// All retries exhausted — overlay is sticky. Build the descriptive error.
|
||||
let occ = last_occ.expect("loop ran at least once");
|
||||
if let Some(reason) = occ.reason {
|
||||
return Err(format!(
|
||||
"Ref {} cannot be clicked at its computed position: {}. \
|
||||
The element may have moved off-screen — re-run snapshot.",
|
||||
ref_id, reason
|
||||
));
|
||||
}
|
||||
let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string());
|
||||
if let Some(t) = occ.testid {
|
||||
desc.push_str(&format!("[testid={}]", t));
|
||||
}
|
||||
if let Some(r) = occ.role {
|
||||
desc.push_str(&format!("[role={}]", r));
|
||||
}
|
||||
if let Some(a) = occ.aria_label {
|
||||
desc.push_str(&format!("[aria-label=\"{}\"]", a));
|
||||
}
|
||||
if let Some(t) = occ.text {
|
||||
if !t.is_empty() {
|
||||
desc.push_str(&format!(" text=\"{}\"", t));
|
||||
}
|
||||
}
|
||||
let waited_ms = (max_retries as u64) * retry_delay_ms;
|
||||
Err(format!(
|
||||
"Ref {} is occluded by {} at the click point (still occluded after \
|
||||
{} retries / {}ms). A persistent overlay is in the way — \
|
||||
re-run snapshot, dismiss the overlay, or set \
|
||||
AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass.",
|
||||
ref_id, desc, max_retries, waited_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||
@@ -550,6 +850,12 @@ async fn resolve_by_selector(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A syntactically-invalid CSS selector makes querySelector throw — surface
|
||||
// that as "invalid selector" rather than a misleading "element not found".
|
||||
if let Some(ex) = result.exception_details {
|
||||
return Err(format!("Invalid selector '{}': {}", selector, ex.text));
|
||||
}
|
||||
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
let x = val.get("x").and_then(|v| v.as_f64());
|
||||
let y = val.get("y").and_then(|v| v.as_f64());
|
||||
@@ -571,6 +877,35 @@ fn box_model_center(model: &BoxModel) -> (f64, f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre plus width/height of the content box, derived from the quad's
|
||||
/// bounding extent. Width/height feed humanize's in-bounds landing jitter; a
|
||||
/// degenerate quad yields zero size, which the jitter treats as "land on
|
||||
/// centre" (no jitter).
|
||||
fn box_model_dims(model: &BoxModel) -> (f64, f64, f64, f64) {
|
||||
let (cx, cy) = box_model_center(model);
|
||||
if model.content.len() >= 8 {
|
||||
let xs = [
|
||||
model.content[0],
|
||||
model.content[2],
|
||||
model.content[4],
|
||||
model.content[6],
|
||||
];
|
||||
let ys = [
|
||||
model.content[1],
|
||||
model.content[3],
|
||||
model.content[5],
|
||||
model.content[7],
|
||||
];
|
||||
let w = xs.iter().cloned().fold(f64::MIN, f64::max)
|
||||
- xs.iter().cloned().fold(f64::MAX, f64::min);
|
||||
let h = ys.iter().cloned().fold(f64::MIN, f64::max)
|
||||
- ys.iter().cloned().fold(f64::MAX, f64::min);
|
||||
(cx, cy, w.max(0.0), h.max(0.0))
|
||||
} else {
|
||||
(cx, cy, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_element_text(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Human-like input behaviour for stealth.
|
||||
//!
|
||||
//! When agent-browser drives a real Chrome over CDP, the input events it
|
||||
//! dispatches are already `isTrusted` — but a click that teleports the cursor
|
||||
//! straight to an element's exact centre, with no approach path and zero delay
|
||||
//! between move/press/release, is a behavioural tell that advanced anti-bot
|
||||
//! vendors (Akamai, PerimeterX, DataDome) look for.
|
||||
//!
|
||||
//! This module produces **human-like motion plans** — curved, eased cursor
|
||||
//! trajectories and variable keystroke timing — as *pure data*. It performs no
|
||||
//! I/O and knows nothing about CDP: callers turn the returned steps into
|
||||
//! `Input.dispatchMouseEvent` / `dispatchKeyEvent` calls. Keeping the maths pure
|
||||
//! makes the easing/jitter/detection logic unit-testable and deterministic
|
||||
//! (every randomised value comes from a caller-supplied seed).
|
||||
//!
|
||||
//! Design (see brainstorm 2026-06-11):
|
||||
//! - Three levels: [`HumanizeLevel::Off`] (instant, today's behaviour),
|
||||
//! `Fast` (a few cheap eased steps), `Human` (full curved trajectory + jitter).
|
||||
//! - Baseline is `Off`; the daemon escalates a session to `Human` when
|
||||
//! [`detect_level`] spots a known anti-bot vendor on the page. `--humanize` /
|
||||
//! `AGENT_BROWSER_HUMANIZE` force a fixed level.
|
||||
//! - Humanization only changes *how* the cursor reaches a target, never *which*
|
||||
//! element is hit: the landing jitter stays inside the caller-provided bounds.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
// ---- daemon-wide runtime state -------------------------------------------
|
||||
//
|
||||
// The pure motion maths above are stateless. The daemon drives one active page
|
||||
// at a time, so we keep the *current* humanize level and last cursor position
|
||||
// in process-global slots rather than threading them through every call site.
|
||||
// (The adaptive detector flips the level per navigation; `dispatch_click` reads
|
||||
// the level + cursor here, so no signature in the click/type call graph has to
|
||||
// change.)
|
||||
|
||||
/// `AGENT_BROWSER_HUMANIZE` forces a fixed level, overriding the adaptive
|
||||
/// detector. Parsed once.
|
||||
fn env_override() -> Option<HumanizeLevel> {
|
||||
static OVERRIDE: OnceLock<Option<HumanizeLevel>> = OnceLock::new();
|
||||
*OVERRIDE.get_or_init(|| {
|
||||
std::env::var("AGENT_BROWSER_HUMANIZE")
|
||||
.ok()
|
||||
.and_then(|s| HumanizeLevel::parse(&s))
|
||||
})
|
||||
}
|
||||
|
||||
fn session_level() -> &'static Mutex<HumanizeLevel> {
|
||||
static LEVEL: OnceLock<Mutex<HumanizeLevel>> = OnceLock::new();
|
||||
LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off))
|
||||
}
|
||||
|
||||
fn last_cursor_slot() -> &'static Mutex<(f64, f64)> {
|
||||
static CURSOR: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||
CURSOR.get_or_init(|| Mutex::new((0.0, 0.0)))
|
||||
}
|
||||
|
||||
/// The level that should apply right now: the env override if set, else the
|
||||
/// level the detector last chose for the active page.
|
||||
pub fn active_level() -> HumanizeLevel {
|
||||
env_override().unwrap_or_else(|| *session_level().lock().unwrap())
|
||||
}
|
||||
|
||||
/// Set by the adaptive detector after navigation. Ignored while an env override
|
||||
/// is in force (so `--humanize` always wins).
|
||||
pub fn set_detected_level(level: HumanizeLevel) {
|
||||
*session_level().lock().unwrap() = level;
|
||||
}
|
||||
|
||||
/// Where the virtual cursor currently sits, so the next move starts from there
|
||||
/// instead of teleporting.
|
||||
pub fn last_cursor() -> (f64, f64) {
|
||||
*last_cursor_slot().lock().unwrap()
|
||||
}
|
||||
|
||||
/// Record the cursor landing point after a move/click.
|
||||
pub fn set_last_cursor(p: (f64, f64)) {
|
||||
*last_cursor_slot().lock().unwrap() = p;
|
||||
}
|
||||
|
||||
/// A fresh seed per action so repeated clicks on the same point still vary,
|
||||
/// without touching the wall clock or a global RNG (both would break replay).
|
||||
pub fn next_seed() -> u64 {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0x1234_5678);
|
||||
COUNTER
|
||||
.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
|
||||
.rotate_left(17)
|
||||
}
|
||||
|
||||
/// How human-like input motion should be.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum HumanizeLevel {
|
||||
/// Instant: a single move to the exact point, no delays. Original behaviour.
|
||||
#[default]
|
||||
Off,
|
||||
/// A few eased steps with small delays — cheap cover for ordinary sites.
|
||||
Fast,
|
||||
/// Full curved, decelerating trajectory with landing jitter and press
|
||||
/// dwell — for pages guarded by behavioural anti-bot systems.
|
||||
Human,
|
||||
}
|
||||
|
||||
impl HumanizeLevel {
|
||||
/// Parse a user-supplied level (`--humanize` / `AGENT_BROWSER_HUMANIZE`).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "instant" | "0" => Some(Self::Off),
|
||||
"fast" | "light" | "low" => Some(Self::Fast),
|
||||
"human" | "full" | "high" | "max" => Some(Self::Human),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_off(self) -> bool {
|
||||
matches!(self, Self::Off)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a humanized cursor move: dispatch `mouseMoved` to (`x`, `y`),
|
||||
/// then sleep for `delay` before the next step. The final step's point is where
|
||||
/// the press/release should land.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MoveStep {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
/// Tiny deterministic PRNG (xorshift64*). Seeded by the caller so trajectories
|
||||
/// are reproducible in tests; we avoid pulling in the `rand` crate and never
|
||||
/// call a wall-clock/global RNG (which would also break workflow replay).
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Avoid the zero state, which xorshift cannot escape.
|
||||
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1).
|
||||
fn unit(&mut self) -> f64 {
|
||||
// Top 53 bits → f64 mantissa.
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Uniform in [-1, 1).
|
||||
fn signed(&mut self) -> f64 {
|
||||
self.unit() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Smootherstep ease (zero velocity at both ends) — used to bias the per-step
|
||||
/// timing so the cursor accelerates away from the start and decelerates into
|
||||
/// the target, the way a hand does.
|
||||
fn ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// Cubic Bézier point at parameter `t`.
|
||||
fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
(
|
||||
a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
|
||||
a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Pick a landing point inside `bbox` (`x`, `y`, `width`, `height`). `Off`
|
||||
/// returns the exact centre; `Fast`/`Human` jitter around the centre but stay
|
||||
/// well inside the element so the click still lands on it.
|
||||
pub fn landing_point(bbox: (f64, f64, f64, f64), level: HumanizeLevel, seed: u64) -> (f64, f64) {
|
||||
let (bx, by, bw, bh) = bbox;
|
||||
let cx = bx + bw / 2.0;
|
||||
let cy = by + bh / 2.0;
|
||||
if level.is_off() || bw <= 1.0 || bh <= 1.0 {
|
||||
return (cx, cy);
|
||||
}
|
||||
// Keep within the inner 60% so jitter never lands on a neighbouring element
|
||||
// or the element's padding/edge.
|
||||
let spread = match level {
|
||||
HumanizeLevel::Human => 0.30,
|
||||
_ => 0.15,
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
(
|
||||
cx + rng.signed() * bw * spread,
|
||||
cy + rng.signed() * bh * spread,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the cursor path from `from` to `to`. The last [`MoveStep`] is the
|
||||
/// landing point. `Off` yields a single zero-delay step at `to` (today's
|
||||
/// teleport), so callers can use one code path for every level.
|
||||
pub fn move_path(
|
||||
from: (f64, f64),
|
||||
to: (f64, f64),
|
||||
level: HumanizeLevel,
|
||||
seed: u64,
|
||||
) -> Vec<MoveStep> {
|
||||
if level.is_off() {
|
||||
return vec![MoveStep {
|
||||
x: to.0,
|
||||
y: to.1,
|
||||
delay: Duration::ZERO,
|
||||
}];
|
||||
}
|
||||
|
||||
let dist = (to.0 - from.0).hypot(to.1 - from.1);
|
||||
if dist < 1.0 {
|
||||
return vec![MoveStep {
|
||||
x: to.0,
|
||||
y: to.1,
|
||||
delay: Duration::ZERO,
|
||||
}];
|
||||
}
|
||||
|
||||
let (steps, total_ms, arc) = match level {
|
||||
HumanizeLevel::Fast => {
|
||||
let s = ((dist / 120.0).round() as usize).clamp(3, 6);
|
||||
(s, (dist * 0.35).clamp(40.0, 130.0), 0.06)
|
||||
}
|
||||
// Off handled above.
|
||||
_ => {
|
||||
let s = ((dist / 45.0).round() as usize).clamp(8, 24);
|
||||
(s, (dist * 0.9).clamp(140.0, 650.0), 0.16)
|
||||
}
|
||||
};
|
||||
|
||||
let mut rng = Rng::new(seed);
|
||||
|
||||
// Two control points along the line, pushed perpendicular to it to bow the
|
||||
// path into a gentle, slightly asymmetric arc.
|
||||
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
|
||||
let (nx, ny) = (-dy / dist, dx / dist); // unit normal
|
||||
let bow = dist * arc * rng.signed();
|
||||
let ctrl = |frac: f64, jitter: f64, rng: &mut Rng| {
|
||||
let base = (from.0 + dx * frac, from.1 + dy * frac);
|
||||
let off = bow * (1.0 + jitter * rng.signed());
|
||||
(base.0 + nx * off, base.1 + ny * off)
|
||||
};
|
||||
let p1 = ctrl(0.33, 0.4, &mut rng);
|
||||
let p2 = ctrl(0.66, 0.4, &mut rng);
|
||||
|
||||
let mut out = Vec::with_capacity(steps);
|
||||
let mut prev_ease = 0.0;
|
||||
for i in 1..=steps {
|
||||
let t = i as f64 / steps as f64;
|
||||
// Ease maps wall-time progress so most points cluster near the ends
|
||||
// (slow start, slow finish, fast middle).
|
||||
let te = ease(t);
|
||||
let (x, y) = bezier(from, p1, p2, to, te);
|
||||
let frac = te - prev_ease;
|
||||
prev_ease = te;
|
||||
out.push(MoveStep {
|
||||
x,
|
||||
y,
|
||||
delay: Duration::from_micros((total_ms * frac * 1000.0).max(0.0) as u64),
|
||||
});
|
||||
}
|
||||
// Guarantee the final point is exactly the target.
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.x = to.0;
|
||||
last.y = to.1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a wheel scroll of (`total_dx`, `total_dy`) into eased segments. `Off`
|
||||
/// returns a single instant segment (today's one-shot scroll); `Fast`/`Human`
|
||||
/// break it into several accelerate-then-decelerate chunks with small,
|
||||
/// jittered inter-segment delays, the way a trackpad/wheel flick actually
|
||||
/// lands. The segment deltas always sum to the requested total.
|
||||
pub fn scroll_segments(
|
||||
total_dx: f64,
|
||||
total_dy: f64,
|
||||
level: HumanizeLevel,
|
||||
seed: u64,
|
||||
) -> Vec<(f64, f64, Duration)> {
|
||||
if level.is_off() {
|
||||
return vec![(total_dx, total_dy, Duration::ZERO)];
|
||||
}
|
||||
let (segs, base_ms) = match level {
|
||||
HumanizeLevel::Fast => (4usize, 18.0),
|
||||
_ => (9usize, 28.0),
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut out = Vec::with_capacity(segs);
|
||||
let mut prev = 0.0;
|
||||
for i in 1..=segs {
|
||||
let f = ease(i as f64 / segs as f64);
|
||||
let frac = f - prev;
|
||||
prev = f;
|
||||
let jitter = 1.0 + 0.3 * rng.signed();
|
||||
out.push((
|
||||
total_dx * frac,
|
||||
total_dy * frac,
|
||||
Duration::from_millis((base_ms * jitter).max(4.0) as u64),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dwell between `mousePressed` and `mouseReleased` (a real click isn't
|
||||
/// instantaneous). Zero for `Off`.
|
||||
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
||||
match level {
|
||||
HumanizeLevel::Off => Duration::ZERO,
|
||||
HumanizeLevel::Fast => Duration::from_millis(20 + (seed % 30)),
|
||||
HumanizeLevel::Human => Duration::from_millis(50 + (seed % 90)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-character delays for typing `len` characters. `Off` is all-zero (use a
|
||||
/// single `Input.insertText`); `Fast`/`Human` produce variable inter-keystroke
|
||||
/// gaps with the occasional longer "think" pause, like a real typist.
|
||||
pub fn keystroke_delays(len: usize, level: HumanizeLevel, seed: u64) -> Vec<Duration> {
|
||||
if level.is_off() || len == 0 {
|
||||
return vec![Duration::ZERO; len];
|
||||
}
|
||||
let (mean, jitter, pause_chance, pause_extra) = match level {
|
||||
HumanizeLevel::Fast => (25.0, 15.0, 0.0, 0.0),
|
||||
_ => (95.0, 55.0, 0.06, 220.0),
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
let mut ms = (mean + rng.signed() * jitter).max(8.0);
|
||||
if pause_chance > 0.0 && rng.unit() < pause_chance {
|
||||
ms += rng.unit() * pause_extra;
|
||||
}
|
||||
Duration::from_millis(ms as u64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Page signals sampled after navigation, used to decide whether to escalate a
|
||||
/// session to [`HumanizeLevel::Human`]. All strings are matched case-insensitively.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DetectSignals {
|
||||
/// Cookie names present on the document (e.g. `_abck`, `datadome`).
|
||||
pub cookie_names: Vec<String>,
|
||||
/// `src` of loaded scripts.
|
||||
pub script_urls: Vec<String>,
|
||||
/// Names of suspicious globals on `window` (e.g. `_px`, `bmak`).
|
||||
pub window_globals: Vec<String>,
|
||||
}
|
||||
|
||||
/// Known behavioural anti-bot fingerprints: (substring, vendor). Matched against
|
||||
/// cookie names, script URLs, and window globals.
|
||||
const VENDOR_MARKERS: &[(&str, &str)] = &[
|
||||
("_abck", "akamai"),
|
||||
("bm_sz", "akamai"),
|
||||
("ak_bmsc", "akamai"),
|
||||
("bmak", "akamai"),
|
||||
("_px", "perimeterx"),
|
||||
("perimeterx", "perimeterx"),
|
||||
("px-cloud", "perimeterx"),
|
||||
("datadome", "datadome"),
|
||||
("kpsdk", "kasada"),
|
||||
("incap_ses", "imperva"),
|
||||
("visid_incap", "imperva"),
|
||||
("reese84", "imperva"),
|
||||
("__cf_bm", "cloudflare-bot-mgmt"),
|
||||
];
|
||||
|
||||
/// Decide the level for a page. Returns `Human` if any known anti-bot vendor is
|
||||
/// present, otherwise `baseline`. Misses just stay at baseline and false hits
|
||||
/// only cost a little latency, so matching is deliberately liberal.
|
||||
pub fn detect_level(signals: &DetectSignals, baseline: HumanizeLevel) -> HumanizeLevel {
|
||||
let hay: Vec<String> = signals
|
||||
.cookie_names
|
||||
.iter()
|
||||
.chain(signals.script_urls.iter())
|
||||
.chain(signals.window_globals.iter())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
let matched = VENDOR_MARKERS
|
||||
.iter()
|
||||
.any(|(marker, _)| hay.iter().any(|h| h.contains(marker)));
|
||||
if matched {
|
||||
HumanizeLevel::Human
|
||||
} else {
|
||||
baseline
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_known_levels_and_rejects_junk() {
|
||||
assert_eq!(HumanizeLevel::parse("off"), Some(HumanizeLevel::Off));
|
||||
assert_eq!(HumanizeLevel::parse(" FAST "), Some(HumanizeLevel::Fast));
|
||||
assert_eq!(HumanizeLevel::parse("Human"), Some(HumanizeLevel::Human));
|
||||
assert_eq!(HumanizeLevel::parse("max"), Some(HumanizeLevel::Human));
|
||||
assert_eq!(HumanizeLevel::parse("wat"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_level_teleports_in_one_step() {
|
||||
let path = move_path((0.0, 0.0), (100.0, 50.0), HumanizeLevel::Off, 1);
|
||||
assert_eq!(path.len(), 1);
|
||||
assert_eq!((path[0].x, path[0].y), (100.0, 50.0));
|
||||
assert_eq!(path[0].delay, Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanized_path_is_multi_step_and_lands_exactly_on_target() {
|
||||
let to = (640.0, 480.0);
|
||||
let path = move_path((10.0, 10.0), to, HumanizeLevel::Human, 42);
|
||||
assert!(path.len() >= 8, "human path should have many steps");
|
||||
let last = path.last().unwrap();
|
||||
assert_eq!((last.x, last.y), to, "final point must equal the target");
|
||||
// Path must actually leave the straight line at some point (it's a curve).
|
||||
let straight = path.iter().all(|s| {
|
||||
let t = (s.x - 10.0) / (to.0 - 10.0);
|
||||
(s.y - (10.0 + t * (to.1 - 10.0))).abs() < 0.5
|
||||
});
|
||||
assert!(!straight, "human path should bow off the straight line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_path_is_shorter_than_human() {
|
||||
let fast = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Fast, 7);
|
||||
let human = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Human, 7);
|
||||
assert!(fast.len() < human.len());
|
||||
assert!((3..=6).contains(&fast.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_path_is_deterministic_for_a_seed() {
|
||||
let a = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||
let b = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||
assert_eq!(a, b);
|
||||
let c = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 100);
|
||||
assert_ne!(a, c, "different seeds should differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_point_stays_inside_bounds_and_centres_when_off() {
|
||||
let bbox = (100.0, 100.0, 40.0, 20.0);
|
||||
assert_eq!(landing_point(bbox, HumanizeLevel::Off, 1), (120.0, 110.0));
|
||||
for seed in 0..200 {
|
||||
let (x, y) = landing_point(bbox, HumanizeLevel::Human, seed);
|
||||
assert!(x > 100.0 && x < 140.0, "x {x} escaped bbox");
|
||||
assert!(y > 100.0 && y < 120.0, "y {y} escaped bbox");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_delays_zero_when_off_and_positive_otherwise() {
|
||||
assert!(keystroke_delays(5, HumanizeLevel::Off, 1)
|
||||
.iter()
|
||||
.all(|d| *d == Duration::ZERO));
|
||||
let human = keystroke_delays(20, HumanizeLevel::Human, 3);
|
||||
assert_eq!(human.len(), 20);
|
||||
assert!(human.iter().all(|d| *d >= Duration::from_millis(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_segments_sum_to_total_and_single_when_off() {
|
||||
let off = scroll_segments(0.0, 600.0, HumanizeLevel::Off, 1);
|
||||
assert_eq!(off.len(), 1);
|
||||
assert_eq!((off[0].0, off[0].1), (0.0, 600.0));
|
||||
assert_eq!(off[0].2, Duration::ZERO);
|
||||
|
||||
let human = scroll_segments(0.0, 600.0, HumanizeLevel::Human, 5);
|
||||
assert!(human.len() >= 5);
|
||||
let total_dy: f64 = human.iter().map(|s| s.1).sum();
|
||||
assert!(
|
||||
(total_dy - 600.0).abs() < 1e-6,
|
||||
"segments must sum to total"
|
||||
);
|
||||
assert!(human.iter().all(|s| s.2 >= Duration::from_millis(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_escalates_on_known_vendor_else_baseline() {
|
||||
let mut s = DetectSignals::default();
|
||||
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Off);
|
||||
|
||||
s.cookie_names = vec!["sessionid".into(), "_abck".into()];
|
||||
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||
|
||||
let s2 = DetectSignals {
|
||||
script_urls: vec!["https://cdn.example.com/DataDome-tags.js".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s2, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||
|
||||
let s3 = DetectSignals {
|
||||
window_globals: vec!["_pxAppId".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s3, HumanizeLevel::Fast), HumanizeLevel::Human);
|
||||
|
||||
// Unknown signals keep the baseline.
|
||||
let s4 = DetectSignals {
|
||||
cookie_names: vec!["cart".into(), "theme".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s4, HumanizeLevel::Fast), HumanizeLevel::Fast);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use serde_json::Value;
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
||||
use super::humanize;
|
||||
|
||||
pub async fn click(
|
||||
client: &CdpClient,
|
||||
@@ -15,7 +16,138 @@ pub async fn click(
|
||||
click_count: i32,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
// AGENT_BROWSER_CLICK_MODE: "" (default) = coordinate click with a DOM
|
||||
// fallback; "coord" = strict coordinate only (no fallback); "dom" = always
|
||||
// dispatch through the DOM.
|
||||
let mode = std::env::var("AGENT_BROWSER_CLICK_MODE").unwrap_or_default();
|
||||
|
||||
// (A) Scroll the target into view first so the computed coordinates land
|
||||
// inside the viewport. Without this, an element below the fold (or revealed
|
||||
// after scroll/popup) yields off-viewport coordinates and the click lands on
|
||||
// whatever currently occupies that point. Best-effort: ignore failures.
|
||||
scroll_into_view_if_needed(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
|
||||
if mode == "dom" {
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let resolved = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
|
||||
match resolved {
|
||||
Ok((cx, cy, w, h, effective_session_id)) => {
|
||||
// Land on a jittered point inside the element rather than its exact
|
||||
// centre (Fast/Human). Zero size or Off → exact centre.
|
||||
let (tx, ty) = humanize::landing_point(
|
||||
(cx - w / 2.0, cy - h / 2.0, w, h),
|
||||
humanize::active_level(),
|
||||
humanize::next_seed(),
|
||||
);
|
||||
dispatch_click(client, &effective_session_id, tx, ty, button, click_count).await
|
||||
}
|
||||
Err(e) => {
|
||||
// (B) The coordinate path failed — typically a persistent overlay
|
||||
// failing the occlusion guard, or coordinates that won't resolve.
|
||||
// Fall back to a DOM-dispatched `.click()` on the intended element,
|
||||
// which targets the element directly instead of a screen point.
|
||||
// Skipped for strict "coord" mode and for non-left / multi-clicks
|
||||
// (a DOM `.click()` can't express right/middle/double semantics).
|
||||
if mode == "coord" || button != "left" || click_count != 1 {
|
||||
return Err(e);
|
||||
}
|
||||
eprintln!(
|
||||
"[click] coordinate click failed ({e}); falling back to DOM dispatch \
|
||||
(set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
||||
);
|
||||
dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
||||
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
||||
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
||||
/// the subsequent resolve will surface a real "not found" error.
|
||||
async fn scroll_into_view_if_needed(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) {
|
||||
let Ok((object_id, effective_session_id)) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let js = "function() { try { \
|
||||
if (typeof this.scrollIntoViewIfNeeded === 'function') { this.scrollIntoViewIfNeeded(true); } \
|
||||
else { this.scrollIntoView({ block: 'center', inline: 'center' }); } \
|
||||
} catch (e) {} }";
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await;
|
||||
// Let the scroll settle so the following getBoxModel sees final coordinates.
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
}
|
||||
|
||||
/// Dispatch a click through the DOM (`element.click()`) instead of via screen
|
||||
/// coordinates. Targets the intended element directly, so it works when a
|
||||
/// floating layer occludes the click point or the element sits in a portal that
|
||||
/// confuses `elementFromPoint`. Used as the fallback for `click` and when
|
||||
/// `AGENT_BROWSER_CLICK_MODE=dom`.
|
||||
async fn dom_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -23,7 +155,21 @@ pub async fn click(
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.click(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
@@ -52,7 +198,7 @@ pub async fn hover(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -211,9 +357,18 @@ pub async fn type_text_into_active_context(
|
||||
text: &str,
|
||||
delay_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let delay = delay_ms.unwrap_or(0);
|
||||
// Per-character timing: an explicit `delay_ms` wins (caller asked for a
|
||||
// fixed cadence); otherwise fall back to humanize — variable, human-like
|
||||
// inter-keystroke gaps at Fast/Human, all-zero (instant) at Off.
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let cadence: Vec<std::time::Duration> = match delay_ms {
|
||||
Some(d) => vec![std::time::Duration::from_millis(d); chars.len()],
|
||||
None => {
|
||||
humanize::keystroke_delays(chars.len(), humanize::active_level(), humanize::next_seed())
|
||||
}
|
||||
};
|
||||
|
||||
for ch in text.chars() {
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
if matches!(ch, '\n' | '\r' | '\t') {
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
let text_str = key_text(&key);
|
||||
@@ -265,8 +420,9 @@ pub async fn type_text_into_active_context(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
let gap = cadence[i];
|
||||
if !gap.is_zero() {
|
||||
tokio::time::sleep(gap).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,7 +1006,7 @@ pub async fn tap_touch(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -903,8 +1059,15 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
requestAnimationFrame(() => \
|
||||
requestAnimationFrame(() => \
|
||||
queueMicrotask(() => resolve(true)))))";
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
// Tight 500ms timeout. RAF normally fires at 16ms, two RAFs total ~33ms.
|
||||
// If the tab is hidden / throttled / page is doing something pathological
|
||||
// and RAF doesn't fire in 500ms, we'd rather return now than stall the
|
||||
// user's click. Without this cap, a stuck RAF inherited the default 30s
|
||||
// CDP timeout and was the main contributor to the "click hangs 5+ min"
|
||||
// user report.
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
client.send_command_typed::<_, Value>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: script.to_string(),
|
||||
@@ -912,8 +1075,9 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
@@ -924,24 +1088,38 @@ async fn dispatch_click(
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
// Move
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
// Move toward the target along a human-like path. At HumanizeLevel::Off this
|
||||
// is a single zero-delay step to (x, y) — identical to the old teleport — so
|
||||
// the default behaviour is unchanged. At Fast/Human it's a curved,
|
||||
// decelerating trajectory starting from where the cursor last landed, which
|
||||
// removes the "instant jump to exact centre, no prior movement" tell that
|
||||
// behavioural anti-bot systems flag.
|
||||
let level = humanize::active_level();
|
||||
let start = humanize::last_cursor();
|
||||
let seed = humanize::next_seed();
|
||||
for step in humanize::move_path(start, (x, y), level, seed) {
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x: step.x,
|
||||
y: step.y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
if !step.delay.is_zero() {
|
||||
tokio::time::sleep(step.delay).await;
|
||||
}
|
||||
}
|
||||
humanize::set_last_cursor((x, y));
|
||||
|
||||
let button_value = match button {
|
||||
"right" => 2,
|
||||
@@ -968,6 +1146,13 @@ async fn dispatch_click(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Hold briefly before releasing — a real click isn't instantaneous. Zero at
|
||||
// HumanizeLevel::Off.
|
||||
let dwell = humanize::press_dwell(level, seed);
|
||||
if !dwell.is_zero() {
|
||||
tokio::time::sleep(dwell).await;
|
||||
}
|
||||
|
||||
// Release
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod adaptive;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
@@ -15,6 +17,8 @@ pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod humanize;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
@@ -29,6 +33,8 @@ pub mod react;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod relay;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod snapshot;
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Relay between the `ab-connect` browser extension and the daemon's `CdpClient`.
|
||||
//!
|
||||
//! The extension speaks a small CDP-over-WebSocket "envelope" protocol (adapted
|
||||
//! from openclaw-browser-relay) and drives the user's real tabs via per-tab
|
||||
//! `chrome.debugger`. The daemon's `CdpClient`, however, expects a **browser-
|
||||
//! level** CDP endpoint (`Target.getTargets` / `Target.attachToTarget` → a
|
||||
//! `sessionId`, then per-session commands). This relay bridges the two: it
|
||||
//! tracks the targets the extension reports, answers the browser-level
|
||||
//! `Target.*` discovery commands LOCALLY, and forwards everything else to the
|
||||
//! extension as `forwardCDPCommand`. That keeps `CdpClient` and `browser.rs`
|
||||
//! unchanged.
|
||||
//!
|
||||
//! ## Multiple clients (concurrent agents on one shared browser)
|
||||
//!
|
||||
//! Several agent-browser daemons (one per `--session`) can connect to the same
|
||||
//! relay/Chrome at once. The extension is a single peer, so the relay must
|
||||
//! demultiplex: every forwarded command is re-keyed to a relay-global id mapped
|
||||
//! back to the originating client, and the extension's reply is routed to **only
|
||||
//! that client** (with its original id restored). Command ids from different
|
||||
//! clients therefore never collide, and one client never sees another's command
|
||||
//! replies. CDP *events* (no id) fan out to all clients, which ignore events for
|
||||
//! sessions they didn't attach.
|
||||
//!
|
||||
//! This module is the pure translation core (no I/O) so the protocol can be
|
||||
//! unit-tested; the tokio WebSocket server that drives it lives alongside.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Protocol version advertised in the connect handshake (matches the extension).
|
||||
pub const RELAY_PROTOCOL: i64 = 3;
|
||||
|
||||
/// Identifies one connected CDP client (agent-browser daemon) for routing.
|
||||
pub type ClientId = u64;
|
||||
|
||||
/// One target (tab) the extension has attached, as the relay tracks it.
|
||||
#[derive(Clone)]
|
||||
struct TargetEntry {
|
||||
session_id: String,
|
||||
target_info: Value,
|
||||
}
|
||||
|
||||
/// Relay translation state: the targets the extension exposes, plus the
|
||||
/// in-flight command map used to route extension replies back to the right
|
||||
/// client.
|
||||
#[derive(Default)]
|
||||
pub struct RelayState {
|
||||
/// targetId -> entry
|
||||
targets: HashMap<String, TargetEntry>,
|
||||
/// relay-global command id -> (client that sent it, its original id)
|
||||
pending: HashMap<i64, (ClientId, Value)>,
|
||||
/// monotonic source of relay-global command ids
|
||||
next_global_id: i64,
|
||||
}
|
||||
|
||||
/// What to do with a raw CDP command received from a `CdpClient`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClientRoute {
|
||||
/// Answer locally; the value is a raw CDP response `{id, result}` to send
|
||||
/// back to the originating client only.
|
||||
Local(Value),
|
||||
/// Forward to the extension; the value is a `forwardCDPCommand` envelope
|
||||
/// already re-keyed to a relay-global id.
|
||||
Forward(Value),
|
||||
}
|
||||
|
||||
/// An output the relay emits while handling an extension message.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RelayOut {
|
||||
/// Send this raw CDP message to clients. `to = Some(id)` targets one client
|
||||
/// (a command reply); `to = None` broadcasts (a CDP event).
|
||||
ToClient { to: Option<ClientId>, msg: Value },
|
||||
/// Send this envelope message back to the extension.
|
||||
ToExt(Value),
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The challenge the relay sends to the extension as soon as it connects,
|
||||
/// kicking off the connect handshake.
|
||||
pub fn connect_challenge(nonce: &str) -> Value {
|
||||
json!({ "type": "event", "event": "connect.challenge", "payload": { "nonce": nonce } })
|
||||
}
|
||||
|
||||
/// A keepalive ping for the extension.
|
||||
pub fn ping() -> Value {
|
||||
json!({ "method": "ping" })
|
||||
}
|
||||
|
||||
/// Forget a disconnected client's in-flight commands so its orphaned
|
||||
/// `pending` entries don't leak.
|
||||
pub fn drop_client(&mut self, client_id: ClientId) {
|
||||
self.pending.retain(|_, (cid, _)| *cid != client_id);
|
||||
}
|
||||
|
||||
/// Route a raw CDP command `{id, method, params?, sessionId?}` from a
|
||||
/// `CdpClient`: answer browser-level `Target.*` discovery locally, forward
|
||||
/// the rest to the extension under a relay-global id keyed to `client_id`.
|
||||
pub fn route_client_command(&mut self, client_id: ClientId, raw: &Value) -> ClientRoute {
|
||||
let id = raw.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = raw.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let params = raw.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
|
||||
|
||||
match method {
|
||||
// Browser-level command the daemon uses as its liveness probe
|
||||
// (`is_connection_alive` → `Browser.getVersion`). The extension only
|
||||
// speaks per-tab `chrome.debugger`, so forwarding it errors → the
|
||||
// daemon would deem the connection dead and reconnect+re-discover on
|
||||
// EVERY command, resetting the active tab (eval/screenshot drift).
|
||||
// Answer it locally so the relay connection reads as alive.
|
||||
"Browser.getVersion" => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "1.3",
|
||||
"product": "Chrome/ab-connect-relay",
|
||||
"revision": "",
|
||||
"userAgent": "",
|
||||
"jsVersion": ""
|
||||
}
|
||||
})),
|
||||
// Discovery is best-effort and event-driven in real CDP; abs only
|
||||
// reads the getTargets result, so an empty ack is enough here.
|
||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
"Target.getTargets" => {
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.values()
|
||||
.map(|t| t.target_info.clone())
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
"Target.attachToTarget" => {
|
||||
let target_id = params
|
||||
.get("targetId")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
match self.targets.get(target_id) {
|
||||
Some(entry) => ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
|
||||
),
|
||||
None => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"error": { "code": -32602, "message": format!("No such target {target_id}") }
|
||||
})),
|
||||
}
|
||||
}
|
||||
// Everything else goes to the extension's chrome.debugger. Re-key the
|
||||
// id so this client's reply can be routed back unambiguously.
|
||||
_ => {
|
||||
self.next_global_id += 1;
|
||||
let gid = self.next_global_id;
|
||||
self.pending.insert(gid, (client_id, id));
|
||||
ClientRoute::Forward(json!({
|
||||
"id": gid,
|
||||
"method": "forwardCDPCommand",
|
||||
"params": { "method": method, "params": params, "sessionId": session_id },
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded message from the extension. Updates target state and
|
||||
/// returns the messages to emit (routed to a client and/or back to the
|
||||
/// extension). `expected_token` is matched against the connect handshake.
|
||||
pub fn handle_ext_message(&mut self, msg: &Value, expected_token: &str) -> Vec<RelayOut> {
|
||||
// Connect handshake request from the extension.
|
||||
if msg.get("type").and_then(|t| t.as_str()) == Some("req")
|
||||
&& msg.get("method").and_then(|m| m.as_str()) == Some("connect")
|
||||
{
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let token = msg
|
||||
.get("params")
|
||||
.and_then(|p| p.get("auth"))
|
||||
.and_then(|a| a.get("token"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
let ok = !expected_token.is_empty() && token == expected_token;
|
||||
let mut res = json!({ "type": "res", "id": id, "ok": ok });
|
||||
if !ok {
|
||||
res["error"] = json!({ "message": "invalid relay token" });
|
||||
}
|
||||
return vec![RelayOut::ToExt(res)];
|
||||
}
|
||||
|
||||
// Keepalive.
|
||||
if msg.get("method").and_then(|m| m.as_str()) == Some("pong") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Response to a forwardCDPCommand we sent → route the raw CDP response
|
||||
// back to the client that issued it, with its original id restored.
|
||||
if msg.get("id").is_some()
|
||||
&& (msg.get("result").is_some() || msg.get("error").is_some())
|
||||
&& msg.get("method").is_none()
|
||||
{
|
||||
let gid = msg.get("id").and_then(|i| i.as_i64());
|
||||
let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) {
|
||||
Some((client_id, orig)) => (Some(client_id), orig),
|
||||
// No mapping (stale/unknown id) — fall back to broadcasting with
|
||||
// whatever id the extension echoed.
|
||||
None => (None, msg.get("id").cloned().unwrap_or(Value::Null)),
|
||||
};
|
||||
let mut out = json!({ "id": orig_id });
|
||||
if let Some(r) = msg.get("result") {
|
||||
out["result"] = r.clone();
|
||||
}
|
||||
if let Some(e) = msg.get("error") {
|
||||
// CdpClient expects an error object; wrap a bare string.
|
||||
out["error"] = match e {
|
||||
Value::String(s) => json!({ "code": -32000, "message": s }),
|
||||
other => other.clone(),
|
||||
};
|
||||
}
|
||||
return vec![RelayOut::ToClient { to, msg: out }];
|
||||
}
|
||||
|
||||
// CDP event forwarded from a tab.
|
||||
if msg.get("method").and_then(|m| m.as_str()) == Some("forwardCDPEvent") {
|
||||
let p = msg.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let inner_method = p.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let inner_params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let session_id = p.get("sessionId").and_then(|s| s.as_str());
|
||||
|
||||
// Learn/forget targets from the extension's synthesized Target events.
|
||||
// We consume these to maintain state and do NOT forward them: abs
|
||||
// discovers targets by pulling getTargets, and forwarding a second
|
||||
// attachedToTarget would duplicate the one attachToTarget emits.
|
||||
match inner_method {
|
||||
"Target.attachedToTarget" => {
|
||||
if let Some(info) = inner_params.get("targetInfo") {
|
||||
if let Some(tid) = info.get("targetId").and_then(|t| t.as_str()) {
|
||||
let sid = inner_params
|
||||
.get("sessionId")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
self.targets.insert(
|
||||
tid.to_string(),
|
||||
TargetEntry {
|
||||
session_id: sid,
|
||||
target_info: info.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
"Target.detachedFromTarget" => {
|
||||
let gone = inner_params.get("sessionId").and_then(|s| s.as_str());
|
||||
if let Some(gone) = gone {
|
||||
self.targets.retain(|_, e| e.session_id != gone);
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Regular CDP event → fan out to all clients (each filters by the
|
||||
// sessions it attached to).
|
||||
let mut ev = json!({ "method": inner_method, "params": inner_params });
|
||||
if let Some(sid) = session_id {
|
||||
ev["sessionId"] = json!(sid);
|
||||
}
|
||||
return vec![RelayOut::ToClient { to: None, msg: ev }];
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn seed_target(&mut self, target_id: &str, session_id: &str) {
|
||||
self.targets.insert(
|
||||
target_id.to_string(),
|
||||
TargetEntry {
|
||||
session_id: session_id.to_string(),
|
||||
target_info: json!({
|
||||
"targetId": target_id,
|
||||
"type": "page",
|
||||
"title": "",
|
||||
"url": "about:blank",
|
||||
"attached": true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attached_event(target_id: &str, session_id: &str) -> Value {
|
||||
json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"targetInfo": { "targetId": target_id, "type": "page", "url": "https://x", "title": "X" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learns_target_from_attached_event_and_does_not_forward_it() {
|
||||
let mut s = RelayState::new();
|
||||
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"attachedToTarget should be consumed, not forwarded"
|
||||
);
|
||||
// Now getTargets must report it.
|
||||
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
let infos = v["result"]["targetInfos"].as_array().unwrap();
|
||||
assert_eq!(infos.len(), 1);
|
||||
assert_eq!(infos[0]["targetId"], "T1");
|
||||
}
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_get_version_is_answered_locally() {
|
||||
// Liveness probe must NOT be forwarded (the extension can't do
|
||||
// browser-level commands) — else the daemon reconnects on every command.
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(1, &json!({ "id": 7, "method": "Browser.getVersion" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
assert_eq!(v["id"], 7);
|
||||
assert!(v["result"]["protocolVersion"].is_string());
|
||||
}
|
||||
_ => panic!("Browser.getVersion must be answered locally"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_to_target_returns_known_session() {
|
||||
let mut s = RelayState::new();
|
||||
s.seed_target("T1", "cb-tab-1");
|
||||
let route = s.route_client_command(
|
||||
7,
|
||||
&json!({ "id": 5, "method": "Target.attachToTarget", "params": { "targetId": "T1", "flatten": true } }),
|
||||
);
|
||||
assert_eq!(
|
||||
route,
|
||||
ClientRoute::Local(json!({ "id": 5, "result": { "sessionId": "cb-tab-1" } }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_to_unknown_target_errors_locally() {
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 6, "method": "Target.attachToTarget", "params": { "targetId": "nope" } }),
|
||||
);
|
||||
match route {
|
||||
ClientRoute::Local(v) => assert!(v.get("error").is_some()),
|
||||
_ => panic!("should answer locally"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_commands_forward_under_global_id() {
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(
|
||||
42,
|
||||
&json!({ "id": 9, "method": "Page.navigate", "params": { "url": "https://x" }, "sessionId": "cb-tab-1" }),
|
||||
);
|
||||
match route {
|
||||
ClientRoute::Forward(v) => {
|
||||
assert_eq!(v["method"], "forwardCDPCommand");
|
||||
// id is re-keyed to a relay-global id (not the client's 9).
|
||||
assert_eq!(v["id"], 1);
|
||||
assert_eq!(v["params"]["method"], "Page.navigate");
|
||||
assert_eq!(v["params"]["sessionId"], "cb-tab-1");
|
||||
assert_eq!(v["params"]["params"]["url"], "https://x");
|
||||
}
|
||||
_ => panic!("Page.navigate must forward"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_routes_back_to_the_issuing_client_with_original_id() {
|
||||
let mut s = RelayState::new();
|
||||
// Two clients each send a command that happens to share original id 1.
|
||||
let r1 = s.route_client_command(
|
||||
100,
|
||||
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let r2 = s.route_client_command(
|
||||
200,
|
||||
&json!({ "id": 1, "method": "Page.reload", "params": {} }),
|
||||
);
|
||||
let g1 = match r1 {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let g2 = match r2 {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
assert_ne!(g1, g2, "global ids must be distinct across clients");
|
||||
|
||||
// Extension replies for g2 → must go to client 200 with original id 1.
|
||||
let out = s.handle_ext_message(&json!({ "id": g2, "result": { "ok": true } }), "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: Some(200),
|
||||
msg: json!({ "id": 1, "result": { "ok": true } })
|
||||
}]
|
||||
);
|
||||
// And g1 → client 100.
|
||||
let out = s.handle_ext_message(&json!({ "id": g1, "result": { "ok": false } }), "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: Some(100),
|
||||
msg: json!({ "id": 1, "result": { "ok": false } })
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_command_error_is_wrapped_and_routed() {
|
||||
let mut s = RelayState::new();
|
||||
let r = s.route_client_command(
|
||||
5,
|
||||
&json!({ "id": 3, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let gid = match r {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let out = s.handle_ext_message(&json!({ "id": gid, "error": "boom" }), "tok");
|
||||
match &out[0] {
|
||||
RelayOut::ToClient { to, msg } => {
|
||||
assert_eq!(*to, Some(5));
|
||||
assert_eq!(msg["id"], 3);
|
||||
assert_eq!(msg["error"]["message"], "boom");
|
||||
}
|
||||
_ => panic!("expected ToClient"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_event_broadcasts_with_session() {
|
||||
let mut s = RelayState::new();
|
||||
let ev = json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": { "sessionId": "cb-tab-1", "method": "Page.loadEventFired", "params": { "timestamp": 1.0 } }
|
||||
});
|
||||
let out = s.handle_ext_message(&ev, "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: None,
|
||||
msg: json!({
|
||||
"method": "Page.loadEventFired",
|
||||
"params": { "timestamp": 1.0 },
|
||||
"sessionId": "cb-tab-1"
|
||||
})
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_client_clears_its_pending() {
|
||||
let mut s = RelayState::new();
|
||||
let r = s.route_client_command(
|
||||
9,
|
||||
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let gid = match r {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
s.drop_client(9);
|
||||
// Reply now has no mapping → broadcast fallback (to: None), echoed id.
|
||||
let out = s.handle_ext_message(&json!({ "id": gid, "result": {} }), "tok");
|
||||
match &out[0] {
|
||||
RelayOut::ToClient { to, .. } => assert_eq!(*to, None),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_handshake_validates_token() {
|
||||
let mut s = RelayState::new();
|
||||
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
|
||||
let ok = s.handle_ext_message(&req, "good");
|
||||
assert_eq!(
|
||||
ok,
|
||||
vec![RelayOut::ToExt(
|
||||
json!({ "type": "res", "id": "c1", "ok": true })
|
||||
)]
|
||||
);
|
||||
|
||||
let bad = s.handle_ext_message(&req, "different");
|
||||
match &bad[0] {
|
||||
RelayOut::ToExt(v) => {
|
||||
assert_eq!(v["ok"], false);
|
||||
assert!(v.get("error").is_some());
|
||||
}
|
||||
_ => panic!("expected ToExt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::adaptive::ElementFingerprint;
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
||||
@@ -148,6 +149,122 @@ impl TreeNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an AX fingerprint for a tree node, used by adaptive @ref relocation.
|
||||
/// Pulls only data already in the AX tree (no extra CDP calls): role as `tag`,
|
||||
/// accessible name as `text`, a few discriminating AX properties as `attrs`, and
|
||||
/// the ancestor/parent/sibling structure from the tree links.
|
||||
fn build_ax_fingerprint(tree_nodes: &[TreeNode], idx: usize) -> ElementFingerprint {
|
||||
let node = &tree_nodes[idx];
|
||||
|
||||
let mut attrs = std::collections::BTreeMap::new();
|
||||
if let Some(v) = &node.value_text {
|
||||
if !v.is_empty() {
|
||||
attrs.insert("value".to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(u) = &node.url {
|
||||
if !u.is_empty() {
|
||||
attrs.insert("url".to_string(), u.clone());
|
||||
}
|
||||
}
|
||||
if let Some(l) = node.level {
|
||||
attrs.insert("level".to_string(), l.to_string());
|
||||
}
|
||||
if let Some(c) = &node.checked {
|
||||
attrs.insert("checked".to_string(), c.clone());
|
||||
}
|
||||
|
||||
// Ancestor roles, nearest first, capped to keep the signature stable.
|
||||
let mut ancestors = Vec::new();
|
||||
let mut cur = node.parent_idx;
|
||||
while let Some(pidx) = cur {
|
||||
if ancestors.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
let role = tree_nodes[pidx].role.clone();
|
||||
if !role.is_empty() {
|
||||
ancestors.push(role);
|
||||
}
|
||||
cur = tree_nodes[pidx].parent_idx;
|
||||
}
|
||||
|
||||
let (parent_tag, parent_text) = node
|
||||
.parent_idx
|
||||
.map(|pidx| (tree_nodes[pidx].role.clone(), tree_nodes[pidx].name.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Position among same-role siblings under the same parent.
|
||||
let (sibling_index, sibling_count) = match node.parent_idx {
|
||||
Some(pidx) => {
|
||||
let mut count = 0u32;
|
||||
let mut index = 0u32;
|
||||
for &child in &tree_nodes[pidx].children {
|
||||
if tree_nodes[child].role == node.role {
|
||||
if child == idx {
|
||||
index = count;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(index, count)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
ElementFingerprint {
|
||||
tag: node.role.clone(),
|
||||
text: node.name.clone(),
|
||||
attrs,
|
||||
ancestors,
|
||||
parent_tag,
|
||||
parent_text,
|
||||
sibling_index,
|
||||
sibling_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect AX fingerprints for every node that has a backend node id, used as the
|
||||
/// candidate set when relocating a stale @ref. Reuses the same extraction as the
|
||||
/// baseline so the two are scored in the same space.
|
||||
fn collect_fingerprints(tree_nodes: &[TreeNode]) -> Vec<(i64, ElementFingerprint)> {
|
||||
tree_nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, n)| {
|
||||
n.backend_node_id
|
||||
.map(|bid| (bid, build_ax_fingerprint(tree_nodes, idx)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch a fresh AX tree for the given frame and return `(backend_node_id,
|
||||
/// fingerprint)` for every node — the candidate set for adaptive @ref
|
||||
/// relocation. One `getFullAXTree` call, no per-element work.
|
||||
pub(super) async fn collect_current_fingerprints(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Vec<(i64, ElementFingerprint)>, String> {
|
||||
let (ax_params, effective_session_id) =
|
||||
resolve_ax_session(frame_id, session_id, iframe_sessions);
|
||||
let _ = client
|
||||
.send_command_no_params("DOM.enable", Some(effective_session_id))
|
||||
.await;
|
||||
let _ = client
|
||||
.send_command_no_params("Accessibility.enable", Some(effective_session_id))
|
||||
.await;
|
||||
let ax_tree: GetFullAXTreeResult = client
|
||||
.send_command_typed(
|
||||
"Accessibility.getFullAXTree",
|
||||
&ax_params,
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
let (tree_nodes, _roots) = build_tree(&ax_tree.nodes);
|
||||
Ok(collect_fingerprints(&tree_nodes))
|
||||
}
|
||||
|
||||
/// The type of a hidden form input found inside a cursor-interactive element.
|
||||
#[derive(Clone, Copy)]
|
||||
enum HiddenInputKind {
|
||||
@@ -397,6 +514,7 @@ pub async fn take_snapshot(
|
||||
actual_nth,
|
||||
frame_id,
|
||||
);
|
||||
ref_map.set_fingerprint(&ref_id, build_ax_fingerprint(&tree_nodes, *idx));
|
||||
|
||||
tree_nodes[*idx].has_ref = true;
|
||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||
|
||||
@@ -119,6 +119,8 @@ async fn collect_storage_via_temp_target(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
// Transient internal target (storage collection) — never grouped.
|
||||
agent_group: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -51,13 +51,18 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
hide_canvas_enabled(),
|
||||
canvas_noise_seed(),
|
||||
);
|
||||
|
||||
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
||||
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
||||
// declaration and the whole script dies with a redeclaration SyntaxError.
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
@@ -65,6 +70,35 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether canvas/audio fingerprint noise is opted into (FullLaunch only).
|
||||
/// OFF by default: injecting noise is a deliberate "lie" that can itself be a
|
||||
/// tell, so it's reserved for users who explicitly want it via
|
||||
/// `AGENT_BROWSER_HIDE_CANVAS=1`.
|
||||
fn hide_canvas_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_HIDE_CANVAS")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
||||
/// device returns the same hash on repeated reads) but differs from the
|
||||
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
||||
fn canvas_noise_seed() -> u32 {
|
||||
use std::sync::OnceLock;
|
||||
static SEED: OnceLock<u32> = OnceLock::new();
|
||||
*SEED.get_or_init(|| {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0x9e3779b9);
|
||||
// mix the bits a little, then force non-zero
|
||||
let mixed = nanos ^ nanos.rotate_left(13).wrapping_mul(2654435761);
|
||||
mixed | 1
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
@@ -118,11 +152,72 @@ pub async fn apply_stealth(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Align the timezone for fresh launches when explicitly requested.
|
||||
// Headless/launched Chrome often reports UTC (or the host's zone), which
|
||||
// can contradict a proxy's geolocation or a spoofed locale.
|
||||
// `Emulation.setTimezoneOverride` is a NATIVE override — Intl.DateTimeFormat
|
||||
// and Date both follow it with no detectable JS lie. Opt-in only:
|
||||
// AGENT_BROWSER_TIMEZONE=<IANA id> -> use that zone (e.g. align to proxy)
|
||||
// AGENT_BROWSER_TIMEZONE=auto -> derive a default from the locale
|
||||
// (unset) -> leave the real timezone untouched
|
||||
if let Some(tz) = resolve_timezone(locale) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setTimezoneOverride",
|
||||
Some(json!({ "timezoneId": tz })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the timezone to emulate for a fresh-launch session, if any.
|
||||
/// Controlled by `AGENT_BROWSER_TIMEZONE`: an explicit IANA id, or `auto` to
|
||||
/// derive a sensible default from the locale. Returns `None` (leave the real
|
||||
/// timezone) when unset, empty, or when `auto` can't map the locale.
|
||||
fn resolve_timezone(locale: Option<&str>) -> Option<String> {
|
||||
let raw = std::env::var("AGENT_BROWSER_TIMEZONE").ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if raw.eq_ignore_ascii_case("auto") {
|
||||
return locale.and_then(locale_default_timezone).map(str::to_string);
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
/// Best-effort IANA timezone for a locale. Used only for
|
||||
/// `AGENT_BROWSER_TIMEZONE=auto`; unknown locales return `None` so the real
|
||||
/// timezone is left untouched rather than guessing a wrong one.
|
||||
fn locale_default_timezone(locale: &str) -> Option<&'static str> {
|
||||
let tz = match locale.to_ascii_lowercase().as_str() {
|
||||
"en-us" => "America/New_York",
|
||||
"en-ca" => "America/Toronto",
|
||||
"en-gb" => "Europe/London",
|
||||
"en-au" => "Australia/Sydney",
|
||||
"ja" | "ja-jp" => "Asia/Tokyo",
|
||||
"ko" | "ko-kr" => "Asia/Seoul",
|
||||
"zh-cn" | "zh-hans" | "zh-hans-cn" => "Asia/Shanghai",
|
||||
"zh-tw" | "zh-hant" | "zh-hant-tw" => "Asia/Taipei",
|
||||
"zh-hk" => "Asia/Hong_Kong",
|
||||
"de" | "de-de" => "Europe/Berlin",
|
||||
"fr" | "fr-fr" => "Europe/Paris",
|
||||
"es" | "es-es" => "Europe/Madrid",
|
||||
"it" | "it-it" => "Europe/Rome",
|
||||
"nl" | "nl-nl" => "Europe/Amsterdam",
|
||||
"pt-br" => "America/Sao_Paulo",
|
||||
"pt" | "pt-pt" => "Europe/Lisbon",
|
||||
"ru" | "ru-ru" => "Europe/Moscow",
|
||||
_ => return None,
|
||||
};
|
||||
Some(tz)
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
@@ -168,18 +263,22 @@ pub fn strip_source_url_labels(input: &str) -> String {
|
||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||
let output = re_line.replace_all(input, "");
|
||||
// Remove /*# sourceURL=...*/ block comments
|
||||
let re_block =
|
||||
regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
let re_block = regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
re_block.replace_all(&output, "").to_string()
|
||||
}
|
||||
|
||||
/// The legacy `navigator.platform` value (set via the CDP
|
||||
/// `Emulation.setUserAgentOverride` `platform` field). This is NOT the UA-CH
|
||||
/// platform (see `platform_hint`): real Chrome reports `MacIntel` on macOS and
|
||||
/// `Linux x86_64` on Linux, so emitting the UA-CH form ("macOS"/"Linux") here is
|
||||
/// a detectable mismatch against the UA's "Intel Mac OS X" / Linux strings.
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
"MacIntel"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux"
|
||||
"Linux x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,3 +334,46 @@ fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
||||
"wow64": false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timezone_tests {
|
||||
use super::{locale_default_timezone, resolve_timezone};
|
||||
|
||||
#[test]
|
||||
fn maps_common_locales_case_insensitively() {
|
||||
assert_eq!(locale_default_timezone("en-US"), Some("America/New_York"));
|
||||
assert_eq!(locale_default_timezone("ja-JP"), Some("Asia/Tokyo"));
|
||||
assert_eq!(locale_default_timezone("zh-CN"), Some("Asia/Shanghai"));
|
||||
assert_eq!(locale_default_timezone("ZH-TW"), Some("Asia/Taipei"));
|
||||
assert_eq!(locale_default_timezone("ja"), Some("Asia/Tokyo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_locale_returns_none() {
|
||||
assert_eq!(locale_default_timezone("xx-YY"), None);
|
||||
assert_eq!(locale_default_timezone(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timezone_honors_env() {
|
||||
// Serialized via a single test to avoid cross-test env races on this key.
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "Europe/Berlin");
|
||||
assert_eq!(resolve_timezone(None), Some("Europe/Berlin".to_string()));
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", " ");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
|
||||
assert_eq!(
|
||||
resolve_timezone(Some("ja-JP")),
|
||||
Some("Asia/Tokyo".to_string())
|
||||
);
|
||||
assert_eq!(resolve_timezone(Some("xx-YY")), None);
|
||||
assert_eq!(resolve_timezone(None), None);
|
||||
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
|
||||
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
|
||||
// the way real Chrome exposes these — as prototype getters, NOT instance own
|
||||
// properties. Adding an own property to the `navigator` instance is itself a
|
||||
// detectable automation tell: real Chrome's `Object.getOwnPropertyNames(navigator)`
|
||||
// is empty, so any name we leave on the instance is caught by rebrowser's
|
||||
// `navigatorWebdriver` probe and similar checks. We mirror the proven `vendor`
|
||||
// patch below: define on the prototype, native-mask the getter's toString, then
|
||||
// delete any instance shadow. Falls back to an instance define only if the
|
||||
// prototype is locked. (A top-level `const` like this is script-scoped, not a
|
||||
// `window` property, so it does not leak — same as `__abStealth` above.)
|
||||
const __abRedefineNavProto = (name, getterImpl) => {
|
||||
try {
|
||||
const proto = Object.getPrototypeOf(navigator);
|
||||
const nativeGet = Object.getOwnPropertyDescriptor(proto, name) && Object.getOwnPropertyDescriptor(proto, name).get;
|
||||
const getter = function () { return getterImpl(); };
|
||||
if (nativeGet) {
|
||||
Object.defineProperty(getter, 'name', { value: 'get ' + name, configurable: true });
|
||||
Object.defineProperty(getter, 'toString', { value: () => nativeGet.toString(), configurable: true, writable: true });
|
||||
}
|
||||
Object.defineProperty(proto, name, { get: getter, configurable: true, enumerable: true });
|
||||
try { delete navigator[name]; } catch (e) {}
|
||||
return true;
|
||||
} catch (e) {
|
||||
try { Object.defineProperty(navigator, name, { get: () => getterImpl(), configurable: true }); } catch (e2) {}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
(function(){
|
||||
const removeWebdriver = (target) => {
|
||||
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||
// lie-detection (creepjs). Only intervene when webdriver is still truthy
|
||||
// (e.g. older Chrome without that override) and force it to FALSE.
|
||||
//
|
||||
// Never `delete` webdriver: real Chrome reports `false`, so `undefined` is
|
||||
// itself a tell, and deleting it removes the native `false` the override set.
|
||||
const forceWebdriverFalse = (target) => {
|
||||
if (!target) return;
|
||||
try { delete target.webdriver; } catch {}
|
||||
try {
|
||||
if (target.webdriver === true) {
|
||||
Object.defineProperty(target, 'webdriver', {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
removeWebdriver(navigator);
|
||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
||||
removeWebdriver(Navigator.prototype);
|
||||
forceWebdriverFalse(navigator);
|
||||
forceWebdriverFalse(Object.getPrototypeOf(navigator));
|
||||
forceWebdriverFalse(Navigator.prototype);
|
||||
if (typeof WorkerNavigator !== 'undefined') {
|
||||
removeWebdriver(WorkerNavigator.prototype);
|
||||
forceWebdriverFalse(WorkerNavigator.prototype);
|
||||
}
|
||||
})();
|
||||
(function(){
|
||||
@@ -339,18 +381,8 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
const config = (typeof __abStealth === 'object' && __abStealth) ? __abStealth : null;
|
||||
if (!config || !Array.isArray(config.languages) || config.languages.length === 0) return;
|
||||
const locale = typeof config.locale === 'string' ? config.locale : config.languages[0];
|
||||
try {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
get: () => locale,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => config.languages.slice(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
__abRedefineNavProto('language', () => locale);
|
||||
__abRedefineNavProto('languages', () => config.languages.slice());
|
||||
})();
|
||||
(function(){
|
||||
const ua = String(navigator.userAgent || '');
|
||||
@@ -379,6 +411,24 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
defineVendor(navigator);
|
||||
})();
|
||||
(function(){
|
||||
// Native > JS lies: a real headed Chrome already exposes the correct, fully
|
||||
// native navigator.plugins (5 PDF-viewer aliases, a native item() that does
|
||||
// the WebIDL uint32-index wrap, length on the prototype). Overriding that
|
||||
// with a JS fake is strictly worse — it ships a non-native item() whose
|
||||
// .toString() reveals the patch, breaks the uint32 wrap (incolumitas
|
||||
// overflowTest), and pins an anachronistic "Native Client" plugin that modern
|
||||
// Chrome removed. Since this fork forbids headless and always launches headed,
|
||||
// the native plugins are present, so we leave them alone. We only fall back to
|
||||
// a synthetic list when native plugins are genuinely empty (e.g. the
|
||||
// discouraged AGENT_BROWSER_ALLOW_HEADLESS escape on old headless).
|
||||
try {
|
||||
const np = navigator.plugins;
|
||||
const itemNative =
|
||||
np && typeof np.item === 'function' &&
|
||||
/\[native code\]/.test(Function.prototype.toString.call(np.item));
|
||||
if (np && np.length > 0 && itemNative) return;
|
||||
} catch (e) {}
|
||||
|
||||
const makeMimeType = (type, suffixes, description) => {
|
||||
const mime = Object.create(MimeType.prototype);
|
||||
Object.defineProperties(mime, {
|
||||
@@ -412,40 +462,54 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
return plugin;
|
||||
};
|
||||
|
||||
// Make a fake method masquerade as native: name + `[native code]` toString.
|
||||
const maskNative = (fn, name) => {
|
||||
Object.defineProperty(fn, 'name', { value: name, configurable: true });
|
||||
Object.defineProperty(fn, 'toString', {
|
||||
value: () => `function ${name}() { [native code] }`,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return fn;
|
||||
};
|
||||
|
||||
// Modern Chrome (since ~v109) exposes exactly these 5 PDF-viewer aliases and
|
||||
// two mimeTypes (application/pdf, text/pdf). Native Client was removed years
|
||||
// ago, so it must NOT appear. Each plugin carries both mimeTypes.
|
||||
const pdfMime = makeMimeType('application/pdf', 'pdf', 'Portable Document Format');
|
||||
const chromePdfMime = makeMimeType(
|
||||
'application/x-google-chrome-pdf',
|
||||
'pdf',
|
||||
'Portable Document Format'
|
||||
);
|
||||
const naclMime = makeMimeType('application/x-nacl', '', 'Native Client Executable');
|
||||
const pnaclMime = makeMimeType('application/x-pnacl', '', 'Portable Native Client Executable');
|
||||
const textPdfMime = makeMimeType('text/pdf', 'pdf', 'Portable Document Format');
|
||||
const mimes = [pdfMime, textPdfMime];
|
||||
|
||||
const plugins = [
|
||||
makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer', [chromePdfMime]),
|
||||
makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', [pdfMime]),
|
||||
makePlugin('Native Client', '', 'internal-nacl-plugin', [naclMime, pnaclMime]),
|
||||
];
|
||||
'PDF Viewer',
|
||||
'Chrome PDF Viewer',
|
||||
'Chromium PDF Viewer',
|
||||
'Microsoft Edge PDF Viewer',
|
||||
'WebKit built-in PDF',
|
||||
].map((name) => makePlugin(name, 'Portable Document Format', 'internal-pdf-viewer', mimes));
|
||||
|
||||
const pluginArray = Object.create(PluginArray.prototype);
|
||||
plugins.forEach((p, i) => {
|
||||
pluginArray[i] = p;
|
||||
pluginArray[p.name] = p;
|
||||
});
|
||||
Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
|
||||
pluginArray.item = (i) => plugins[i] || null;
|
||||
pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
|
||||
pluginArray.refresh = () => {};
|
||||
// `i >>> 0` replicates the WebIDL unsigned-long index coercion, so
|
||||
// item(2**32) wraps to item(0) like the real native PluginArray.item.
|
||||
pluginArray.item = maskNative((i) => plugins[i >>> 0] || null, 'item');
|
||||
pluginArray.namedItem = maskNative((name) => plugins.find(p => p.name === name) || null, 'namedItem');
|
||||
pluginArray.refresh = maskNative(() => {}, 'refresh');
|
||||
pluginArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
|
||||
|
||||
const mimeTypes = [chromePdfMime, pdfMime, naclMime, pnaclMime];
|
||||
const mimeTypes = [pdfMime, textPdfMime];
|
||||
const mimeTypeArray = Object.create(MimeTypeArray.prototype);
|
||||
mimeTypes.forEach((m, i) => {
|
||||
mimeTypeArray[i] = m;
|
||||
mimeTypeArray[m.type] = m;
|
||||
});
|
||||
Object.defineProperty(mimeTypeArray, 'length', { get: () => mimeTypes.length });
|
||||
mimeTypeArray.item = (i) => mimeTypes[i] || null;
|
||||
mimeTypeArray.namedItem = (name) => mimeTypes.find(m => m.type === name) || null;
|
||||
mimeTypeArray.item = maskNative((i) => mimeTypes[i >>> 0] || null, 'item');
|
||||
mimeTypeArray.namedItem = maskNative((name) => mimeTypes.find(m => m.type === name) || null, 'namedItem');
|
||||
mimeTypeArray[Symbol.iterator] = function*() { for (const m of mimeTypes) yield m; };
|
||||
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
@@ -1008,10 +1072,15 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (defineContacts(navigator)) return;
|
||||
try {
|
||||
defineContacts(Object.getPrototypeOf(navigator));
|
||||
} catch {}
|
||||
// Prototype-first (like the vendor patch): real Chrome exposes navigator
|
||||
// members on the prototype, not as instance own properties. Define on the
|
||||
// prototype and remove any instance shadow so Object.getOwnPropertyNames(navigator)
|
||||
// stays empty; fall back to the instance only if the prototype is locked.
|
||||
if (defineContacts(Object.getPrototypeOf(navigator))) {
|
||||
try { delete navigator.contacts; } catch {}
|
||||
return;
|
||||
}
|
||||
defineContacts(navigator);
|
||||
})();
|
||||
(function(){
|
||||
const ContentIndexCtor = typeof ContentIndex === 'function'
|
||||
@@ -1218,12 +1287,7 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
}
|
||||
return values;
|
||||
};
|
||||
try {
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
get: () => patched,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
__abRedefineNavProto('userAgentData', () => patched);
|
||||
})();
|
||||
(function(){
|
||||
const ua = navigator.userAgent;
|
||||
@@ -1260,3 +1324,126 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
}
|
||||
}
|
||||
})();
|
||||
// Canvas + audio fingerprint noise (OPT-IN, full-launch only).
|
||||
// Headless Chrome produces a stable canvas/audio hash that trackers use as a
|
||||
// device id. When __abStealth.hideCanvas is on we perturb readback APIs with a
|
||||
// SESSION-STABLE, sub-perceptual amount of noise: repeated reads on this page
|
||||
// return the same noised result (a real device is consistent too), but the
|
||||
// hash differs from the headless default. Off by default — noise is itself a
|
||||
// "lie", so it's reserved for users who explicitly enable it.
|
||||
(function(){
|
||||
if (!__abStealth || __abStealth.hideCanvas !== true) return;
|
||||
|
||||
// Deterministic PRNG keyed by the per-session seed plus a position, so the
|
||||
// same pixel/sample is perturbed identically every read within the session.
|
||||
const baseSeed = (__abStealth.canvasSeed >>> 0) || 0x9e3779b9;
|
||||
const noiseAt = (n) => {
|
||||
let t = (baseSeed ^ Math.imul(n | 0, 0x6d2b79f5)) >>> 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
// Make a wrapped function masquerade as the native one (toString + name).
|
||||
const mask = (wrapped, native) => {
|
||||
try {
|
||||
Object.defineProperty(wrapped, 'name', {
|
||||
value: native.name,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(wrapped, 'toString', {
|
||||
value: () => native.toString(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {}
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
// ---- Canvas 2D readback ---------------------------------------------------
|
||||
const perturbImageData = (imageData) => {
|
||||
const data = imageData && imageData.data;
|
||||
if (!data || !data.length) return imageData;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Touch ~5% of pixels by +/-1 on each RGB channel; leave alpha alone.
|
||||
if (noiseAt(i) < 0.05) {
|
||||
const delta = noiseAt(i + 1) < 0.5 ? -1 : 1;
|
||||
data[i] = Math.max(0, Math.min(255, data[i] + delta));
|
||||
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + delta));
|
||||
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + delta));
|
||||
}
|
||||
}
|
||||
return imageData;
|
||||
};
|
||||
|
||||
try {
|
||||
const ctxProto = (typeof CanvasRenderingContext2D !== 'undefined')
|
||||
? CanvasRenderingContext2D.prototype : null;
|
||||
if (ctxProto && typeof ctxProto.getImageData === 'function') {
|
||||
const nativeGetImageData = ctxProto.getImageData;
|
||||
ctxProto.getImageData = mask(function(...args) {
|
||||
return perturbImageData(nativeGetImageData.apply(this, args));
|
||||
}, nativeGetImageData);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// For toDataURL/toBlob, draw the (already-rendered) canvas onto a scratch
|
||||
// canvas, perturb its pixels, then encode that — so the export hash shifts
|
||||
// without disturbing what the page sees on screen.
|
||||
const exportNoised = (canvas) => {
|
||||
try {
|
||||
const w = canvas.width, h = canvas.height;
|
||||
if (!w || !h) return null;
|
||||
const scratch = document.createElement('canvas');
|
||||
scratch.width = w; scratch.height = h;
|
||||
const sctx = scratch.getContext('2d');
|
||||
if (!sctx) return null;
|
||||
sctx.drawImage(canvas, 0, 0);
|
||||
const img = sctx.getImageData(0, 0, w, h);
|
||||
perturbImageData(img);
|
||||
sctx.putImageData(img, 0, 0);
|
||||
return scratch;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
try {
|
||||
const canvasProto = (typeof HTMLCanvasElement !== 'undefined')
|
||||
? HTMLCanvasElement.prototype : null;
|
||||
if (canvasProto && typeof canvasProto.toDataURL === 'function') {
|
||||
const nativeToDataURL = canvasProto.toDataURL;
|
||||
canvasProto.toDataURL = mask(function(...args) {
|
||||
const scratch = exportNoised(this);
|
||||
return nativeToDataURL.apply(scratch || this, args);
|
||||
}, nativeToDataURL);
|
||||
}
|
||||
if (canvasProto && typeof canvasProto.toBlob === 'function') {
|
||||
const nativeToBlob = canvasProto.toBlob;
|
||||
canvasProto.toBlob = mask(function(cb, ...rest) {
|
||||
const scratch = exportNoised(this);
|
||||
return nativeToBlob.call(scratch || this, cb, ...rest);
|
||||
}, nativeToBlob);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ---- AudioBuffer readback -------------------------------------------------
|
||||
// Perturb time-domain samples by a tiny, seed-stable amount so the audio
|
||||
// fingerprint (sum/hash of channel data) shifts without audible effect.
|
||||
try {
|
||||
const audioProto = (typeof AudioBuffer !== 'undefined') ? AudioBuffer.prototype : null;
|
||||
if (audioProto && typeof audioProto.getChannelData === 'function') {
|
||||
const nativeGetChannelData = audioProto.getChannelData;
|
||||
const seen = new WeakSet();
|
||||
audioProto.getChannelData = mask(function(...args) {
|
||||
const channel = nativeGetChannelData.apply(this, args);
|
||||
// Only perturb once per buffer to keep reads consistent.
|
||||
if (channel && !seen.has(channel)) {
|
||||
seen.add(channel);
|
||||
for (let i = 0; i < channel.length; i += 100) {
|
||||
channel[i] = channel[i] + (noiseAt(i) - 0.5) * 1e-7;
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
}, nativeGetChannelData);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
@@ -33,14 +33,141 @@ pub(super) fn cors_headers_for_origin(origin: Option<&str>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn request_headers(request: &str) -> &str {
|
||||
request
|
||||
.find("\r\n\r\n")
|
||||
.or_else(|| request.find("\n\n"))
|
||||
.map(|header_end| &request[..header_end])
|
||||
.unwrap_or(request)
|
||||
}
|
||||
|
||||
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request_headers(request).lines().find_map(|line| {
|
||||
let (header_name, value) = line.split_once(':')?;
|
||||
if header_name.trim().eq_ignore_ascii_case(name) {
|
||||
Some(value.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_origin(peeked: &[u8]) -> Option<String> {
|
||||
let header_str = std::str::from_utf8(peeked).ok()?;
|
||||
for line in header_str.lines() {
|
||||
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") {
|
||||
return Some(line[8..].trim().to_string());
|
||||
request_header_value(header_str, "origin").map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn normalize_origin_authority(origin: &str) -> Option<String> {
|
||||
let url = url::Url::parse(origin).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
let default_port = (url.scheme() == "http" && url.port() == Some(80))
|
||||
|| (url.scheme() == "https" && url.port() == Some(443));
|
||||
Some(match url.port() {
|
||||
Some(port) if !default_port => format!("{host}:{port}"),
|
||||
_ => host,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_host_authority(host: &str) -> String {
|
||||
let host = host.trim().to_ascii_lowercase();
|
||||
|
||||
if let Some(bracket_end) = host.rfind(']') {
|
||||
if bracket_end == host.len() - 1 {
|
||||
return host;
|
||||
}
|
||||
|
||||
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
|
||||
let port = &host[bracket_end + 2..];
|
||||
if port == "80" || port == "443" {
|
||||
return host[..=bracket_end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
if let Some((name, port)) = host.rsplit_once(':') {
|
||||
if !name.contains(':') && (port == "80" || port == "443") {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
host
|
||||
}
|
||||
|
||||
fn authority_host(authority: &str) -> &str {
|
||||
if let Some(stripped) = authority.strip_prefix('[') {
|
||||
if let Some(bracket_end) = stripped.find(']') {
|
||||
return &authority[..=bracket_end + 1];
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((host, _port)) = authority.rsplit_once(':') {
|
||||
if !host.contains(':') {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
authority
|
||||
}
|
||||
|
||||
fn is_loopback_authority(authority: &str) -> bool {
|
||||
matches!(
|
||||
authority_host(authority),
|
||||
"localhost" | "127.0.0.1" | "::1" | "[::1]"
|
||||
)
|
||||
}
|
||||
|
||||
fn header_authority_matches_host(request: &str, header_name: &str) -> bool {
|
||||
let Some(authority) =
|
||||
request_header_value(request, header_name).and_then(normalize_origin_authority)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = request_header_value(request, "host").map(normalize_host_authority) else {
|
||||
return false;
|
||||
};
|
||||
authority == host && is_loopback_authority(&authority) && is_loopback_authority(&host)
|
||||
}
|
||||
|
||||
/// Protects the command relay by requiring same-origin browser metadata.
|
||||
fn is_same_origin_command_request(request: &str) -> bool {
|
||||
if request_header_value(request, "origin").is_some() {
|
||||
header_authority_matches_host(request, "origin")
|
||||
} else {
|
||||
header_authority_matches_host(request, "referer")
|
||||
}
|
||||
}
|
||||
|
||||
fn command_cors_headers(request: &str) -> String {
|
||||
match request_header_value(request, "origin") {
|
||||
Some(origin) if is_same_origin_command_request(request) => format!(
|
||||
"Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nVary: Origin\r\n"
|
||||
),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_json_error_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
error: &str,
|
||||
) {
|
||||
let body = format!(
|
||||
r#"{{"success":false,"error":{}}}"#,
|
||||
serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error))
|
||||
);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
}
|
||||
|
||||
pub(super) async fn handle_http_request(
|
||||
@@ -61,6 +188,25 @@ pub(super) async fn handle_http_request(
|
||||
let origin = parse_origin(peeked);
|
||||
|
||||
if method == "OPTIONS" {
|
||||
if path == "/api/command" {
|
||||
if !is_same_origin_command_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let cors_headers = command_cors_headers(&request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 204 No Content\r\n{cors_headers}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
@@ -69,13 +215,28 @@ pub(super) async fn handle_http_request(
|
||||
}
|
||||
|
||||
if method == "POST" {
|
||||
if path == "/api/command" && !is_same_origin_command_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let full_body = read_full_body(&mut stream, peeked).await;
|
||||
if full_body.is_none()
|
||||
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
|
||||
{
|
||||
let body = r#"{"error":"Request body too large"}"#;
|
||||
let cors_headers = if path == "/api/command" {
|
||||
command_cors_headers(&request)
|
||||
} else {
|
||||
CORS_HEADERS.to_string()
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
@@ -117,8 +278,9 @@ pub(super) async fn handle_http_request(
|
||||
),
|
||||
),
|
||||
};
|
||||
let cors_headers = command_cors_headers(&request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
resp_body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
@@ -313,3 +475,241 @@ pub(super) fn serve_embedded_file(url_path: &str) -> (&'static str, &'static str
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
async fn send_request_to_handler(request: &str, session_name: &str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let peeked = request.as_bytes().to_vec();
|
||||
let last_tabs = Arc::new(RwLock::new(Vec::new()));
|
||||
let last_engine = Arc::new(RwLock::new("chrome".to_string()));
|
||||
let session_name = session_name.to_string();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
handle_http_request(stream, &peeked, &last_tabs, &last_engine, &session_name).await;
|
||||
});
|
||||
|
||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
client.write_all(request.as_bytes()).await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
client.read_to_end(&mut response).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
String::from_utf8(response).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn spawn_fake_daemon(
|
||||
socket_dir: &std::path::Path,
|
||||
session_name: &str,
|
||||
) -> oneshot::Receiver<String> {
|
||||
let socket_path = socket_dir.join(format!("{session_name}.sock"));
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = tokio::io::BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
stream
|
||||
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
stream.write_all(b"\n").await.unwrap();
|
||||
let _ = tx.send(line);
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_command_post_is_rejected_without_relaying_to_daemon() {
|
||||
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("t");
|
||||
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("ab-")
|
||||
.tempdir_in(temp_parent)
|
||||
.unwrap();
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir.path().to_str().unwrap(),
|
||||
);
|
||||
guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let session_name = "x";
|
||||
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, session_name).await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), daemon_command)
|
||||
.await
|
||||
.is_err(),
|
||||
"cross-origin request reached daemon command relay"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_command_preflight_is_rejected_without_wildcard_cors() {
|
||||
let request = concat!(
|
||||
"OPTIONS /api/command HTTP/1.1\r\n",
|
||||
"Host: localhost:7777\r\n",
|
||||
"Origin: https://evil.example\r\n",
|
||||
"Access-Control-Request-Method: POST\r\n",
|
||||
"Access-Control-Request-Headers: content-type\r\n",
|
||||
"\r\n"
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command preflight exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_without_origin_or_referer_is_rejected() {
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_with_dns_rebinding_host_is_rejected() {
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: attacker.example:7777\r\nOrigin: http://attacker.example:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_ignores_header_like_body_lines() {
|
||||
let body = "Referer: http://localhost:7777\r\n{\"action\":\"tabs\"}";
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn same_origin_command_post_relays_without_wildcard_cors() {
|
||||
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("t");
|
||||
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("ab-")
|
||||
.tempdir_in(temp_parent)
|
||||
.unwrap();
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir.path().to_str().unwrap(),
|
||||
);
|
||||
guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let session_name = "x";
|
||||
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: http://localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, session_name).await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 200 OK"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
response.contains("Access-Control-Allow-Origin: http://localhost:7777"),
|
||||
"same-origin command response did not reflect origin: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"same-origin command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
|
||||
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +228,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
// Navigation response
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||
println!("{} {}", color::success_indicator(), color::bold(title));
|
||||
println!(" {}", color::dim(url));
|
||||
return;
|
||||
let title = data
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
match title {
|
||||
Some(t) => {
|
||||
println!("{} {}", color::success_indicator(), color::bold(t));
|
||||
println!(" {}", color::dim(url));
|
||||
}
|
||||
// Title-less page: show the URL with the checkmark instead of an
|
||||
// empty title line.
|
||||
None => println!("{} {}", color::success_indicator(), color::dim(url)),
|
||||
}
|
||||
println!("{}", url);
|
||||
return;
|
||||
}
|
||||
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
||||
@@ -1042,6 +1050,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
|
||||
// Default success
|
||||
println!("{} Done", color::success_indicator());
|
||||
} else {
|
||||
// Success response with no data payload — still confirm the command ran
|
||||
// instead of printing nothing (a silent exit 0 looks like a no-op and
|
||||
// hides whether anything happened).
|
||||
println!("{} Done", color::success_indicator());
|
||||
}
|
||||
|
||||
print_warning(resp);
|
||||
@@ -1077,7 +1090,7 @@ Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--headed Show browser window
|
||||
--headed Show browser window (default; headless is forbidden — it's a bot tell)
|
||||
--enable react-devtools Inject the React DevTools hook before any page JS
|
||||
--init-script <path> Register a page init script (repeatable)
|
||||
|
||||
@@ -1583,6 +1596,8 @@ Usage: agent-browser screenshot [selector] [path]
|
||||
|
||||
Captures a screenshot of the current page. If no path is provided,
|
||||
saves to a temporary directory with a generated filename.
|
||||
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||
Pass --hide-scrollbars false when launching to keep native scrollbars visible.
|
||||
|
||||
Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
@@ -3098,6 +3113,8 @@ Options:
|
||||
e.g., --proxy-bypass "localhost,*.internal.com"
|
||||
--ignore-https-errors Ignore HTTPS certificate errors
|
||||
--allow-file-access Allow file:// URLs to access local files (Chromium only)
|
||||
--hide-scrollbars <bool> Hide native scrollbars in headless Chromium screenshots (default: true)
|
||||
Use --hide-scrollbars false to keep scrollbars visible
|
||||
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse, browserless, agentcore
|
||||
--device <name> iOS device name (e.g., "iPhone 15 Pro")
|
||||
--json JSON output
|
||||
@@ -3105,7 +3122,8 @@ Options:
|
||||
--screenshot-dir <path> Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
|
||||
--screenshot-quality <n> JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY)
|
||||
--screenshot-format <fmt> Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
|
||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
|
||||
--headed Always on (default). Headless is forbidden (bot-detection tell);
|
||||
display-less servers can opt back in with AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||
--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)
|
||||
@@ -3137,11 +3155,12 @@ Configuration:
|
||||
Boolean flags accept an optional true/false value to override config:
|
||||
--headed (same as --headed true)
|
||||
--headed false (disables "headed": true from config)
|
||||
--hide-scrollbars false (keeps native scrollbars visible in headless Chromium screenshots)
|
||||
|
||||
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, "hideScrollbars": false, "proxy": "http://localhost:8080"}}
|
||||
|
||||
Environment:
|
||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||
@@ -3161,6 +3180,7 @@ Environment:
|
||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse, browserless, agentcore)
|
||||
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_HIDE_SCROLLBARS Hide scrollbars in headless Chromium screenshots (default: true)
|
||||
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default action timeout in ms (default: 25000)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use include_dir::{include_dir, Dir};
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -6,6 +7,12 @@ use std::process::exit;
|
||||
|
||||
use crate::color;
|
||||
|
||||
/// Skill content compiled into the binary so `skills get` works on a
|
||||
/// single-binary install (GitHub Release / install.sh), where there is no
|
||||
/// adjacent `skills/` or `skill-data/` on disk the way an npm install has.
|
||||
static EMBEDDED_SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skills");
|
||||
static EMBEDDED_SKILL_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skill-data");
|
||||
|
||||
struct SkillInfo {
|
||||
name: String,
|
||||
description: String,
|
||||
@@ -63,6 +70,31 @@ fn find_package_root() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the binary-embedded skill content to a per-version cache dir on
|
||||
/// first use, returning a package root that contains `skills/` and
|
||||
/// `skill-data/`. Fallback for single-binary installs (GitHub Release /
|
||||
/// install.sh) that have no on-disk skill directories. Version-stamped so an
|
||||
/// upgraded binary re-extracts fresh content.
|
||||
fn embedded_skills_root() -> Option<PathBuf> {
|
||||
let base = dirs::cache_dir()?
|
||||
.join("agent-browser")
|
||||
.join(concat!("skills-", env!("CARGO_PKG_VERSION")));
|
||||
let marker = base.join(".extracted");
|
||||
if !marker.exists() {
|
||||
let _ = fs::create_dir_all(base.join("skills"));
|
||||
let _ = fs::create_dir_all(base.join("skill-data"));
|
||||
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|
||||
|| EMBEDDED_SKILL_DATA
|
||||
.extract(base.join("skill-data"))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let _ = fs::write(&marker, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
base.join("skills").is_dir().then_some(base)
|
||||
}
|
||||
|
||||
/// Collect all skill directories to search, respecting the env var override.
|
||||
fn find_skills_dirs() -> Vec<PathBuf> {
|
||||
// Env var override: single directory, used as-is
|
||||
@@ -73,15 +105,28 @@ fn find_skills_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
let Some(root) = find_package_root() else {
|
||||
return vec![];
|
||||
};
|
||||
// On-disk package root (npm install layout, or dev build walking up to repo).
|
||||
if let Some(root) = find_package_root() {
|
||||
let dirs: Vec<PathBuf> = SKILL_DIRS
|
||||
.iter()
|
||||
.map(|d| root.join(d))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
if !dirs.is_empty() {
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
|
||||
SKILL_DIRS
|
||||
.iter()
|
||||
.map(|d| root.join(d))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect()
|
||||
// Fallback: skill content compiled into the binary (single-binary install).
|
||||
if let Some(root) = embedded_skills_root() {
|
||||
return SKILL_DIRS
|
||||
.iter()
|
||||
.map(|d| root.join(d))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
|
||||
|
||||
@@ -1,284 +1,73 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
use std::process::{exit, Command};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
||||
|
||||
enum InstallMethod {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
Homebrew,
|
||||
Cargo,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn fetch_latest_version() -> Result<String, String> {
|
||||
let resp = reqwest::get(NPM_REGISTRY_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
body.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No version field in registry response".to_string())
|
||||
}
|
||||
|
||||
/// Parse the `.install-method` marker written by postinstall.js.
|
||||
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
|
||||
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
|
||||
match contents.trim() {
|
||||
"npm" => Some(InstallMethod::Npm),
|
||||
"pnpm" => Some(InstallMethod::Pnpm),
|
||||
"yarn" => Some(InstallMethod::Yarn),
|
||||
"bun" => Some(InstallMethod::Bun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_install_method() -> InstallMethod {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Resolve symlinks to find the real binary location
|
||||
let real_path = exe.canonicalize().unwrap_or(exe);
|
||||
|
||||
// Preferred: read the marker file written at install time
|
||||
if let Some(dir) = real_path.parent() {
|
||||
if let Some(method) = read_install_method_marker(dir) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from executable path
|
||||
let path_str = real_path.to_string_lossy();
|
||||
|
||||
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/Cellar/agent-browser/")
|
||||
|| path_str.contains("/homebrew/")
|
||||
|| path_str.contains("/linuxbrew/")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if path_str.contains("/.bun/") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules/agent-browser")
|
||||
|| path_str.contains("node_modules\\agent-browser")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: probe package managers via subprocess
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if command_succeeds("brew", &["list", "agent-browser"]) {
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
}
|
||||
|
||||
if command_output_contains(
|
||||
"pnpm",
|
||||
&["list", "-g", "agent-browser", "--depth=0"],
|
||||
"agent-browser",
|
||||
) {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
}
|
||||
|
||||
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: &InstallMethod) -> bool {
|
||||
let (cmd, args, display): (&str, &[&str], &str) = match method {
|
||||
InstallMethod::Npm => (
|
||||
"npm",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"npm install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Pnpm => (
|
||||
"pnpm",
|
||||
&["add", "-g", "agent-browser@latest"],
|
||||
"pnpm add -g agent-browser@latest",
|
||||
),
|
||||
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
|
||||
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
|
||||
InstallMethod::Yarn => (
|
||||
"yarn",
|
||||
&["global", "add", "agent-browser@latest"],
|
||||
"yarn global add agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Bun => (
|
||||
"bun",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"bun install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Homebrew => (
|
||||
"brew",
|
||||
&["upgrade", "agent-browser"],
|
||||
"brew upgrade agent-browser",
|
||||
),
|
||||
InstallMethod::Cargo => (
|
||||
"cargo",
|
||||
&["install", "agent-browser", "--force"],
|
||||
"cargo install agent-browser --force",
|
||||
),
|
||||
InstallMethod::Unknown => return false,
|
||||
};
|
||||
|
||||
println!("Running: {}", display);
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Canonical installer for the stealth fork. `upgrade` just re-runs it, so the
|
||||
/// upgrade path and the install path are identical (GitHub Release, no npm).
|
||||
const INSTALL_URL: &str =
|
||||
"https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh";
|
||||
|
||||
/// Upgrade to the latest GitHub Release.
|
||||
///
|
||||
/// The stealth fork ships as a prebuilt binary attached to a GitHub Release —
|
||||
/// NOT via the npm registry. Earlier this command (inherited from upstream)
|
||||
/// ran `npm/pnpm install -g agent-browser@latest`, which installed the
|
||||
/// UNRELATED upstream `agent-browser` package and clobbered the user's setup.
|
||||
/// Now `upgrade` simply re-runs install.sh into the same directory as the
|
||||
/// current binary, so it always tracks the freshest GitHub Release.
|
||||
pub fn run_upgrade() {
|
||||
let current = CURRENT_VERSION;
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser-stealth (currently v{}) from the latest GitHub Release...",
|
||||
CURRENT_VERSION
|
||||
))
|
||||
);
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
});
|
||||
|
||||
let latest = match rt.block_on(fetch_latest_version()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not check latest version: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !latest.is_empty() && current == latest.as_str() {
|
||||
println!(
|
||||
"{} agent-browser is already at the latest version (v{})",
|
||||
color::success_indicator(),
|
||||
current
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
|
||||
let method_name = match &method {
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Pnpm => "pnpm",
|
||||
InstallMethod::Yarn => "yarn",
|
||||
InstallMethod::Bun => "bun",
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Unknown => "",
|
||||
};
|
||||
|
||||
if matches!(method, InstallMethod::Unknown) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
eprintln!(
|
||||
"{} Could not detect installation method.",
|
||||
color::error_indicator()
|
||||
"{} Automatic upgrade isn't supported on Windows.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
eprintln!(" To update manually, run one of:");
|
||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
||||
eprintln!(" yarn global add agent-browser@latest # yarn");
|
||||
eprintln!(" bun install -g agent-browser@latest # bun");
|
||||
eprintln!(" brew upgrade agent-browser # Homebrew");
|
||||
eprintln!(" cargo install agent-browser --force # Cargo");
|
||||
eprintln!(" Download the latest agent-browser-win32-x64.tar.gz from:");
|
||||
eprintln!(" https://github.com/leeguooooo/agent-browser-stealth/releases/latest");
|
||||
eprintln!(" and replace agent-browser.exe on your PATH.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
println!("Detected installation via {}.", method_name);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Install into the SAME directory as the running binary (in-place
|
||||
// upgrade), so we don't create a second copy elsewhere on PATH.
|
||||
let bin_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser... v{} → v{}",
|
||||
current, latest
|
||||
))
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
||||
);
|
||||
}
|
||||
let install_cmd = format!("curl -fsSL {} | sh", INSTALL_URL);
|
||||
println!("Running: {}", install_cmd);
|
||||
|
||||
let success = run_upgrade_command(&method);
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(&install_cmd);
|
||||
if let Some(ref dir) = bin_dir {
|
||||
cmd.env("AGENT_BROWSER_BIN_DIR", dir);
|
||||
}
|
||||
|
||||
if success {
|
||||
if !latest.is_empty() {
|
||||
let ok = cmd.status().map(|s| s.success()).unwrap_or(false);
|
||||
if ok {
|
||||
println!(
|
||||
"{} Done! v{} → v{}",
|
||||
color::success_indicator(),
|
||||
current,
|
||||
latest
|
||||
"{} Upgrade complete — run `agent-browser-stealth --version` to confirm.",
|
||||
color::success_indicator()
|
||||
);
|
||||
} else {
|
||||
println!("{} Done!", color::success_indicator());
|
||||
eprintln!(
|
||||
"{} Upgrade failed. Install manually:",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ fn build_doctor_cmd(tmp: &TempDir, args: &[&str]) -> Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
// `doctor --offline --quick` runs the full check suite and, on Windows, does
|
||||
// not exit while its stdout is captured by `Command::output()` (the `--help`
|
||||
// variant below exits fine) — so the test would block forever. The 767-test
|
||||
// main suite passes on Windows; this is the one binary-spawning doctor check
|
||||
// that hangs there. Skip it on Windows until the Windows doctor exit/pipe
|
||||
// behavior is fixed; it still runs on Linux/macOS.
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "doctor --offline hangs on Windows under captured stdout"
|
||||
)]
|
||||
#[test]
|
||||
fn doctor_offline_quick_json_emits_valid_payload() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -20,13 +20,19 @@ services:
|
||||
|
||||
# Build both targets in parallel
|
||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
||||
PID1=$!
|
||||
PID1=$$!
|
||||
|
||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||
PID2=$!
|
||||
PID2=$$!
|
||||
|
||||
# Wait for both to complete
|
||||
wait $PID1 $PID2
|
||||
# Wait for both and check exit codes individually — without this
|
||||
# the outer script exits 0 even if one of the parallel builds
|
||||
# failed, silently leaving a stale binary in /output from the
|
||||
# previous release. Caused 0.27.0-fork.5 to ship with a stale
|
||||
# linux-x64 binary at the first publish attempt until caught
|
||||
# manually by checking the embedded version string.
|
||||
wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
|
||||
wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "✓ Linux platforms built successfully!"
|
||||
@@ -65,10 +71,21 @@ services:
|
||||
environment:
|
||||
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
|
||||
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
||||
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
|
||||
# interpolated by docker compose at YAML parse time against the *host*
|
||||
# environment, which silently drops script-local variables like SRC
|
||||
# (caused 0.27.0-fork.7 to ship with a stale linux-arm64 binary because
|
||||
# the cp command resolved to `cp "" "/output/"` after compose ate $SRC
|
||||
# and $OUTPUT_NAME). $TARGET / $OUTPUT_NAME are set via `environment:`
|
||||
# below — those are also passed into the container, so $$TARGET and
|
||||
# $$OUTPUT_NAME read them at script time.
|
||||
command: |
|
||||
-c '
|
||||
cargo zigbuild --release --target $TARGET
|
||||
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
|
||||
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $OUTPUT_NAME"
|
||||
set -e
|
||||
cargo zigbuild --release --target $$TARGET
|
||||
SRC="/build/target/$$TARGET/release/agent-browser"
|
||||
if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
|
||||
cp "$$SRC" "/output/$$OUTPUT_NAME"
|
||||
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $$OUTPUT_NAME"
|
||||
'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Attribution
|
||||
|
||||
The chrome.debugger attach + CDP Target handling in `background.js` is adapted
|
||||
from **openclaw-browser-relay** by chengyixu
|
||||
(https://github.com/chengyixu/openclaw-browser-relay, MIT per its README).
|
||||
|
||||
Changes for agent-browser-stealth: rebranded to "agent-browser connect"; the
|
||||
transport is rewritten from a localhost WebSocket + shared token to Chrome
|
||||
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
|
||||
Chrome authenticates the extension to the host by id. WebSocket/token/options
|
||||
code removed.
|
||||
@@ -0,0 +1,382 @@
|
||||
// agent-browser connect — MV3 service worker.
|
||||
//
|
||||
// Bridges the user's real Chrome tabs to the local agent-browser daemon over a
|
||||
// Chrome **native messaging** channel (no localhost port, no token: Chrome
|
||||
// authenticates this extension to the host by id). It attaches chrome.debugger
|
||||
// to eligible tabs and relays CDP both ways via a tiny envelope:
|
||||
// host → ext : {id, method:"forwardCDPCommand", params:{method,params,sessionId}}
|
||||
// ext → host : {id, result|error} (command reply)
|
||||
// ext → host : {method:"forwardCDPEvent", params:{sessionId,method,params}}
|
||||
//
|
||||
// Target/discovery semantics (getTargets/attachToTarget) are emulated on the
|
||||
// daemon side; here we just attach tabs and announce them as
|
||||
// Target.attachedToTarget so the daemon's CDP client sees them appear.
|
||||
//
|
||||
// Adapted from openclaw-browser-relay (MIT, chengyixu) — the chrome.debugger
|
||||
// attach + Target handling; the transport is rewritten from WebSocket+token to
|
||||
// native messaging.
|
||||
|
||||
const HOST_NAME = 'com.agent_browser.connect'
|
||||
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
|
||||
|
||||
/** @type {chrome.runtime.Port|null} */
|
||||
let port = null
|
||||
/** Whether the native-messaging host (the local agent-browser CLI) is linked.
|
||||
* Read by the popup status page. */
|
||||
let hostConnected = false
|
||||
let nextSession = 1
|
||||
/** tabId -> { sessionId, targetId } */
|
||||
const tabs = new Map()
|
||||
/** sessionId -> tabId (main session per tab) */
|
||||
const sessionToTab = new Map()
|
||||
/** child (OOPIF/worker) sessionId -> tabId */
|
||||
const childSessionToTab = new Map()
|
||||
/** tab-group name -> chrome tabGroups id (best-effort cache) */
|
||||
const groupIdByName = new Map()
|
||||
|
||||
// Deterministic color per group name so a given session keeps the same color.
|
||||
const GROUP_COLORS = ['blue', 'cyan', 'green', 'yellow', 'orange', 'red', 'pink', 'purple', 'grey']
|
||||
function colorForName(name) {
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return GROUP_COLORS[h % GROUP_COLORS.length]
|
||||
}
|
||||
|
||||
// Put a freshly-created tab into the agent/session's own Chrome tab group, so
|
||||
// each agent's tabs are visually separated (from each other and from the user's
|
||||
// own tabs) on the shared real browser. Best-effort: grouping failures never
|
||||
// break tab creation.
|
||||
async function groupTabInto(tabId, name) {
|
||||
if (!name || !chrome.tabGroups || !chrome.tabs.group) return
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||
if (!tab) return
|
||||
let gid = groupIdByName.get(name)
|
||||
if (gid != null) {
|
||||
const ok = await chrome.tabGroups.get(gid).then(() => true).catch(() => false)
|
||||
if (!ok) {
|
||||
gid = null
|
||||
groupIdByName.delete(name)
|
||||
}
|
||||
}
|
||||
if (gid == null) {
|
||||
// Reuse a same-titled group already in this window (survives SW restarts).
|
||||
const found = await chrome.tabGroups.query({ windowId: tab.windowId, title: name }).catch(() => [])
|
||||
if (found && found[0]) gid = found[0].id
|
||||
}
|
||||
if (gid == null) {
|
||||
gid = await chrome.tabs.group({ tabIds: tabId })
|
||||
await chrome.tabGroups.update(gid, { title: name, color: colorForName(name) }).catch(() => {})
|
||||
} else {
|
||||
await chrome.tabs.group({ groupId: gid, tabIds: tabId }).catch(() => {})
|
||||
}
|
||||
groupIdByName.set(name, gid)
|
||||
}
|
||||
|
||||
function postToHost(msg) {
|
||||
try {
|
||||
if (port) port.postMessage(msg)
|
||||
} catch (e) {
|
||||
// port died; onDisconnect will reconnect.
|
||||
}
|
||||
}
|
||||
|
||||
function setBadge(tabId, kind) {
|
||||
const map = { on: '', connecting: '…', error: '!' }
|
||||
const colors = { on: '#16a34a', connecting: '#d97706', error: '#b91c1c' }
|
||||
try {
|
||||
chrome.action.setBadgeText({ tabId, text: map[kind] ?? '' })
|
||||
if (colors[kind]) chrome.action.setBadgeBackgroundColor({ tabId, color: colors[kind] })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- native messaging transport ------------------------------------------
|
||||
|
||||
function connectHost() {
|
||||
if (port) return
|
||||
try {
|
||||
port = chrome.runtime.connectNative(HOST_NAME)
|
||||
hostConnected = true
|
||||
} catch (e) {
|
||||
port = null
|
||||
hostConnected = false
|
||||
return
|
||||
}
|
||||
port.onMessage.addListener((msg) => void whenReady(() => onHostMessage(msg)))
|
||||
port.onDisconnect.addListener(() => {
|
||||
port = null
|
||||
hostConnected = false
|
||||
// Sessions are stale once the host is gone; the daemon re-discovers on
|
||||
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
|
||||
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
|
||||
})
|
||||
// Tell the daemon about everything we already have attached, then attach
|
||||
// anything new.
|
||||
reannounceAttachedTabs()
|
||||
void attachAllTabs()
|
||||
}
|
||||
|
||||
async function onHostMessage(msg) {
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
// Optional keepalive.
|
||||
if (msg.method === 'ping') {
|
||||
postToHost({ method: 'pong' })
|
||||
return
|
||||
}
|
||||
// Daemon (re)connected — (re)attach and announce every tab so it discovers
|
||||
// the user's existing tabs rather than racing an empty target list.
|
||||
if (msg.method === 'attachAll') {
|
||||
reannounceAttachedTabs()
|
||||
await attachAllTabs()
|
||||
return
|
||||
}
|
||||
if (typeof msg.id !== 'undefined' && msg.method === 'forwardCDPCommand') {
|
||||
try {
|
||||
const result = await handleForwardCdpCommand(msg)
|
||||
postToHost({ id: msg.id, result })
|
||||
} catch (err) {
|
||||
postToHost({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CDP command dispatch -------------------------------------------------
|
||||
|
||||
function tabForSession(sessionId) {
|
||||
return sessionToTab.get(sessionId) ?? childSessionToTab.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function tabForTarget(targetId) {
|
||||
for (const [tabId, t] of tabs.entries()) if (t.targetId === targetId) return tabId
|
||||
return null
|
||||
}
|
||||
|
||||
function anyConnectedTab() {
|
||||
const it = tabs.keys().next()
|
||||
return it.done ? null : it.value
|
||||
}
|
||||
|
||||
async function handleForwardCdpCommand(msg) {
|
||||
const method = String(msg?.params?.method || '')
|
||||
const params = msg?.params?.params || undefined
|
||||
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
||||
|
||||
// Browser-level Target methods that map onto chrome.tabs.
|
||||
if (method === 'Target.createTarget') {
|
||||
const url = typeof params?.url === 'string' && params.url ? params.url : 'about:blank'
|
||||
const tab = await chrome.tabs.create({ url, active: false })
|
||||
if (!tab.id) throw new Error('createTarget: no tab id')
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const t = await attachTab(tab.id)
|
||||
// Per-session tab grouping (non-CDP hint from the daemon). Best-effort.
|
||||
const group = typeof params?.agentGroup === 'string' ? params.agentGroup.trim() : ''
|
||||
if (group) {
|
||||
try {
|
||||
await groupTabInto(tab.id, group)
|
||||
} catch {}
|
||||
}
|
||||
return { targetId: t.targetId }
|
||||
}
|
||||
if (method === 'Target.closeTarget') {
|
||||
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const tabId = tid ? tabForTarget(tid) : null
|
||||
if (!tabId) return { success: false }
|
||||
try {
|
||||
await chrome.tabs.remove(tabId)
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
if (method === 'Target.activateTarget') {
|
||||
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const tabId = tid ? tabForTarget(tid) : null
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||
if (tab?.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
||||
await chrome.tabs.update(tabId, { active: true }).catch(() => {})
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// Everything else → chrome.debugger on the resolved tab.
|
||||
const tabId =
|
||||
(sessionId ? tabForSession(sessionId) : null) ??
|
||||
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
|
||||
anyConnectedTab()
|
||||
if (!tabId) throw new Error(`no attached tab for ${method}`)
|
||||
const dbg = { tabId }
|
||||
|
||||
// Re-enabling Runtime can leave a stale state; bounce it (matches upstream).
|
||||
if (method === 'Runtime.enable') {
|
||||
try {
|
||||
await chrome.debugger.sendCommand(dbg, 'Runtime.disable')
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
} catch {}
|
||||
return await chrome.debugger.sendCommand(dbg, 'Runtime.enable', params)
|
||||
}
|
||||
return await chrome.debugger.sendCommand(dbg, method, params)
|
||||
}
|
||||
|
||||
// ---- attach / detach ------------------------------------------------------
|
||||
|
||||
async function attachTab(tabId) {
|
||||
const existing = tabs.get(tabId)
|
||||
if (existing) return existing
|
||||
const dbg = { tabId }
|
||||
try {
|
||||
await chrome.debugger.attach(dbg, '1.3')
|
||||
} catch (e) {
|
||||
// After a service-worker restart, chrome.debugger may still be bound to
|
||||
// this tab from the previous instance — "Another debugger is already
|
||||
// attached". The tab is still controllable via {tabId}, so don't skip it
|
||||
// (skipping is why existing tabs went un-announced and the daemon opened a
|
||||
// blank tab instead). Re-announce it. Any other error (restricted page) is
|
||||
// surfaced and the caller skips this tab.
|
||||
const msg = String((e && e.message) || e)
|
||||
if (!/already attached|already being debugged/i.test(msg)) throw e
|
||||
}
|
||||
await chrome.debugger.sendCommand(dbg, 'Page.enable').catch(() => {})
|
||||
const info = /** @type {any} */ (await chrome.debugger.sendCommand(dbg, 'Target.getTargetInfo'))
|
||||
const targetInfo = info?.targetInfo
|
||||
const targetId = String(targetInfo?.targetId || '')
|
||||
if (!targetId) throw new Error('attachTab: no targetId')
|
||||
const sessionId = `cb-tab-${nextSession++}`
|
||||
const entry = { sessionId, targetId }
|
||||
tabs.set(tabId, entry)
|
||||
sessionToTab.set(sessionId, tabId)
|
||||
setBadge(tabId, port ? 'on' : 'connecting')
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId, targetInfo: { ...targetInfo, attached: true } },
|
||||
},
|
||||
})
|
||||
return entry
|
||||
}
|
||||
|
||||
function detachTab(tabId, notify) {
|
||||
const entry = tabs.get(tabId)
|
||||
if (!entry) return
|
||||
tabs.delete(tabId)
|
||||
sessionToTab.delete(entry.sessionId)
|
||||
for (const [sid, tid] of childSessionToTab.entries()) if (tid === tabId) childSessionToTab.delete(sid)
|
||||
if (notify) {
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: { sessionId: entry.sessionId, method: 'Target.detachedFromTarget', params: { sessionId: entry.sessionId } },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function eligible(tab) {
|
||||
return !!tab && !!tab.id && typeof tab.url === 'string' && !SKIP_URL.test(tab.url)
|
||||
}
|
||||
|
||||
async function attachAllTabs() {
|
||||
let all = []
|
||||
try {
|
||||
all = await chrome.tabs.query({})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const tab of all) {
|
||||
if (eligible(tab) && !tabs.has(tab.id)) {
|
||||
try {
|
||||
await attachTab(tab.id)
|
||||
} catch {
|
||||
// Tab may be a restricted page or already attached elsewhere.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reannounceAttachedTabs() {
|
||||
for (const [, entry] of tabs.entries()) {
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- chrome.debugger events ----------------------------------------------
|
||||
|
||||
chrome.debugger.onEvent.addListener((source, method, params) =>
|
||||
void whenReady(() => {
|
||||
const tabId = source.tabId
|
||||
if (!tabId) return
|
||||
const entry = tabs.get(tabId)
|
||||
if (!entry) return
|
||||
if (method === 'Target.attachedToTarget' && params?.sessionId) {
|
||||
childSessionToTab.set(String(params.sessionId), tabId)
|
||||
}
|
||||
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
|
||||
childSessionToTab.delete(String(params.sessionId))
|
||||
}
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: { sessionId: source.sessionId || entry.sessionId, method, params },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
chrome.debugger.onDetach.addListener((source) =>
|
||||
void whenReady(() => {
|
||||
if (source.tabId) detachTab(source.tabId, true)
|
||||
}),
|
||||
)
|
||||
|
||||
// ---- tab lifecycle --------------------------------------------------------
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =>
|
||||
void whenReady(async () => {
|
||||
if (changeInfo.status === 'complete' && eligible(tab) && !tabs.has(tabId) && port) {
|
||||
try {
|
||||
await attachTab(tabId)
|
||||
} catch {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => detachTab(tabId, true)))
|
||||
|
||||
// ---- bootstrap + keepalive ------------------------------------------------
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost))
|
||||
chrome.runtime.onStartup.addListener(() => void whenReady(connectHost))
|
||||
|
||||
// Popup status page asks for the live pairing state. Attempt a (re)connect on
|
||||
// demand so opening the popup also nudges the link awake, then report.
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg && msg.type === 'ab-status') {
|
||||
if (!port) {
|
||||
try { connectHost() } catch (e) {}
|
||||
}
|
||||
sendResponse({ connected: hostConnected, tabCount: tabs.size, host: HOST_NAME })
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// MV3 service workers get suspended; an alarm wakes us to keep the host link
|
||||
// and badges fresh.
|
||||
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 })
|
||||
chrome.alarms.onAlarm.addListener((a) => {
|
||||
if (a.name !== 'keepalive') return
|
||||
void whenReady(() => {
|
||||
if (!port) connectHost()
|
||||
else void attachAllTabs()
|
||||
})
|
||||
})
|
||||
|
||||
// Gate placeholder so future async state-rehydration can hook in.
|
||||
async function whenReady(fn) {
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Kick a connection attempt as soon as the worker starts.
|
||||
connectHost()
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.4.1",
|
||||
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
"nativeMessaging",
|
||||
"storage",
|
||||
"alarms",
|
||||
"webNavigation"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth",
|
||||
"default_popup": "popup.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a21;
|
||||
--fg: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--cyan: #2ad4ff;
|
||||
--green: #3fb950;
|
||||
--amber: #d29922;
|
||||
--border: #232a33;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header img { width: 32px; height: 32px; border-radius: 7px; }
|
||||
header .title { font-weight: 600; font-size: 14px; }
|
||||
header .ver { color: var(--muted); font-size: 11px; }
|
||||
main { padding: 14px 16px 8px; }
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
}
|
||||
.dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--muted); flex: none;
|
||||
box-shadow: 0 0 0 0 rgba(0,0,0,0);
|
||||
}
|
||||
.dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.dot.off { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
|
||||
.status .label { font-weight: 600; }
|
||||
.status .sub { color: var(--muted); font-size: 11px; }
|
||||
.desc { color: var(--muted); margin: 12px 2px 4px; }
|
||||
.hint {
|
||||
margin: 10px 0 2px;
|
||||
padding: 9px 11px;
|
||||
background: #1d1a12;
|
||||
border: 1px solid #3a3014;
|
||||
border-radius: 8px;
|
||||
color: #e3c878;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.hint code {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
padding: 6px 8px;
|
||||
background: #0b0d10;
|
||||
border-radius: 6px;
|
||||
color: var(--cyan);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
user-select: all;
|
||||
}
|
||||
footer {
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
footer .privacy { color: var(--muted); font-size: 11px; }
|
||||
footer a { color: var(--cyan); text-decoration: none; font-size: 11px; cursor: pointer; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="icons/icon128.png" alt="" />
|
||||
<div>
|
||||
<div class="title">agent-browser-stealth</div>
|
||||
<div class="ver">local automation bridge</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="status">
|
||||
<span id="dot" class="dot"></span>
|
||||
<div>
|
||||
<div class="label" id="statusLabel">Checking…</div>
|
||||
<div class="sub" id="statusSub">contacting the local CLI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="desc">
|
||||
Lets your locally-installed <strong>agent-browser</strong> command-line tool
|
||||
drive your own logged-in Chrome tabs — entirely on this machine, only when
|
||||
you run a command. No remote server, no data collection.
|
||||
</p>
|
||||
|
||||
<div class="hint" id="hint">
|
||||
Not linked yet. Install & pair the CLI, then reopen this popup:
|
||||
<code>agent-browser extension install</code>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span class="privacy">No tracking · no remote server</span>
|
||||
<a id="repo" data-href="https://github.com/leeguooooo/agent-browser-stealth">GitHub ↗</a>
|
||||
</footer>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
// Popup status page for agent-browser-stealth.
|
||||
// Asks the service worker whether the native-messaging link to the local
|
||||
// agent-browser CLI is live, and renders a paired / not-paired indicator.
|
||||
|
||||
const dot = document.getElementById('dot')
|
||||
const label = document.getElementById('statusLabel')
|
||||
const sub = document.getElementById('statusSub')
|
||||
const hint = document.getElementById('hint')
|
||||
|
||||
let resolved = false
|
||||
|
||||
function render(state) {
|
||||
resolved = true
|
||||
const connected = !!(state && state.connected)
|
||||
dot.classList.remove('on', 'off')
|
||||
if (connected) {
|
||||
dot.classList.add('on')
|
||||
label.textContent = 'Connected'
|
||||
const n = state.tabCount | 0
|
||||
sub.textContent =
|
||||
n > 0
|
||||
? `bridged to the local CLI · ${n} tab${n === 1 ? '' : 's'} attached`
|
||||
: 'bridged to the local CLI · ready'
|
||||
hint.style.display = 'none'
|
||||
} else {
|
||||
dot.classList.add('off')
|
||||
label.textContent = 'Not paired'
|
||||
sub.textContent = 'no local agent-browser CLI linked'
|
||||
hint.style.display = 'block'
|
||||
}
|
||||
}
|
||||
|
||||
function queryStatus() {
|
||||
try {
|
||||
chrome.runtime.sendMessage({ type: 'ab-status' }, (resp) => {
|
||||
// lastError fires if the service worker can't be reached.
|
||||
if (chrome.runtime.lastError) {
|
||||
render({ connected: false })
|
||||
return
|
||||
}
|
||||
render(resp)
|
||||
})
|
||||
} catch (e) {
|
||||
render({ connected: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Open the repo in a real tab (no inline handlers under MV3 CSP).
|
||||
const repo = document.getElementById('repo')
|
||||
if (repo) {
|
||||
repo.addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: repo.dataset.href })
|
||||
})
|
||||
}
|
||||
|
||||
// Query now, then once more shortly after — opening the popup also nudges the
|
||||
// service worker to (re)connect the host, which may complete a beat later.
|
||||
queryStatus()
|
||||
setTimeout(queryStatus, 700)
|
||||
|
||||
// Never leave the popup stuck on "Checking…" if the worker never answers.
|
||||
setTimeout(() => {
|
||||
if (!resolved) render({ connected: false })
|
||||
}, 1500)
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chrome Web Store 提交指南 — agent-browser-stealth</title>
|
||||
<style>
|
||||
:root{--fg:#1a1a1a;--muted:#5c5c5c;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--border:#e2e2e2;--bg:#fff;--code:#f5f5f7}
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--fg);background:var(--bg);max-width:880px;margin:0 auto;padding:48px 24px;line-height:1.65}
|
||||
header{border-bottom:2px solid var(--fg);padding-bottom:16px;margin-bottom:24px}
|
||||
h1{font-size:1.7rem;margin:0 0 4px}
|
||||
.sub{color:var(--muted)}
|
||||
h2{font-size:1.2rem;margin:34px 0 10px;border-left:3px solid var(--accent);padding-left:10px}
|
||||
h3{font-size:1rem;margin:20px 0 6px}
|
||||
code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.88em}
|
||||
pre{background:var(--code);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow:auto;font-size:.86rem;white-space:pre-wrap}
|
||||
table{border-collapse:collapse;width:100%;margin:12px 0;font-size:.92rem}
|
||||
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--code)}
|
||||
ol li,ul li{margin:6px 0}
|
||||
.warn{background:#fffbeb;border:1px solid #fde68a;border-left:4px solid var(--warn);padding:12px 14px;border-radius:6px;margin:16px 0}
|
||||
.ok{background:#f0fdf4;border:1px solid #bbf7d0;border-left:4px solid var(--ok);padding:12px 14px;border-radius:6px;margin:16px 0}
|
||||
.field{font-weight:600;color:var(--accent)}
|
||||
footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Chrome Web Store 提交指南</h1>
|
||||
<div class="sub">agent-browser-stealth · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
|
||||
</header>
|
||||
|
||||
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
|
||||
|
||||
<div class="warn">
|
||||
<strong>评审风险(务必知道):</strong> 本扩展用了 <code>debugger</code> 权限,这是 Chrome Web Store 审核最严的权限之一。理由必须写清楚"只在用户本机、用户主动发指令时驱动用户自己的标签页,无远程服务器"。类似工具(如 claude-in-chrome)能过审,但可能被多问一轮、审核时间偏长(几天到一两周)。
|
||||
</div>
|
||||
|
||||
<h2>一、前置(你来做,一次性)</h2>
|
||||
<ol>
|
||||
<li>用一个 Google 账号登录 <code>https://chrome.google.com/webstore/devconsole</code></li>
|
||||
<li>首次需付 <strong>$5</strong> 一次性开发者注册费</li>
|
||||
<li>(隐私政策需要一个公开 URL,见第四节 —— 我可以帮你开 GitHub Pages 托管 <code>privacy.html</code>)</li>
|
||||
</ol>
|
||||
|
||||
<h2>二、上传</h2>
|
||||
<ol>
|
||||
<li>devconsole → <span class="field">New item</span> → 上传 <code>extensions/ab-connect.zip</code></li>
|
||||
<li>上传后确认分配到的 Item ID = <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>(因为 manifest 里保留了 <code>key</code>,id 会被锁成这个,native messaging 的 allowed_origins 才对得上)。<strong>若 id 不是这个,告诉我,我重签。</strong></li>
|
||||
</ol>
|
||||
|
||||
<h2>三、商店信息(直接复制以下文案)</h2>
|
||||
|
||||
<h3>名称 / Name</h3>
|
||||
<pre>agent-browser-stealth</pre>
|
||||
|
||||
<h3>简介 / Summary(≤132 字符)</h3>
|
||||
<pre>Let your own agent-browser CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
|
||||
|
||||
<h3>详细描述 / Description</h3>
|
||||
<pre>agent-browser-stealth is the in-browser half of the open-source agent-browser CLI. It lets the
|
||||
command-line tool you installed on this same computer automate the Chrome you're already logged
|
||||
into — opening pages, clicking, filling forms, reading the DOM — driven entirely by you.
|
||||
|
||||
How it works
|
||||
- The extension talks ONLY to the local agent-browser CLI over Chrome native messaging (a local
|
||||
inter-process channel — no network socket, no token, no remote server).
|
||||
- When you run an automation command, the extension relays Chrome DevTools Protocol operations to
|
||||
the tab you target, then returns the result to the CLI.
|
||||
|
||||
Privacy
|
||||
- No analytics, no trackers, no data collection.
|
||||
- Nothing is sent to any remote server. The only message peer is the local CLI.
|
||||
- Source is open (Apache-2.0): https://github.com/leeguooooo/agent-browser-stealth
|
||||
|
||||
You need the agent-browser CLI installed and paired (run: agent-browser extension install) for this
|
||||
extension to do anything.</pre>
|
||||
|
||||
<h3>类别 / Category</h3>
|
||||
<pre>Developer Tools</pre>
|
||||
|
||||
<h3>语言 / Language</h3>
|
||||
<pre>English</pre>
|
||||
|
||||
<h2>四、隐私实践(Privacy practices 标签页 —— 必填)</h2>
|
||||
|
||||
<h3>Single purpose(单一用途)</h3>
|
||||
<pre>Bridge the user's locally-installed agent-browser CLI to their own logged-in Chrome so the CLI can
|
||||
automate pages the user is working with, entirely on the user's machine and at the user's command.</pre>
|
||||
|
||||
<h3>各权限理由 / Permission justifications</h3>
|
||||
<table>
|
||||
<tr><th>权限</th><th>理由(复制到对应输入框)</th></tr>
|
||||
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local agent-browser CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
|
||||
<tr><td class="field">tabs</td><td>Enumerate and target the correct open tab to attach automation to.</td></tr>
|
||||
<tr><td class="field">tabGroups</td><td>Organizes the tabs the local agent-browser CLI drives into a labeled, colored Chrome tab group per automation session, so the user can see at a glance which tabs are under automation and they stay visually separated from the user's own tabs.</td></tr>
|
||||
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the agent-browser CLI installed on the same machine. No network is used.</td></tr>
|
||||
<tr><td class="field">storage</td><td>Persist small local pairing/configuration state for the extension.</td></tr>
|
||||
<tr><td class="field">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
|
||||
<tr><td class="field">webNavigation</td><td>Detect page loads/navigations so automation can wait for the right moment before acting.</td></tr>
|
||||
<tr><td class="field">host permissions(若被问)</td><td>The extension declares none; tab access is mediated through the debugger attach the user initiates.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>数据用途勾选 / Data usage</h3>
|
||||
<ul>
|
||||
<li>不勾选任何"collects user data"类别。</li>
|
||||
<li>三个合规声明全部勾选可以为真:不卖数据 / 不挪作无关用途 / 不用于判断信用资质。</li>
|
||||
<li><span class="field">Privacy policy URL</span>:填 <code>privacy.html</code> 的公开地址(见下)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>五、隐私政策 URL</h2>
|
||||
<p>商店要求一个公开可访问的隐私政策地址。GitHub Pages <strong>已开启</strong>,直接填这个(渲染好看):</p>
|
||||
<pre>https://leeguooooo.github.io/agent-browser-stealth/extensions/store/privacy.html</pre>
|
||||
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/extensions/store/privacy.html</code>。)</p>
|
||||
|
||||
<h2>六、截图 / Screenshots(至少 1 张,1280×800 或 640×400)</h2>
|
||||
<p>可以截一张 CLI + Chrome 并排的演示图。<em>需要的话我用 cua-driver 截一张合规尺寸的图给你。</em></p>
|
||||
|
||||
<h2>七、提交后</h2>
|
||||
<ol>
|
||||
<li>提交审核 → 等几天。审核通过且状态变 <em>Published</em> 后告诉我。</li>
|
||||
<li>我会把 <code>extension install</code> 的 force-install <code>update_url</code> 切到商店地址并发布新 fork;之后用户 <code>extension install</code> → 批准一次描述文件 → 静默装好(商店扩展不再 <code>[BLOCKED]</code>);或者用户在商店页一键 <span class="field">Add to Chrome</span>。</li>
|
||||
</ol>
|
||||
|
||||
<div class="ok">
|
||||
<strong>今天的临时可用方案:</strong> 在你这台 Mac 上 <code>chrome://extensions</code> → 打开开发者模式 → Load unpacked → 选 <code>extensions/ab-connect</code>,30 秒手动装一次,native messaging + <code>extension connect</code> 立即可用。等商店过审再切静默路径。
|
||||
</div>
|
||||
|
||||
<footer>agent-browser-stealth · 提交包与文案随扩展版本更新;改扩展后重跑 <code>scripts/pack-extension.sh</code> 并重打 <code>ab-connect.zip</code>。</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy — agent-browser-stealth</title>
|
||||
<style>
|
||||
:root{
|
||||
--fg:#1a1a1a; --muted:#5c5c5c; --accent:#2563eb; --border:#e2e2e2; --bg:#fff; --code:#f5f5f5;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
color:var(--fg);background:var(--bg);max-width:820px;margin:0 auto;padding:48px 24px;line-height:1.65}
|
||||
header{border-bottom:2px solid var(--fg);padding-bottom:16px;margin-bottom:28px}
|
||||
h1{font-size:1.7rem;margin:0 0 4px}
|
||||
.sub{color:var(--muted);font-size:.95rem}
|
||||
h2{font-size:1.15rem;margin:32px 0 8px;border-left:3px solid var(--accent);padding-left:10px}
|
||||
code{background:var(--code);padding:1px 5px;border-radius:4px;font-size:.88em}
|
||||
table{border-collapse:collapse;width:100%;margin:12px 0;font-size:.92rem}
|
||||
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--code)}
|
||||
.key{font-weight:600;color:var(--accent)}
|
||||
footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem}
|
||||
strong{color:var(--fg)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Privacy Policy — agent-browser-stealth</h1>
|
||||
<div class="sub">Chrome extension (id <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>) · Last updated 2026-06-09</div>
|
||||
</header>
|
||||
|
||||
<p><strong>Summary: this extension collects no personal data, contains no analytics or
|
||||
trackers, and sends nothing to any remote server.</strong> It is a local bridge that lets the
|
||||
user's own <code>agent-browser</code> command-line tool, running on the same computer, drive the
|
||||
user's logged-in Chrome.</p>
|
||||
|
||||
<h2>What the extension does</h2>
|
||||
<p>agent-browser-stealth pairs Chrome with the locally-installed <code>agent-browser</code> CLI over
|
||||
Chrome <em>native messaging</em> (a local inter-process channel; no network socket, no token). When
|
||||
the user issues an automation command in the CLI, the extension relays Chrome DevTools Protocol
|
||||
operations to the tab the user targets. Everything happens on the user's machine, initiated by the
|
||||
user.</p>
|
||||
|
||||
<h2>Data collection & use</h2>
|
||||
<table>
|
||||
<tr><th>Category</th><th>Collected?</th><th>Detail</th></tr>
|
||||
<tr><td class="key">Personally identifiable information</td><td>No</td><td>Never read, stored, or transmitted.</td></tr>
|
||||
<tr><td class="key">Browsing history</td><td>No</td><td>Not collected. Page content is acted on transiently only while the user is running an automation command, and is never stored or sent off-device.</td></tr>
|
||||
<tr><td class="key">Authentication / cookies / credentials</td><td>No</td><td>Not read or exported by the extension.</td></tr>
|
||||
<tr><td class="key">Analytics / telemetry</td><td>No</td><td>The extension contains no analytics, tracking, or crash-reporting code.</td></tr>
|
||||
<tr><td class="key">Remote transmission</td><td>No</td><td>The extension's only message peer is the local <code>agent-browser</code> CLI via native messaging. It makes no outbound network requests of its own.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Permissions & why they are needed</h2>
|
||||
<table>
|
||||
<tr><th>Permission</th><th>Purpose</th></tr>
|
||||
<tr><td class="key">debugger</td><td>Attach the Chrome DevTools Protocol to the user's own tab so the local CLI can automate it, only while the user is actively running a command.</td></tr>
|
||||
<tr><td class="key">tabs</td><td>Enumerate and target the correct open tab to automate.</td></tr>
|
||||
<tr><td class="key">nativeMessaging</td><td>The local transport to the paired <code>agent-browser</code> CLI — the extension's sole communication channel.</td></tr>
|
||||
<tr><td class="key">storage</td><td>Persist small local pairing/state values.</td></tr>
|
||||
<tr><td class="key">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
|
||||
<tr><td class="key">webNavigation</td><td>Detect page loads so automation can wait for the right moment.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Data sharing</h2>
|
||||
<p>None. No data is sold, shared, or transferred to third parties. There are no third parties — the
|
||||
extension talks only to a program the user installed on the same computer.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/agent-browser-stealth</code></p>
|
||||
|
||||
<footer>
|
||||
agent-browser-stealth is open source (Apache-2.0). This policy applies to the extension only.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 249 KiB |
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<style>
|
||||
html,body{margin:0;width:1280px;height:800px;overflow:hidden;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",sans-serif;
|
||||
background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);color:#e2e8f0}
|
||||
.wrap{display:flex;flex-direction:column;height:100%;padding:56px 64px;box-sizing:border-box}
|
||||
h1{font-size:46px;margin:0 0 6px;font-weight:700;letter-spacing:-.5px;color:#fff}
|
||||
.tag{font-size:21px;color:#94a3b8;margin:0 0 32px;font-weight:400}
|
||||
.accent{color:#38bdf8}
|
||||
.term{background:#0b1220;border:1px solid #334155;border-radius:14px;
|
||||
box-shadow:0 24px 60px rgba(0,0,0,.45);overflow:hidden;flex:1;display:flex;flex-direction:column}
|
||||
.bar{background:#1e293b;padding:13px 18px;display:flex;gap:9px;align-items:center;border-bottom:1px solid #334155}
|
||||
.dot{width:13px;height:13px;border-radius:50%}
|
||||
.r{background:#ff5f56}.y{background:#ffbd2e}.g{background:#27c93f}
|
||||
.bartitle{color:#64748b;font-size:14px;margin-left:12px;font-family:ui-monospace,monospace}
|
||||
pre{margin:0;padding:26px 30px;font-family:ui-monospace,"SF Mono",Menlo,monospace;
|
||||
font-size:19.5px;line-height:1.72;flex:1}
|
||||
.p{color:#38bdf8}.c{color:#f1f5f9;font-weight:600}.o{color:#94a3b8}.ok{color:#4ade80}.dim{color:#475569}
|
||||
.foot{display:flex;gap:40px;margin-top:30px;font-size:18px;color:#cbd5e1}
|
||||
.foot b{color:#fff}
|
||||
.pill{display:inline-block;background:#0c4a6e;color:#7dd3fc;font-size:15px;padding:4px 13px;
|
||||
border-radius:999px;margin-left:14px;vertical-align:middle;font-weight:600}
|
||||
</style></head>
|
||||
<body><div class="wrap">
|
||||
<h1>agent-browser connect <span class="pill">local · no token · no remote</span></h1>
|
||||
<p class="tag">Let your own <span class="accent">agent-browser</span> CLI drive the Chrome you're already logged into.</p>
|
||||
<div class="term">
|
||||
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span><span class="bartitle">zsh — agent-browser</span></div>
|
||||
<pre><span class="p">$</span> <span class="c">agent-browser extension install</span>
|
||||
<span class="ok">✓</span> <span class="o">native-messaging host installed (com.agent_browser.connect)</span>
|
||||
<span class="ok">✓</span> <span class="o">extension ready — add it from the Chrome Web Store</span>
|
||||
|
||||
<span class="p">$</span> <span class="c">agent-browser open</span> <span class="o">"https://mail.google.com"</span> <span class="dim"># your logged-in tab</span>
|
||||
<span class="p">$</span> <span class="c">agent-browser snapshot -i</span> <span class="dim"># read the page</span>
|
||||
<span class="p">$</span> <span class="c">agent-browser click</span> <span class="o">@e42</span> <span class="dim"># act on it</span>
|
||||
<span class="ok">✓</span> <span class="o">driving your real session — no re-login, no confirmation</span>
|
||||
</pre>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<span>🔌 <b>Native messaging</b> — local only</span>
|
||||
<span>🧩 <b>chrome.debugger</b> — on your command</span>
|
||||
<span>🔓 <b>Open source</b> · Apache-2.0</span>
|
||||
</div>
|
||||
</div></body></html>
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/bin/sh
|
||||
# agent-browser-stealth installer — downloads the prebuilt binary from the
|
||||
# GitHub Release (no npm, no auth for you or your users).
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||
#
|
||||
# Env overrides:
|
||||
# AGENT_BROWSER_VERSION=v0.27.0-fork.11 pin a specific release tag
|
||||
# AGENT_BROWSER_BIN_DIR=/usr/local/bin install location (auto-detected otherwise)
|
||||
set -eu
|
||||
|
||||
REPO="leeguooooo/agent-browser-stealth"
|
||||
BIN_NAME="agent-browser"
|
||||
|
||||
err() { printf '\033[31merror:\033[0m %s\n' "$1" >&2; exit 1; }
|
||||
info() { printf '\033[36m==>\033[0m %s\n' "$1" >&2; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || err "curl is required"
|
||||
command -v tar >/dev/null 2>&1 || err "tar is required"
|
||||
|
||||
# --- detect platform -> release asset name -------------------------------
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
case "$os" in
|
||||
Darwin) plat="darwin" ;;
|
||||
Linux) plat="linux" ;;
|
||||
*) err "unsupported OS: $os (use the Windows .exe asset from the Releases page)" ;;
|
||||
esac
|
||||
case "$arch" in
|
||||
x86_64|amd64) cpu="x64" ;;
|
||||
arm64|aarch64) cpu="arm64" ;;
|
||||
*) err "unsupported architecture: $arch" ;;
|
||||
esac
|
||||
|
||||
# musl (Alpine etc.) gets the statically-linked Linux build
|
||||
libc=""
|
||||
if [ "$plat" = "linux" ] && ! ldd /bin/sh 2>/dev/null | grep -qi 'gnu\|glibc'; then
|
||||
if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then
|
||||
libc="-musl"
|
||||
fi
|
||||
fi
|
||||
asset="agent-browser-${plat}${libc}-${cpu}"
|
||||
|
||||
# --- resolve release tag --------------------------------------------------
|
||||
tag="${AGENT_BROWSER_VERSION:-}"
|
||||
if [ -z "$tag" ]; then
|
||||
info "resolving latest release..."
|
||||
# Resolve via the releases/latest redirect on the github.com web host, NOT the
|
||||
# api.github.com JSON API (which rate-limits unauthenticated callers to 60/hr).
|
||||
# github.com/<repo>/releases/latest -> 302 -> github.com/<repo>/releases/tag/<TAG>
|
||||
loc=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
|
||||
"https://github.com/${REPO}/releases/latest" 2>/dev/null || true)
|
||||
case "$loc" in
|
||||
*/releases/tag/*) tag="${loc##*/releases/tag/}" ;;
|
||||
*) tag="" ;;
|
||||
esac
|
||||
[ -n "$tag" ] || err "could not resolve latest release (set AGENT_BROWSER_VERSION=vX.Y.Z)"
|
||||
fi
|
||||
|
||||
base="https://github.com/${REPO}/releases/download/${tag}"
|
||||
tgz_url="${base}/${asset}.tar.gz"
|
||||
sha_url="${tgz_url}.sha256"
|
||||
|
||||
# --- download + verify ----------------------------------------------------
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
info "downloading ${asset} (${tag})..."
|
||||
curl -fsSL "$tgz_url" -o "$tmp/pkg.tar.gz" \
|
||||
|| err "download failed: $tgz_url (is asset '${asset}.tar.gz' attached to release ${tag}?)"
|
||||
|
||||
if curl -fsSL "$sha_url" -o "$tmp/pkg.sha256" 2>/dev/null; then
|
||||
info "verifying checksum..."
|
||||
expected=$(awk '{print $1}' "$tmp/pkg.sha256")
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$tmp/pkg.tar.gz" | awk '{print $1}')
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$tmp/pkg.tar.gz" | awk '{print $1}')
|
||||
else
|
||||
actual=""; info "no sha256 tool found, skipping verification"
|
||||
fi
|
||||
[ -z "$actual" ] || [ "$expected" = "$actual" ] || err "checksum mismatch (expected $expected, got $actual)"
|
||||
else
|
||||
info "no .sha256 published, skipping verification"
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp/pkg.tar.gz" -C "$tmp"
|
||||
[ -f "$tmp/${BIN_NAME}" ] || err "archive did not contain ${BIN_NAME}"
|
||||
chmod +x "$tmp/${BIN_NAME}"
|
||||
|
||||
# --- choose install dir ---------------------------------------------------
|
||||
bindir="${AGENT_BROWSER_BIN_DIR:-}"
|
||||
if [ -z "$bindir" ]; then
|
||||
if [ -w /usr/local/bin ] 2>/dev/null; then bindir="/usr/local/bin"; else bindir="$HOME/.local/bin"; fi
|
||||
fi
|
||||
mkdir -p "$bindir"
|
||||
|
||||
mv "$tmp/${BIN_NAME}" "$bindir/${BIN_NAME}"
|
||||
# Aliases pointing at the same binary: `abs` (short) and `agent-browser-stealth`
|
||||
# (the fork's package name). All three names work, and an upgrade refreshes
|
||||
# whichever name you actually run.
|
||||
for alias_name in abs agent-browser-stealth; do
|
||||
ln -sf "$bindir/${BIN_NAME}" "$bindir/${alias_name}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
info "installed -> ${bindir}/ (agent-browser, agent-browser-stealth, abs)"
|
||||
"$bindir/${BIN_NAME}" --version 2>/dev/null || true
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$bindir:"*) : ;;
|
||||
*) printf '\033[33mnote:\033[0m %s is not on your PATH. Add:\n export PATH="%s:$PATH"\n' "$bindir" "$bindir" >&2 ;;
|
||||
esac
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.5",
|
||||
"version": "0.27.0-fork.37",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
"files": [
|
||||
"bin",
|
||||
"scripts",
|
||||
@@ -16,14 +17,14 @@
|
||||
"abs": "bin/agent-browser.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"prepare": "husky || true",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build: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 && bash -c 'cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & PID1=$!; cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1' && 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:all-platforms": "npm run version:sync && npm run build:linux && npm run build:windows && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
packages:
|
||||
- '.'
|
||||
minimumReleaseAge: 2880
|
||||
allowBuilds:
|
||||
'@mongodb-js/zstd': false
|
||||
msw: false
|
||||
node-liblzma: false
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
|
||||
@@ -27,17 +27,10 @@ if (!cargoVersionMatch) {
|
||||
|
||||
const cargoVersion = cargoVersionMatch[1];
|
||||
|
||||
// Read dashboard package.json version
|
||||
const dashboardPkg = JSON.parse(readFileSync(join(rootDir, 'packages/dashboard/package.json'), 'utf-8'));
|
||||
const dashboardVersion = dashboardPkg.version;
|
||||
|
||||
const mismatches = [];
|
||||
if (packageVersion !== cargoVersion) {
|
||||
mismatches.push(` cli/Cargo.toml: ${cargoVersion}`);
|
||||
}
|
||||
if (packageVersion !== dashboardVersion) {
|
||||
mismatches.push(` packages/dashboard: ${dashboardVersion}`);
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error('Version mismatch detected!');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
# Build the Chrome Web Store upload package extensions/ab-connect.zip (and a signed
|
||||
# extensions/ab-connect.crx for reference) from extensions/ab-connect.
|
||||
#
|
||||
# IMPORTANT — the "key" field:
|
||||
# * The unpacked DIR (Load-unpacked) and the signed .crx KEEP the manifest "key",
|
||||
# which pins the id to ciiljdlhdpfckdcfkphgmfalanpdejep so the native-messaging
|
||||
# allowed_origins + managed force-install policy keep matching for local/dev use.
|
||||
# * The Web Store UPLOAD zip MUST NOT contain "key" — the store rejects it
|
||||
# ("manifest must not contain 'key'") and assigns its own id. So this script
|
||||
# strips "key" from the manifest inside the zip only. After the first upload,
|
||||
# note the store-assigned id and add it to the native-messaging allowed_origins
|
||||
# (cli/src/connect.rs EXTENSION_ID) so the store build can pair too.
|
||||
#
|
||||
# The private key lives at .secrets/ab-connect.pem and is git-ignored.
|
||||
#
|
||||
# After changing the extension:
|
||||
# 1. bump "version" in extensions/ab-connect/manifest.json
|
||||
# 2. run this script
|
||||
# 3. commit extensions/ab-connect.zip (+ .crx) + manifest.json
|
||||
# 4. upload ab-connect.zip to the Web Store (see extensions/store/SUBMISSION.html)
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
KEY=.secrets/ab-connect.pem
|
||||
EXT=extensions/ab-connect
|
||||
CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
|
||||
|
||||
# Web Store upload package: stage a copy with the "key" field removed, then zip.
|
||||
STAGE=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -R "$EXT/." "$STAGE/"
|
||||
python3 - "$STAGE/manifest.json" <<'PY'
|
||||
import json, sys
|
||||
p = sys.argv[1]
|
||||
m = json.load(open(p))
|
||||
m.pop("key", None) # the Web Store forbids the "key" field in uploads
|
||||
json.dump(m, open(p, "w"), indent=2)
|
||||
open(p, "a").write("\n")
|
||||
PY
|
||||
rm -f extensions/ab-connect.zip
|
||||
( cd "$STAGE" && zip -rq "$OLDPWD/extensions/ab-connect.zip" . -x '.*' )
|
||||
[ -f extensions/ab-connect.zip ] || { echo "error: zip failed" >&2; exit 1; }
|
||||
if unzip -p extensions/ab-connect.zip manifest.json | grep -q '"key"'; then
|
||||
echo "error: 'key' still present in upload zip" >&2; exit 1
|
||||
fi
|
||||
echo "packed extensions/ab-connect.zip (key stripped for Web Store)"
|
||||
|
||||
# Signed crx (reference / non-store force-install for managed setups) — keeps "key"
|
||||
# via the signing key so the id stays ciiljdlhdpfckdcfkphgmfalanpdejep.
|
||||
if [ -f "$KEY" ]; then
|
||||
rm -f extensions/ab-connect.crx
|
||||
"$CHROME" --pack-extension="$PWD/$EXT" --pack-extension-key="$PWD/$KEY" >/dev/null 2>&1 || true
|
||||
ID=$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null \
|
||||
| openssl dgst -sha256 -binary | xxd -p -c256 | head -c32 | tr '0-9a-f' 'a-p')
|
||||
echo "local/crx extension id: $ID"
|
||||
else
|
||||
echo "note: $KEY missing — built zip only (no crx)."
|
||||
fi
|
||||
echo "manifest version: $(grep -o '"version"[^,]*' "$EXT/manifest.json" | head -1)"
|
||||
@@ -287,21 +287,20 @@ async function fixWindowsShims() {
|
||||
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`;
|
||||
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
|
||||
|
||||
// Only rewrite shims if the native binary actually exists
|
||||
if (!existsSync(absoluteBinaryPath)) {
|
||||
// Point the shims at the binary's ABSOLUTE path. The previous code rebuilt a
|
||||
// relative `node_modules\agent-browser\bin\...` path, but this fork's package
|
||||
// is `agent-browser-stealth`, so that path never existed → the rewrite was
|
||||
// skipped and the shim stayed the (slower) JS wrapper. `binaryPath` is the
|
||||
// real absolute path to the native binary inside this package.
|
||||
if (!existsSync(binaryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
|
||||
const cmdContent = `@ECHO off\r\n"${binaryPath}" %*\r\n`;
|
||||
writeFileSync(cmdShim, cmdContent);
|
||||
|
||||
const ps1Content = `#!/usr/bin/env pwsh\r\n$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n& "$basedir\\${relativeBinaryPath}" $args\r\nexit $LASTEXITCODE\r\n`;
|
||||
const ps1Content = `#!/usr/bin/env pwsh\r\n& "${binaryPath}" $args\r\nexit $LASTEXITCODE\r\n`;
|
||||
writeFileSync(ps1Shim, ps1Content);
|
||||
|
||||
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
||||
|
||||
@@ -15,6 +15,13 @@ Most normal web tasks (navigate, read, click, fill, extract, screenshot) are
|
||||
covered here. Load a specialized skill when the task falls outside browser
|
||||
web pages — see [When to load another skill](#when-to-load-another-skill).
|
||||
|
||||
> **Hit a rough edge? Please report it.** If a command surprised you — a
|
||||
> confusing error, a stale `@ref`, an occluded click, a flaky wait, a missing
|
||||
> feature, or anything that cost you extra turns — open a quick issue at
|
||||
> **<https://github.com/leeguooooo/agent-browser-stealth/issues>** with the exact
|
||||
> command and what happened vs. what you expected. Agent-filed friction reports
|
||||
> are how this tool gets sharper; a 30-second issue is genuinely valuable.
|
||||
|
||||
## The core loop
|
||||
|
||||
```bash
|
||||
@@ -29,6 +36,95 @@ Refs (`@e1`, `@e2`, ...) are assigned fresh on every snapshot. They become
|
||||
submits, dynamic re-renders, dialog opens. Always re-snapshot before your
|
||||
next ref interaction.
|
||||
|
||||
## Before you automate: pick the cheapest tool
|
||||
|
||||
Driving a browser is the heavy option. agent-browser earns its keep when you
|
||||
need a **real, logged-in browser** — not for reading text off a public page.
|
||||
|
||||
| You need | Use |
|
||||
|---|---|
|
||||
| Discover what exists / find sources | `WebSearch` |
|
||||
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
|
||||
| Login state, interaction, JS-rendered or anti-bot pages | **agent-browser** (this skill) |
|
||||
| A page the user saved before / an internal system | `agent-browser find-url <keywords>` (their bookmarks), then open it |
|
||||
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
|
||||
|
||||
Don't hand-build deep URLs with query params — links discovered by *interacting*
|
||||
with the site carry the right hidden context and dodge anti-bot checks; a
|
||||
hand-constructed URL often doesn't.
|
||||
|
||||
### Driving the user's real, already-open Chrome (extension)
|
||||
|
||||
When the task needs the user's *live* logged-in window (their real session, the
|
||||
window they're looking at — not a fresh browser), use the extension connect flow.
|
||||
One-time setup:
|
||||
1. `agent-browser extension install` — registers the native-messaging host.
|
||||
2. Install the **agent-browser-stealth** extension. Easiest (and restart-stable):
|
||||
the **Chrome Web Store**, one-click *Add to Chrome*:
|
||||
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
|
||||
(Dev fallback: `chrome://extensions` → Developer mode → *Load unpacked* →
|
||||
`extensions/ab-connect`. Load-unpacked can be disabled on Chrome restart, so
|
||||
prefer the Store build for unattended setups.)
|
||||
|
||||
Once installed, plain `agent-browser open <url>` auto-connects through the
|
||||
extension relay — `auto_connect_cdp` **prefers the live relay over a raw
|
||||
`--remote-debugging-port`**, so Chrome 136+'s "Allow remote debugging?" consent
|
||||
popup never fires. `agent-browser extension connect` is the explicit form of the
|
||||
same path. Zero-confirmation, zero-token. Use `--launch` instead when a fresh,
|
||||
isolated browser is fine.
|
||||
|
||||
**If you DO hit the "Allow remote debugging?" dialog**, don't keep retrying (every
|
||||
attempt re-pops it). One of two things is true:
|
||||
|
||||
1. **You're on a stale build.** The relay-preference that avoids this dialog
|
||||
landed in **fork.30**. Run `agent-browser --version`: if it's below
|
||||
`0.27.0-fork.30`, upgrade and retry:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||
```
|
||||
If `which -a agent-browser` shows more than one install, an old **npm/pnpm**
|
||||
copy (the npm registry lags behind — Releases are the source of truth) may be
|
||||
shadowing the upgraded one; remove the stale copy
|
||||
(`npm rm -g agent-browser-stealth` / `pnpm rm -g agent-browser-stealth`) so the
|
||||
`install.sh` build wins. A tool that bundles its *own* pinned copy
|
||||
(e.g. `node .../agent-browser-stealth@0.24.x/.../agent-browser`) needs that
|
||||
copy upgraded too.
|
||||
2. **The extension/relay isn't live.** Tell the user to install the Store
|
||||
extension (one click, above); after that the relay stays up and the dialog
|
||||
never returns.
|
||||
|
||||
Each `--session` that connects gets its **own colored Chrome tab group** (named
|
||||
after the session) and drives only its own tabs — multiple agents share the one
|
||||
real browser without cross-talk, and the user's own tabs are never grouped. CDP
|
||||
drives the page without moving the user's mouse/keyboard, so it doesn't fight
|
||||
them for control. **Anti-detection ranking: this real logged-in Chrome (extension
|
||||
connect) > a headed launched browser > headless (forbidden).** A genuine human
|
||||
browser has no headless/automation tells at all, so prefer it for anything
|
||||
anti-bot-sensitive.
|
||||
|
||||
## Two ways to drive a page — and when to drop to `eval`
|
||||
|
||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||
|
||||
1. **Structured** (`snapshot` + `@ref`, `find`, typed actions) — convenient and
|
||||
readable; best for straightforward forms and navigation. But the a11y view is
|
||||
*lossy and fragile*: refs go stale on any change, hidden inputs never show up,
|
||||
overlays can block coordinate clicks.
|
||||
2. **eval-first** (`agent-browser eval "<js>"`) — your eyes and hands on the real
|
||||
DOM: read hidden inputs, reach into Shadow DOM / iframes, inspect
|
||||
`form.elements` and `.validity`, extract the exact shape you want, or call
|
||||
`el.click()` directly. **The moment the structured path fights you, drop to
|
||||
`eval` instead of retrying it** — it's the fast way to find *why* something
|
||||
failed (e.g. a hidden `point_choice=none` the UI never exposes).
|
||||
|
||||
```bash
|
||||
# "what's actually in this form / why won't it submit?"
|
||||
agent-browser eval "[...document.forms[0].elements].map(e=>[e.name,e.type,e.value,e.checked])"
|
||||
agent-browser eval "document.querySelector('[name=point_choice]')?.value"
|
||||
agent-browser eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).map(e=>e.name+': '+e.validationMessage)"
|
||||
agent-browser eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
@@ -137,9 +233,17 @@ agent-browser fill "input[name=email]" "user@test.com"
|
||||
agent-browser click "button.primary"
|
||||
```
|
||||
|
||||
Rule of thumb: snapshot + `@eN` refs are fastest and most reliable for
|
||||
AI agents. `find role/text/label` is next best and doesn't require a prior
|
||||
snapshot. Raw CSS is a fallback when the others fail.
|
||||
Escalation ladder: snapshot + `@eN` refs are quickest for straightforward
|
||||
pages → `find role/text/label` when you'd rather skip the snapshot → raw CSS
|
||||
→ **`eval` the moment any of those fight you** (stale refs, hidden state,
|
||||
occluded clicks). Don't retry a flaky structured locator three times; drop to
|
||||
`eval` and act on the DOM directly.
|
||||
|
||||
`click` auto-scrolls into view and, if the coordinate click is occluded, falls
|
||||
back to a DOM `.click()`. If a click *reports success but nothing happened* —
|
||||
classic for an autocomplete/menu `<li>` that closes on the input's blur — retry
|
||||
that one with `AGENT_BROWSER_CLICK_MODE=dom agent-browser click ...`, or just
|
||||
`agent-browser eval "<select the item via JS>"`.
|
||||
|
||||
## Waiting (read this)
|
||||
|
||||
@@ -209,6 +313,44 @@ AGENT_BROWSER_SESSION_NAME=my-app agent-browser open https://app.example.com
|
||||
# State is auto-saved and restored on subsequent runs with the same name.
|
||||
```
|
||||
|
||||
### Remember a site's quirks (site notes)
|
||||
|
||||
A site behaves the same every time you visit it. When you work out something
|
||||
durable — a working selector, a URL pattern, a hidden field a form needs, an
|
||||
anti-bot trap, what requires login — **write it down so the next run doesn't
|
||||
re-discover it.** Keep one markdown file per domain (these are your own notes,
|
||||
not shipped with the skill):
|
||||
|
||||
```
|
||||
~/.agent-browser/site-patterns/<domain>.md
|
||||
```
|
||||
|
||||
**Before** working on a domain, read its file if it exists (use your normal file
|
||||
tools — this is plain markdown you own). Treat it as *hints, not guarantees* —
|
||||
sites change; verify before relying. **After** a successful session that taught
|
||||
you something durable, create or update it. Suggested shape:
|
||||
|
||||
```markdown
|
||||
---
|
||||
domain: app.example.com
|
||||
updated: 2026-06-05
|
||||
---
|
||||
## Platform traits
|
||||
SPA; form renders ~1s after load (wait --text). Cloudflare on /login.
|
||||
|
||||
## Working patterns
|
||||
- Address pick: the `<li>` closes on blur — select with CLICK_MODE=dom.
|
||||
- Submit needs hidden `point_choice` set (eval), the UI never exposes it.
|
||||
- Stable selector for "Continue": button[data-testid=submit]
|
||||
|
||||
## Known traps (date them)
|
||||
- 2026-06-05: @ref to the basket button goes stale after the mini-cart opens;
|
||||
re-snapshot or use `find role button --name "Checkout"`.
|
||||
```
|
||||
|
||||
This is how repeat visits get fast and reliable instead of re-solving the same
|
||||
page every time.
|
||||
|
||||
### Extract data
|
||||
|
||||
```bash
|
||||
@@ -243,6 +385,9 @@ agent-browser screenshot --full full.png # full scroll height
|
||||
agent-browser screenshot --annotate map.png # numbered labels + legend keyed to snapshot refs
|
||||
```
|
||||
|
||||
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
||||
|
||||
`--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`.
|
||||
|
||||
### Handle multiple pages via tabs
|
||||
@@ -250,13 +395,14 @@ agent-browser screenshot --annotate map.png # numbered labels + legend keyed
|
||||
```bash
|
||||
agent-browser tab # list open tabs (with stable tabId)
|
||||
agent-browser tab new https://docs... # open a new tab (and switch to it)
|
||||
agent-browser tab 2 # switch to tab 2
|
||||
agent-browser tab close 2 # close tab 2
|
||||
agent-browser tab t2 # switch to tab t2
|
||||
agent-browser tab close t2 # close tab t2
|
||||
```
|
||||
|
||||
Stable `tabId`s mean `tab 2` points at the same tab across commands even
|
||||
when other tabs open or close. After switching, refs from a prior snapshot
|
||||
on a different tab no longer apply — re-snapshot.
|
||||
Tab ids are stable strings (`t1`, `t2`, …), never reused within a session, so
|
||||
the same id keeps referring to the same tab across commands. Positional
|
||||
integers are **not** accepted — use `t2`, not `2`. After switching, refs from a
|
||||
prior snapshot on a different tab no longer apply — re-snapshot.
|
||||
|
||||
### Run multiple browsers in parallel
|
||||
|
||||
@@ -406,7 +552,9 @@ and [references/authentication.md](references/authentication.md).
|
||||
```bash
|
||||
--session <name> # isolated browser session
|
||||
--json # JSON output (for machine parsing)
|
||||
--headed # show the window (default is headless)
|
||||
--headed # default & always-on for stealth — headless is FORBIDDEN
|
||||
# (a bot tell: creepjs flags ~33% headless vs 0% headed).
|
||||
# Display-less servers only: AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||
--auto-connect # connect to an already-running Chrome
|
||||
--cdp <port> # connect to a specific CDP port
|
||||
--profile <name|path> # use a Chrome profile (login state survives)
|
||||
|
||||
@@ -103,6 +103,9 @@ agent-browser screenshot --full # Full page
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
```
|
||||
|
||||
Headless Chromium screenshots hide native scrollbars for consistent image output.
|
||||
Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
||||
|
||||
## Video Recording
|
||||
|
||||
```bash
|
||||
@@ -299,7 +302,8 @@ agent-browser state load auth.json # Restore saved state
|
||||
```bash
|
||||
agent-browser --session <name> ... # Isolated browser session
|
||||
agent-browser --json ... # JSON output for parsing
|
||||
agent-browser --headed ... # Show browser window (not headless)
|
||||
agent-browser --headed ... # Default & always-on (stealth). Headless is FORBIDDEN
|
||||
# (bot tell); display-less servers: AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||
agent-browser --full ... # Full page screenshot (-f)
|
||||
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
|
||||
agent-browser -p <provider> ... # Cloud browser provider (--provider)
|
||||
@@ -309,20 +313,83 @@ agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
|
||||
agent-browser --executable-path <p> # Custom browser executable
|
||||
agent-browser --extension <path> ... # Load browser extension (repeatable)
|
||||
agent-browser --ignore-https-errors # Ignore SSL certificate errors
|
||||
agent-browser --hide-scrollbars false # Keep native scrollbars visible in headless Chromium screenshots
|
||||
agent-browser --help # Show help (-h)
|
||||
agent-browser --version # Show version (-V)
|
||||
agent-browser <command> --help # Show detailed help for a command
|
||||
```
|
||||
|
||||
## Drive your real, logged-in Chrome (extension — zero confirmation)
|
||||
|
||||
Chrome 136 blocked `--remote-debugging-port` on the default profile, so to drive
|
||||
the user's *existing* logged-in window, agent-browser uses a Chrome **extension**
|
||||
over native messaging — no port, no token, no per-use confirmation (the
|
||||
codex/claude approach).
|
||||
|
||||
One-time setup:
|
||||
```bash
|
||||
agent-browser extension install # writes the native-messaging host manifest
|
||||
```
|
||||
|
||||
The native-messaging host accepts **both** extension origins, so either install
|
||||
works — but prefer the Store build:
|
||||
|
||||
1. **Chrome Web Store (recommended)** — one-click *Add to Chrome*:
|
||||
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
|
||||
Restart-stable and auto-updating (store id `knfcmbamhjmaonkfnjhldjedeobeafmk`).
|
||||
2. **Load unpacked (dev)** — load `<repo>/extensions/ab-connect` from source;
|
||||
its pinned `key` gives the stable id `ciiljdlhd…`. NOTE: Load-unpacked
|
||||
extensions can be disabled/dropped on Chrome restart (Developer-mode handling),
|
||||
which silently drops the relay — so for unattended setups use the Store build.
|
||||
|
||||
For Load unpacked — a GUI step (Chrome's `chrome://extensions` is privileged; the
|
||||
CLI can't load an unpacked extension):
|
||||
|
||||
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked** →
|
||||
> select `<repo>/extensions/ab-connect` (it appears in the list as
|
||||
> **agent-browser-stealth**)
|
||||
|
||||
Once loaded, the relay goes live and plain `agent-browser open <url>` connects
|
||||
through it automatically — `auto_connect_cdp` prefers the live extension relay
|
||||
over a raw `--remote-debugging-port`, so Chrome 136+'s "Allow remote debugging?"
|
||||
consent popup never appears. `agent-browser extension connect` is the explicit
|
||||
form of the same path.
|
||||
|
||||
**You can do this load step yourself with a computer-use / GUI-automation tool**
|
||||
(e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer
|
||||
mode, click *Load unpacked*, pick the folder in the Open dialog. If the extension
|
||||
is already loaded, clicking its **Reload** (↻) button after a code change is
|
||||
enough. Notes from doing this live: tools that send *synthetic keystrokes* (e.g.
|
||||
peekaboo) often don't reach Chrome — **`cua-driver` works** because it reads
|
||||
Chrome's accessibility tree and clicks real elements. The native "Open" file
|
||||
dialog is the fiddly part; if keystroke entry there fails, ask the user to pick
|
||||
the folder (one click). After it loads, Chrome assigns the extension a fixed id
|
||||
(pinned in its manifest) and auto-connects the host.
|
||||
|
||||
Then, any time (pure CLI, zero confirmation):
|
||||
```bash
|
||||
agent-browser extension connect # auto-attaches to the live, logged-in tabs
|
||||
agent-browser tab # list the real tabs it now controls
|
||||
agent-browser tab t3 # switch the session to one of them
|
||||
agent-browser snapshot -i / eval / click ... # drive it like any session
|
||||
agent-browser extension status # is the host installed?
|
||||
agent-browser extension uninstall # remove the host manifest
|
||||
```
|
||||
|
||||
Security: the extension↔host link is authenticated by Chrome (extension id); the
|
||||
host↔agent-browser CDP link uses an unguessable URL in a 0600 file. Use this when
|
||||
you need the user's real cookies/login on their actual machine. (`--extension
|
||||
<path>` is unrelated — that loads an extension into a *launched* browser.)
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
agent-browser --headed open example.com # Show browser window
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
agent-browser connect 9222 # Alternative: connect command
|
||||
agent-browser console # View console messages
|
||||
agent-browser console # View console messages (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors # View page errors (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for this session
|
||||
@@ -332,6 +399,41 @@ agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile
|
||||
```
|
||||
|
||||
### Finding a page the user saved (`find-url`)
|
||||
|
||||
Search the user's local Chrome/Edge **bookmarks** by keyword — for internal
|
||||
systems or previously-saved pages that public search can't reach. Local read, no
|
||||
browser/daemon needed.
|
||||
|
||||
```bash
|
||||
agent-browser find-url jira board # all keywords must match (name or url)
|
||||
agent-browser find-url --limit 10 invoices
|
||||
agent-browser find-url --browser edge --profile "Profile 1" wiki
|
||||
agent-browser find-url grafana --json # {results:[{name,url,folder}], count}
|
||||
```
|
||||
|
||||
Results are most-recently-added first. `javascript:`/`data:` bookmarklets are
|
||||
skipped. (Visited-history search isn't included yet — bookmarks only.)
|
||||
|
||||
### Debugging forms / hidden state with `eval`
|
||||
|
||||
The a11y `snapshot` shows visible, interactive elements — it does **not** show
|
||||
hidden inputs or a control's actual submitted value. When a form "looks filled"
|
||||
but submit-validation rejects it, go straight to the DOM with `eval` instead of
|
||||
guessing from the snapshot. This is usually the fastest way to find the real
|
||||
problem (e.g. a hidden `point_choice=none` that the visible UI never exposes):
|
||||
|
||||
```bash
|
||||
# Dump every field's name → value, including hidden inputs and unchecked radios
|
||||
agent-browser eval "JSON.stringify([...document.forms[0].elements].map(e=>({name:e.name,type:e.type,value:e.value,checked:e.checked})).filter(e=>e.name))"
|
||||
|
||||
# Inspect one hidden field directly
|
||||
agent-browser eval "document.querySelector('[name=point_choice]')?.value"
|
||||
|
||||
# Why won't it submit? Ask the browser's own validity API
|
||||
agent-browser eval "[...document.forms[0].elements].filter(e=>!e.validity?.valid).map(e=>e.name+': '+e.validationMessage)"
|
||||
```
|
||||
|
||||
## React / Web Vitals
|
||||
|
||||
Requires `--enable react-devtools` at launch for the `react ...` commands.
|
||||
@@ -383,7 +485,42 @@ AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
|
||||
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
||||
AGENT_BROWSER_INIT_SCRIPTS="/a.js,/b.js" # Comma-separated init script paths
|
||||
AGENT_BROWSER_ENABLE="react-devtools" # Comma-separated built-in init script features
|
||||
AGENT_BROWSER_HIDE_SCROLLBARS="false" # Keep native scrollbars visible in headless Chromium screenshots
|
||||
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
||||
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
|
||||
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
||||
AGENT_BROWSER_CLICK_MODE="dom" # Click strategy: "" (default: scroll-in + coordinate
|
||||
# click, DOM-dispatch fallback), "coord" (strict
|
||||
# coordinate only), "dom" (always element.click())
|
||||
```
|
||||
|
||||
### Click reliability
|
||||
|
||||
`click` auto-scrolls the target into view first, then dispatches a coordinate
|
||||
click. If that fails (a floating layer fails the occlusion guard, or the point
|
||||
won't resolve) it falls back to a DOM-dispatched `.click()` on the intended
|
||||
element. If a click *reports success but the page didn't react* — common for
|
||||
autocomplete/menu `<li>` items that close on the input's blur — retry that one
|
||||
with `AGENT_BROWSER_CLICK_MODE=dom` (a DOM dispatch doesn't move focus the way a
|
||||
real pointer press does, so the item still selects). `=coord` disables the
|
||||
fallback when you specifically want a hard failure on occlusion.
|
||||
|
||||
### Stealth / anti-detection knobs (fork)
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_CAPTURE_CONSOLE="1" # Enable `console`/`errors` capture. OFF by default:
|
||||
# a live CDP Runtime domain is a detectable bot signal,
|
||||
# so console/errors return empty (with a hint) until set.
|
||||
AGENT_BROWSER_TIMEZONE="Asia/Tokyo" # --launch only. Native timezone override (IANA id, or
|
||||
# "auto" to derive from locale). Aligns Intl+Date to a proxy.
|
||||
AGENT_BROWSER_BLOCK_WEBRTC="1" # --launch only. Hide local IP via WebRTC. Auto-forces WebRTC
|
||||
# through the proxy when one is set; "0" opts out.
|
||||
AGENT_BROWSER_HIDE_CANVAS="1" # --launch only. Session-stable canvas/audio fingerprint noise.
|
||||
AGENT_BROWSER_ADAPTIVE_REF="0" # Disable adaptive @ref relocation (on by default; relocates a
|
||||
# moved element by fingerprint when role/name re-query fails).
|
||||
```
|
||||
|
||||
> **Heads-up for `console` / `errors`:** capture is **off by default** in this stealth
|
||||
> fork. Both commands return `{"messages":[]}` / `{"errors":[]}` plus a `hint` until you
|
||||
> launch the session with `AGENT_BROWSER_CAPTURE_CONSOLE=1`. This keeps the CDP `Runtime`
|
||||
> domain disabled (a known bot signal) for the common automation path.
|
||||
|
||||
@@ -96,7 +96,7 @@ Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full l
|
||||
- Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals.
|
||||
- Check edge cases: empty states, error handling, boundary inputs.
|
||||
- Try realistic end-to-end workflows (create, edit, delete flows).
|
||||
- Check the browser console for errors periodically.
|
||||
- Check the browser console for errors periodically. **Console/error capture is off by default in this stealth fork** — start the dogfood session with `AGENT_BROWSER_CAPTURE_CONSOLE=1` (e.g. `AGENT_BROWSER_CAPTURE_CONSOLE=1 agent-browser --session {SESSION} open <url>`) or `console`/`errors` will return empty.
|
||||
|
||||
**At each page:**
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ Electron apps often have multiple windows or webviews. Use tab commands to list
|
||||
# List all available targets (windows, webviews, etc.)
|
||||
agent-browser tab
|
||||
|
||||
# Switch to a specific tab by index
|
||||
agent-browser tab 2
|
||||
# Switch to a specific tab by id (t1, t2, …; integers not accepted)
|
||||
agent-browser tab t2
|
||||
|
||||
# Switch by URL pattern
|
||||
agent-browser tab --url "*settings*"
|
||||
@@ -117,7 +117,7 @@ agent-browser tab
|
||||
# 1: [webview] Embedded Content https://example.com/widget
|
||||
|
||||
# Switch to a webview
|
||||
agent-browser tab 1
|
||||
agent-browser tab t1
|
||||
|
||||
# Interact with the webview normally
|
||||
agent-browser snapshot -i
|
||||
|
||||
@@ -230,6 +230,9 @@ agent-browser snapshot -i | grep -c "treeitem"
|
||||
|
||||
### Check console for errors
|
||||
|
||||
Console/error capture is off by default in this stealth fork — launch the session with
|
||||
`AGENT_BROWSER_CAPTURE_CONSOLE=1` first, or these return empty.
|
||||
|
||||
```bash
|
||||
agent-browser console
|
||||
agent-browser errors
|
||||
|
||||
@@ -10,7 +10,13 @@ hidden: true
|
||||
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
|
||||
accessibility-tree snapshots and compact `@eN` element refs.
|
||||
|
||||
Install: `npm i -g agent-browser && agent-browser install`
|
||||
**Install / self-heal:** if the `agent-browser` (or `abs`) command is missing —
|
||||
e.g. a command errors with "command not found" — install it from the GitHub
|
||||
Release (no npm, no token), then retry. Do NOT fall back to other browser tools:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
|
||||
```
|
||||
|
||||
## Start here
|
||||
|
||||
|
||||