Verified on Chrome 149 (unmanaged macOS): a force-install policy pointing at a
SELF-HOSTED crx is tagged [BLOCKED] in chrome://policy ("Error, Warning") — Chrome
refuses off-Web-Store force-installs on non-cloud-managed browsers. So the
self-hosted-crx approach cannot work on consumer Chrome; the extension must ship
via the Chrome Web Store (same reason codex/claude do).
- UPDATE_URL -> Chrome Web Store update endpoint; add STORE_URL (one-click Add to
Chrome) as the guaranteed path + headless fallback
- install instructions now offer: A) one-click store link, B) silent profile
force-install (works once published), with Load-unpacked as the pre-publish stopgap
- build extensions/ab-connect.zip (CWS upload package; manifest "key" kept so the
published id stays ciiljdlhdpfckdcfkphgmfalanpdejep)
- extensions/store/{SUBMISSION.html,privacy.html}: full listing copy, permission
justifications (debugger is the review-sensitive one), privacy policy
- drop dead self-hosted extensions/updates.xml; pack-extension.sh now builds the zip
Not released yet — force-install only works after the store listing is Published.
Chrome 149 killed every GUI-free way to load an *unpacked* extension into the
real profile: --load-extension removed in Chrome 142 (incl. the
--disable-features workaround), local-.crx external install blocked on macOS
since Chrome 44, remote-debugging-port killed in Chrome 136. So agents were
stuck automating the chrome://extensions Load-unpacked native file dialog —
unworkable.
`extension install` now writes a macOS configuration profile that force-installs
the signed .crx from a hosted update_url (ExtensionInstallForcelist policy). One
approval in System Settings (a single fixed Install button — cua-driver-friendly,
unlike a file dialog) → Chrome force-installs + auto-updates the extension on next
launch. No token, no per-use confirmation, and binary-install users no longer
need the extensions/ folder (crx is fetched from the URL).
- pin a stable signing key; new extension id ciiljdlhdpfckdcfkphgmfalanpdejep
- ship signed extensions/ab-connect.crx + extensions/updates.xml (raw GH host)
- scripts/pack-extension.sh re-signs with the stable key; .secrets/*.pem ignored
- uninstall removes the profile file + prints `profiles remove` hint
So an agent can operate the zero-confirmation real-Chrome feature itself:
- SKILL.md: tool matrix gains "the user's own already-open, logged-in window →
extension connect", plus a short section pointing at the flow.
- commands.md: the one-time "Load unpacked" is a privileged GUI step the CLI
can't do — call it out that the agent can perform it with a computer-use /
GUI-automation tool (cua-driver), with the live gotchas (synthetic-keystroke
tools like peekaboo don't reach Chrome; cua-driver does; the native file
dialog may need the user to pick the folder).
Completes the zero-confirmation real-Chrome feature.
- Drive the user's EXISTING logged-in tabs (not just newly-created ones):
extension attachTab now treats "already attached" (a lingering chrome.debugger
binding after a service-worker restart) as success and announces the tab
anyway, instead of skipping it. The nm-host also sends {method:"attachAll"}
when an agent-browser CDP client connects, so the daemon doesn't race an empty
target list.
- `agent-browser extension connect` auto-discovers the relay's CDP url
(~/.agent-browser/relay-cdp-url) and attaches — no copying a ws URL. Rewrites
into the normal `connect <url>` flow; `extension install/status/uninstall`
unchanged.
- Skill docs: a "drive your real, logged-in Chrome (extension)" section.
Verified end-to-end: `extension connect` listed the user's real tabs (Lark,
LINUX DO, Rakuten, Discord) and read a logged-in Lark doc's title — zero token,
zero confirmation. Full suite 768 passed.
The __nm-host now exposes a Chrome-compatible CDP WebSocket endpoint and bridges
it to the extension over native messaging via the relay translation core
(relay.rs): incoming raw CDP commands are answered locally for browser-level
Target discovery or forwarded to the extension as forwardCDPCommand; the
extension's forwardCDPEvent/results are relayed back as raw CDP.
Security without a token or user interaction: the ws URL carries an unguessable
guid and is written to ~/.agent-browser/relay-cdp-url (perms 600), so only this
user's agent-browser can drive the browser — mirroring how Chrome guards its own
remote-debugging URL.
Verified end-to-end on real Chrome: `agent-browser connect <relay-url>` then an
eval navigated a tab and read back "Example Domain | https://example.com/" —
abs → CDP → relay → native messaging → extension → chrome.debugger → real tab,
zero token, zero confirmation. Adds the tokio io-std feature for the host's
stdio.
Remaining polish: re-attach the user's EXISTING tabs after a service-worker
restart (currently attaches new tabs cleanly; existing ones need detach+reattach
since chrome.debugger may still be bound), and an `open --extension` UX that
reads relay-cdp-url so the URL isn't passed by hand.
Optimal architecture (chosen over the WS+token copy): the ab-connect extension
talks to a local agent-browser native-messaging host. No localhost port, no
token — Chrome authenticates the extension to the host by id. This is the
codex/claude-style "install once, no per-use confirmation" model.
- extensions/ab-connect: rewritten transport WebSocket+token → native messaging
(chrome.runtime.connectNative). Pinned the extension id via a manifest `key`
(→ bdoiejojpjogcjojeladhioioijhgade) so the host manifest can authorize it.
Kept the proven chrome.debugger attach + Target.attachedToTarget emulation;
dropped WS/token/options. Rebranded to "agent-browser connect".
- cli connect.rs: `agent-browser extension install` writes the native-messaging
host manifest (Chrome/Chromium/Edge/Brave) + a launcher; hidden `__nm-host`
speaks the 4-byte-length native-messaging framing.
Validated end-to-end on real Chrome: Chrome spawned the host (origin matched the
pinned id) and the extension attached the user's real logged-in tabs, streaming
Target.attachedToTarget over native messaging — zero token, zero port.
Next: bridge the host to the daemon relay (relay.rs) + CdpClient so
`agent-browser click/eval/...` drives those tabs.
Pure, unit-tested core of the daemon-side relay that bridges the ab-connect
extension to the existing CdpClient. The extension exposes per-tab
chrome.debugger + synthesized Target events; CdpClient expects a browser-level
endpoint. So RelayState:
- answers Target.getTargets / attachToTarget / setDiscoverTargets LOCALLY from
targets learned via the extension's forwardCDPEvent(Target.attachedToTarget),
returning the extension's cb-tab-N sessionId (consumes those synth events
rather than double-forwarding them);
- forwards every other command as a forwardCDPCommand envelope (carrying
method/params/sessionId);
- maps forwardCDPCommand responses and forwardCDPEvent events back to raw CDP;
- validates the connect-handshake token; emits challenge/ping.
Keeps CdpClient and browser.rs unchanged. 8 unit tests; clippy clean. Still
inert — the tokio WS server + `connect` command wire it next.
First step toward zero-confirmation direct connect to the user's real Chrome:
Chrome 136 killed --remote-debugging-port on the default profile, so the only
sanctioned way to drive the user's live logged-in window is an extension using
chrome.debugger (same approach as Codex/Claude, whose extensions are closed).
Vendors the MIT-licensed openclaw-browser-relay extension into
extensions/ab-connect/, rebranded to "agent-browser connect" (NOTICE.md keeps
attribution). It already handles the hard parts: chrome.debugger auto-attach all
tabs, new-tab auto-attach, MV3 service-worker keepalive (alarms) + reconnect,
sessionId↔tab mapping, token auth, and a CDP-over-WebSocket envelope
(connect handshake / forwardCDPCommand / forwardCDPEvent / ping-pong).
Inert for now — not wired. Next: an abs-daemon relay that speaks this envelope
and bridges it to the existing CdpClient (raw CDP), then a `connect` command.
Borrow web-access's site-experience persistence as an agent-workflow convention
(no CLI code): keep one markdown file per domain under
~/.agent-browser/site-patterns/<domain>.md. Read it before working a domain
(hints, not guarantees); update it after learning something durable — working
selectors, required hidden fields, anti-bot traps, login needs. Makes repeat
visits fast instead of re-solving the same page every run.
Borrow web-access's find-url: locate an internal system or a previously-saved
page that public search can't reach, without opening a browser.
- `agent-browser find-url <keywords> [--browser chrome|edge] [--profile X]
[--limit N] [--json]` — local command, no daemon. All keywords must match a
bookmark's name or url; results are most-recently-added first.
- Cross-platform Bookmarks JSON paths (macOS / Linux / Windows), zero new deps
(serde_json). Skips javascript:/data: bookmarklets.
- Skill docs: "pick the cheapest tool" matrix now points at find-url, plus a
commands.md section.
Bookmarks only for now — visited-history is a locked SQLite DB and would need a
SQLite dependency (deferred to avoid C-dep cross-compile risk in the release
pipeline).
Real dogfooding showed the skill pushed agents straight into the fragile
snapshot/@ref path. Reframe the core guidance toward how a developer actually
drives a real browser:
- "Pick the cheapest tool" matrix: WebSearch / WebFetch+curl for static, reach
for agent-browser only when you need a real logged-in / interactive / dynamic
browser. Plus: don't hand-build deep URLs — use links found by interacting.
- "Two ways to drive a page": structured (@ref/find) is convenient but lossy &
fragile; eval-first (`eval "<js>"`) is the real DOM — read hidden inputs,
Shadow DOM, form.elements/.validity, or el.click() directly. Drop to eval the
moment the structured path fights you, instead of retrying it.
- Escalation ladder rewritten (refs → find → CSS → eval) and a note to retry a
no-op click with AGENT_BROWSER_CLICK_MODE=dom.
Doc-only; closes the biggest part of the "abs feels worse than web-access" gap.
Real-world dogfooding surfaced clicks that resolve a valid @ref but still miss:
- Scroll the target into view before computing click coordinates
(scrollIntoViewIfNeeded). Without it, an element below the fold — or revealed
after a scroll/popup — yields off-viewport coordinates and the click lands on
whatever occupies that screen point.
- Fall back to a DOM-dispatched `.click()` when the coordinate path fails (a
persistent floating layer failing the occlusion guard, or coordinates that
won't resolve). The DOM dispatch targets the intended element directly instead
of a screen point, so an overlay or portal can't divert it.
- AGENT_BROWSER_CLICK_MODE: "" (default: scroll + coordinate + DOM fallback),
"coord" (strict coordinate, hard-fail on occlusion), "dom" (always
element.click() — best for autocomplete/menu <li> that close on input blur).
Fallback is limited to left single-clicks (DOM .click() can't express
right/middle/double). Non-left/multi and "coord" mode keep the original error.
Docs: README knob table + skill commands.md gain CLICK_MODE, a click-reliability
note, and a "debug forms/hidden inputs with eval" section (snapshot doesn't show
hidden inputs — the fast path to bugs like a hidden point_choice=none).
6 click/interaction e2e green; full suite 760 passed.
console/errors capture is off by default in this fork (Runtime.enable is a
detectable CDP signal). Update the agent-facing skill docs so agents don't
treat empty console output as a bug:
- commands.md: new "Stealth / anti-detection knobs" env-var block
(CAPTURE_CONSOLE, TIMEZONE, BLOCK_WEBRTC, HIDE_CANVAS, ADAPTIVE_REF) plus a
heads-up note; annotate the console/errors lines.
- dogfood/slack SKILL.md: note that console/errors need
AGENT_BROWSER_CAPTURE_CONSOLE=1.
- snapshot: make collect_fingerprints private (TreeNode is private, so a
pub(super) fn leaked a more-private type)
- adaptive: if-let instead of single-arm match in attr_score
- stealth: move timezone test module to end of file (items-after-test-module)
No behavior change. Pre-release cleanup.
Borrow Scrapling's adaptive element finding, adapted to this project's
in-session AX-ref model. When a saved @ref's node is gone (or its identity
no longer matches) and the role/name/nth re-query also fails, score the
current page's candidate elements against an AX fingerprint captured at
snapshot time and relocate to the best match.
- New `adaptive` module: pure, browser-free scoring (role, accessible name
via Levenshtein, AX properties, ancestor-role LCS, parent/sibling) plus
pick_best with a high absolute threshold (0.70) AND a clear margin (0.15)
over the runner-up — so ambiguous twins are refused rather than mis-clicked,
matching the existing "fail loudly over wrong click" posture.
- Fingerprint captured during the existing AX-tree snapshot walk — no extra
CDP round-trips. TreeNode is AX-only (no DOM tag/attrs), so we use AX role
as the type and a few discriminating AX properties (value/url/level/checked);
DOM id/class would have cost an N×describeNode storm per snapshot.
- Wired into both resolve_element_center and resolve_element_object_id: on a
verify-identity mismatch or a stale-node fallback miss, relocation is tried
before erroring. A confident match overrides the identity guard; otherwise
the original error is surfaced. Opt out with AGENT_BROWSER_ADAPTIVE_REF=0.
README documents the new tuning knobs. Adds 9 unit tests; full suite 760 passed.
Borrow anti-detection hardening from Scrapling/patchright, preferring native
CDP/Chrome overrides over JS lies:
- Runtime.enable is now opt-in via AGENT_BROWSER_CAPTURE_CONSOLE (default off).
It was called on every session INCLUDING CdpAttach (the user's real Chrome),
leaking the patchright/rebrowser "runtime" CDP signal and undermining the
"real browser, no lies" guarantee. Runtime.evaluate/callFunctionOn and
runIfWaitingForDebugger work without it; only console/error capture needs it.
The console/errors commands now return a hint when capture is disabled.
- Timezone alignment via native Emulation.setTimezoneOverride, opt-in with
AGENT_BROWSER_TIMEZONE=<IANA>|auto (FullLaunch only). Intl and Date both
follow with no JS artifact.
- WebRTC IP-leak handling via the --force-webrtc-ip-handling-policy Chrome
flag: auto disable_non_proxied_udp when a proxy is set (so the real IP can't
leak past the proxy); AGENT_BROWSER_BLOCK_WEBRTC=1 hides the local IP when
there is no proxy; =0 opts out.
- Opt-in canvas/audio fingerprint noise via AGENT_BROWSER_HIDE_CANVAS=1
(FullLaunch only). Session-stable seed so reads stay consistent within a
session while differing from the headless-stable hash.
Adds 5 unit tests; full suite 751 passed, 0 failed.
install.sh created `agent-browser` + `abs` but not `agent-browser-stealth`, so
users who invoke `agent-browser-stealth` (the fork's package name) weren't
getting it updated on curl-install/upgrade. Now all three names — agent-browser,
agent-browser-stealth, abs — symlink to the same binary, so an upgrade refreshes
whichever name you actually run.
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.
`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.
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.
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.
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).
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.
`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.
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.
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).
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.
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
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.
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.
- 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.
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).
Upstream cherry-picks (onto v0.27.0 base):
- security: same-origin stream command relay (#1355)
- feat: hide scrollbars in headless screenshots (#1396)
- chore: pnpm minimum release age + node pinning (#1377, fork-adapted)
Fork fixes:
- fix(connect): stop remote-debugging consent storm — is_connection_alive no
longer tears down an externally-attached browser on a transient liveness
timeout (was an endless prompt loop / browser freeze)
- fix(connect): single consenting WebSocket — drop the throwaway verify probe
so the user's one "Allow remote debugging?" click sticks to the real
connection
auto-connect resolved the DevToolsActivePort URL by first opening a
verification WebSocket (verify_ws_endpoint: connect, Browser.getVersion,
close) and only then opening the real connection. On Chrome 136+ the
"Allow remote debugging?" consent is granted per-connection, so the user's
single Allow click was consumed by the throwaway probe and the real
connection (opened afterwards) asked again — surfacing as repeated prompts
or a hung command after the user had already clicked Allow.
resolve_cdp_from_active_port now gates the direct DevToolsActivePort URL on
a consent-free TCP liveness check (tcp_port_alive) instead of a WebSocket
probe, so the real connection is the single WebSocket the user consents to.
A bare TCP connect does not trigger the consent flow (that fires on the CDP
upgrade), and the real connect_async has no client-side timeout, so it waits
for the user to click Allow at their own pace. verify_ws_endpoint removed;
discovery-order tests updated, plus a guard test that resolution opens no
WebSocket.
Verified live: single prompt on a real Chrome attach, then open + eval +
scroll x2 + eval with zero re-prompts and no freeze.
The daemon re-validates the CDP connection before every browsing command via
is_connection_alive() (Browser.getVersion, 3s timeout). It treated any
timeout-or-error as "dead" and tore the connection down + reconnected.
For an externally-attached browser (the stealth fork's default — the user's
real Chrome), a timed-out probe is almost always Chrome being briefly busy or
showing the Chrome 136+ "Allow remote debugging?" consent modal, which blocks
CDP responses until the user clicks Allow. Tearing the already-consented
connection down forces a reconnect that re-pops the consent prompt — repeated
on every command this becomes an endless prompt loop, and the close +
multiple new /devtools/browser WS probes storm Chrome into a freeze.
Fix: distinguish the probe outcome.
- Responded -> alive
- TransportError -> dead (WS closed/reset; user closing Chrome lands here too,
so zombie-socket detection is preserved)
- TimedOut -> alive for an external attach (don't tear down a consented
connection on transient slowness); dead for a browser we
launched ourselves (a real hang worth reconnecting, and no
consent modal in play).
Extracted the verdict into a pure connection_alive_from_probe() with unit
tests covering all outcomes. No behavior change for locally-launched browsers.
Cherry-picks upstream agent-browser #1396. Adds a configurable
--hide-scrollbars flag (AGENT_BROWSER_HIDE_SCROLLBARS env, hideScrollbars
config key, default true) that appends Chrome's --hide-scrollbars launch arg
for headless (non-extension) launches so native scrollbars aren't painted into
screenshots. Plumbed through flags.rs, connection.rs, main.rs, native/actions.rs
and native/cdp/chrome.rs; help text in output.rs + skill-data.
Fork adaptation:
- the arg lands in the headless && !has_extensions block, separate from the
stealth base args — no interaction with anti-detection.
- dropped upstream docs/, agent-browser.schema.json and README hunks (removed
or rewritten in this fork).
Verified: cargo check --tests passes.
Cherry-picks upstream agent-browser #1377 (chore: enforce pnpm minimum
release age), adapted for the fork:
- add .node-version (24); workflows read node-version-file instead of inline
- pin packageManager pnpm@11.1.3; drop hard-coded pnpm/action-setup versions
- pnpm-workspace.yaml: add minimumReleaseAge (48h supply-chain cooldown) +
allowBuilds allowlist, keeping our trimmed packages list (no packages/*, docs)
Deliberately dropped from upstream:
- engines.node >=24 / engines.pnpm >=11 — would impose a Node 24 floor on
end-users of the published agent-browser-stealth CLI (a compiled binary that
doesn't need it). packageManager + .node-version cover dev/CI pinning.
- docs/ and README hunks — those paths are removed/rewritten in this fork.
Two related bugs that conspired to ship stale linux binaries on
0.27.0-fork.5/.7/.8 (caught only by manually grepping the embedded
version string each release):
1. build:all-platforms used `(... & npm run build:linux & wait)`.
The bare `wait` waits for ALL children but exits with the LAST
waited child's status, not each individually. So if linux fell
over and windows succeeded last, the script reported success.
Worse, when both processes shared cli/target/ and fought over
cargo's filesystem locks, one would silently bail out and the
missing binary just stayed at the previous release's bytes.
Now serial: `npm run build:linux && npm run build:windows &&
npm run build:macos`. Costs ~3 extra minutes wall-clock vs.
parallel; trades latency for "every release ships what it says".
2. build:macos had the same `(... & ... & wait)` parallel pattern
for arm64 + x64 cross-compiles. Native cargo builds against the
same target/ dir share even more state than the docker'd Linux
build did, so the failure mode is the same. Now uses explicit
`PID1=$!; PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1`
so both must succeed.
Companion to the docker-compose $$ fix in 947d150 (which fixed the
*inside-container* wait+cp eating shell vars). This one fixes the
*outer* npm-script layer.
fork.7 caught the X mask-overlay race correctly but reported it to
the user verbatim — every transient overlay (modal backdrop, focus
ring, click-outside mask, sticky banner) became an error the user
had to wrap in their own retry loop. Most of these clear within a
frame or two on their own.
Now `verify_click_target` retries the elementFromPoint probe a few
times (default 3 × 200ms = 600ms total grace period) before failing.
Real-world overlays that blink in for a render cycle clear during
the first retry; persistent overlays still surface as errors with
the same actionable message — just qualified with "still occluded
after N retries / Mms" so the user knows we tried.
Tunable:
AGENT_BROWSER_OCCLUSION_RETRIES (default 3, 0 disables)
AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS (default 200)
DOM.resolveNode is called once outside the loop — backendNodeId is
stable across renders, only the element under (x, y) changes when
overlays flicker. Each probe is still capped at 500ms so a stuck
Runtime.callFunctionOn can't stall a click for longer than the user
expects.
Real bug behind 0.27.0-fork.5 and fork.7 shipping stale linux binaries.
Docker compose interpolates \${VAR} (and \$VAR) at YAML parse time
against the host shell — including inside `command:` blocks. So:
PID1=\$! ← compose sees \$! → host has no `!` var → ""
wait \$PID1 ... ← compose sees \$PID1 → "" → becomes `wait `
SRC="...\$TARGET..." ← \$TARGET still works (set in `environment:`)
cp "\$SRC" "..." ← \$SRC eaten → empty → cp errors silently
Result: the per-PID error check I added in dbf272c never fired
because both lines were `wait` (no args) — which waits for ALL
children and exits with the LAST one's status, not each individually.
A failing arm64 build couldn't fail the script.
Fix: escape every script-local \$ as \$\$. Docker compose translates
\$\$ → literal \$ when materializing the command for the container,
and the in-container shell then expands \$VAR correctly.
Verified by `docker compose config` showing the resolved command
contains \$\$PID1 / \$\$SRC etc (which becomes \$PID1 / \$SRC in the
container's bash).
Closes the "modal silently closes when clicking 'Add post' on a thread"
bug. Verified root cause via instrumented page-side click logger:
click @e31 (aria-label="Add post" at button (1034, 285))
→ mouse event dispatched to (1045, 296)
→ document.elementFromPoint(1045, 296) returned:
DIV[testid="mask"], bounds (0,0,1746x934)
→ X interpreted as "click outside modal" → close + nav to /home
The cached coordinates were correct. Between snapshot and click, X
laid a transient full-viewport mask over the modal (their own
"click-outside-to-close" overlay). stealth dispatched the click
without checking what was actually at that pixel — the overlay
intercepted it.
Fix: just before returning (x, y) from resolve_element_center for
ref-based interactions, run a Runtime.callFunctionOn against the
ref's resolved element with `function(x, y) { return this.contains(
document.elementFromPoint(x, y)) || that.contains(this) ? null :
{...occluder details...}; }`. If the element at the point isn't us
(or our descendant — clicking the SVG icon inside a button is fine
— or our ancestor), we fail with a specific message:
Ref @e31 is occluded by DIV[testid=mask] at the click point.
A transient overlay (modal backdrop, mask, sticky banner, etc.)
appeared between snapshot and click. Wait for it to clear or
re-snapshot, then retry.
So instead of silently submitting an entire thread or nuking the
user's modal, agent gets a parseable error and can wait + retry.
Tight 500ms timeout per CDP call (matching the verify_ref_identity
defensive guard from fork.6) so a stuck DOM.resolveNode can't
re-introduce the multi-minute hang we just fixed. On any timeout
or error in the guard itself, fall through and let the click
proceed — strictly no worse than the unguarded code path.
Disable with AGENT_BROWSER_VERIFY_CLICK_TARGET=0.
Reported: a single `click @ref` could hang 5+ minutes, with multiple
queued click invocations adding up to 7+ minutes — worst case 30s
timeout × 3 CDP calls × N parallel processes:
- verify_ref_identity (Accessibility.getPartialAXTree) → default 30s
- resolveNode / getBoxModel → default 30s
- wait_for_paint_settled (Runtime.evaluate awaitPromise) → default 30s
The latter two are best-effort defenses added in fork.3-5 to fix SPA
race / DOM-reuse bugs. They should never block a real click for
30s — the unguarded code path was always faster than the guarded
path-that-hangs.
- verify_ref_identity capped at 1s (skips check on timeout)
- wait_for_paint_settled capped at 500ms (skips wait on timeout)
Both skip-on-timeout intentionally: the worst case is the click
behaves like fork.2 (race-prone but fast), which is strictly better
than the user pkilling stuck processes.
Also rewrites the misleading "Chrome 144+ chrome://inspect tip" in
the auto-connect failure message — the toggle exposes target
discovery only, not the /json/version HTTP API the auto-connect
flow expects (verified by user: lsof shows :9222 listening but
curl /json/version returns 404).
Two latent bugs in the release pipeline that conspired to ship a stale
linux-x64 binary in 0.27.0-fork.5 (only caught by manually grepping
the embedded version string):
1. build-linux ran x64 and arm64 in parallel and used a single
`wait $PID1 $PID2` to join them. That command waits for both, but
its exit code is the LAST waited pid only — so if x64 silently
broke and arm64 succeeded, the outer script exited 0 and shipped
whatever was already in /output from the previous release. Now we
wait on each pid individually and exit 1 on either failure.
2. build-single's cp used `agent-browser*` which globs to BOTH the
binary and its `.d` dependency file. When two sources are passed,
cp requires the destination to be a directory. We weren't, so cp
exited non-zero with "Not a directory" and the build script
shrugged it off because the next line was `chmod ... || true`.
Now we resolve a single explicit source path.
Two changes that pair with each other:
1. connect_auto_with_fresh_tab now does a Runtime.evaluate "1"
round-trip after creating the fresh tab. This catches the zombie
CDP socket case (process alive, websocket dead) where every step
up to that point reports success but the next user command would
silently no-op against a dead session. Failing here lets the
caller surface a proper "CDP session unresponsive" error instead
of returning Ok and letting `agent-browser open URL` exit 0 with
a still-blank tab.
2. handle_wait now recognizes @ref selectors (e.g. `wait @e8 --gone`).
It polls resolve_element_object_id, which already runs the
verify_ref_identity check from 007fd1b — so:
- `wait @e8` succeeds while the original element is
still mounted with its snapshot role+name
- `wait @e8 --gone` succeeds when the ref's identity changes
(modal closed, button re-textified, etc.)
This gives users the "assert modal still open" primitive that
prior versions could only approximate with screenshots.
In CDP-attach mode (the default since 0.24.0-fork.1), --headed has no
effect — the user's existing Chrome is already visible, and the
generic "use 'agent-browser close' first to restart" advice doesn't
help (the new daemon attaches right back). Explicitly say --headed is
moot and point to --launch as the actual escape hatch.
Other ignored flags (--profile, --proxy, etc.) keep the existing
"close + reopen" message because for those it IS the right advice.
Closes the "click @e20 hits the sibling element" bug. Real-world
example: snapshot shows @e20=[button "Add post"] next to
@e17=[button "Post all"]. By the time you click @e20, React has
re-rendered — and React often re-uses the same <button> DOM node
across renders, just updating its accessible name. The cached
backendNodeId still resolves to a real, well-positioned node, so
the click lands cleanly. It just lands on what is now the "Post all"
button, silently submitting the entire thread instead of adding a
draft row.
Before every ref-based interaction (click / fill / type / hover /
select / drag — anything routing through resolve_element_center or
resolve_element_object_id), call Accessibility.getPartialAXTree for
the cached backendNodeId and check role + name still match the
snapshot entry. On mismatch, abort with an error that names both
labels:
Ref @e20 no longer matches its snapshot. Was [button "Add post"],
now [button "Post all"].
...Take a fresh snapshot, then re-target.
If the node is gone (CDP fails / no AX node), we silently fall
through to the existing "find by role+name" recovery path, so this
guard never makes a working flow worse.
Adds one CDP roundtrip per ref interaction (~5–20ms). Disable with
AGENT_BROWSER_VERIFY_REF=0 if you control the page lifecycle and
need the latency back.
Pairs with the click paint-settle fix: even with that, a thread builder
that clicks "Add post" can race a misbehaving handler that closes the
parent modal instead of mounting the next textbox. To make that case
observable instead of silently corrupting the next inserttext, you can
now write:
click @add-post
wait .modal --gone --timeout 2000 # asserts modal stays mounted
inserttext "tweet 3"
If the modal vanished, `wait --gone` succeeds — flip the assertion to
`wait .modal` (default visible) to fail-fast on disappearance.
Implementation just sets `state: "detached"` (or "hidden") on the wait
command — daemon-side `wait_for_selector` already supported these
states; only the CLI parser was missing the user-facing flag.
Also accepts `--detached` as alias for `--gone` to match the daemon's
internal vocabulary.
Closes a real-world race that broke X multi-tweet thread composition
(and similar SPA flows): clicking "Add post" returned immediately,
inserttext fired before React had committed the new textarea, the
keystroke landed on the dialog wrapper, and X interpreted the stray
input as a request to dismiss the modal.
After mouseReleased we now wait for two requestAnimationFrame ticks
plus a microtask boundary (~33ms at 60fps, bounded). That's enough
for React/Vue/Svelte to commit any state update scheduled by the
click handler. Errors during the wait are swallowed — a click never
fails because of post-processing.
Opt out for perf-sensitive scripts that don't drive SPA UIs:
AGENT_BROWSER_CLICK_WAIT_STABLE=0
The previous lockfile had ~11k lines of transitive deps for
packages/dashboard which we deleted in 86c4cff. Re-running pnpm install
shrinks it to ~24 lines (just husky for git hooks).
Before: after `npm i -g` upgrade, the next agent-browser command would
detect daemon version mismatch, kill the old daemon, spawn a fresh one,
and connect to a brand-new about:blank tab. The user's previous
navigation state was silently lost — `get url` returned about:blank
even though the user's Chrome was still on the same page.
Now: before killing the old daemon, the CLI synchronously asks it for
its current URL via the existing socket. If non-empty and not
about:blank, it's persisted to a `.restore-url` sidecar in the socket
dir. After the new daemon spawns and auto-connects, it reads the
sidecar (read-and-delete), navigates the fresh tab to the saved URL,
and prints `⚠ Restored previous URL: <url>`.
Manual `agent-browser close` does NOT write the sidecar, so a clean
shutdown won't trigger surprise navigation. The sidecar is consumed on
read regardless of whether navigation succeeded, so a stale entry
can't haunt later auto-launches.
Before, `agent-browser find role button --name Submit` errored at the
daemon side with the cryptic `Unknown subaction: --name`. Now it errors
at parse time with the offending flag echoed back, the list of valid
actions (click, fill, check, hover, text), and a "Did you mean" hint
showing where to put the action verb.
Backwards compat: `find role button` (no flags, no action) still
defaults to click — only `--xxx` in action position errors.
npm 10+ strips bin paths starting with ./ as invalid, leaving the
package with no executable entries (so `npm i -g` doesn't put any
binary on PATH). Match the upstream form `bin/agent-browser.js`.
- Add fork binary names (agent-browser-stealth, abs) to allowed-tools
in all 6 SKILL.md files so installs into Claude Code / Cursor don't
prompt for permission on every command
- Document `npx skills add leeguooooo/agent-browser-stealth` in README
- Bump README upstream-base mention from v0.24.0 to v0.27.0
These directories are TypeScript-side tooling that the fork dropped at
v0.24.0 to keep the repo focused on the stealth CLI binary. Upstream
either kept evolving them (docs, packages/dashboard) or added new ones
(evals/) — they came back during the v0.27.0 rebase, so prune again.
Also include skill-data/ in package.json `files` so the specialized
skills (electron, slack, dogfood, etc.) that upstream relocated from
skills/ to skill-data/ still ship in the npm tarball.
Key insight: ANY JS-level modification to navigator.webdriver is detectable
by creepjs's lieProps system. The only undetectable approach is
Emulation.setAutomationOverride at the CDP protocol level, which tells
Chrome to natively return false for navigator.webdriver.
In CdpAttach mode, we now inject ZERO JavaScript patches — the browser's
real fingerprint is already perfect. Only the CDP protocol command is needed.
CreepJS results now match manual Chrome exactly:
- 0% headless (was 33%)
- 0% stealth (unchanged)
- 25% like headless (Chrome baseline, same as manual)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CreepJS detects three things for webDriverIsOn:
1. Property deletion (navigator.webdriver === undefined)
2. Value check (!!navigator.webdriver)
3. Lie detection (descriptor tampering via lieProps)
Changed from delete/defineProperty-value approach to replacing the CDP
getter with a getter returning false, matching the native descriptor shape.
Note: 33% headless in CreepJS is a CDP-inherent signal (lieProps detects
the getter replacement). This cannot be eliminated at the JS layer since
CDP sets the webdriver getter before init scripts run. Real-world impact
is minimal — Cloudflare Turnstile passes successfully.
Also confirmed: Chrome's remote_debugging preference in Local State
persists across restarts, so users only need to enable CDP once via
chrome://inspect/#remote-debugging.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CdpAttach mode: only removes navigator.webdriver (user's real Chrome
already has genuine fingerprint, heavy patches create detectable lies)
- FullLaunch mode: applies all 32 patches (new Chrome needs full coverage)
- Improved webdriver removal: uses Object.defineProperty to override CDP
getter on Navigator.prototype, not just delete
- CreepJS results: 0% stealth (was 20%), hasIframeProxy: gone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Auto-connect is now ON by default (was opt-in via --auto-connect)
- Added --launch/--new flags to explicitly start a fresh browser
- CI environments (CI env var) automatically use --launch mode
- Friendly error message with platform-specific Chrome relaunch guide
- Mentions Chrome 144+ runtime CDP toggle (chrome://inspect)
- --cdp and --provider flags implicitly disable auto-connect
- AGENT_BROWSER_NO_AUTO_CONNECT=1 to disable, AGENT_BROWSER_FORCE_LAUNCH=1 to force
Track 3 of native-stealth migration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Created cli/src/native/stealth.rs with stealth JS injection via CDP
- Extracted 32 patch IIFEs from TS stealth.ts into stealth_scripts.js
- Injected via Page.addScriptToEvaluateOnNewDocument on every launch/connect
- Added stealth Chrome args (disable AutomationControlled, use ANGLE GL)
- Auto-detects and cleans HeadlessChrome from User-Agent string
- Overrides navigator.userAgentData high-entropy hints
- Stealth enabled by default, disable with AGENT_BROWSER_STEALTH=0
Track 2 of native-stealth migration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>