Compare commits

...
Author SHA1 Message Date
leeguooooo ab9b8d96ca chore(release): bump to 0.27.0-fork.14 — fix upgrade footgun + CI Node 24
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
`agent-browser-stealth upgrade` no longer installs the wrong upstream npm
package; it re-runs the GitHub-Release install.sh in place. CI actions bumped
off Node 20.
2026-06-01 18:46:27 +09:00
leeguooooo 5c734c51b6 fix(upgrade): re-run install.sh instead of installing the wrong npm package
`agent-browser-stealth upgrade` (inherited from upstream) queried
registry.npmjs.org/agent-browser and ran `npm/pnpm install -g
agent-browser@latest` — installing the UNRELATED upstream `agent-browser`
package and clobbering the user's stealth install (reported in testing).

The stealth fork ships via GitHub Releases, so `upgrade` now just re-runs
install.sh into the same directory as the current binary — identical to the
install path, always tracking the freshest Release. (Windows prints manual
download instructions.)

Also bump CI actions off the deprecated Node 20 runtime (GitHub forces Node 24
on 2026-06-16): checkout v4->v6, upload-artifact v4->v7, download-artifact
v4->v8, action-gh-release v2->v3.
2026-06-01 18:46:16 +09:00
leeguooooo dc54855784 chore(release): bump to 0.27.0-fork.13 — --profile auto + temp-profile warnings
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Stops agents from silently launching a temporary empty profile (no login).
Adds --profile auto, warns on bare --launch, and recommends --profile auto in
connect-failure errors. Addresses issue #1 follow-up.
2026-06-01 18:30:28 +09:00
leeguooooo ed61be3359 feat(profile): --profile auto + stop steering users into temp-profile launches
Addresses the footgun raised in issue #1 follow-up: plain `--launch` silently
uses a temporary EMPTY profile (no cookies/login), and the connect-failure
error even recommended it — trapping agents into thinking they reused the
logged-in browser when they didn't.

- `--profile auto`: resolves to the Chrome profile last used (from Local State
  `profile.last_used`), falling back to "Default", then the first profile. So
  `--launch --profile auto open <url>` reuses real login state without naming
  the profile. (--profile <name>/Default already worked.)
- connect-failure error now recommends `--launch --profile auto` and states
  plainly that bare `--launch` is a temporary EMPTY profile — no cookies/login.
