Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9b8d96ca | ||
|
|
5c734c51b6 | ||
|
|
dc54855784 | ||
|
|
ed61be3359 | ||
|
|
54b61f4375 | ||
|
|
9ae82d620e | ||
|
|
8f67cff3e1 | ||
|
|
e70d841a94 | ||
|
|
6032deabd5 | ||
|
|
27dff19105 | ||
|
|
21d591ee65 | ||
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 |
@@ -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,14 +93,24 @@ 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
|
||||||
|
|||||||
Generated
+21
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.9"
|
version = "0.27.0-fork.14"
|
||||||
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.9"
|
version = "0.27.0-fork.14"
|
||||||
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"] }
|
||||||
|
|||||||
@@ -1066,6 +1066,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// === Get ===
|
// === Get ===
|
||||||
"get" => parse_get(&rest, &id),
|
"get" => parse_get(&rest, &id),
|
||||||
|
|
||||||
|
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||||
|
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
||||||
|
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||||
|
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value"
|
||||||
|
| "count" | "box" | "styles" | "attr" => {
|
||||||
|
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
||||||
|
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||||
|
get_args.push(sub);
|
||||||
|
get_args.extend_from_slice(&rest);
|
||||||
|
parse_get(&get_args, &id)
|
||||||
|
}
|
||||||
|
|
||||||
// === Is (state checks) ===
|
// === Is (state checks) ===
|
||||||
"is" => parse_is(&rest, &id),
|
"is" => parse_is(&rest, &id),
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
+85
-36
@@ -633,6 +633,8 @@ impl DaemonState {
|
|||||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
// Hide automation markers in this cross-origin iframe session too.
|
||||||
|
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for sid in &drained.detached_iframe_sessions {
|
for sid in &drained.detached_iframe_sessions {
|
||||||
@@ -1605,11 +1607,17 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
// Return a helpful error guiding the user to enable it.
|
// Return a helpful error guiding the user to enable it.
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
|
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\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 \
|
||||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
agent-browser --launch --profile auto open <url>\n\
|
||||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
(plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
|
||||||
|
no logged-in sessions.)\n\n\
|
||||||
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
A full restart with --remote-debugging-port=<port> is required.",
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
@@ -1758,45 +1766,62 @@ fn chrome_relaunch_hint() -> &'static str {
|
|||||||
/// Called after every successful launch / CDP connect / auto-connect.
|
/// Called after every successful launch / CDP connect / auto-connect.
|
||||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
fn stealth_enabled() -> bool {
|
||||||
return; // Explicitly disabled
|
!env::var("AGENT_BROWSER_STEALTH")
|
||||||
}
|
.map(|v| v == "0")
|
||||||
let Some(ref mgr) = state.browser else {
|
.unwrap_or(false)
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
let Ok(session_id) = mgr.active_session_id() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
///
|
||||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
/// Stealth scripts are registered per-session via
|
||||||
|
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
|
||||||
|
/// tabs or cross-origin iframe sessions created after the initial page. We must
|
||||||
|
/// re-apply to every session the user can touch, otherwise automation markers
|
||||||
|
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
|
||||||
|
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
|
||||||
|
if !stealth_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Determine mode: an external attach uses minimal patches (the user's real
|
||||||
|
// Chrome already has a genuine fingerprint — heavy patches create detectable
|
||||||
|
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
|
||||||
let mode = if mgr.is_cdp_connection() {
|
let mode = if mgr.is_cdp_connection() {
|
||||||
stealth::StealthMode::CdpAttach
|
stealth::StealthMode::CdpAttach
|
||||||
} else {
|
} else {
|
||||||
stealth::StealthMode::FullLaunch
|
stealth::StealthMode::FullLaunch
|
||||||
};
|
};
|
||||||
|
|
||||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||||
if let Err(e) = stealth::apply_stealth(
|
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||||
&mgr.client,
|
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
|
||||||
session_id,
|
|
||||||
mode,
|
|
||||||
locale.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
|
||||||
}
|
}
|
||||||
// Also inject into the current page (already loaded before our init script)
|
// Also inject into the current page (already loaded before our init script).
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
|
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply stealth to a specific session of the active browser (no-op if no
|
||||||
|
/// browser or stealth disabled).
|
||||||
|
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
|
||||||
|
if let Some(ref mgr) = state.browser {
|
||||||
|
apply_stealth_via_mgr(mgr, session_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply stealth to the active page session (initial connect/launch).
|
||||||
|
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||||
|
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
|
||||||
|
Some(sid) => sid.to_string(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
apply_stealth_to_session(state, &session_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
||||||
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
||||||
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
||||||
@@ -2148,11 +2173,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
|
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\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 \
|
||||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
agent-browser --launch --profile auto open <url>\n\
|
||||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
(plain `--launch` alone uses a temporary EMPTY profile — no cookies, \
|
||||||
|
no logged-in sessions.)\n\n\
|
||||||
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
A full restart with --remote-debugging-port=<port> is required.",
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
@@ -2293,6 +2324,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||||
|
|
||||||
apply_launch_init_scripts(state).await;
|
apply_launch_init_scripts(state).await;
|
||||||
|
// Apply stealth patches (the 32 JS patches + HeadlessChrome UA strip in
|
||||||
|
// FullLaunch mode). The fresh-launch path was missing this — only the launch
|
||||||
|
// FLAGS (e.g. --disable-blink-features) were applied, so the JS patches never
|
||||||
|
// ran and navigator.userAgent kept the HeadlessChrome marker.
|
||||||
|
apply_stealth_to_browser(state).await;
|
||||||
|
|
||||||
Ok(json!({ "launched": true }))
|
Ok(json!({ "launched": true }))
|
||||||
}
|
}
|
||||||
@@ -3948,13 +3984,26 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
|
||||||
let url = cmd.get("url").and_then(|v| v.as_str());
|
let url = cmd.get("url").and_then(|v| v.as_str());
|
||||||
let label = cmd.get("label").and_then(|v| v.as_str());
|
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
mgr.tab_new(url, label).await
|
let result = {
|
||||||
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
|
mgr.tab_new(url, label).await?
|
||||||
|
};
|
||||||
|
// A new tab is a new CDP session; stealth scripts registered on the prior
|
||||||
|
// session don't carry over, so patch the new tab too.
|
||||||
|
if let Some(sid) = state
|
||||||
|
.browser
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.active_session_id().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
|
apply_stealth_to_session(state, &sid).await;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
|||||||
Some((port, ws_path))
|
Some((port, ws_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove leftover Chrome temp profile directories from daemons that were
|
||||||
|
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
|
||||||
|
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
|
||||||
|
/// per session under the system temp dir. On daemon startup we sweep them — but
|
||||||
|
/// ONLY dirs that no running process still references as `--user-data-dir`, so
|
||||||
|
/// a profile in active use is never deleted.
|
||||||
|
pub fn cleanup_orphaned_chrome_profiles() {
|
||||||
|
let tmp = std::env::temp_dir();
|
||||||
|
let Ok(entries) = std::fs::read_dir(&tmp) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Snapshot live process command lines once. If we can't determine them,
|
||||||
|
// skip cleanup entirely rather than risk deleting an in-use profile.
|
||||||
|
let Some(live_cmdlines) = running_process_cmdlines() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let in_use = live_cmdlines
|
||||||
|
.iter()
|
||||||
|
.any(|cmd| cmd.contains(path_str.as_ref()));
|
||||||
|
if !in_use {
|
||||||
|
let _ = std::fs::remove_dir_all(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||||
|
let output = std::process::Command::new("ps")
|
||||||
|
.args(["-axww", "-o", "command="])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||||
|
// Best-effort: skip cleanup where we can't cheaply enumerate full process
|
||||||
|
// command lines, to avoid deleting a profile that is still in use.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||||
let user_data_dirs = get_chrome_user_data_dirs();
|
let user_data_dirs = get_chrome_user_data_dirs();
|
||||||
|
|
||||||
@@ -685,7 +741,11 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
Err("No running Chrome with remote debugging found. Remote debugging is a \
|
||||||
|
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
||||||
|
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
||||||
|
--cdp <port>/--launch."
|
||||||
|
.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||||
@@ -857,6 +917,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);
|
||||||
|
|
||||||
@@ -868,6 +940,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());
|
||||||
@@ -1613,6 +1700,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();
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
|||||||
match discover_cdp_ws(host, port, timeout).await {
|
match discover_cdp_ws(host, port, timeout).await {
|
||||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||||
Err(ws_err) => Err(format!(
|
Err(ws_err) => Err(format!(
|
||||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
"All CDP discovery methods failed for {host}:{port}. \
|
||||||
host, port, version_err, list_err, ws_err
|
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
|
||||||
|
(/json/version, /json/list), so `--cdp <port>` cannot find the target — \
|
||||||
|
use the default auto-connect (just `agent-browser open <url>`), which reads \
|
||||||
|
DevToolsActivePort and attaches over WebSocket. \
|
||||||
|
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ pub async fn run_daemon(session: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
|
||||||
|
// run on kill -9). Only removes dirs no live process references.
|
||||||
|
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
|
||||||
|
|
||||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||||
let _ = fs::write(&pid_path, process::id().to_string());
|
let _ = fs::write(&pid_path, process::id().to_string());
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||||
(function(){
|
(function(){
|
||||||
const removeWebdriver = (target) => {
|
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||||
|
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||||
|
// lie-detection (creepjs). Only intervene when webdriver is still truthy
|
||||||
|
// (e.g. older Chrome without that override) and force it to FALSE.
|
||||||
|
//
|
||||||
|
// Never `delete` webdriver: real Chrome reports `false`, so `undefined` is
|
||||||
|
// itself a tell, and deleting it removes the native `false` the override set.
|
||||||
|
const forceWebdriverFalse = (target) => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
try { delete target.webdriver; } catch {}
|
try {
|
||||||
|
if (target.webdriver === true) {
|
||||||
|
Object.defineProperty(target, 'webdriver', {
|
||||||
|
get: () => false,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
removeWebdriver(navigator);
|
forceWebdriverFalse(navigator);
|
||||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
forceWebdriverFalse(Object.getPrototypeOf(navigator));
|
||||||
removeWebdriver(Navigator.prototype);
|
forceWebdriverFalse(Navigator.prototype);
|
||||||
if (typeof WorkerNavigator !== 'undefined') {
|
if (typeof WorkerNavigator !== 'undefined') {
|
||||||
removeWebdriver(WorkerNavigator.prototype);
|
forceWebdriverFalse(WorkerNavigator.prototype);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
|
|||||||
@@ -1042,6 +1042,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
|
|
||||||
// Default success
|
// Default success
|
||||||
println!("{} Done", color::success_indicator());
|
println!("{} Done", color::success_indicator());
|
||||||
|
} else {
|
||||||
|
// Success response with no data payload — still confirm the command ran
|
||||||
|
// instead of printing nothing (a silent exit 0 looks like a no-op and
|
||||||
|
// hides whether anything happened).
|
||||||
|
println!("{} Done", color::success_indicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
print_warning(resp);
|
print_warning(resp);
|
||||||
|
|||||||
+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
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/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}"
|
||||||
|
# convenience aliases: `abs` (short) -> agent-browser
|
||||||
|
ln -sf "$bindir/${BIN_NAME}" "$bindir/abs" 2>/dev/null || true
|
||||||
|
|
||||||
|
info "installed ${BIN_NAME} -> ${bindir}/${BIN_NAME}"
|
||||||
|
"$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.9",
|
"version": "0.27.0-fork.14",
|
||||||
"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