Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c499885e5 | ||
|
|
fc2621559b | ||
|
|
8b55c553e6 | ||
|
|
6b99d304b1 | ||
|
|
900a5b5cde | ||
|
|
ab9b8d96ca | ||
|
|
5c734c51b6 | ||
|
|
dc54855784 | ||
|
|
ed61be3359 | ||
|
|
54b61f4375 | ||
|
|
9ae82d620e | ||
|
|
8f67cff3e1 | ||
|
|
e70d841a94 | ||
|
|
6032deabd5 |
@@ -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,332 +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: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
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 }}
|
|
||||||
@@ -21,9 +21,20 @@ For basic usage, commands, and API reference, see the [upstream documentation](h
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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
|
### 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):
|
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 +45,39 @@ 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`.
|
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
|
## Setup: connect to your Chrome
|
||||||
2. Toggle the switch on
|
|
||||||
|
|
||||||
That's it. This setting persists across Chrome restarts.
|
Attaching uses the Chrome DevTools Protocol, which Chrome only exposes when it is
|
||||||
|
**launched with a remote-debugging port**. This is a startup flag, not a setting
|
||||||
|
— the `chrome://inspect` toggle alone is **not** enough (it only enables target
|
||||||
|
discovery, not the CDP attach).
|
||||||
|
|
||||||
|
**Recommended — fully quit Chrome, then relaunch with the port:**
|
||||||
|
|
||||||
|
```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 run `agent-browser open <url>` — it auto-discovers the port and attaches.
|
||||||
|
On first attach, **Chrome 136+ shows an "Allow remote debugging?" dialog — click
|
||||||
|
Allow once** (it persists for that Chrome session).
|
||||||
|
|
||||||
|
**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
|
## Usage
|
||||||
|
|
||||||
@@ -57,21 +93,32 @@ 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.
|
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
|
```bash
|
||||||
|
# Throwaway: fresh, EMPTY profile — no cookies, no login (good for CI/testing)
|
||||||
agent-browser --launch open https://example.com
|
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.
|
In CI environments, standalone mode is used automatically.
|
||||||
|
|
||||||
## Anti-detection
|
## Anti-detection
|
||||||
|
|
||||||
When connected to your real Chrome, we inject **zero** JavaScript patches. Your browser's fingerprint is completely genuine.
|
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.
|
||||||
|
|
||||||
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.
|
- `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 results (connected to real Chrome):**
|
||||||
|
|
||||||
@@ -83,6 +130,16 @@ The only thing we do is call `Emulation.setAutomationOverride` via CDP to set `n
|
|||||||
|
|
||||||
When using `--launch` mode (standalone browser), a full suite of 32 stealth patches is applied for headless Chrome.
|
When using `--launch` mode (standalone browser), a full suite of 32 stealth patches is applied for headless Chrome.
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
## Differences from upstream
|
## Differences from upstream
|
||||||
|
|
||||||
Based on [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser). Changes:
|
Based on [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser). Changes:
|
||||||
|
|||||||
Generated
+21
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.11"
|
version = "0.27.0-fork.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -57,6 +57,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
|
"include_dir",
|
||||||
"libc",
|
"libc",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1048,6 +1049,25 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.11"
|
version = "0.27.0-fork.15"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
@@ -19,6 +19,7 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
regex-lite = "0.1"
|
regex-lite = "0.1"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
include_dir = "0.7"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
getrandom = "0.2"
|
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", "time", "sync", "signal", "process"] }
|
||||||
|
|||||||
@@ -531,6 +531,18 @@ fn main() {
|
|||||||
let mut flags = parse_flags(&args);
|
let mut flags = parse_flags(&args);
|
||||||
let clean = clean_args(&args);
|
let 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_help = args.iter().any(|a| a == "--help" || a == "-h");
|
||||||
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
|
let has_version = args.iter().any(|a| a == "--version" || a == "-V");
|
||||||
|
|
||||||
|
|||||||
@@ -1609,9 +1609,12 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
Otherwise, to let agent-browser work with your existing Chrome (recommended):\n\
|
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
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 — \
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
does NOT expose the CDP HTTP API on /json/version. \
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
@@ -2172,9 +2175,12 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
Otherwise, to let agent-browser work with your existing Chrome (recommended):\n\
|
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
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 — \
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
chrome://inspect/#remote-debugging only enables target discovery and \
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
does NOT expose the CDP HTTP API on /json/version. \
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
@@ -3684,13 +3690,38 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
|||||||
state.event_tracker.clear_console();
|
state.event_tracker.clear_console();
|
||||||
Ok(json!({ "cleared": true }))
|
Ok(json!({ "cleared": true }))
|
||||||
} else {
|
} 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)
|
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> {
|
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> {
|
async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
//! 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -305,6 +305,22 @@ pub struct BrowserManager {
|
|||||||
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
||||||
visited_origins: HashSet<String>,
|
visited_origins: HashSet<String>,
|
||||||
next_tab_id: u32,
|
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_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
@@ -413,6 +429,7 @@ impl BrowserManager {
|
|||||||
ignore_https_errors,
|
ignore_https_errors,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
manager.discover_and_attach_targets().await?;
|
manager.discover_and_attach_targets().await?;
|
||||||
manager
|
manager
|
||||||
@@ -502,6 +519,7 @@ impl BrowserManager {
|
|||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if direct_page {
|
if direct_page {
|
||||||
@@ -629,9 +647,14 @@ impl BrowserManager {
|
|||||||
self.client
|
self.client
|
||||||
.send_command_no_params("Page.enable", Some(session_id))
|
.send_command_no_params("Page.enable", Some(session_id))
|
||||||
.await?;
|
.await?;
|
||||||
self.client
|
// `Runtime.enable` leaves a detectable CDP signal (the patchright/rebrowser
|
||||||
.send_command_no_params("Runtime.enable", Some(session_id))
|
// "runtime leak"), so only enable it when console/error capture is opted in.
|
||||||
.await?;
|
// `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.
|
// Resume the target if it is paused waiting for the debugger.
|
||||||
// This is needed for real browser sessions (Chrome 144+) where targets
|
// This is needed for real browser sessions (Chrome 144+) where targets
|
||||||
// are paused after attach until explicitly resumed. No-op otherwise.
|
// are paused after attach until explicitly resumed. No-op otherwise.
|
||||||
@@ -665,9 +688,12 @@ impl BrowserManager {
|
|||||||
self.client
|
self.client
|
||||||
.send_command_no_params("Page.enable", None)
|
.send_command_no_params("Page.enable", None)
|
||||||
.await?;
|
.await?;
|
||||||
self.client
|
// See `enable_domains`: `Runtime.enable` is a CDP fingerprint, gated on opt-in.
|
||||||
.send_command_no_params("Runtime.enable", None)
|
if self.capture_console {
|
||||||
.await?;
|
self.client
|
||||||
|
.send_command_no_params("Runtime.enable", None)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
let _ = self
|
let _ = self
|
||||||
.client
|
.client
|
||||||
.send_command_no_params("Runtime.runIfWaitingForDebugger", None)
|
.send_command_no_params("Runtime.runIfWaitingForDebugger", None)
|
||||||
@@ -1659,6 +1685,7 @@ async fn initialize_lightpanda_manager(
|
|||||||
ignore_https_errors: false,
|
ignore_https_errors: false,
|
||||||
visited_origins: HashSet::new(),
|
visited_origins: HashSet::new(),
|
||||||
next_tab_id: 1,
|
next_tab_id: 1,
|
||||||
|
capture_console: console_capture_enabled(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
|
match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
|
||||||
|
|||||||
@@ -146,6 +146,30 @@ struct ChromeArgs {
|
|||||||
temp_user_data_dir: Option<PathBuf>,
|
temp_user_data_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--remote-debugging-port=0".to_string(),
|
"--remote-debugging-port=0".to_string(),
|
||||||
@@ -204,6 +228,20 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
|||||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
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 (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
|
||||||
let expanded = expand_tilde(profile);
|
let expanded = expand_tilde(profile);
|
||||||
let dir = PathBuf::from(&expanded);
|
let dir = PathBuf::from(&expanded);
|
||||||
@@ -917,6 +955,18 @@ pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec<ChromeProfile> {
|
|||||||
/// 3. Case-insensitive directory name match
|
/// 3. Case-insensitive directory name match
|
||||||
///
|
///
|
||||||
/// Returns the resolved directory name, or an error with available profiles.
|
/// 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> {
|
pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result<String, String> {
|
||||||
let profiles = list_chrome_profiles(user_data_dir);
|
let profiles = list_chrome_profiles(user_data_dir);
|
||||||
|
|
||||||
@@ -928,6 +978,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
|
// Tier 1: exact directory name match
|
||||||
if let Some(p) = profiles.iter().find(|p| p.directory == input) {
|
if let Some(p) = profiles.iter().find(|p| p.directory == input) {
|
||||||
return Ok(p.directory.clone());
|
return Ok(p.directory.clone());
|
||||||
@@ -1316,6 +1381,37 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_utils::EnvGuard;
|
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)]
|
#[cfg(unix)]
|
||||||
fn spawn_noop_child() -> Child {
|
fn spawn_noop_child() -> Child {
|
||||||
Command::new("/bin/sh")
|
Command::new("/bin/sh")
|
||||||
@@ -1673,6 +1769,44 @@ mod tests {
|
|||||||
assert!(!is_chrome_profile_name("relative/path"));
|
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.
|
/// Helper to create a fake Chrome user-data dir with a `Local State` file.
|
||||||
fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) {
|
fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) {
|
||||||
let mut info_cache = serde_json::Map::new();
|
let mut info_cache = serde_json::Map::new();
|
||||||
|
|||||||
+107
-23
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::adaptive::{self, ElementFingerprint};
|
||||||
use super::cdp::client::CdpClient;
|
use super::cdp::client::CdpClient;
|
||||||
use super::cdp::types::*;
|
use super::cdp::types::*;
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ pub struct RefEntry {
|
|||||||
pub nth: Option<usize>,
|
pub nth: Option<usize>,
|
||||||
pub selector: Option<String>,
|
pub selector: Option<String>,
|
||||||
pub frame_id: 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 {
|
pub struct RefMap {
|
||||||
@@ -57,10 +61,19 @@ impl RefMap {
|
|||||||
nth,
|
nth,
|
||||||
selector: None,
|
selector: None,
|
||||||
frame_id: frame_id.map(|s| s.to_string()),
|
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(
|
pub fn add_selector(
|
||||||
&mut self,
|
&mut self,
|
||||||
ref_id: String,
|
ref_id: String,
|
||||||
@@ -78,6 +91,7 @@ impl RefMap {
|
|||||||
nth,
|
nth,
|
||||||
selector: Some(selector),
|
selector: Some(selector),
|
||||||
frame_id: None,
|
frame_id: None,
|
||||||
|
fingerprint: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,6 +160,46 @@ pub fn parse_ref(input: &str) -> Option<String> {
|
|||||||
None
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn resolve_element_center(
|
pub async fn resolve_element_center(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -163,15 +217,19 @@ pub async fn resolve_element_center(
|
|||||||
|
|
||||||
// Try cached backend_node_id first (fast path)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
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
|
// Identity check: React often re-uses the same DOM node when
|
||||||
// re-rendering — backendNodeId stays the same but accessibleName
|
// re-rendering — backendNodeId stays the same but accessibleName
|
||||||
// / role changes. Without this verification, `click @e20` (saved
|
// / role changes. Without this verification, `click @e20` (saved
|
||||||
// when the button said "Add post") happily clicks the *same*
|
// when the button said "Add post") happily clicks the *same*
|
||||||
// node that now says "Post all", silently submitting the thread.
|
// node that now says "Post all", silently submitting the thread.
|
||||||
//
|
//
|
||||||
// Set AGENT_BROWSER_VERIFY_REF=0 to skip (saves one CDP
|
// On mismatch, try adaptive fingerprint relocation before failing:
|
||||||
// roundtrip per ref-based interaction; only safe if you know
|
// a confident high-score/high-margin match is a stronger identity
|
||||||
// the page is static between snapshot and click).
|
// 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 std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||||
if let Err(e) = verify_ref_identity(
|
if let Err(e) = verify_ref_identity(
|
||||||
client,
|
client,
|
||||||
@@ -183,7 +241,12 @@ pub async fn resolve_element_center(
|
|||||||
)
|
)
|
||||||
.await
|
.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 +254,7 @@ pub async fn resolve_element_center(
|
|||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.getBoxModel",
|
"DOM.getBoxModel",
|
||||||
&DomGetBoxModelParams {
|
&DomGetBoxModelParams {
|
||||||
backend_node_id: Some(backend_node_id),
|
backend_node_id: Some(active_id),
|
||||||
node_id: None,
|
node_id: None,
|
||||||
object_id: None,
|
object_id: None,
|
||||||
},
|
},
|
||||||
@@ -213,15 +276,9 @@ pub async fn resolve_element_center(
|
|||||||
//
|
//
|
||||||
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
||||||
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
||||||
if let Err(e) = verify_click_target(
|
if let Err(e) =
|
||||||
client,
|
verify_click_target(client, effective_session_id, active_id, &ref_id, x, y)
|
||||||
effective_session_id,
|
.await
|
||||||
backend_node_id,
|
|
||||||
&ref_id,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -231,8 +288,9 @@ pub async fn resolve_element_center(
|
|||||||
// backend_node_id is stale; re-query the accessibility tree below
|
// 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
|
// Fallback: re-query the accessibility tree to find a fresh node by role/name.
|
||||||
let fresh_id = find_node_id_by_role_name(
|
// If that fails, try adaptive fingerprint relocation before giving up.
|
||||||
|
let fresh_id = match find_node_id_by_role_name(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
&entry.role,
|
&entry.role,
|
||||||
@@ -241,7 +299,16 @@ pub async fn resolve_element_center(
|
|||||||
entry.frame_id.as_deref(),
|
entry.frame_id.as_deref(),
|
||||||
iframe_sessions,
|
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
|
let result: DomGetBoxModelResult = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.getBoxModel",
|
"DOM.getBoxModel",
|
||||||
@@ -279,9 +346,11 @@ pub async fn resolve_element_object_id(
|
|||||||
|
|
||||||
// Try cached backend_node_id first (fast path)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
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
|
// Same identity guard as resolve_element_center — see that
|
||||||
// function for why React DOM-node-reuse breaks ref-based
|
// 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 std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||||
if let Err(e) = verify_ref_identity(
|
if let Err(e) = verify_ref_identity(
|
||||||
client,
|
client,
|
||||||
@@ -293,7 +362,12 @@ pub async fn resolve_element_object_id(
|
|||||||
)
|
)
|
||||||
.await
|
.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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +375,7 @@ pub async fn resolve_element_object_id(
|
|||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.resolveNode",
|
"DOM.resolveNode",
|
||||||
&DomResolveNodeParams {
|
&DomResolveNodeParams {
|
||||||
backend_node_id: Some(backend_node_id),
|
backend_node_id: Some(active_id),
|
||||||
node_id: None,
|
node_id: None,
|
||||||
object_group: Some("agent-browser".to_string()),
|
object_group: Some("agent-browser".to_string()),
|
||||||
},
|
},
|
||||||
@@ -317,8 +391,9 @@ pub async fn resolve_element_object_id(
|
|||||||
// backend_node_id is stale; re-query the accessibility tree below
|
// 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
|
// Fallback: re-query the accessibility tree to find a fresh node by role/name.
|
||||||
let fresh_id = find_node_id_by_role_name(
|
// If that fails, try adaptive fingerprint relocation before giving up.
|
||||||
|
let fresh_id = match find_node_id_by_role_name(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
&entry.role,
|
&entry.role,
|
||||||
@@ -327,7 +402,16 @@ pub async fn resolve_element_object_id(
|
|||||||
entry.frame_id.as_deref(),
|
entry.frame_id.as_deref(),
|
||||||
iframe_sessions,
|
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
|
let result: DomResolveNodeResult = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.resolveNode",
|
"DOM.resolveNode",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
pub mod adaptive;
|
||||||
|
#[allow(dead_code)]
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod browser;
|
pub mod browser;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use super::cdp::client::CdpClient;
|
|||||||
use super::cdp::types::{
|
use super::cdp::types::{
|
||||||
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
||||||
};
|
};
|
||||||
|
use super::adaptive::ElementFingerprint;
|
||||||
use super::element::{resolve_ax_session, RefMap};
|
use super::element::{resolve_ax_session, RefMap};
|
||||||
|
|
||||||
const INTERACTIVE_ROLES: &[&str] = &[
|
const INTERACTIVE_ROLES: &[&str] = &[
|
||||||
@@ -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.
|
/// The type of a hidden form input found inside a cursor-interactive element.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum HiddenInputKind {
|
enum HiddenInputKind {
|
||||||
@@ -397,6 +514,7 @@ pub async fn take_snapshot(
|
|||||||
actual_nth,
|
actual_nth,
|
||||||
frame_id,
|
frame_id,
|
||||||
);
|
);
|
||||||
|
ref_map.set_fingerprint(&ref_id, build_ax_fingerprint(&tree_nodes, *idx));
|
||||||
|
|
||||||
tree_nodes[*idx].has_ref = true;
|
tree_nodes[*idx].has_ref = true;
|
||||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||||
|
|||||||
+139
-2
@@ -51,13 +51,18 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
|||||||
vec![locale, base_lang]
|
vec![locale, base_lang]
|
||||||
};
|
};
|
||||||
let config_line = format!(
|
let config_line = format!(
|
||||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
|
||||||
locale,
|
locale,
|
||||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
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(
|
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)
|
format!("{}{}", config_line, rest)
|
||||||
} else {
|
} 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.
|
/// Apply stealth patches to a browser session.
|
||||||
///
|
///
|
||||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||||
@@ -118,11 +152,74 @@ pub async fn apply_stealth(
|
|||||||
.await?;
|
.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(())
|
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.
|
/// Get the browser's User-Agent string via CDP.
|
||||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||||
let result = client
|
let result = client
|
||||||
@@ -235,3 +332,43 @@ fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
|||||||
"wow64": false,
|
"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,4 +1,4 @@
|
|||||||
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 };
|
||||||
(function(){
|
(function(){
|
||||||
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||||
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||||
@@ -1275,3 +1275,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 {}
|
||||||
|
})();
|
||||||
|
|||||||
+51
-8
@@ -1,3 +1,4 @@
|
|||||||
|
use include_dir::{include_dir, Dir};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -6,6 +7,12 @@ use std::process::exit;
|
|||||||
|
|
||||||
use crate::color;
|
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 {
|
struct SkillInfo {
|
||||||
name: String,
|
name: String,
|
||||||
description: String,
|
description: String,
|
||||||
@@ -63,6 +70,29 @@ fn find_package_root() -> Option<PathBuf> {
|
|||||||
None
|
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.
|
/// Collect all skill directories to search, respecting the env var override.
|
||||||
fn find_skills_dirs() -> Vec<PathBuf> {
|
fn find_skills_dirs() -> Vec<PathBuf> {
|
||||||
// Env var override: single directory, used as-is
|
// Env var override: single directory, used as-is
|
||||||
@@ -73,15 +103,28 @@ fn find_skills_dirs() -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(root) = find_package_root() else {
|
// On-disk package root (npm install layout, or dev build walking up to repo).
|
||||||
return vec![];
|
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
|
// Fallback: skill content compiled into the binary (single-binary install).
|
||||||
.iter()
|
if let Some(root) = embedded_skills_root() {
|
||||||
.map(|d| root.join(d))
|
return SKILL_DIRS
|
||||||
.filter(|p| p.is_dir())
|
.iter()
|
||||||
.collect()
|
.map(|d| root.join(d))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
|
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
|
||||||
|
|||||||
+49
-263
@@ -1,284 +1,70 @@
|
|||||||
use crate::color;
|
use crate::color;
|
||||||
use std::path::Path;
|
use std::process::{exit, Command};
|
||||||
use std::process::{exit, Command, Stdio};
|
|
||||||
|
|
||||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
|
||||||
|
|
||||||
enum InstallMethod {
|
/// Canonical installer for the stealth fork. `upgrade` just re-runs it, so the
|
||||||
Npm,
|
/// upgrade path and the install path are identical (GitHub Release, no npm).
|
||||||
Pnpm,
|
const INSTALL_URL: &str =
|
||||||
Yarn,
|
"https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh";
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// 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() {
|
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()
|
#[cfg(windows)]
|
||||||
.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) {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{} Could not detect installation method.",
|
"{} Automatic upgrade isn't supported on Windows.",
|
||||||
color::error_indicator()
|
color::warning_indicator()
|
||||||
);
|
);
|
||||||
eprintln!(" To update manually, run one of:");
|
eprintln!(" Download the latest agent-browser-win32-x64.tar.gz from:");
|
||||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
eprintln!(" https://github.com/leeguooooo/agent-browser-stealth/releases/latest");
|
||||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
eprintln!(" and replace agent-browser.exe on your PATH.");
|
||||||
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");
|
|
||||||
exit(1);
|
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() {
|
let install_cmd = format!("curl -fsSL {} | sh", INSTALL_URL);
|
||||||
println!(
|
println!("Running: {}", install_cmd);
|
||||||
"{}",
|
|
||||||
color::cyan(&format!(
|
|
||||||
"Upgrading agent-browser... v{} → v{}",
|
|
||||||
current, latest
|
|
||||||
))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
let ok = cmd.status().map(|s| s.success()).unwrap_or(false);
|
||||||
if !latest.is_empty() {
|
if ok {
|
||||||
println!(
|
println!(
|
||||||
"{} Done! v{} → v{}",
|
"{} Upgrade complete — run `agent-browser-stealth --version` to confirm.",
|
||||||
color::success_indicator(),
|
color::success_indicator()
|
||||||
current,
|
|
||||||
latest
|
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+111
@@ -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
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.11",
|
"version": "0.27.0-fork.15",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ hidden: true
|
|||||||
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
|
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
|
||||||
accessibility-tree snapshots and compact `@eN` element refs.
|
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
|
## Start here
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user