- bare `--launch` (no --profile, not CI) now prints a warning to that effect.
- README: fix Setup (relaunch with --remote-debugging-port, not chrome://inspect)
  and split Standalone mode into throwaway vs. keep-your-login (`--profile auto`).

Tests: resolve_chrome_profile("auto") prefers last_used, falls back to Default.
2026-06-01 18:30:11 +09:00
leeguooooo 54b61f4375 docs(readme): fix Setup (remote-debugging-port, not chrome://inspect) + clarify aliases
Addresses issue #1. The "Setup (one time)" section told users to toggle
chrome://inspect, which only enables target discovery and is NOT enough to
attach — the most-reported first-run failure. Replace with the correct model:
relaunch Chrome with --remote-debugging-port (a startup flag), expect the
Chrome 136+ "Allow remote debugging?" consent dialog, and use --launch as a
zero-setup fallback. Add a "Command names" note that agent-browser /
agent-browser-stealth / abs are the same binary (stealth is runtime behavior,
not a separate executable).
2026-06-01 18:12:18 +09:00
leeguooooo 9ae82d620e chore(release): bump to 0.27.0-fork.12 — embedded skills for single-binary install
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
`abs skills get core` now works on GitHub-Release/install.sh installs (skill
content is embedded in the binary and extracted to a cache dir on first use).
First release via the automated tag-push -> release-binaries CI flow.
2026-06-01 17:24:56 +09:00
leeguooooo 8f67cff3e1 fix(skills): embed skill content in the binary for single-binary installs
`skills get core` (the first step the agent-browser skill stub tells agents to
run) failed with "Skills directory not found" on a GitHub-Release / install.sh
install: only the binary is shipped, with no adjacent skills/ or skill-data/
the way an npm install bundles them, so find_package_root() returned nothing.

Embed skills/ and skill-data/ into the binary via include_dir (168K) and, when
no on-disk skill dirs are found, extract them once to a per-version cache dir
($CACHE/agent-browser/skills-<version>/) and serve from there. npm/dev installs
still use the on-disk dirs unchanged.

Verified: from an isolated dir (no skills/ nearby), `skills list` shows all 6
skills and `skills get core` serves content.
2026-06-01 17:24:34 +09:00
leeguooooo e70d841a94 fix(install): resolve latest release via redirect, not the rate-limited API
install.sh resolved the latest tag through api.github.com/.../releases/latest,
which rate-limits unauthenticated callers to 60/hr and returned 403 in testing.
Use the github.com/<repo>/releases/latest 302 redirect instead (web host, not
rate-limited) and parse the tag from the resolved /releases/tag/<TAG> URL.

Verified: `curl … install.sh | sh` resolves v0.27.0-fork.11, downloads the
darwin-arm64 asset, verifies the .sha256, installs agent-browser + abs.
2026-06-01 17:09:54 +09:00
leeguooooo 6032deabd5 feat(dist): ship via GitHub Release binaries + install.sh (drop npm as primary)
Distribute the prebuilt binary through GitHub Releases instead of the npm
registry — zero auth for the publisher (CI's GITHUB_TOKEN) and zero auth for
consumers (no npm token / 2FA / OTP, no GitHub Packages .npmrc).

- install.sh: detects OS/arch (incl. linux musl), downloads the matching
  agent-browser-<platform>.tar.gz from the GitHub Release, verifies .sha256,
  installs `agent-browser` + `abs` to /usr/local/bin or ~/.local/bin.
  Override via AGENT_BROWSER_VERSION / AGENT_BROWSER_BIN_DIR.
- .github/workflows/release-binaries.yml: on tag push (v*), build all 7
  platform variants (reusing the zigbuild cross-compile matrix), package each
  as .tar.gz + .sha256, attach to the tag's GitHub Release. No npm, no token.
- remove .github/workflows/release.yml: it published to npm (--provenance) and
  built the (removed) dashboard, so it broke on every main push.
- README install now leads with `curl … install.sh | sh`; npm demoted to a
  legacy alternative.
- skill stub self-heals: if `agent-browser` is missing, run install.sh (don't
  fall back to other browser tools).
2026-06-01 16:46:11 +09:00
leeguooooo 27dff19105 chore(release): bump to 0.27.0-fork.11 — FullLaunch stealth now fully applied
Fixes the longstanding FullLaunch (--launch) stealth gap: handle_launch's
fresh-launch path now calls apply_stealth_to_browser, so the 32 JS fingerprint
patches and the HeadlessChrome→Chrome UA strip run on launched browsers (they
never did before — only the launch flags applied).

Verified FullLaunch headless: navigator.webdriver=false,
navigator.userAgent=Chrome/<v> (no HeadlessChrome), new tabs + initial page
clean, bot.sannysoft.com 0 failed / 31 passed.
2026-06-01 14:45:12 +09:00
leeguooooo 21d591ee65 fix(stealth): apply stealth on the --launch path (FullLaunch JS patches + UA strip)
handle_launch's fresh-launch path (the path `--launch open <url>` takes) never
called apply_stealth_to_browser — only the launch FLAGS were applied (e.g.
--disable-blink-features=AutomationControlled, which is why navigator.webdriver
was already false). As a result the 32 JS fingerprint patches and the
Emulation.setUserAgentOverride HeadlessChrome→Chrome UA strip NEVER ran on a
launched browser: navigator.userAgent kept the HeadlessChrome marker (a
longstanding bug — identical on the prior prebuilt binary).

Add the apply_stealth_to_browser call after launch (the auto_launch path
already had it; only the explicit-launch path was missing it).

Verified, FullLaunch headless:
- navigator.webdriver === false, navigator.userAgent => Chrome/<v> (no Headless)
- new tabs and the initial page both clean
- bot.sannysoft.com: 0 failed / 31 passed
2026-06-01 14:39:03 +09:00
leeguooooo a6b2f5a192 chore(release): bump to 0.27.0-fork.10 — UX batch + stealth coverage/webdriver
Fixes since fork.9 (UX audit batch):
- stealth: per-session coverage so new tabs (tab new) and cross-origin iframe
  sessions get patched (were unpatched/detectable)
- stealth: navigator.webdriver = false (boolean), not undefined — never delete
  the property (undefined is itself a detection tell)
- hygiene: sweep orphaned temp Chrome profiles on daemon startup (only dirs no
  live process references) — fixes the kill -9 temp-dir disk leak
- ux: success-with-no-data prints "Done" instead of a silent exit 0
- ux: top-level aliases for `get` reads (url, cdp-url, title, html, text, ...)
- ux: clearer connect errors (consent dialog, "startup flag" guidance, and
  --cdp on Chrome 136+ points to auto-connect)

Known follow-up (not in this release): FullLaunch (--launch) browsers don't get
the JS patches / UA-strip applied (navigator.userAgent still shows
HeadlessChrome); secondary to the primary CdpAttach mode. Tracked for a
dedicated fix.
2026-06-01 14:25:08 +09:00
leeguooooo 7a1ca90416 fix(stealth): webdriver = false (not undefined) — never delete the property
The webdriver patch deleted navigator.webdriver, leaving it `undefined`. Real
Chrome reports `false`, so `undefined` is itself a detection tell, and deleting
it also removes the native `false` that Emulation.setAutomationOverride sets.

Now we rely on setAutomationOverride for a native (undetectable) `false` and
only force `false` via a getter as a fallback when webdriver is still `true`
(older Chrome without that override) — never delete it. Verified: FullLaunch
headless now reports navigator.webdriver === false (boolean), consistently.
2026-06-01 13:41:49 +09:00
leeguooooo ad0fb424c3 fix(ux): silent-output, command aliases, and clearer connection errors
- output: a success response with no data payload now prints "Done" instead of
  nothing (a silent exit 0 looked like a no-op).
- commands: add top-level aliases for `get` status reads — `url`, `cdp-url`
  (and `cdp_url`), `title`, `html`, `text`, `value`, `count`, `box`, `styles`,
  `attr` — so `agent-browser url` no longer errors "Unknown command".
- connect errors now explain the Chrome 136+ realities:
  - connect-failure mentions the "Allow remote debugging?" consent dialog and
    that remote debugging is a startup flag, not a setting.
  - no-Chrome error tells the user to relaunch Chrome with
    --remote-debugging-port (auto-connect then works).
  - --cdp discovery failure explains Chrome 136+ dropped the HTTP discovery
    endpoints and to use the default auto-connect instead.
2026-06-01 12:49:35 +09:00
leeguooooo f62e204038 fix(stealth,hygiene): per-session stealth coverage + orphaned temp-profile sweep
Stealth coverage (the fork's core value was leaking on secondary surfaces):
- stealth scripts are registered per CDP session, so new tabs (`tab new`) and
  cross-origin iframe sessions created after the initial page had NO patches.
  Extract apply_stealth_via_mgr/apply_stealth_to_session and re-apply on
  tab_new and on iframe attach. Fixes automation markers (and FullLaunch UA)
  leaking in new tabs / cross-origin frames.

Resource hygiene (temp profiles filled the disk):
- ChromeProcess::drop already cleans the temp user-data-dir on normal exit, but
  a hard kill (kill -9 / version-mismatch restart / crash) skips Drop and leaks
  ~50MB per session. Add cleanup_orphaned_chrome_profiles() on daemon startup
  that sweeps agent-browser-chrome-* temp dirs NOT referenced by any live
  process (so an in-use profile is never deleted).
2026-06-01 12:38:57 +09:00
18 changed files with 700 additions and 660 deletions
+137
View File
@@ -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
-332
View File
@@ -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 }}
+54 -8
View File
@@ -21,9 +21,20 @@ For basic usage, commands, and API reference, see the [upstream documentation](h
## Install
```bash
npm install -g agent-browser-stealth
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
```
Downloads the prebuilt binary for your platform from the latest [GitHub Release](https://github.com/leeguooooo/agent-browser-stealth/releases) and installs `agent-browser` (+ the `abs` alias). No npm, no tokens.
<details>
<summary>Other ways to install</summary>
- **Pin a version:** `AGENT_BROWSER_VERSION=v0.27.0-fork.12 curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh`
- **Custom location:** `AGENT_BROWSER_BIN_DIR=$HOME/bin curl -fsSL … | sh`
- **Windows:** download `agent-browser-win32-x64.tar.gz` from the [Releases page](https://github.com/leeguooooo/agent-browser-stealth/releases) and put `agent-browser.exe` on your PATH.
- **npm (legacy):** `npm install -g agent-browser-stealth` — still published, but GitHub Releases is the primary channel now.
</details>
### Install the AI agent skills
The repo ships SKILL.md files for Claude Code, Cursor, etc. Pull them into the current project with [skills.sh](https://skills.sh):
@@ -34,14 +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`.
## Setup (one time)
## Command names
Enable Chrome DevTools Protocol in your Chrome:
`agent-browser`, `agent-browser-stealth`, and `abs` are **the same binary**
`abs` is just a short alias. There is no separate "stealth executable"; stealth
is a runtime behavior (see [Anti-detection](#anti-detection) below), applied
automatically based on whether you attach to your real Chrome or `--launch` a
fresh one.
1. Open `chrome://inspect/#remote-debugging` in Chrome
2. Toggle the switch on
## Setup: connect to your Chrome
That's it. This setting persists across Chrome restarts.
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
@@ -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.
### Standalone mode
### Standalone mode (`--launch`)
If you need a separate browser (CI, testing, etc.):
Spawn a separate browser instead of attaching to your running Chrome:
```bash
# Throwaway: fresh, EMPTY profile — no cookies, no login (good for CI/testing)
agent-browser --launch open https://example.com
# Keep your login: launch with your real Chrome profile (cookies/sessions intact)
agent-browser --launch --profile auto open https://x.com/home
# or name it explicitly: --profile Default / --profile "Profile 1"
```
> ⚠️ Plain `--launch` (no `--profile`) uses a **temporary empty profile** — you will
> NOT be logged into anything. For logged-in sites use `--profile auto` (picks the
> Chrome profile you used most recently) or `--profile <name>`. agent-browser prints
> a warning when you `--launch` without a profile.
In CI environments, standalone mode is used automatically.
## Anti-detection
+21 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.9"
version = "0.27.0-fork.14"
dependencies = [
"aes-gcm",
"async-trait",
@@ -57,6 +57,7 @@ dependencies = [
"hex",
"hmac",
"image",
"include_dir",
"libc",
"regex-lite",
"reqwest",
@@ -1048,6 +1049,25 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.13.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.9"
version = "0.27.0-fork.14"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
@@ -19,6 +19,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex-lite = "0.1"
dirs = "5.0"
include_dir = "0.7"
base64 = "0.22"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
+12
View File
@@ -1066,6 +1066,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Get ===
"get" => parse_get(&rest, &id),
// Top-level shortcuts for `get <x>` status reads — users naturally type
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value"
| "count" | "box" | "styles" | "attr" => {
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
get_args.push(sub);
get_args.extend_from_slice(&rest);
parse_get(&get_args, &id)
}
// === Is (state checks) ===
"is" => parse_is(&rest, &id),
+12
View File
@@ -531,6 +531,18 @@ fn main() {
let mut flags = parse_flags(&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_version = args.iter().any(|a| a == "--version" || a == "-V");
+85 -36
View File
@@ -633,6 +633,8 @@ impl DaemonState {
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
.await;
}
// Hide automation markers in this cross-origin iframe session too.
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
}
}
for sid in &drained.detached_iframe_sessions {
@@ -1605,11 +1607,17 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
// Return a helpful error guiding the user to enable it.
return Err(format!(
"Could not connect to your Chrome browser.\n\n\
To let agent-browser work with your existing Chrome (recommended):\n\
If Chrome showed an \"Allow remote debugging?\" dialog, click \
Allow and re-run that consent is what lets agent-browser attach.\n\n\
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
{}\n\n\
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
it does NOT expose the standard CDP HTTP API on /json/version. \
Or launch a separate browser that KEEPS your login state:\n \
agent-browser --launch --profile auto open <url>\n\
(plain `--launch` alone uses a temporary EMPTY profile no cookies, \
no logged-in sessions.)\n\n\
Note: remote debugging is a startup flag, not a Chrome setting \
chrome://inspect/#remote-debugging only enables target discovery and \
does NOT expose the CDP HTTP API on /json/version. \
A full restart with --remote-debugging-port=<port> is required.",
chrome_relaunch_hint(),
));
@@ -1758,45 +1766,62 @@ fn chrome_relaunch_hint() -> &'static str {
/// Called after every successful launch / CDP connect / auto-connect.
/// Uses `CdpAttach` mode for external connections (minimal patches) and
/// `FullLaunch` mode for newly launched Chrome (all patches).
async fn apply_stealth_to_browser(state: &DaemonState) {
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
return; // Explicitly disabled
}
let Some(ref mgr) = state.browser else {
return;
};
let Ok(session_id) = mgr.active_session_id() else {
return;
};
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
fn stealth_enabled() -> bool {
!env::var("AGENT_BROWSER_STEALTH")
.map(|v| v == "0")
.unwrap_or(false)
}
// Determine mode: if we attached to an external browser, use minimal patches.
// The user's real Chrome already has a genuine fingerprint — heavy patches
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
/// Apply stealth patches to ONE CDP session of the given browser.
///
/// Stealth scripts are registered per-session via
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
/// tabs or cross-origin iframe sessions created after the initial page. We must
/// re-apply to every session the user can touch, otherwise automation markers
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
if !stealth_enabled() {
return;
}
// Determine mode: an external attach uses minimal patches (the user's real
// Chrome already has a genuine fingerprint — heavy patches create detectable
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
let mode = if mgr.is_cdp_connection() {
stealth::StealthMode::CdpAttach
} else {
stealth::StealthMode::FullLaunch
};
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
if let Err(e) = stealth::apply_stealth(
&mgr.client,
session_id,
mode,
locale.as_deref(),
)
.await
{
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
}
// Also inject into the current page (already loaded before our init script)
// Also inject into the current page (already loaded before our init script).
if let Err(e) =
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
.await
{
eprintln!("[stealth] Failed to patch current page: {}", e);
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
}
}
/// Apply stealth to a specific session of the active browser (no-op if no
/// browser or stealth disabled).
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
if let Some(ref mgr) = state.browser {
apply_stealth_via_mgr(mgr, session_id).await;
}
}
/// Apply stealth to the active page session (initial connect/launch).
async fn apply_stealth_to_browser(state: &DaemonState) {
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
Some(sid) => sid.to_string(),
None => return,
};
apply_stealth_to_session(state, &session_id).await;
}
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
/// by a version-mismatch restart), navigate the freshly-connected browser to
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
@@ -2148,11 +2173,17 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
Err(_e) => {
return Err(format!(
"Could not connect to your Chrome browser.\n\n\
To let agent-browser work with your existing Chrome (recommended):\n\
If Chrome showed an \"Allow remote debugging?\" dialog, click \
Allow and re-run that consent is what lets agent-browser attach.\n\n\
Otherwise, to let agent-browser reuse your logged-in Chrome (recommended):\n\
{}\n\n\
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
it does NOT expose the standard CDP HTTP API on /json/version. \
Or launch a separate browser that KEEPS your login state:\n \
agent-browser --launch --profile auto open <url>\n\
(plain `--launch` alone uses a temporary EMPTY profile no cookies, \
no logged-in sessions.)\n\n\
Note: remote debugging is a startup flag, not a Chrome setting \
chrome://inspect/#remote-debugging only enables target discovery and \
does NOT expose the CDP HTTP API on /json/version. \
A full restart with --remote-debugging-port=<port> is required.",
chrome_relaunch_hint(),
));
@@ -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?;
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 }))
}
@@ -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> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
let url = cmd.get("url").and_then(|v| v.as_str());
let label = cmd.get("label").and_then(|v| v.as_str());
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
mgr.tab_new(url, label).await
let result = {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
mgr.tab_new(url, label).await?
};
// A new tab is a new CDP session; stealth scripts registered on the prior
// session don't carry over, so patch the new tab too.
if let Some(sid) = state
.browser
.as_ref()
.and_then(|m| m.active_session_id().ok())
.map(|s| s.to_string())
{
apply_stealth_to_session(state, &sid).await;
}
Ok(result)
}
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
+126 -1
View File
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
Some((port, ws_path))
}
/// Remove leftover Chrome temp profile directories from daemons that were
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
/// per session under the system temp dir. On daemon startup we sweep them — but
/// ONLY dirs that no running process still references as `--user-data-dir`, so
/// a profile in active use is never deleted.
pub fn cleanup_orphaned_chrome_profiles() {
let tmp = std::env::temp_dir();
let Ok(entries) = std::fs::read_dir(&tmp) else {
return;
};
// Snapshot live process command lines once. If we can't determine them,
// skip cleanup entirely rather than risk deleting an in-use profile.
let Some(live_cmdlines) = running_process_cmdlines() else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
continue;
}
let path = entry.path();
let path_str = path.to_string_lossy();
let in_use = live_cmdlines
.iter()
.any(|cmd| cmd.contains(path_str.as_ref()));
if !in_use {
let _ = std::fs::remove_dir_all(&path);
}
}
}
#[cfg(unix)]
fn running_process_cmdlines() -> Option<Vec<String>> {
let output = std::process::Command::new("ps")
.args(["-axww", "-o", "command="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.to_string())
.collect(),
)
}
#[cfg(not(unix))]
fn running_process_cmdlines() -> Option<Vec<String>> {
// Best-effort: skip cleanup where we can't cheaply enumerate full process
// command lines, to avoid deleting a profile that is still in use.
None
}
pub async fn auto_connect_cdp() -> Result<String, String> {
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.
@@ -857,6 +917,18 @@ pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec<ChromeProfile> {
/// 3. Case-insensitive directory name match
///
/// Returns the resolved directory name, or an error with available profiles.
/// Read `profile.last_used` (the directory name of the profile Chrome opened
/// most recently) from a user-data dir's `Local State`. Used to resolve
/// `--profile auto`.
fn read_last_used_profile(user_data_dir: &Path) -> Option<String> {
let content = std::fs::read_to_string(user_data_dir.join("Local State")).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
json.get("profile")?
.get("last_used")?
.as_str()
.map(String::from)
}
pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result<String, String> {
let profiles = list_chrome_profiles(user_data_dir);
@@ -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
if let Some(p) = profiles.iter().find(|p| p.directory == input) {
return Ok(p.directory.clone());
@@ -1613,6 +1700,44 @@ mod tests {
assert!(!is_chrome_profile_name("relative/path"));
}
#[test]
fn test_resolve_chrome_profile_auto_prefers_last_used() {
let tmp = std::env::temp_dir().join("ab-auto-lastused-test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let local_state = serde_json::json!({
"profile": {
"last_used": "Profile 2",
"info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} }
}
});
std::fs::write(
tmp.join("Local State"),
serde_json::to_string(&local_state).unwrap(),
)
.unwrap();
assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Profile 2");
assert_eq!(resolve_chrome_profile(&tmp, "AUTO").unwrap(), "Profile 2");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_resolve_chrome_profile_auto_falls_back_to_default() {
let tmp = std::env::temp_dir().join("ab-auto-default-test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let local_state = serde_json::json!({
"profile": { "info_cache": { "Default": {"name": "Person 1"}, "Profile 2": {"name": "Work"} } }
});
std::fs::write(
tmp.join("Local State"),
serde_json::to_string(&local_state).unwrap(),
)
.unwrap();
assert_eq!(resolve_chrome_profile(&tmp, "auto").unwrap(), "Default");
let _ = std::fs::remove_dir_all(&tmp);
}
/// Helper to create a fake Chrome user-data dir with a `Local State` file.
fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) {
let mut info_cache = serde_json::Map::new();
+6 -2
View File
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
match discover_cdp_ws(host, port, timeout).await {
Ok(ws_url) => Ok(append_query(&ws_url, query)),
Err(ws_err) => Err(format!(
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
host, port, version_err, list_err, ws_err
"All CDP discovery methods failed for {host}:{port}. \
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
(/json/version, /json/list), so `--cdp <port>` cannot find the target \
use the default auto-connect (just `agent-browser open <url>`), which reads \
DevToolsActivePort and attaches over WebSocket. \
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
)),
}
}
+4
View File
@@ -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 _ = fs::write(&pid_path, process::id().to_string());
+21 -6
View File
@@ -1,14 +1,29 @@
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
(function(){
const removeWebdriver = (target) => {
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
// lie-detection (creepjs). Only intervene when webdriver is still truthy
// (e.g. older Chrome without that override) and force it to FALSE.
//
// Never `delete` webdriver: real Chrome reports `false`, so `undefined` is
// itself a tell, and deleting it removes the native `false` the override set.
const forceWebdriverFalse = (target) => {
if (!target) return;
try { delete target.webdriver; } catch {}
try {
if (target.webdriver === true) {
Object.defineProperty(target, 'webdriver', {
get: () => false,
configurable: true,
enumerable: false,
});
}
} catch {}
};
removeWebdriver(navigator);
removeWebdriver(Object.getPrototypeOf(navigator));
removeWebdriver(Navigator.prototype);
forceWebdriverFalse(navigator);
forceWebdriverFalse(Object.getPrototypeOf(navigator));
forceWebdriverFalse(Navigator.prototype);
if (typeof WorkerNavigator !== 'undefined') {
removeWebdriver(WorkerNavigator.prototype);
forceWebdriverFalse(WorkerNavigator.prototype);
}
})();
(function(){
+5
View File
@@ -1042,6 +1042,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Default success
println!("{} Done", color::success_indicator());
} else {
// Success response with no data payload — still confirm the command ran
// instead of printing nothing (a silent exit 0 looks like a no-op and
// hides whether anything happened).
println!("{} Done", color::success_indicator());
}
print_warning(resp);
+51 -8
View File
@@ -1,3 +1,4 @@
use include_dir::{include_dir, Dir};
use serde_json::json;
use std::env;
use std::fs;
@@ -6,6 +7,12 @@ use std::process::exit;
use crate::color;
/// Skill content compiled into the binary so `skills get` works on a
/// single-binary install (GitHub Release / install.sh), where there is no
/// adjacent `skills/` or `skill-data/` on disk the way an npm install has.
static EMBEDDED_SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skills");
static EMBEDDED_SKILL_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skill-data");
struct SkillInfo {
name: String,
description: String,
@@ -63,6 +70,29 @@ fn find_package_root() -> Option<PathBuf> {
None
}
/// Extract the binary-embedded skill content to a per-version cache dir on
/// first use, returning a package root that contains `skills/` and
/// `skill-data/`. Fallback for single-binary installs (GitHub Release /
/// install.sh) that have no on-disk skill directories. Version-stamped so an
/// upgraded binary re-extracts fresh content.
fn embedded_skills_root() -> Option<PathBuf> {
let base = dirs::cache_dir()?
.join("agent-browser")
.join(concat!("skills-", env!("CARGO_PKG_VERSION")));
let marker = base.join(".extracted");
if !marker.exists() {
let _ = fs::create_dir_all(base.join("skills"));
let _ = fs::create_dir_all(base.join("skill-data"));
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|| EMBEDDED_SKILL_DATA.extract(base.join("skill-data")).is_err()
{
return None;
}
let _ = fs::write(&marker, env!("CARGO_PKG_VERSION"));
}
base.join("skills").is_dir().then_some(base)
}
/// Collect all skill directories to search, respecting the env var override.
fn find_skills_dirs() -> Vec<PathBuf> {
// Env var override: single directory, used as-is
@@ -73,15 +103,28 @@ fn find_skills_dirs() -> Vec<PathBuf> {
}
}
let Some(root) = find_package_root() else {
return vec![];
};
// On-disk package root (npm install layout, or dev build walking up to repo).
if let Some(root) = find_package_root() {
let dirs: Vec<PathBuf> = SKILL_DIRS
.iter()
.map(|d| root.join(d))
.filter(|p| p.is_dir())
.collect();
if !dirs.is_empty() {
return dirs;
}
}
SKILL_DIRS
.iter()
.map(|d| root.join(d))
.filter(|p| p.is_dir())
.collect()
// Fallback: skill content compiled into the binary (single-binary install).
if let Some(root) = embedded_skills_root() {
return SKILL_DIRS
.iter()
.map(|d| root.join(d))
.filter(|p| p.is_dir())
.collect();
}
vec![]
}
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
+49 -263
View File
@@ -1,284 +1,70 @@
use crate::color;
use std::path::Path;
use std::process::{exit, Command, Stdio};
use std::process::{exit, Command};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
enum InstallMethod {
Npm,
Pnpm,
Yarn,
Bun,
Homebrew,
Cargo,
Unknown,
}
async fn fetch_latest_version() -> Result<String, String> {
let resp = reqwest::get(NPM_REGISTRY_URL)
.await
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse version info: {}", e))?;
body.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No version field in registry response".to_string())
}
/// Parse the `.install-method` marker written by postinstall.js.
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
match contents.trim() {
"npm" => Some(InstallMethod::Npm),
"pnpm" => Some(InstallMethod::Pnpm),
"yarn" => Some(InstallMethod::Yarn),
"bun" => Some(InstallMethod::Bun),
_ => None,
}
}
fn detect_install_method() -> InstallMethod {
if let Ok(exe) = std::env::current_exe() {
// Resolve symlinks to find the real binary location
let real_path = exe.canonicalize().unwrap_or(exe);
// Preferred: read the marker file written at install time
if let Some(dir) = real_path.parent() {
if let Some(method) = read_install_method_marker(dir) {
return method;
}
}
// Fallback: infer from executable path
let path_str = real_path.to_string_lossy();
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
return InstallMethod::Cargo;
}
if path_str.contains("/Cellar/agent-browser/")
|| path_str.contains("/homebrew/")
|| path_str.contains("/linuxbrew/")
{
return InstallMethod::Homebrew;
}
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
return InstallMethod::Pnpm;
}
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
return InstallMethod::Yarn;
}
if path_str.contains("/.bun/") {
return InstallMethod::Bun;
}
if path_str.contains("node_modules/agent-browser")
|| path_str.contains("node_modules\\agent-browser")
{
return InstallMethod::Npm;
}
}
// Last resort: probe package managers via subprocess
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if command_succeeds("brew", &["list", "agent-browser"]) {
return InstallMethod::Homebrew;
}
}
if command_output_contains(
"pnpm",
&["list", "-g", "agent-browser", "--depth=0"],
"agent-browser",
) {
return InstallMethod::Pnpm;
}
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
return InstallMethod::Yarn;
}
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
return InstallMethod::Bun;
}
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
return InstallMethod::Npm;
}
InstallMethod::Unknown
}
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
Command::new(cmd)
.args(args)
.stderr(Stdio::null())
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
.unwrap_or(false)
}
fn run_upgrade_command(method: &InstallMethod) -> bool {
let (cmd, args, display): (&str, &[&str], &str) = match method {
InstallMethod::Npm => (
"npm",
&["install", "-g", "agent-browser@latest"],
"npm install -g agent-browser@latest",
),
InstallMethod::Pnpm => (
"pnpm",
&["add", "-g", "agent-browser@latest"],
"pnpm add -g agent-browser@latest",
),
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
InstallMethod::Yarn => (
"yarn",
&["global", "add", "agent-browser@latest"],
"yarn global add agent-browser@latest",
),
InstallMethod::Bun => (
"bun",
&["install", "-g", "agent-browser@latest"],
"bun install -g agent-browser@latest",
),
InstallMethod::Homebrew => (
"brew",
&["upgrade", "agent-browser"],
"brew upgrade agent-browser",
),
InstallMethod::Cargo => (
"cargo",
&["install", "agent-browser", "--force"],
"cargo install agent-browser --force",
),
InstallMethod::Unknown => return false,
};
println!("Running: {}", display);
Command::new(cmd)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Canonical installer for the stealth fork. `upgrade` just re-runs it, so the
/// upgrade path and the install path are identical (GitHub Release, no npm).
const INSTALL_URL: &str =
"https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh";
/// Upgrade to the latest GitHub Release.
///
/// The stealth fork ships as a prebuilt binary attached to a GitHub Release —
/// NOT via the npm registry. Earlier this command (inherited from upstream)
/// ran `npm/pnpm install -g agent-browser@latest`, which installed the
/// UNRELATED upstream `agent-browser` package and clobbered the user's setup.
/// Now `upgrade` simply re-runs install.sh into the same directory as the
/// current binary, so it always tracks the freshest GitHub Release.
pub fn run_upgrade() {
let current = CURRENT_VERSION;
println!(
"{}",
color::cyan(&format!(
"Upgrading agent-browser-stealth (currently v{}) from the latest GitHub Release...",
CURRENT_VERSION
))
);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!(
"{} Failed to create runtime: {}",
color::error_indicator(),
e
);
exit(1);
});
let latest = match rt.block_on(fetch_latest_version()) {
Ok(v) => v,
Err(e) => {
eprintln!(
"{} Could not check latest version: {}",
color::warning_indicator(),
e
);
String::new()
}
};
if !latest.is_empty() && current == latest.as_str() {
println!(
"{} agent-browser is already at the latest version (v{})",
color::success_indicator(),
current
);
return;
}
let method = detect_install_method();
let method_name = match &method {
InstallMethod::Npm => "npm",
InstallMethod::Pnpm => "pnpm",
InstallMethod::Yarn => "yarn",
InstallMethod::Bun => "bun",
InstallMethod::Homebrew => "Homebrew",
InstallMethod::Cargo => "Cargo",
InstallMethod::Unknown => "",
};
if matches!(method, InstallMethod::Unknown) {
#[cfg(windows)]
{
eprintln!(
"{} Could not detect installation method.",
color::error_indicator()
"{} Automatic upgrade isn't supported on Windows.",
color::warning_indicator()
);
eprintln!(" To update manually, run one of:");
eprintln!(" npm install -g agent-browser@latest # npm");
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
eprintln!(" yarn global add agent-browser@latest # yarn");
eprintln!(" bun install -g agent-browser@latest # bun");
eprintln!(" brew upgrade agent-browser # Homebrew");
eprintln!(" cargo install agent-browser --force # Cargo");
eprintln!(" Download the latest agent-browser-win32-x64.tar.gz from:");
eprintln!(" https://github.com/leeguooooo/agent-browser-stealth/releases/latest");
eprintln!(" and replace agent-browser.exe on your PATH.");
exit(1);
}
println!("Detected installation via {}.", method_name);
#[cfg(not(windows))]
{
// Install into the SAME directory as the running binary (in-place
// upgrade), so we don't create a second copy elsewhere on PATH.
let bin_dir = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
if !latest.is_empty() {
println!(
"{}",
color::cyan(&format!(
"Upgrading agent-browser... v{} → v{}",
current, latest
))
);
} else {
println!(
"{}",
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
);
}
let install_cmd = format!("curl -fsSL {} | sh", INSTALL_URL);
println!("Running: {}", install_cmd);
let success = run_upgrade_command(&method);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&install_cmd);
if let Some(ref dir) = bin_dir {
cmd.env("AGENT_BROWSER_BIN_DIR", dir);
}
if success {
if !latest.is_empty() {
let ok = cmd.status().map(|s| s.success()).unwrap_or(false);
if ok {
println!(
"{} Done! v{} → v{}",
color::success_indicator(),
current,
latest
"{} Upgrade complete — run `agent-browser-stealth --version` to confirm.",
color::success_indicator()
);
} else {
println!("{} Done!", color::success_indicator());
eprintln!("{} Upgrade failed. Install manually:", color::error_indicator());
eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
exit(1);
}
} else {
eprintln!("{} Upgrade failed.", color::error_indicator());
exit(1);
}
}
Executable
+107
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"packageManager": "pnpm@11.1.3",
+7 -1
View File
@@ -10,7 +10,13 @@ hidden: true
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
accessibility-tree snapshots and compact `@eN` element refs.
Install: `npm i -g agent-browser && agent-browser install`
**Install / self-heal:** if the `agent-browser` (or `abs`) command is missing —
e.g. a command errors with "command not found" — install it from the GitHub
Release (no npm, no token), then retry. Do NOT fall back to other browser tools:
```bash
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
```
## Start here