Compare commits

...
57 Commits
Author SHA1 Message Date
leeguooooo 5b4ffdb2bb chore(release): 1.4.0 — no-hijack open + tab adopt-by-targetId + keydown/keyup docs + canvas hint + all-component version coherence (doctor)
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
2026-06-13 23:45:30 +09:00
leeguooooo 62e7229b47 feat(version): extension reports its version; doctor shows all-component coherence
The upgrade story spanned four parts (CLI, daemon, extension, skill) with no
single view and — worst — the extension was a total black box: nothing reported
which build was live, so a user could sit on a stale extension with zero signal.

- ext (0.4.7): on connect the extension sends a `hello` with
  chrome.runtime.getManifest().version; the native-messaging host records it to a
  `relay-ext-version` sidecar (next to relay-cdp-url, removed on exit).
- build.rs embeds the shipped extension version (AB_CONNECT_VERSION, read from the
  ext manifest at compile time) so the CLI knows what extension it expects.
- `chrome-use doctor` gains a Versions section: CLI (vs the cached latest from the
  background update check), extension (connected version vs the bundled expected —
  warns + tells you to reload it in Chrome if behind), and skill (bundled, version-
  locked; `skills add` copies may be stale). Daemon coherence was already covered.

So 'which of the four parts is on what version, and what needs upgrading' is now
one command. Verified: doctor warns on a simulated old extension and passes on a
current one; gracefully shows 'not connected / predates reporting' when the host
hasn't learned a version yet.
2026-06-13 23:41:16 +09:00
leeguooooo 23ab4ce68f fix(relay): don't hijack a user tab on open; surface keydown/keyup + canvas hint
Dogfooding a canvas game over the extension relay surfaced three issues:

1. (serious) A fresh relay session's first `open` navigated one of the USER's
   existing tabs instead of opening its own — in testing it replaced a
   half-filled form with the target site. On connect the daemon passively
   attaches to the user's tabs and pinned one as active; navigate() then drove
   it. Now: on the relay (agent_group set), if the active tab isn't one this
   session created, navigate() opens its own tab in the session's group first.
   Off the relay (a browser we launched) reusing the active tab stays correct.
   Pure helper active_index_is_owned() + regression tests.

2. (discoverability) `keydown <key>` / `keyup <key>` (hold-to-move, essential
   for games/shortcuts) already existed as commands+daemon handlers but were
   absent from --help and the skill, so they were undiscoverable. Documented in
   --help, the core skill, and the canvas-app hint.

3. (UX) Canvas/WebGL pages expose almost no a11y tree, so `snapshot` is empty
   and agents get stuck hunting refs. snapshot now detects a viewport-dominating
   canvas with a sparse tree and prints a hint pointing at the screenshot +
   coordinate-click + keydown/keyup path.

Verified live over the relay: `open` now lands the game in its own new tab with
the user's tabs (incl. the Rakuten recovery form) untouched; the canvas hint
fires on the game page; `close` cleans up only the session's own tab.
2026-06-13 23:27:17 +09:00
leeguooooo e272546b5c chore(release): 1.3.0 — daemon restart/status (#20.2) + live tab resync, adopt-by-targetId, open --reuse-tab (#21)
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
2026-06-13 17:16:49 +09:00
leeguooooo c7de19b099 feat(tabs): live tab resync + adopt-by-targetId + open --reuse-tab (#21)
Multi-session over one relayed Chrome had a tab-identity fracture: each daemon
discovered targets ONCE at connect and assigned its own t<N> indices, so a tab
filled in session A was unreachable from session B — B saw a disjoint/blank set
and rebinding via 'open' piled up duplicate tabs. A stranded, still-filled tab
could not be finished from any other session.

- 'tab list' now re-syncs the live target set on every call: adopts tabs other
  sessions opened (or that re-attached after a cross-process nav), drops gone
  ones (clears phantom rows), and refreshes url/title from each live tab via
  Target.getTargetInfo (the relay only stamps target_info on attach, so it goes
  stale/blank after navigation — which made rows indistinguishable).
- 'tab list --full' now prints each tab's stable CDP targetId. Unlike t<N>
  (per-session, reassigned each connect), targetId is stable across every session
  on the relayed Chrome.
- 'tab <targetId>' adopts a specific pre-existing tab — including another
  session's — WITHOUT reloading, so a half-filled form survives. handle_tab_switch
  resyncs first, then resolves a raw targetId before falling back to t<N>/label.
- 'open <url> --reuse-tab' (alias --reuse) switches to an existing tab already on
  that URL (matched by origin+path, ignoring volatile query/fragment) instead of
  spawning a duplicate.

Verified live over the extension relay: a fresh session's 'tab list --full' lists
the user's real tabs with correct titles + full URLs + targetIds, and
'tab <targetId>' lands on and reads the exact stranded Rakuten account-recovery
form from the report. Unit tests cover URL normalization + --reuse-tab parsing;
full suite green. Docs: --help Tabs section + core skill multi-session guidance.
2026-06-13 17:11:38 +09:00
leeguooooo 6b9de10c73 fix(ab-connect): transparently re-attach a stale cb-tab session before failing (0.4.6, #20.1)
When a tab navigates across render processes (e.g. an SSO redirect to another
origin like login.account.rakuten.com), the debugger handle detaches and the
session drops out of the relay maps, so the next command dead-ends with
'stale sessionId ... its tab is gone' — even open/navigate, which should always
be able to drive the tab. But cb-tab-<tabId> encodes the STABLE Chrome tabId
(#17), and the tab itself usually survives the nav.

So before throwing, recoverSessionTab() parses the tabId out of the session,
checks the tab still exists + is eligible, and re-attaches (attachTab re-mints
the identical cb-tab-<tabId> session, keeping the daemon's binding valid), then
the in-flight command retries against the recovered tab. Complements the 0.4.5
onDetach proactive re-attach: that heals on the detach event, this heals lazily
on the next command if the event was missed. Falls back to the original error
only when the tab is genuinely gone (closed/restricted).
2026-06-13 16:50:07 +09:00
leeguooooo 63e0dd5921 docs(skill): document single-session relay limit — no cross-session tab reads (#20.3) 2026-06-13 16:43:21 +09:00
leeguooooo c0ee65d0d8 feat(cli): add 'chrome-use daemon restart|status' to reset stuck session state
A mid-session 'chrome-use upgrade' (or a crashed worker) can leave per-session
daemons holding stale/cross-leaked tab handles, and the only fix was hunting
PIDs with pgrep/kill. Add a first-class command:

- 'daemon restart' kills every session daemon worker (SIGTERM→SIGKILL +
  sidecar cleanup) but leaves the Chrome-launched __nm-host bridge alone, so
  the extension relay stays up — the next command spins a fresh, clean daemon
  against the same live Chrome. Closes no tabs.
- 'daemon status' lists running session daemons (pid + version) and relay state.

Wires connection::restart_all_daemons(), skips the command in the update-notify
nag, documents it in --help and the core skill. Unit tests cover the empty case
and a live-session kill (spawns a real child, asserts it's reaped + sidecars
cleaned). Issue #20.
2026-06-13 16:42:53 +09:00
leeguooooo 7601919a04 fix(ab-connect): auto-reattach on cross-process detach (Rakuten SSO #19 follow-up)
v1.2.3's stable per-tab session id (#17) fixed sessionId STABILITY, but nothing
re-attached when an origin swaps the render process (e.g. the
login.account.rakuten.com SSO redirect — full-page nav + OOPIF). chrome.debugger
detached, the tab survived, but only onUpdated('complete') could re-attach — and
for that flow it didn't, so the session went permanently stale (even
open/navigate failed, retries didn't recover).

onDetach now proactively re-attaches the surviving tab (retry w/ backoff for the
swapped-in process to settle; skips user/DevTools-initiated detaches), so the
stable cb-tab-<tabId> session is restored and commands self-heal. Extension
0.4.4 → 0.4.5; needs a Web Store republish + dogfood on the Rakuten flow.
2026-06-13 16:14:15 +09:00
leeguooooo 345c0d62a2 ci(release): checkout repo in the release job so the changelog isn't empty
The changelog step lived in the separate `release` job (needs: build), which
had no checkout — so git ran with no repo ('fatal: not a git repository') and
the body came out empty. Add a fetch-depth:0 checkout to that job; drop the
now-pointless fetch-depth:0 from the build job.
2026-06-13 15:55:46 +09:00
leeguooooo 0644fb2d0b chore(release): 1.2.3 — bringToFront command + tab list --full untruncated URLs (#19)
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
2026-06-13 15:45:03 +09:00
leeguooooo 1ea6b1a2c5 fix(cli): add bringToFront command + tab list --full untruncated URLs (issue #19)
Two SPA-SSO debugging gaps:
- The core skill referenced `bringToFront` but the CLI parser never mapped it
  (the daemon handler existed) → 'Unknown command'. Wire up
  bringToFront / bring-to-front / bringtofront → the existing action.
- 'stale sessionId — re-open your target URL' recovery was impossible because
  `tab list` truncates long URLs with '…', cutting client_id/state out of SSO
  links. Add `tab list --full` (also `tab --full`) to print untruncated URLs;
  SKILL.md documents the recovery (full URL + re-open the stable entry URL).

The stale-session itself auto-recovers via the stable per-tab relay session id
(#17, extension 0.4.4). Parse tests for both new forms; verified live.
2026-06-13 15:44:25 +09:00
leeguooooo 7bb50d54b3 ci(release): fetch tags before building changelog (was empty)
v1.2.2's auto-changelog came out empty: in a detached-HEAD tag checkout the
tag refs git describe/git log need aren't reliably present even with
fetch-depth:0. Fetch them explicitly first.
2026-06-12 22:55:47 +09:00
leeguooooo e8864c96e2 chore(release): 1.2.2 — non-blocking 'update available' notice + release changelogs
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
- feat(cli): non-blocking update-available notice (stderr, once/day, opt-out) so users learn to upgrade
- ci(release): auto-generate changelog from commit log on every release
2026-06-12 22:47:03 +09:00
leeguooooo 8432f6cd69 feat(cli): non-blocking 'update available' notice so users know to upgrade
The CLI ships as a GitHub Release binary with a manual `chrome-use upgrade`,
but nothing told users a newer version existed — so releases didn't reach them.

Add a lightweight update check: each run reads a cached latest-version and, if
it's newer than the running binary, prints a one-line hint to STDERR (never
stdout, so --json stays clean): "⚠ chrome-use X.Y.Z is available — run
chrome-use upgrade". The cache is refreshed at most once a day by a DETACHED
`__update-check` worker (curl → GitHub latest release), so the current command
never waits on the network. Skipped for meta commands (upgrade/install/doctor/
__*/--version/--help), in CI, in daemon mode, and via
CHROME_USE_NO_UPDATE_CHECK / AGENT_BROWSER_NO_UPDATE_CHECK.

Verified: nag shows for a newer cached version, suppressed by the opt-out env +
on meta commands + when up-to-date; the detached worker writes the real latest
tag from the GitHub API.
2026-06-12 22:46:44 +09:00
leeguooooo a8089310a6 ci(release): build changelog from commit log (not PR-only notes)
GitHub's generate_release_notes only lists merged PRs — near-empty for this
commit-to-main repo, so releases still showed nothing. Render the
conventional-commit subjects since the previous tag instead, and full-clone
(fetch-depth:0) so the diff is available.
2026-06-12 18:18:11 +09:00
leeguooooo ab92d2590b ci(release): auto-generate release changelog (commits + merged PRs since last tag)
GitHub Releases had an empty body — you couldn't tell what changed between
versions. Add generate_release_notes:true so every release ships an
auto-generated changelog.
2026-06-12 18:15:18 +09:00
leeguooooo c1417c3c70 chore(release): 1.2.1 — relay tab-drift pin on open (#14/#18) + stable per-tab relay session (#17)
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
- fix(relay): pin active target on open so commands don't drift tabs (#14, #18)
- fix(connect): stable per-tab relay session id — re-attach auto-recovers (#17)
- docs: chrome-use test in README
2026-06-12 18:08:49 +09:00
郭立lee 7cb69bd444 fix(relay): pin active target on open so commands don't drift tabs (#14) (#18)
When connected to the user's real Chrome via the extension relay, sequential
commands could land on the wrong tab: `get url` returned x.com/home, then with
no navigation in between `eval` executed against x.com/notifications — so it
read the wrong page and returned nothing.

Root cause: the session's anti-drift anchor is `active_target_id` (pinned by
stable target_id), documented to be set "on every explicit open". But `open`
runs through `navigate()`, which never pinned. On the relay path `open` reuses
an existing tab via `navigate` rather than `add_page` (the only "explicit" path
that pins), so `active_target_id` stayed `None` and the session rode the fragile
`active_page_index`. A later passive tab close/reorder (drained before every
command) then drifted `eval`/`get url`/`snapshot` onto a foreign tab.

Fix:
- `navigate()` now syncs the index to the resolved active page and pins it by
  target_id after a successful navigation — restoring the "pin on explicit open"
  invariant for the relay path.
- `ensure_page()` pins its freshly-created tab too (matches `add_page`).
- Extract the pin-vs-index resolution into a pure `resolve_active_index()` and
  cover the invariant with unit tests (pin beats stale index; falls back when
  the pin is gone; survives passive background-tab discovery).

cargo fmt + clippy -D warnings clean; full suite 816 passed.
2026-06-12 17:02:21 +08:00
leeguooooo fb27835ebc fix(connect): stable per-tab relay session id — re-attach auto-recovers (#17)
When a tab's chrome.debugger session was torn down and re-established
(cross-process navigation, MV3 service-worker restart wiping the in-memory
maps, DevTools stealing the debugger), the extension minted a brand-new
monotonic `cb-tab-N` for the same tab. The daemon stays bound to the old id and
the relay consumes attach/detach events without telling it to rebind, so the
session was orphaned permanently → `stale sessionId / tab is gone`, and re-open
never recovered.

Derive the session id from the STABLE Chrome tabId (`cb-tab-<tabId>`) instead.
Any re-attach of the same tab now restores the SAME session the daemon already
holds, so eval/snapshot transparently follow the new page after a navigation.
Extension 0.4.3 → 0.4.4. Adds a relay unit test for the detach→reattach-same-
session recovery contract.
2026-06-12 17:43:48 +09:00
leeguooooo 2859da7b7c docs: document chrome-use test in README + point core skill at it 2026-06-12 17:33:24 +09:00
leeguooooo 9ba43e0cbd chore(release): 1.2.0 — chrome-use test (browser test suites)
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
2026-06-12 17:27:38 +09:00
leeguooooo d740884299 feat(test): chrome-use test <suite.yaml> — re-runnable browser test suites
Turn repetitive browser checks into unit-test-style YAML suites. Steps reuse
chrome-use's own commands; assertions (url/visible/hidden/text/count/eval)
compile to a single truthy `eval`. The runner re-invokes the binary per step
(inherits all flag/launch/daemon/ref semantics; the daemon stays up so each
step is a fast socket call), launches an isolated browser by default, captures a
screenshot on failure, and exits non-zero for CI. `setup: account:` injects a
cookie-use login. Ships a `test` skill (skills get test). Unit-tested step/assert
compilation.
2026-06-12 17:27:38 +09:00
leeguooooo db484f2ac9 fix(cli): absolute screenshot path (#16) + show relay in session list (#15)
#16: handle_screenshot now returns a canonicalized ABSOLUTE path, so the
`✓ Screenshot saved to …` line is the same regardless of process cwd and the
agent can read the file without guessing the cwd.

#15: `session list` now reflects the extension-relay connection — when the relay
is up it shows the active session as `(relay/extension → live Chrome)` instead
of "No active sessions", and the --json output gains a `relay` bool. Stops agents
misjudging a live relay connection as down.
2026-06-12 16:43:39 +09:00
leeguooooo b475038e25 fix(upload): actionable error when file upload hits the extension-relay limit (#13)
DOM.setFileInputFiles is forbidden by Chrome's chrome.debugger API, so upload
always fails over the extension relay with an opaque -32000 "Not allowed". Map
it to a clear message: file upload needs a --launch/direct-CDP session, and
point at the cookies export|set --curl workaround. Note the limit in the core
skill upload line too.
2026-06-12 16:09:55 +09:00
leeguooooo 545e2545b4 fix(cookies): drop needless return in transfer arm (clippy -D warnings, CI red)
The cookies transfer arm's tail `return Ok(...)` tripped clippy::needless_return,
failing the CI lint gate (-D warnings). Make it a tail expression.
2026-06-12 15:49:16 +09:00
leeguooooo 5f342e34a2 docs(store): rewrite submission guide for rename-existing-item flow
The CWS rename to chrome-use updates the EXISTING item (knfcmbam…) with a
key-stripped package, not a new key-locked item — existing users auto-update and
reviews are kept. Native host allow_origins already lists both ids so the relay
doesn't break. Also point the icon/screenshots section at the generated assets.
2026-06-12 15:43:43 +09:00
leeguooooo 4106a151a1 feat(connect): install + recognize BOTH native-messaging host names (staged extension migration)
Stage 1 of the agent-browser → chrome-use extension migration: the CLI now
writes a host manifest under both com.agent_browser.connect (extension ≤0.4.2)
AND com.leeguoo.chrome_use (the rebrand 0.5.0+), both pointing at the same
launcher, and host_installed()/uninstall recognize both. So the relay works no
matter which extension version a user has, with no forced re-install — which
lets the store roll 0.4.3 (cosmetic name only, host unchanged) and later 0.5.0
(new host) without ever breaking the relay or re-popping the consent dialog.
2026-06-12 14:50:42 +09:00
leeguooooo eb60053183 fix(launch): serialize concurrent same-profile launches (issue #11)
N parallel `open --profile <same>` (e.g. chatgpt-imagegen's web backend firing
3 image gens at once) collided on the profile-copy disk I/O and Chrome's profile
lock: every candidate burned its full ~30s launch timeout and ALL failed (0
success), because the loser instances hung without writing DevToolsActivePort.

ProfileLaunchLock takes a cross-process flock on a per-resolved-profile lock
file, held across the copy + launch until Chrome is up, so concurrent
same-profile launches queue instead of colliding — the storm becomes
all-succeed-serially instead of all-fail. The kernel releases the lock when the
holder exits, so a crash can't wedge the queue; acquisition is best-effort
(launch proceeds unlocked if it can't be taken). Uncontended single launches
are unaffected.
2026-06-12 14:40:03 +09:00
leeguooooo 2aa216dd7a fix(config): brand-compat config dir (~/.chrome-use ⇄ ~/.agent-browser) so the relay survives the rename
After the agent-browser → chrome-use rename, the new binary used ~/.chrome-use
+ host com.leeguoo.chrome_use and couldn't find the relay that the still-old
native-messaging host wrote to ~/.agent-browser → it fell back to raw
--remote-debugging-port and re-popped 'Allow remote debugging?'.

- config_home()/config_dir_basename(): decide once per run — prefer the new
  .chrome-use, but keep using an existing .agent-browser install if that's the
  only one present; fresh installs get .chrome-use. get_socket_dir() routes
  through it so sockets/state are consistent within a run.
- relay_url_path(): the relay-cdp-url is a cross-binary handoff (host writes,
  CLI reads), so read from whichever brand dir actually has the file
  (~/.chrome-use OR ~/.agent-browser).

Combined with keeping HOST_NAME=com.agent_browser.connect (b6febbe), the renamed
chrome-use binary now relays through the existing ab-connect 0.4.2 extension
with zero dialog. Verified live: chrome-use found ~/.agent-browser/relay-cdp-url
and listed the user's real tabs, no consent dialog.
2026-06-12 14:29:21 +09:00
leeguooooo b6febbef39 fix(connect): keep native-messaging host as com.agent_browser.connect (no relay break)
Reverting the host-name rename from the chrome-use rebrand. The host name is
invisible internal plumbing (lives only in NativeMessagingHosts/*.json and the
extension), so renaming it to com.leeguoo.chrome_use bought nothing user-facing
but broke the relay for every existing user: the new chrome-use binary couldn't
find a matching host/extension, silently fell back to raw --remote-debugging-port,
and re-popped the 'Allow remote debugging?' consent dialog.

Keeping com.agent_browser.connect means the renamed chrome-use binary keeps
working with the already-installed host json and the live ab-connect 0.4.2
extension — zero relay break, no dialog, and the store republish becomes an
OPTIONAL cosmetic display-name update (manifest bumped 0.5.0 → 0.4.3, name stays
chrome-use). Only the binary/command name changed for users.
2026-06-12 14:07:10 +09:00
leeguooooo 5addb94dc4 chore(release): 1.1.0 — cross-profile cookies export/transfer
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
2026-06-12 13:59:38 +09:00
leeguooooo f76ed1ddf5 feat(cookies): cross-profile cookies export / cookies transfer
Transferring a logged-in session between Chrome profiles previously needed
an ad-hoc external script to decrypt the source profile's cookie store. Make
it first-class:

- `cookies export --from <profile> [--domain <d>[,<d>]]` decrypts another
  profile's on-disk cookies and prints CDP-shaped JSON for `cookies set --curl`.
- `cookies transfer --from <profile> [--domain <d>]` exports + injects into
  the connected browser in one shot (reuses the cookies_set path).

Source profile is resolved by directory name, display name, or "auto". The
store is copied to a temp file (immune to a running Chrome's lock/WAL), read
via sqlite3, and values are decrypted (macOS v10: AES-128-CBC, key from the
shared 'Chrome Safe Storage' Keychain entry). httpOnly/secure/per-domain
auth cookies round-trip intact; SameSite=None without Secure is downgraded
so CDP accepts it. macOS only for now (clear error elsewhere).
2026-06-12 13:59:37 +09:00
leeguooooo 7ba82bc6cc art: redo all README illustrations in crude MS-Paint style
Regenerated hero, fingerprint, how-it-works, architecture, and shield as
deliberately crude mouse-drawn Windows-Paint doodles on white — big blocky
flood-fill colors, wobbly aliased outlines, low-res, intentionally rough.
2026-06-12 13:38:12 +09:00
leeguooooo 61060486f4 rebrand: agent-browser-stealth → chrome-use, de-fork, reset to v1.0.0
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
Standalone product rename across the whole repo (issue: project identity):

- Binary/package/repo/skill/docs: agent-browser[-stealth] → chrome-use
  (single binary name `chrome-use`; old aliases agent-browser/abs dropped).
- Version: 0.27.0-fork.51 → 1.0.0 (drop the upstream-fork counter).
- Native-messaging host: com.agent_browser.connect → com.leeguoo.chrome_use
  (CLI + ab-connect extension in lockstep — this is a breaking handshake change,
  extension bumped 0.4.2 → 0.5.0, needs a Web Store republish).
- Config dir: ~/.agent-browser → ~/.chrome-use.
- README/zh: reframed from "stealth fork of agent-browser" to a standalone
  product with a small `originally based on vercel-labs/agent-browser` credit.
- Kept AGENT_BROWSER_* env vars working (63 vars across the codebase; renaming
  them would break every existing script/skill for no user-facing gain).

Build green, 802 unit tests pass, fmt + clippy clean. Upstream attribution to
vercel-labs/agent-browser preserved.
2026-06-12 12:56:21 +09:00
leeguooooo b4c1707a01 fix(open): graceful load-timeout + --wait-until override for SPAs (issue #10)
`open` waits for the `load` event by default. SPAs whose `load` never fires
(a long-pending XHR or a stuck sub-resource holds it open) made `open`
hard-fail after the lifecycle timeout — even though the DOM was ready and
eval/screenshot worked immediately right after.

- Graceful degradation: if the lifecycle event times out but document.readyState
  is interactive/complete, navigate returns success carrying a `warning` in the
  response (the CLI prints it to stderr; --json keeps the field) instead of
  erroring. Only a still-loading document is a real failure.
- `open/goto/navigate` now accept `--wait-until <load|domcontentloaded|
  networkidle|none>` so SPAs can return as soon as the DOM is parsed. The URL
  parser skips the --wait-until value so it isn't mistaken for the URL.
- WaitUntil::as_str() for the warning label; output.rs surfaces response warnings.

Verified live: --wait-until domcontentloaded returns immediately on a page whose
load never fires; default load on the same page now succeeds at the timeout with
a clear stderr warning instead of failing. Adds parse tests for both arg orders
+ bogus value.
2026-06-12 12:19:36 +09:00
leeguooooo 266b610358 feat(launch): label the throwaway --launch profile + document escape hatches (issue #9)
A bare --launch opens an isolated empty profile (no cookies/login/
extensions). A human watching the desktop sees a mystery Chrome window
under an unfamiliar profile and reads it as broken/suspicious.

- Seed the temp profile's Local State (profile.info_cache.Default.name,
  the field Chrome's profile chip reads) + Default/Preferences with
  'agent-browser (<session>)', so the window self-identifies which agent
  session owns it.
- Rewrite the --launch warning to explain it's an isolated test profile and
  point at the escape hatches: --profile auto / AGENT_BROWSER_PROFILE=auto
  to reuse real Chrome, and --args "--load-extension=<dir>" for extensions.
- SKILL.md documents the same.

Adds a unit test for the profile-label writer.
2026-06-12 12:07:46 +09:00
leeguooooo 36f9b99549 docs(skill/help): document fork.51 features — coordinate click, aliases, stale-sessionId + @url drift checks
SKILL.md + click --help + README now cover what agents could otherwise
only discover by trial:
- coordinate click (click <x> <y> / <x>,<y> / --coords) as a first-class form
- tabs / get-text aliases
- the 'stale sessionId … re-open your target URL' relay error and how to recover
- eval/screenshot/network '@ <url>' stamps as a per-read wrong-tab sanity check
- network requests --clear as the 'start capturing fresh' step
2026-06-12 09:56:02 +09:00
leeguooooo 0cf7de2dd6 style: rustfmt the issue #7 regression tests (CI format gate)
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
2026-06-12 01:42:36 +09:00
leeguooooo 658bf4226f chore(release): 0.27.0-fork.51 — --launch Illegal-invocation fix, tab-pin hardening (#7), field-report ergonomics + observability (#8)
- fix(stealth): bind iframe contentWindow proxy methods to the real window
  (--launch "Illegal invocation" on srcdoc-iframe pages)
- fix(tabs): re-pin active target when the pinned page is removed (issue #7)
- feat(cli): coordinate click (click <x> <y> / --coords), tabs/get-text
  aliases, clearer find error (issue #8.4)
- fix(observability): screenshot/network stamp @ <url>; network --clear
  enables capture immediately (issues #8.1/#8.3)
- fix(ab-connect 0.4.2): stale sessionId fails loudly instead of routing to a
  random tab (issue #8.1) — needs a Chrome Web Store republish
- restart notice now flags in-memory context reset (issue #8.2)
2026-06-12 01:39:29 +09:00
leeguooooo 37cd9b91e1 fix(ab-connect): fail loudly on a stale sessionId instead of routing to a random tab (issue #8.1)
handleForwardCdpCommand fell through to anyConnectedTab() when a
daemon-supplied sessionId didn't map to an attached tab, so eval/screenshot/
network silently ran on an arbitrary tab — the root of "eval ran on the
wrong page, no warning" and the blank-screenshot-after-restart symptom.

Now: a provided sessionId/targetId MUST resolve to a real tab or the command
throws an actionable error ("stale sessionId … re-open your target URL").
anyConnectedTab() is only used for genuinely browser-level commands that
specify neither. Manifest 0.4.1 → 0.4.2 (needs a Chrome Web Store republish
for installed users to pick this up).
2026-06-12 01:34:15 +09:00
leeguooooo b2c4aa0004 fix(observability): stamp page URL on screenshot/network; enable capture on --clear (issue #8)
Field report #8: in extension-relay sessions, reads (eval/screenshot/network)
could silently run against whatever tab drifted into focus, with no signal,
and network capture was intermittently empty.

- #8.1: screenshot and `network requests` now print `screenshot @ <url>` /
  `network @ <url>` to stderr (mirrors the existing `eval @ <url>`), and the
  responses carry `origin`. A read against the wrong/drifted tab — and the
  "0 captured" vs "wrong page" ambiguity — is now obvious.
- #8.3: `network requests --clear` now enables Network capture immediately
  instead of lazily on the next read, so requests fired between `--clear` and
  the following read are tracked (fixes the "No requests captured" on first
  try, works on retry" race). Extracted enable_request_tracking helper.
- #8.2: the daemon version-mismatch restart notice now spells out that
  in-memory context (active tab, refs, captured requests) is reset and tells
  the user to re-open the target URL if the next read looks blank/wrong.

Verified on a launched browser: coordinate clicks land, screenshot/network
stamps appear, and a fetch after --clear is captured on the first read.
2026-06-12 01:34:15 +09:00
leeguooooo ec8d01ef4c feat(cli): coordinate click + command aliases + clearer find error (issue #8.4)
Field-report ergonomics fixes so agents stop wasting a round on a wrong guess:

- Coordinate click is now first-class: `click <x> <y>`, `click <x>,<y>`,
  and `click --coords <x>,<y>` dispatch a raw viewport-point click (no
  element resolution), reusing the humanize trajectory + press dwell. Was
  previously only reachable via eval(elementFromPoint(...).click()).
- Aliases: `tabs` (plural) → the `tab` subcommand tree; `get-text`/`get_text`
  → `get text <selector>`.
- `find <value> <action>` with a bare value (no locator keyword), e.g.
  `find "I'm not a robot" click`, now errors with the corrected command
  (`find text "I'm not a robot" click`) plus concrete examples, instead of
  a bare "Valid options: role, text, ..." list.

Adds parse-layer regression tests for every form.
2026-06-12 01:21:39 +09:00
leeguooooo 0e5409a81e fix(tabs): re-pin active target when the pinned page is removed (issue #7)
remove_page_by_target_id left active_target_id dangling when the pinned
page itself was removed, so resolved_active_index silently fell back to
active_page_index — which after a passive about:blank discovery can point
at a blank tab. That matches issue #7's intermittent symptom: `wait` then
eval/snapshot landing on about:blank in a --launch session.

Re-pin to the surviving active page after removing the pinned target so
the pin is never left pointing at a target that no longer exists. Adds
pure regression tests for the re-anchor invariant (BrowserManager needs a
live CDP client, so the method can't be unit-constructed directly).
2026-06-12 01:05:33 +09:00
leeguooooo 3ded30c210 fix(stealth): bind iframe contentWindow proxy methods to the real window
The srcdoc-iframe contentWindow Proxy returned native window methods
unbound, so iframe.contentWindow.getComputedStyle()/addEventListener()/
setTimeout() ran with the Proxy as `this` and threw "Illegal invocation"
on any page that uses a srcdoc iframe under --launch (FullLaunch). The
sibling matchMedia proxy already bound its methods; this one did not.

Wrap each function in an apply/construct trap that swaps the Proxy
receiver for the real window while passing .prototype/.name/.toString/
identity straight through (a plain .bind() drops .prototype and breaks
instanceof/constructors). Cached in a WeakMap for stable identity.

Verified before/after on a launched stealth browser: getComputedStyle,
addEventListener, setTimeout all OK; .prototype preserved.
2026-06-12 01:05:33 +09:00
leeguooooo 6e50f0ecab chore(release): 0.27.0-fork.50 — tab-title truncation + multi-agent/eval/type docs
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
2026-06-11 23:55:01 +09:00
leeguooooo 6f71f4e1ff fix: truncate tab-list title too; doc raw --cdp isolation limit + eval/type notes
From Hermes's fork.49 re-dogfood (9/11 fixes confirmed PASS):
- tab list: a page can set its title to a multi-KB string (= a giant URL); cap
  the title column like the URL so the row stays readable.
- skill: clarify that true multi-agent isolation needs the extension-connect path
  (per-session tab groups) — raw `--cdp` shares the browser, so a sibling
  session's `open` can navigate your tab. Use the extension for concurrent agents.
- skill: prefer `eval --json` for array/object results (plain render is
  multi-line / pipe-hostile); note type/fill don't fire keydown (use `keyboard
  type` when key events are required).

(Hermes's "find-text click bypasses humanize" was a false alarm — verified both
paths curve; the apparent 1-vs-12 was cursor continuity on the same target.)
2026-06-11 23:55:00 +09:00
leeguooooo 31ef0d7e6a chore(release): 0.27.0-fork.49 — embed stealth-status + multi-agent skill guidance
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
2026-06-11 23:36:14 +09:00
leeguooooo 42560b56fc docs(skill): concurrent agents must use distinct --session (issue #6)
Within a session, commands are pinned to the agent's opened tab (fork.47). But
two agents on the same (default) session share one daemon + active tab and
clobber each other. Document that each concurrent agent must use a unique
--session — which gives it its own isolated tab group on the shared real Chrome.
2026-06-11 23:35:03 +09:00
leeguooooo 0c7534d9b2 chore(release): 0.27.0-fork.48 — iframe-proxy toggle (#4) + stealth status (#5)
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
2026-06-11 23:33:26 +09:00
leeguooooo ad4fb14ed9 feat: stealth status self-check command (issue #5)
Local stealth verification with no external detector: reports mode (connect vs
launch), live fingerprint probes (navigator.webdriver / window.chrome / plugins /
UA-headless) as pass/fail, and an audit of the active overrides for the path
(incl. the iframe-proxy state from #4). `--json` for a stable shape agents can
gate a sensitive flow on. Distinct from `doctor` (install/env health).
2026-06-11 23:33:24 +09:00
leeguooooo a976287f03 fix(stealth): AGENT_BROWSER_DISABLE_IFRAME_PROXY for a clean 0% CreepJS (issue #4)
--launch mode scored ~20% stealth on CreepJS because the srcdoc-iframe
contentWindow Proxy trips `hasIframeProxy` — the proxy that hides automation is
itself a fingerprintable tell (violates this fork's own "native > JS lies" rule).
Add a config-driven opt-out (no detectable global): AGENT_BROWSER_DISABLE_IFRAME_PROXY=1
drops the patch via __abStealth.disableIframeProxy → the iframe IIFE early-returns
→ clean 0% CreepJS, trading the niche srcdoc-iframe masking. Default keeps current
behavior. README now documents the --launch 20% honestly and scopes the headline
0% to the extension-connect path. Verified: launch + srcdoc page intact with the
toggle; stealth tests green (config strip-prefix kept in sync).
2026-06-11 23:25:24 +09:00
leeguooooo 649fa4ce94 chore(release): 0.27.0-fork.47 — tab-drift pin, snapshot -c keeps interactive, stale-ref guidance
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
2026-06-11 23:15:38 +09:00
leeguooooo 3ac69e822a fix: keep interactive nodes in snapshot -c; better stale-ref guidance (issue #2/#3)
- snapshot -c (compact) now always keeps lines with an interactive ARIA role
  (button/link/textbox/combobox/option/…), not only `ref=`/`": "` lines — so a
  clickable control can't vanish from compact output and leave the agent clicking
  an empty ref (issue #2 P1). Additive: only ever keeps more. compact tests green.
- stale-ref error now leads with "take a fresh snapshot" and points to the `eval`
  fallback for ref-churning SPAs, and demotes AGENT_BROWSER_VERIFY_REF=0 to a
  flagged last resort instead of presenting it as the fix (issue #3 P1).
2026-06-11 23:15:37 +09:00
leeguooooo d7a0ed85f9 fix(tabs): pin the active tab by target_id — stop command drift (issue #2/#3 P0)
The session's active tab was a bare index into `pages`, which drifts when a
foreign/user/other-session tab is passively discovered, a tab closes, or the list
reorders — so `eval`/`screenshot`/`snapshot`/`click` could land on the wrong page.
With login state that's a safety bug (a fetch firing on the wrong origin), and it
made screenshot disagree with snapshot/eval.

Pin the intended tab by stable target_id (`active_target_id`), set on every
explicit open / tab new / tab switch / connect. `active_session_id` and
`active_target_id` resolve through it (falling back to the index only if the
pinned tab is gone), so all commands stick to the agent's tab regardless of
passive churn — and they all agree.

Verified (--cdp, multi-tab): a window.open foreign tab no longer drifts eval;
tab new / switch re-pin correctly.
2026-06-11 23:10:29 +09:00
leeguooooo 9eaa5495ae chore(release): 0.27.0-fork.46 — cap --annotate legend
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
2026-06-11 22:58:58 +09:00
leeguooooo 68734fcb36 fix(screenshot): cap the --annotate legend (don't flood the terminal)
Dense pages produced hundreds of legend lines on stdout (Hermes: HN dumped 320).
Print the first 40 with a "… and N more" summary; every marker is still drawn in
the image, and --json still returns the full list.
2026-06-11 22:58:56 +09:00
105 changed files with 5329 additions and 2017 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agent-browser",
"name": "chrome-use",
"description": "Browser automation for AI agents",
"owner": {
"name": "Vercel",
@@ -8,11 +8,11 @@
},
"plugins": [
{
"name": "agent-browser",
"name": "chrome-use",
"description": "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
"source": "./",
"strict": false,
"skills": ["./skills/agent-browser"],
"skills": ["./skills/chrome-use"],
"category": "development"
}
]
+19 -19
View File
@@ -122,7 +122,7 @@ jobs:
runs-on: windows-latest
needs: rust-cross
# Headless-forbidden fork on a headless CI runner — opt into the escape so
# `agent-browser open` can launch Chrome.
# `chrome-use open` can launch Chrome.
env:
AGENT_BROWSER_ALLOW_HEADLESS: "1"
@@ -145,13 +145,13 @@ jobs:
- name: Copy CLI binary to bin directory
run: |
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
Copy-Item cli/target/x86_64-pc-windows-msvc/release/chrome-use.exe bin/chrome-use-win32-x64.exe
- name: Test agent-browser install command
- name: Test chrome-use install command
run: |
$env:PATH = "$pwd\bin;$env:PATH"
for ($i = 1; $i -le 3; $i++) {
bin/agent-browser-win32-x64.exe install
bin/chrome-use-win32-x64.exe install
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host "Attempt $i failed, retrying in 10 seconds..."
Start-Sleep -Seconds 10
@@ -167,14 +167,14 @@ jobs:
# --launch: spawn a standalone browser. Without it, `open` defaults to
# auto-connect and looks for an existing Chrome on a debug port — which
# a fresh CI runner doesn't have, so it errors "Could not connect".
bin/agent-browser-win32-x64.exe --launch open https://example.com
bin/chrome-use-win32-x64.exe --launch open https://example.com
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
Write-Host "--- Taking snapshot ---"
$snapshot = bin/agent-browser-win32-x64.exe snapshot
$snapshot = bin/chrome-use-win32-x64.exe snapshot
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
Write-Host $snapshot
Write-Host "--- Closing browser ---"
bin/agent-browser-win32-x64.exe close
bin/chrome-use-win32-x64.exe close
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
Write-Host "--- Windows daemon lifecycle test passed ---"
shell: pwsh
@@ -190,13 +190,13 @@ jobs:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: agent-browser-linux-x64
binary: chrome-use-linux-x64
- os: macos-latest
target: aarch64-apple-darwin
binary: agent-browser-darwin-arm64
binary: chrome-use-darwin-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: agent-browser-win32-x64.exe
binary: chrome-use-win32-x64.exe
steps:
- name: Checkout repository
@@ -222,23 +222,23 @@ jobs:
- name: Copy CLI binary to bin directory (Unix)
if: runner.os != 'Windows'
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
run: cp cli/target/${{ matrix.target }}/release/chrome-use bin/${{ matrix.binary }}
- name: Copy CLI binary to bin directory (Windows)
if: runner.os == 'Windows'
run: Copy-Item cli/target/${{ matrix.target }}/release/agent-browser.exe bin/${{ matrix.binary }}
run: Copy-Item cli/target/${{ matrix.target }}/release/chrome-use.exe bin/${{ matrix.binary }}
- name: Test npm global install
run: |
npm pack
npm install -g agent-browser-*.tgz
agent-browser --version
npm install -g chrome-use-*.tgz
chrome-use --version
shell: bash
- name: Verify symlink points to native binary (Unix)
if: runner.os != 'Windows'
run: |
SYMLINK=$(npm prefix -g)/bin/agent-browser
SYMLINK=$(npm prefix -g)/bin/chrome-use
TARGET=$(readlink "$SYMLINK")
echo "Symlink: $SYMLINK"
echo "Target: $TARGET"
@@ -257,13 +257,13 @@ jobs:
# can't happen and the JS wrapper — which spawns the native binary — is
# the valid fallback). Require functionality; prefer, but don't require,
# the native shim.
$ver = agent-browser --version
if ($LASTEXITCODE -ne 0) { Write-Error "agent-browser --version failed"; exit 1 }
$ver = chrome-use --version
if ($LASTEXITCODE -ne 0) { Write-Error "chrome-use --version failed"; exit 1 }
echo "CLI version: $ver"
$content = Get-Content "$(npm prefix -g)\agent-browser.cmd" -Raw
$content = Get-Content "$(npm prefix -g)\chrome-use.cmd" -Raw
echo "Shim content:"
echo $content
if ($content -match "agent-browser-win32-x64\.exe") {
if ($content -match "chrome-use-win32-x64\.exe") {
echo "OK: shim points directly to the native binary (zero overhead)"
} else {
echo "INFO: shim uses the JS wrapper fallback (functional; native-shim optimization not applied)"
+52 -13
View File
@@ -27,13 +27,13 @@ jobs:
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: '' }
- { name: Linux x64, os: ubuntu-latest, target: x86_64-unknown-linux-gnu, asset: chrome-use-linux-x64, use_zigbuild: true, ext: '' }
- { name: Linux ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-gnu, asset: chrome-use-linux-arm64, use_zigbuild: true, ext: '' }
- { name: Linux musl x64, os: ubuntu-latest, target: x86_64-unknown-linux-musl, asset: chrome-use-linux-musl-x64, use_zigbuild: true, ext: '' }
- { name: Linux musl ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-musl, asset: chrome-use-linux-musl-arm64, use_zigbuild: true, ext: '' }
- { name: Windows x64, os: ubuntu-latest, target: x86_64-pc-windows-gnu, asset: chrome-use-win32-x64, use_zigbuild: false, ext: '.exe' }
- { name: macOS x64, os: macos-latest, target: x86_64-apple-darwin, asset: chrome-use-darwin-x64, use_zigbuild: false, ext: '' }
- { name: macOS ARM64, os: macos-latest, target: aarch64-apple-darwin, asset: chrome-use-darwin-arm64, use_zigbuild: false, ext: '' }
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -87,13 +87,13 @@ jobs:
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);
src="cli/target/${{ matrix.target }}/release/chrome-use${{ matrix.ext }}"
# The binary inside every archive is named `chrome-use` (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
cp "$src" "dist/chrome-use${{ matrix.ext }}"
chmod +x "dist/chrome-use${{ matrix.ext }}" || true
( cd dist
tar czf "${{ matrix.asset }}.tar.gz" "agent-browser${{ matrix.ext }}"
tar czf "${{ matrix.asset }}.tar.gz" "chrome-use${{ matrix.ext }}"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
else
@@ -116,6 +116,16 @@ jobs:
permissions:
contents: write
steps:
# The release job is separate from the build matrix and has no repo by
# default — check it out (full history + tags) so the changelog step has a
# git repo to diff. Without this, `git` failed with "not a git repository"
# and the changelog came out empty.
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
@@ -125,6 +135,32 @@ jobs:
- name: List assets
run: ls -la dist
# Build the changelog from conventional-commit subjects since the previous
# tag. GitHub's built-in generate_release_notes only lists merged PRs,
# which is near-empty for this commit-to-main workflow — so we render the
# commit log ourselves and every release shows what actually changed.
- name: Generate changelog
id: changelog
run: |
# fetch-depth:0 gets history, but the tag refs the changelog needs
# aren't always present in a detached-HEAD tag checkout — pull them in.
git fetch --tags --force --quiet origin 2>/dev/null || true
TAG="${{ github.event.inputs.tag || github.ref_name }}"
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
{
echo "notes<<__NOTES_EOF__"
echo "## What changed"
echo ""
if [ -n "$PREV" ]; then
git log "${PREV}..${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
echo ""
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
else
git log "${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
fi
echo "__NOTES_EOF__"
} >> "$GITHUB_OUTPUT"
- name: Attach to release
uses: softprops/action-gh-release@v3
with:
@@ -133,5 +169,8 @@ jobs:
dist/*.tar.gz
dist/*.tar.gz.sha256
fail_on_unmatched_files: true
# keep existing release notes if the release was created beforehand
# The commit-based changelog so every release shows what changed. The
# first matrix job to run creates the release with these notes;
# append_body:false keeps later platform jobs from duplicating them.
body: ${{ steps.changelog.outputs.notes }}
append_body: false
+1
View File
@@ -75,3 +75,4 @@ out/
# extension signing key (never commit) + local-only id record
.secrets/
*.pem
/cu-test-artifacts
+4 -4
View File
@@ -19,7 +19,7 @@ When adding or changing user-facing features (new flags, commands, behaviors, en
1. `cli/src/output.rs``--help` output (flags list, examples, environment variables)
2. `README.md` — Options table, relevant feature sections, examples
3. `skill-data/core/SKILL.md` (and its `references/`) — so AI agents know about the feature when they load the core skill. Edit `skill-data/core/SKILL.md` for overview/workflow changes; edit `skill-data/core/references/*.md` for detailed reference content. Do **not** put feature content in `skills/agent-browser/SKILL.md` — that file is an intentionally thin discovery stub for `npx skills add` and exists only to redirect agents to `agent-browser skills get core`.
3. `skill-data/core/SKILL.md` (and its `references/`) — so AI agents know about the feature when they load the core skill. Edit `skill-data/core/SKILL.md` for overview/workflow changes; edit `skill-data/core/references/*.md` for detailed reference content. Do **not** put feature content in `skills/chrome-use/SKILL.md` — that file is an intentionally thin discovery stub for `npx skills add` and exists only to redirect agents to `chrome-use skills get core`.
4. `docs/src/app/` — the Next.js docs site (MDX pages)
5. Inline doc comments in the relevant source files
@@ -167,13 +167,13 @@ Stop the instance when done (avoids cost):
Run unit tests on Windows:
```bash
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml"
./scripts/windows-debug/run.sh "cd C:\chrome-use && cargo test --manifest-path cli\Cargo.toml"
```
Run e2e tests on Windows:
```bash
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
./scripts/windows-debug/run.sh "cd C:\chrome-use && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
```
Check bootstrap progress (first boot only):
@@ -182,7 +182,7 @@ Check bootstrap progress (first boot only):
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
```
The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
The repo lives at `C:\chrome-use` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
<!-- opensrc:start -->
+87 -46
View File
@@ -1,18 +1,18 @@
# agent-browser-stealth
# chrome-use
**English** · [简体中文](README.zh.md)
![agent-browser-stealth](assets/hero.png)
![chrome-use](assets/hero.png)
Stealth fork of [agent-browser](https://github.com/vercel-labs/agent-browser) — connects to your real Chrome, shares your login sessions, and is undetectable by anti-bot systems.
**chrome-use** drives your real, logged-in Chrome from any AI agent — it shares your existing login sessions and is undetectable by anti-bot systems because it *is* your real browser. Part of the `*-use` family ([iphone-use](https://github.com/leeguooooo) drives your real iPhone; chrome-use drives your real Chrome).
For basic usage, commands, and API reference, see the [upstream documentation](https://github.com/vercel-labs/agent-browser).
<sub>Originally based on [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser) (Apache-2.0); now a standalone project — the stealth/extension-relay architecture, anti-detection, humanize, multi-agent isolation, and CLI have diverged substantially.</sub>
## Give your AI agent the browser you already live in
**No fresh Chrome. No re-login. No "are you a robot?" walls.**
agent-browser-stealth points **any** agent — Claude Code, Cursor, Codex, your own scripts — at the **Chrome you're already signed into everything on**. It clicks in *your* window, so you watch it work and grab the wheel the moment it hits a 2FA prompt or captcha. And because it's literally your real browser (over a one-click extension, native messaging — no debug port), sites read it as 100% human: **[CreepJS scores it 0% bot](#anti-detection).**
chrome-use points **any** agent — Claude Code, Cursor, Codex, your own scripts — at the **Chrome you're already signed into everything on**. It clicks in *your* window, so you watch it work and grab the wheel the moment it hits a 2FA prompt or captcha. And because it's literally your real browser (over a one-click extension, native messaging — no debug port), sites read it as 100% human: **[CreepJS scores it 0% bot](#anti-detection).**
**Why not just use…**
@@ -23,7 +23,7 @@ agent-browser-stealth points **any** agent — Claude Code, Cursor, Codex, your
<details>
<summary><b>Full feature comparison</b> (the receipts)</summary>
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / raw CDP port | Playwright · Puppeteer · browser-use | **agent-browser-stealth** |
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / raw CDP port | Playwright · Puppeteer · browser-use | **chrome-use** |
|---|:---:|:---:|:---:|:---:|
| Works with **any** agent / CLI (not one app) | ❌ Claude only | ✅ | ✅ | ✅ |
| Drives your **real, logged-in** Chrome | ✅ | ✅ | ❌ fresh empty profile | ✅ |
@@ -37,15 +37,15 @@ agent-browser-stealth points **any** agent — Claude Code, Cursor, Codex, your
</details>
## Why this fork?
## Why chrome-use?
<img src="assets/fingerprint.png" alt="real but undetectable fingerprint" width="300" align="right" />
**agent-browser** launches a fresh browser with an empty profile. You need to log in again, and websites can detect it's automated.
**Typical browser automation** (Playwright, Puppeteer, or a fresh `--launch`) opens a brand-new browser with an empty profile. You have to log in again, and websites can tell it's automated.
**agent-browser-stealth** connects to your existing Chrome. Your cookies, sessions, and browser fingerprint are all real — because it IS your real browser.
**chrome-use** connects to your existing Chrome. Your cookies, sessions, and browser fingerprint are all real — because it IS your real browser.
| | agent-browser | agent-browser-stealth |
| | chrome-use | chrome-use |
|---|---|---|
| Browser | Launches new Chrome | Connects to your Chrome |
| Login state | Empty, need to re-login | Your existing sessions |
@@ -57,7 +57,7 @@ agent-browser-stealth points **any** agent — Claude Code, Cursor, Codex, your
![how it works](assets/how-it-works.png)
Your **agent-browser CLI** talks to a tiny **browser extension** over Chrome
Your **chrome-use CLI** talks to a tiny **browser extension** over Chrome
**native messaging** — a local inter-process channel, *no network socket, no
token, no remote server*. The extension uses `chrome.debugger` to drive the tabs
you target in **your own, already-logged-in Chrome**, then hands results back to
@@ -76,7 +76,7 @@ Other local tools drive Chrome over a raw `--remote-debugging-port` (CDP). Since
consent dialog — and the port has to be enabled up front. Our extension uses
native messaging instead: **install once, then zero per-use confirmation.**
| | **agent-browser-stealth** (this extension) | web-access (raw CDP port) | Claude in Chrome (chrome.debugger) |
| | **chrome-use** (this extension) | web-access (raw CDP port) | Claude in Chrome (chrome.debugger) |
|---|---|---|---|
| Connect method | native messaging — no port, no token | `--remote-debugging-port` | `chrome.debugger` |
| **"Allow remote debugging?" popup** | **never** ✅ | **every connection** 🔴 | no |
@@ -84,7 +84,7 @@ native messaging instead: **install once, then zero per-use confirmation.**
| `Runtime.enable` (CDP) leak¹ | **off by default → clean** ✅ | domain enabled | n/a |
| CreepJS stealth score² | **0% stealth · 0% headless** ✅ | real Chrome | real Chrome |
| Per-session tab groups / concurrent agents | **yes** ✅ | no | no |
| Built for the agent-browser CLI | yes | a separate proxy | a single-app assistant |
| Built for the chrome-use CLI | yes | a separate proxy | a single-app assistant |
> ¹ Verified against [rebrowser-bot-detector](https://bot-detector.rebrowser.net/):
> our relay reports `runtimeEnableLeak: 🟢 No leak` and `navigatorWebdriver: 🟢`.
@@ -97,18 +97,18 @@ native messaging instead: **install once, then zero per-use confirmation.**
## Install
```bash
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/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.
Downloads the prebuilt binary for your platform from the latest [GitHub Release](https://github.com/leeguooooo/chrome-use/releases) and installs `chrome-use` (+ 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`
- **Pin a version:** `AGENT_BROWSER_VERSION=v0.27.0-fork.12 curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/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.
- **Windows:** download `chrome-use-win32-x64.tar.gz` from the [Releases page](https://github.com/leeguooooo/chrome-use/releases) and put `chrome-use.exe` on your PATH.
- **npm (legacy):** `npm install -g chrome-use` — still published, but GitHub Releases is the primary channel now.
</details>
### Install the AI agent skills
@@ -116,14 +116,14 @@ Downloads the prebuilt binary for your platform from the latest [GitHub Release]
The repo ships SKILL.md files for Claude Code, Cursor, etc. Pull them into the current project with [skills.sh](https://skills.sh):
```bash
npx skills add leeguooooo/agent-browser-stealth
npx skills add leeguooooo/chrome-use
```
This drops `skills/agent-browser` (and the specialized `skill-data/{core,electron,slack,dogfood,agentcore,vercel-sandbox}`) into your project so your AI agent gets the right usage patterns and pre-approved bash permissions for `agent-browser`, `agent-browser-stealth`, and `abs`.
This drops `skills/chrome-use` (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 `chrome-use`, `chrome-use`, and `abs`.
## Command names
`agent-browser`, `agent-browser-stealth`, and `abs` are **the same binary**
`chrome-use`, `chrome-use`, 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
@@ -132,15 +132,15 @@ fresh one.
## Setup: connect to your Chrome
**Recommended — the browser extension (one click, no popups).** Install the
[**agent-browser-stealth** extension from the Chrome Web Store](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),
[**chrome-use** extension from the Chrome Web Store](https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk),
then register the local bridge once:
```bash
agent-browser extension install # register the native-messaging host (one-time)
agent-browser open https://x.com/home
chrome-use extension install # register the native-messaging host (one-time)
chrome-use open https://x.com/home
```
`agent-browser open` then drives your real, logged-in Chrome over **native
`chrome-use open` then drives your real, logged-in Chrome over **native
messaging** — no debug port, no token, and **no "Allow remote debugging?" dialog,
ever**. The extension auto-updates and survives Chrome restarts, so it stays
connected with zero per-use confirmation (ideal for unattended/agent use).
@@ -148,7 +148,7 @@ connected with zero per-use confirmation (ideal for unattended/agent use).
<details>
<summary>Alternative — raw remote-debugging port (pops a consent dialog)</summary>
Without the extension, agent-browser attaches over the Chrome DevTools Protocol,
Without the extension, chrome-use attaches over the Chrome DevTools Protocol,
which Chrome only exposes when **launched with a remote-debugging port** (a
startup flag — the `chrome://inspect` toggle alone is not enough):
@@ -160,13 +160,13 @@ google-chrome --remote-debugging-port=9222
# Windows: add --remote-debugging-port=9222 to your Chrome shortcut's target
```
Then `agent-browser open <url>` auto-discovers the port. On first attach,
Then `chrome-use open <url>` auto-discovers the port. On first attach,
**Chrome 136+ shows an "Allow remote debugging?" dialog** — click Allow once (it
persists for that Chrome session). The extension above avoids this entirely.
</details>
**No setup / don't want to touch your real Chrome?** Use
`agent-browser --launch open <url>` to spawn a fresh isolated stealth browser
`chrome-use --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.
@@ -174,12 +174,13 @@ port setup and is what CI uses automatically.
```bash
# Connect to your Chrome and navigate
agent-browser open https://example.com
chrome-use open https://example.com
# Everything works through your logged-in browser
agent-browser click "Post"
agent-browser fill "Title" "Hello World"
agent-browser screenshot ./page.png
chrome-use click "Post"
chrome-use click 449 320 # …or click a raw viewport coordinate
chrome-use fill "Title" "Hello World"
chrome-use 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.
@@ -190,20 +191,61 @@ 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
chrome-use --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
chrome-use --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
> Chrome profile you used most recently) or `--profile <name>`. chrome-use prints
> a warning when you `--launch` without a profile.
In CI environments, standalone mode is used automatically.
## Automated testing (`chrome-use test`)
Turn the repetitive "open it, click around, check it's right" work into a
**re-runnable suite** — unit tests for the frontend. Write cases in YAML; steps
reuse chrome-use's own commands and assertions compile to a single check:
```yaml
# smoke.yaml
suite: chatgpt smoke
setup:
- account: chatgpt/huayue # inject a cookie-use login (optional)
cases:
- name: home loads logged in
steps:
- open: https://chatgpt.com/
- wait: { load: networkidle }
assert:
- url: { contains: chatgpt.com }
- visible: "#prompt-textarea"
```
```bash
chrome-use test smoke.yaml # launches an isolated browser, runs cases
chrome-use test smoke.yaml --session default # …or against your connected Chrome
```
```
suite: chatgpt smoke (session cu-test)
✓ home loads logged in 1.2s
✗ composer takes text 0.8s
assert text "#prompt-textarea" contains "hi" → got ""
↳ cu-test-artifacts/composer-takes-text.png
2 cases · 1 passed · 1 failed
```
Exit code is non-zero if any case fails (drop it into CI), and failed cases save
a screenshot. Assertions: `url` · `visible` · `hidden` · `text` · `count` ·
`eval`. Steps: `open` · `click` · `fill` · `type` · `press` · `wait` · `scroll`
· `eval`. Full guide: `chrome-use skills get test`. Found a regression? Add a
case — the suite gets more valuable the more you use it.
## Anti-detection
<img src="assets/shield.png" alt="stealth shield" width="320" align="right" />
@@ -225,7 +267,7 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it passes the suite above — with one caveat: CreepJS reports **~20% stealth** because the srcdoc-iframe `contentWindow` patch trips its `hasIframeProxy` probe (the proxy that hides automation is itself a tell). Everything else is clean (`0% headless`, sannysoft/browserscan green, Cloudflare passed). Set **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** to drop that patch for a clean **0% stealth** (trades the niche srcdoc-iframe masking). The **extension-connect path** (your real Chrome) injects zero JS and is unaffected — it's the genuine 0% path.
### Human-like input (behavioural stealth)
@@ -270,18 +312,17 @@ We deliberately **don't ship our own bot detector** — the strongest, most hone
| `AGENT_BROWSER_ADAPTIVE_REF` | on | When a saved `@ref` moves and the role/name re-query fails, relocate it by fingerprint similarity (high score + clear margin required, else it fails loudly). `0` disables. |
| `AGENT_BROWSER_CLICK_MODE` | _(auto)_ | Click strategy. Default scrolls the target into view, dispatches a coordinate click, and falls back to a DOM `.click()` if a floating layer occludes the point. `dom` always uses `.click()` (best for autocomplete/menu items that close on blur); `coord` is strict coordinate-only (hard-fail on occlusion). |
## Differences from upstream
## What makes chrome-use different
Based on [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser). Changes:
- **Auto-connect is default** — `chrome-use open <url>` drives your existing Chrome instead of launching a new one
- **Extension-relay transport** — a one-click Chrome Web Store extension + native messaging, so there's no debug port and no "Allow remote debugging?" dialog
- **CDP-native stealth** — anti-detection via Chrome/CDP overrides rather than JS patches; zero patches when attached to your real Chrome, full patches only for `--launch`
- **Humanize** — human-like cursor trajectories + adaptive anti-bot handling
- **Multi-agent isolation** — concurrent agents share one real Chrome via per-session tab groups, no cross-talk
- **Silent operation** — runs in the background; never steals your foreground tab
- **Auto-connect is default** — `agent-browser open <url>` connects to your Chrome instead of launching a new one
- **CDP-native stealth** — `Emulation.setAutomationOverride` instead of JS patches
- **Dual stealth mode** — zero patches for real Chrome, full patches for `--launch` mode
- **`--launch` / `--new` flag** — explicitly start a standalone browser
- **CI auto-detection** — standalone mode when `CI` env var is set
All upstream features (commands, snapshots, screenshots, recordings, tabs, sessions, etc.) work the same. See the [upstream repo](https://github.com/vercel-labs/agent-browser) for full documentation.
<sub>Originally based on [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser) (Apache-2.0); the projects have since diverged substantially.</sub>
## License
Apache-2.0 (same as upstream)
Apache-2.0
+36 -37
View File
@@ -1,18 +1,18 @@
# agent-browser-stealth
# chrome-use
[English](README.md) · **简体中文**
![agent-browser-stealth](assets/hero.png)
![chrome-use](assets/hero.png)
[agent-browser](https://github.com/vercel-labs/agent-browser) 的隐身分支 —— 直接连接**你自己**正在用的、已登录的 Chrome复用你的登录态,对反爬/反自动化系统**完全不可检测**。
**chrome-use** 让任意 AI agent 直接操作你自己正在用的、已登录的 Chrome —— 复用你的登录态,对反爬/反自动化系统**完全不可检测**,因为它**就是**你的真实浏览器。属于 `*-use` 家族(iphone-use 驱动你的真实 iPhonechrome-use 驱动你的真实 Chrome
基础用法、命令与 API 参考见[上游文档](https://github.com/vercel-labs/agent-browser)
<sub>最初基于 [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser)(Apache-2.0);现已是独立项目 —— 隐身/扩展中继架构、反检测、humanize、多 agent 隔离与 CLI 都已大幅分化。</sub>
## 把你**已经登录好**的浏览器,交给你的 AI agent
**不用开新 Chrome。不用重新登录。不用跟"你是不是机器人"较劲。**
agent-browser-stealth 让**任意** agentClaude Code、Cursor、Codex、你自己的脚本)直接操作你**已经登录了所有网站**的那个 Chrome。它在**你的窗口里**点击,你看着它干活,撞到 2FA / 验证码的瞬间你接管一下,它接着跑。因为它**就是你的真实浏览器**(一键装的扩展、原生消息、无调试端口),网站眼里它 100% 是人:**[CreepJS 实测 0% 机器人](#反检测)。**
chrome-use 让**任意** agentClaude Code、Cursor、Codex、你自己的脚本)直接操作你**已经登录了所有网站**的那个 Chrome。它在**你的窗口里**点击,你看着它干活,撞到 2FA / 验证码的瞬间你接管一下,它接着跑。因为它**就是你的真实浏览器**(一键装的扩展、原生消息、无调试端口),网站眼里它 100% 是人:**[CreepJS 实测 0% 机器人](#反检测)。**
**为什么不用……**
@@ -23,7 +23,7 @@ agent-browser-stealth 让**任意** agentClaude Code、Cursor、Codex、你
<details>
<summary><b>完整对比矩阵</b>(要细节的看这里)</summary>
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / 裸 CDP 端口 | Playwright · Puppeteer · browser-use | **agent-browser-stealth** |
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / 裸 CDP 端口 | Playwright · Puppeteer · browser-use | **chrome-use** |
|---|:---:|:---:|:---:|:---:|
| **任意** agent / CLI 都能用(不绑单一 app | ❌ 仅 Claude | ✅ | ✅ | ✅ |
| 驱动你**真实、已登录**的 Chrome | ✅ | ✅ | ❌ 全新空 profile | ✅ |
@@ -37,15 +37,15 @@ agent-browser-stealth 让**任意** agentClaude Code、Cursor、Codex、你
</details>
## 为什么要 fork
## 为什么选 chrome-use
<img src="assets/fingerprint.png" alt="真实但不可检测的指纹" width="300" align="right" />
**agent-browser**(上游)启动的是空 profile 的全新浏览器:你得重新登录,网站也能看出是自动化。
**常规浏览器自动化**Playwright / Puppeteer,或全新 `--launch`)启动的是空 profile 的全新浏览器:你得重新登录,网站也能看出是自动化。
**agent-browser-stealth** 连接你**现有**的 Chrome —— cookies、会话、浏览器指纹全是真的,因为它**就是**你的真实浏览器。
**chrome-use** 连接你**现有**的 Chrome —— cookies、会话、浏览器指纹全是真的,因为它**就是**你的真实浏览器。
| | agent-browser | agent-browser-stealth |
| | 常规自动化 | chrome-use |
|---|---|---|
| 浏览器 | 启动新 Chrome | 连接你的 Chrome |
| 登录态 | 空,要重新登 | 你现有的会话 |
@@ -57,7 +57,7 @@ agent-browser-stealth 让**任意** agentClaude Code、Cursor、Codex、你
![工作原理](assets/how-it-works.png)
你的 **agent-browser CLI** 通过 Chrome **原生消息(native messaging** 和一个小**浏览器扩展**通信 —— 这是本机进程间通道,**无网络端口、无 token、无远程服务器**。扩展用 `chrome.debugger` 驱动你指定的标签页(在你**已登录**的 Chrome 里),再把结果交还给 CLI。全程都在你本机。
你的 **chrome-use CLI** 通过 Chrome **原生消息(native messaging** 和一个小**浏览器扩展**通信 —— 这是本机进程间通道,**无网络端口、无 token、无远程服务器**。扩展用 `chrome.debugger` 驱动你指定的标签页(在你**已登录**的 Chrome 里),再把结果交还给 CLI。全程都在你本机。
![架构](assets/architecture.png)
@@ -66,34 +66,34 @@ agent-browser-stealth 让**任意** agentClaude Code、Cursor、Codex、你
## 安装
```bash
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh
```
从最新的 [GitHub Release](https://github.com/leeguooooo/agent-browser-stealth/releases) 下载对应平台的预编译二进制,安装 `agent-browser`(以及 `abs` 别名)。无需 npm,无需 token。
从最新的 [GitHub Release](https://github.com/leeguooooo/chrome-use/releases) 下载对应平台的预编译二进制,安装 `chrome-use`(以及 `abs` 别名)。无需 npm,无需 token。
### 安装 AI agent skills
```bash
npx skills add leeguooooo/agent-browser-stealth
npx skills add leeguooooo/chrome-use
```
`skills/agent-browser` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
`skills/chrome-use` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
## 连接你的 Chrome
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**agent-browser-stealth** 扩展](https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**chrome-use** 扩展](https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
```bash
agent-browser extension install # 注册原生消息 host(一次性)
agent-browser open https://x.com/home
chrome-use extension install # 注册原生消息 host(一次性)
chrome-use open https://x.com/home
```
之后 `agent-browser open` 就通过**原生消息**驱动你真实、已登录的 Chrome —— 无调试端口、无 token、**永远不弹 "Allow remote debugging?"**。扩展自动更新、重启不掉,零确认(适合无人值守 / agent 场景)。
之后 `chrome-use open` 就通过**原生消息**驱动你真实、已登录的 Chrome —— 无调试端口、无 token、**永远不弹 "Allow remote debugging?"**。扩展自动更新、重启不掉,零确认(适合无人值守 / agent 场景)。
<details>
<summary>备选 —— 裸 remote-debugging 端口(会弹同意框)</summary>
不装扩展时,agent-browser 退回用 CDP 连接,而 Chrome 只在带 remote-debugging 端口启动时才暴露它:
不装扩展时,chrome-use 退回用 CDP 连接,而 Chrome 只在带 remote-debugging 端口启动时才暴露它:
```bash
# macOS
@@ -103,19 +103,19 @@ google-chrome --remote-debugging-port=9222
# Windows: 给 Chrome 快捷方式 target 加 --remote-debugging-port=9222
```
然后 `agent-browser open <url>` 自动发现端口。首次连接 **Chrome 136+ 会弹 "Allow remote debugging?"** —— 点一次 Allow(该 Chrome 会话内持续有效)。上面的扩展则完全避开这个框。
然后 `chrome-use open <url>` 自动发现端口。首次连接 **Chrome 136+ 会弹 "Allow remote debugging?"** —— 点一次 Allow(该 Chrome 会话内持续有效)。上面的扩展则完全避开这个框。
</details>
## 用法
```bash
# 连接你的 Chrome 并导航
agent-browser open https://example.com
chrome-use open https://example.com
# 一切都在你已登录的浏览器里进行
agent-browser click "Post"
agent-browser fill "Title" "Hello World"
agent-browser screenshot ./page.png
chrome-use click "Post"
chrome-use fill "Title" "Hello World"
chrome-use screenshot ./page.png
```
Agent 在你的 Chrome 里操作 —— 你能实时看到开标签、加载、点击。任意时刻都能接管(比如手动过验证码),然后让 agent 继续。
@@ -124,10 +124,10 @@ Agent 在你的 Chrome 里操作 —— 你能实时看到开标签、加载、
```bash
# 临时:全新空 profile —— 无 cookie 无登录(适合 CI / 测试)
agent-browser --launch open https://example.com
chrome-use --launch open https://example.com
# 保留登录:用你真实的 Chrome profile 启动
agent-browser --launch --profile auto open https://x.com/home
chrome-use --launch --profile auto open https://x.com/home
```
## 反检测
@@ -167,18 +167,17 @@ agent-browser --launch --profile auto open https://x.com/home
操作你的真实 Chrome 不该打断你的工作。agent **全程在后台操作**:新标签后台打开(在自己的彩色会话标签组里),**从不强制把标签拽到前台**,并用 `Emulation.setFocusEmulationEnabled` 让每个 agent 标签照常渲染、`document.hasFocus()` / `visibilityState` 仍报 `visible`。于是截图正常、页面不被降频,"标签全程隐藏"也不会变成新的机器人信号。你在自己的标签里照常工作,agent 在旁边默默干活。(想置顶某个标签仍可显式调用命令。)
## 与上游的差异
## chrome-use 的独特之处
基于 [agent-browser v0.27.0](https://github.com/vercel-labs/agent-browser)
- **默认 auto-connect** —— `chrome-use open` 连你现有的 Chrome 而非启新的
- **扩展中继传输** —— 一键安装的 Chrome 商店扩展 + 原生消息,无调试端口、无 "Allow remote debugging?" 弹框
- **CDP 原生隐身** —— 反检测走 Chrome/CDP 覆盖而非 JS 补丁;连真实 Chrome 零补丁,仅 `--launch` 用全补丁
- **Humanize** —— 类人光标轨迹 + 自适应反爬处理
- **多 agent 隔离** —— 多个 agent 通过 per-session 标签组共享同一个真实 Chrome,互不串扰
- **静默运行** —— 后台操作,绝不抢你的前台标签
- **默认 auto-connect** —— `agent-browser open` 连你的 Chrome 而非启新的
- **CDP 原生隐身** —— `Emulation.setAutomationOverride` 而非 JS 补丁
- **双隐身模式** —— 真实 Chrome 零补丁,`--launch` 全补丁
- **`--launch` / `--new`** —— 显式启动独立浏览器
- **CI 自动检测** —— 设了 `CI` 环境变量时走独立模式
所有上游功能(命令、快照、截图、录制、标签、会话等)保持一致。
<sub>最初基于 [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser)Apache-2.0);两个项目已大幅分化。</sub>
## License
Apache-2.0(与上游一致)
Apache-2.0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1023 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 888 KiB

BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/**
* Cross-platform CLI wrapper for agent-browser
* Cross-platform CLI wrapper for chrome-use
*
* This wrapper enables npx support on Windows where shell scripts don't work.
* For global installs, postinstall.js patches the shims to invoke the native
@@ -62,7 +62,7 @@ function getBinaryName() {
}
const ext = os === 'win32' ? '.exe' : '';
return `agent-browser-${osKey}-${archKey}${ext}`;
return `chrome-use-${osKey}-${archKey}${ext}`;
}
function main() {
+88 -35
View File
@@ -43,41 +43,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.45"
dependencies = [
"aes-gcm",
"async-trait",
"base64",
"chrono",
"dirs",
"futures-util",
"getrandom 0.2.17",
"hex",
"hmac",
"image",
"include_dir",
"libc",
"regex-lite",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"sha2",
"similar",
"socket2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"url",
"urlencoding",
"uuid",
"windows-sys 0.52.0",
"zip",
]
[[package]]
name = "aligned"
version = "0.4.3"
@@ -245,6 +210,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "built"
version = "0.8.0"
@@ -281,6 +255,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.56"
@@ -305,6 +288,46 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrome-use"
version = "1.4.0"
dependencies = [
"aes",
"aes-gcm",
"async-trait",
"base64",
"cbc",
"chrono",
"dirs",
"futures-util",
"getrandom 0.2.17",
"hex",
"hmac",
"image",
"include_dir",
"libc",
"pbkdf2",
"regex-lite",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"serde_yaml",
"sha1",
"sha2",
"similar",
"socket2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"url",
"urlencoding",
"uuid",
"windows-sys 0.52.0",
"zip",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -1086,6 +1109,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -1376,6 +1400,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1949,6 +1983,19 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -2386,6 +2433,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
+10 -5
View File
@@ -1,17 +1,17 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.45"
name = "chrome-use"
version = "1.4.0"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
repository = "https://github.com/leeguooooo/agent-browser-stealth"
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
repository = "https://github.com/leeguooooo/chrome-use"
homepage = "https://github.com/leeguooooo/chrome-use"
readme = "../README.md"
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "agent-browser"
name = "chrome-use"
path = "src/main.rs"
[dependencies]
@@ -38,9 +38,14 @@ zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
time = { version = "0.3", features = ["formatting"] }
hmac = "0.12"
hex = "0.4"
aes = "0.8"
cbc = "0.1"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
sha1 = "0.10"
chrono = "0.4"
urlencoding = "2"
rust-embed = "8"
serde_yaml = "0.9"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+18
View File
@@ -3,6 +3,23 @@ use std::env;
use std::fs;
use std::path::Path;
/// Embed the version of the `ab-connect` extension this CLI ships alongside, so
/// `doctor` can tell a connected extension "you're older than what this CLI
/// expects, update it." Read from the extension manifest at build time so it
/// stays in sync with whatever extension version is in the same checkout/release
/// (the ext is on its own 0.4.x line, separate from the CLI version). Falls back
/// to "unknown" if the manifest can't be read.
fn embed_extension_version() {
let manifest = Path::new("../extensions/ab-connect/manifest.json");
println!("cargo:rerun-if-changed=../extensions/ab-connect/manifest.json");
let version = fs::read_to_string(manifest)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=AB_CONNECT_VERSION={}", version);
}
/// Ensure `packages/dashboard/out/` exists so `rust-embed` doesn't fail during
/// Rust-only dev builds where the dashboard hasn't been built. The placeholder
/// `index.html` is only written when the directory is completely absent.
@@ -20,6 +37,7 @@ fn ensure_dashboard_dir() {
fn main() {
ensure_dashboard_dir();
embed_extension_version();
let protocol_dir = Path::new("cdp-protocol");
let out_dir = env::var("OUT_DIR").unwrap();
+344 -36
View File
@@ -48,12 +48,12 @@ impl ParseError {
}
ParseError::MissingArguments { context, usage } => {
format!(
"Missing arguments for: {}\nUsage: agent-browser {}",
"Missing arguments for: {}\nUsage: chrome-use {}",
context, usage
)
}
ParseError::InvalidValue { message, usage } => {
format!("{}\nUsage: agent-browser {}", message, usage)
format!("{}\nUsage: chrome-use {}", message, usage)
}
ParseError::InvalidSessionName { name } => session_name_error(name),
}
@@ -320,7 +320,27 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// scripts before the first real navigation (see `batch`).
// `goto` and `navigate` still require a URL since those verbs
// imply the navigation itself.
let first_url = rest.iter().find(|a| !a.starts_with("--"));
// The URL is the first positional arg, skipping flags AND any value
// consumed by `--wait-until` (so it isn't mistaken for the URL).
let first_url = {
let mut url = None;
let mut skip_next = false;
for a in &rest {
if skip_next {
skip_next = false;
continue;
}
if *a == "--wait-until" {
skip_next = true;
continue;
}
if !a.starts_with("--") {
url = Some(a);
break;
}
}
url
};
let url = match first_url {
Some(u) => *u,
None if cmd == "open" => {
@@ -350,6 +370,29 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
if flags.provider.is_some() {
nav_cmd["waitUntil"] = json!("none");
}
// `--reuse-tab`: adopt an existing tab already on this URL instead of
// navigating/spawning a new one (issue #21 — avoids duplicate tabs on
// rebind, preserves in-page state).
if rest.iter().any(|a| *a == "--reuse-tab" || *a == "--reuse") {
nav_cmd["reuseTab"] = json!(true);
}
// Explicit readiness override (issue #10): SPAs whose `load` event
// never fires (a long-lived XHR/websocket holds it open) hang out the
// load-event wait. `--wait-until domcontentloaded` returns as soon as
// the DOM is parsed.
if let Some(i) = rest.iter().position(|a| *a == "--wait-until") {
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
context: "open --wait-until".to_string(),
usage: "open <url> --wait-until <load|domcontentloaded|networkidle|none>",
})?;
if !["load", "domcontentloaded", "networkidle", "none"].contains(val) {
return Err(ParseError::InvalidValue {
message: format!("Unknown --wait-until value: {}", val),
usage: "open <url> --wait-until <load|domcontentloaded|networkidle|none>",
});
}
nav_cmd["waitUntil"] = json!(val);
}
if let Some(ref headers_json) = flags.headers {
let headers =
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
@@ -371,16 +414,35 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"back" => Ok(json!({ "id": id, "action": "back" })),
"forward" => Ok(json!({ "id": id, "action": "forward" })),
"reload" => Ok(json!({ "id": id, "action": "reload" })),
// Explicit opt-in to raise the active tab to the foreground (the core
// skill references it; the daemon handler existed but the CLI didn't map
// it — issue #19). Accept the documented camelCase + kebab/lowercase.
"bringToFront" | "bring-to-front" | "bringtofront" => {
Ok(json!({ "id": id, "action": "bringtofront" }))
}
// === Core Actions ===
"click" => {
let new_tab = rest.contains(&"--new-tab");
// Coordinate click as a first-class form (issue #8.4): when the only
// handle is a pixel position, no element/selector is needed.
// click <x> <y> e.g. click 449 320
// click <x>,<y> e.g. click 449,320
// click --coords <x>,<y> | --coords <x> <y>
let coord_args: Vec<&str> = rest
.iter()
.copied()
.filter(|a| *a != "--new-tab" && *a != "--coords")
.collect();
if let Some((x, y)) = parse_coords(&coord_args) {
return Ok(json!({ "id": id, "action": "click", "x": x, "y": y }));
}
let sel = rest
.iter()
.find(|arg| **arg != "--new-tab")
.ok_or_else(|| ParseError::MissingArguments {
context: "click".to_string(),
usage: "click <selector> [--new-tab]",
usage: "click <selector> | click <x> <y> | click --coords <x>,<y> [--new-tab]",
})?;
if new_tab {
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
@@ -931,6 +993,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
}
// === Stealth self-check ===
"stealth" => {
// `stealth [status]` — local stealth self-check: mode, live probes
// (navigator.webdriver, window.chrome, plugins, UA), and the list of
// active overrides. --json for a stable machine-readable shape.
// (Distinct from `doctor`, which checks install/env/Chrome health.)
Ok(json!({ "id": id, "action": "stealth_status" }))
}
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
@@ -944,7 +1015,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Some("save") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
usage: "chrome-use auth save <name> --url <url> --username <user> --password <pass>",
})?;
let mut url = None;
@@ -989,7 +1060,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
if other.starts_with("--") {
return Err(ParseError::InvalidValue {
message: format!("unknown flag '{}' for auth save", other),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
usage: "chrome-use auth save <name> --url <url> --username <user> --password <pass>",
});
}
}
@@ -999,17 +1070,17 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
let url_val = url.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
usage: "chrome-use auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
let user_val = username.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
usage: "chrome-use auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
if !password_stdin && password.is_none() {
return Err(ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
usage: "chrome-use auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
});
}
@@ -1040,7 +1111,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Some("login") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth login".to_string(),
usage: "agent-browser auth login <name>",
usage: "chrome-use auth login <name>",
})?;
Ok(json!({ "id": id, "action": "auth_login", "name": name }))
}
@@ -1048,14 +1119,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Some("delete") | Some("remove") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth delete".to_string(),
usage: "agent-browser auth delete <name>",
usage: "chrome-use auth delete <name>",
})?;
Ok(json!({ "id": id, "action": "auth_delete", "name": name }))
}
Some("show") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth show".to_string(),
usage: "agent-browser auth show <name>",
usage: "chrome-use auth show <name>",
})?;
Ok(json!({ "id": id, "action": "auth_show", "name": name }))
}
@@ -1070,14 +1141,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"confirm" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "confirm".to_string(),
usage: "agent-browser confirm <confirmation-id>",
usage: "chrome-use confirm <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid }))
}
"deny" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "deny".to_string(),
usage: "agent-browser deny <confirmation-id>",
usage: "chrome-use deny <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "deny", "confirmationId": cid }))
}
@@ -1188,7 +1259,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"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
// `chrome-use 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" => {
@@ -1199,6 +1270,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
parse_get(&get_args, &id)
}
// Hyphen/underscore aliases for `get text <selector>` — agents naturally
// guess `get-text` / `get_text` (issue #8.4).
"get-text" | "get_text" => {
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
get_args.push("text");
get_args.extend_from_slice(&rest);
parse_get(&get_args, &id)
}
// === Is (state checks) ===
"is" => parse_is(&rest, &id),
@@ -1221,6 +1301,51 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"cookies" => {
let op = rest.first().unwrap_or(&"get");
match *op {
"transfer" => {
// Copy a logged-in session between Chrome profiles: decrypt
// the SOURCE profile's on-disk cookie store and inject the
// cookies into the active (connected) session — no CDP access
// to the source, no Chrome restart.
// cookies transfer --from <profile> [--domain <d>[,<d>]]
// `--from` wins; otherwise the global `--profile` is used.
let from = rest
.iter()
.position(|a| *a == "--from")
.and_then(|i| rest.get(i + 1).copied())
.or(flags.profile.as_deref());
let from = from.ok_or_else(|| ParseError::MissingArguments {
context: "cookies transfer".to_string(),
usage: "cookies transfer --from <profile> [--domain <domain>[,<domain>]]",
})?;
let domain = rest
.iter()
.position(|a| *a == "--domain")
.and_then(|i| rest.get(i + 1).copied());
let cookies =
crate::cookie_export::export_cookies(from, domain).map_err(|e| {
ParseError::InvalidValue {
message: format!("cookies transfer: {}", e),
usage: "cookies transfer --from <profile> [--domain <domain>]",
}
})?;
if cookies.is_empty() {
return Err(ParseError::InvalidValue {
message: format!(
"cookies transfer: no cookies found in profile \"{}\"{}",
from,
domain
.map(|d| format!(" for domain {}", d))
.unwrap_or_default()
),
usage: "cookies transfer --from <profile> [--domain <domain>]",
});
}
Ok(json!({
"id": id,
"action": "cookies_set",
"cookies": cookies,
}))
}
"set" => {
// --curl <file> mode: import cookies from a JSON array,
// raw cURL dump, or bare Cookie header. Scoped to the
@@ -1380,8 +1505,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
}
// === Tabs ===
"tab" => {
match rest.first().copied() {
// `tabs` (plural) is a natural guess for the `tab` subcommand tree —
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
"tab" | "tabs" => {
// `--full` makes `tab list` emit untruncated URLs (needed to re-open
// a long SSO/redirect URL after a stale session — issue #19). Pick
// the subcommand as the first non-flag arg so the flag can appear
// anywhere (`tab --full`, `tab list --full`).
let full = rest.contains(&"--full");
match rest.iter().find(|a| !a.starts_with("--")).copied() {
Some("new") => {
// Accepted forms:
// tab new [url]
@@ -1413,7 +1545,13 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
}
Ok(cmd)
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("list") => {
let mut cmd = json!({ "id": id, "action": "tab_list" });
if full {
cmd["full"] = json!(true);
}
Ok(cmd)
}
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(tab_ref) = rest.get(1) {
@@ -1426,7 +1564,13 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"action": "tab_switch",
"tabId": tab_ref,
})),
None => Ok(json!({ "id": id, "action": "tab_list" })),
None => {
let mut cmd = json!({ "id": id, "action": "tab_list" });
if full {
cmd["full"] = json!(true);
}
Ok(cmd)
}
}
}
@@ -2304,19 +2448,6 @@ fn parse_is(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &[
"role",
"text",
"label",
"placeholder",
"alt",
"title",
"testid",
"first",
"last",
"nth",
];
let locator = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "find".to_string(),
usage: "find <locator> <value> [action] [text]",
@@ -2347,7 +2478,7 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
message: format!(
"Missing action verb for `find {locator}` (got `{flag}` where action was expected).\n\
Valid actions: click, fill, check, hover, text\n\
Did you mean: agent-browser find {locator} <value> click {flag} ...?",
Did you mean: chrome-use find {locator} <value> click {flag} ...?",
locator = locator,
flag = s,
),
@@ -2486,13 +2617,37 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
Ok(cmd)
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: locator.to_string(),
valid_options: VALID,
_ => Err(ParseError::InvalidValue {
// The user passed a value where a locator keyword was expected — the
// classic `find "I'm not a robot" click` mistake (issue #8.4). Lead
// with the corrected command using their own value, then the menu.
message: format!(
"`{loc}` is not a find locator. To match by visible text, name the locator:\n \
chrome-use find text \"{loc}\" click\n\n\
Locators: role, text, label, placeholder, alt, title, testid, first, last, nth\n\
Examples:\n \
chrome-use find text \"Sign in\" click\n \
chrome-use find role button --name \"Submit\" click\n \
chrome-use find label \"Email\" fill you@example.com",
loc = locator,
),
usage: "find <locator> <value> [action] [text]",
}),
}
}
/// Parse a coordinate pair from `["449","320"]`, `["449,320"]`, or `["449, 320"]`.
/// Returns None if the args aren't a clean numeric pair (so callers fall back to
/// treating the argument as a selector). Used by first-class coordinate `click`.
fn parse_coords(args: &[&str]) -> Option<(f64, f64)> {
let (a, b) = match args {
[one] => one.split_once(',')?,
[a, b] => (*a, *b),
_ => return None,
};
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
}
fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["move", "down", "up", "wheel"];
@@ -3426,6 +3581,24 @@ mod tests {
assert_eq!(cmd["url"], "https://example.com");
}
#[test]
fn test_navigate_reuse_tab_flag() {
let cmd = parse_command(
&args("open https://example.com --reuse-tab"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(cmd["reuseTab"], true);
// Alias.
let cmd2 =
parse_command(&args("open https://example.com --reuse"), &default_flags()).unwrap();
assert_eq!(cmd2["reuseTab"], true);
// Absent by default.
let cmd3 = parse_command(&args("open https://example.com"), &default_flags()).unwrap();
assert!(cmd3.get("reuseTab").is_none());
}
#[test]
fn test_navigate_with_headers() {
let mut flags = default_flags();
@@ -3541,6 +3714,141 @@ mod tests {
assert_eq!(cmd["action"], "reload");
}
// === issue #8.4: CLI ergonomics ===
#[test]
fn test_click_coords_two_args() {
let cmd = parse_command(&args("click 449 320"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "click");
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
assert!(cmd.get("selector").is_none());
}
#[test]
fn test_click_coords_comma() {
let cmd = parse_command(&args("click 449,320"), &default_flags()).unwrap();
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
}
#[test]
fn test_click_coords_flag() {
let cmd = parse_command(&args("click --coords 449,320"), &default_flags()).unwrap();
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
}
#[test]
fn test_click_selector_not_coords() {
let cmd = parse_command(&args("click button.submit"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "click");
assert_eq!(cmd["selector"], "button.submit");
assert!(cmd.get("x").is_none());
}
#[test]
fn test_tabs_alias_lists() {
assert_eq!(
parse_command(&args("tabs"), &default_flags()).unwrap()["action"],
"tab_list"
);
assert_eq!(
parse_command(&args("tabs list"), &default_flags()).unwrap()["action"],
"tab_list"
);
assert_eq!(
parse_command(&args("tabs new"), &default_flags()).unwrap()["action"],
"tab_new"
);
}
#[test]
fn test_tab_list_full_flag() {
// issue #19: `--full` → untruncated URLs; works as `tab list --full`,
// `tab --full`, and `tabs --full`. Plain list has no `full`.
for inv in ["tab list --full", "tab --full", "tabs --full"] {
let cmd = parse_command(&args(inv), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_list", "{inv}");
assert_eq!(cmd["full"], true, "{inv}");
}
let plain = parse_command(&args("tab list"), &default_flags()).unwrap();
assert_eq!(plain["action"], "tab_list");
assert!(plain.get("full").is_none());
}
#[test]
fn test_bring_to_front_aliases() {
// issue #19: the documented `bringToFront` (+ kebab/lowercase) maps to
// the existing daemon action.
for inv in ["bringToFront", "bring-to-front", "bringtofront"] {
let cmd = parse_command(&args(inv), &default_flags()).unwrap();
assert_eq!(cmd["action"], "bringtofront", "{inv}");
}
}
#[test]
fn test_get_text_hyphen_and_underscore_aliases() {
for verb in ["get-text", "get_text"] {
let cmd = parse_command(&args(&format!("{verb} .price")), &default_flags()).unwrap();
assert_eq!(cmd["action"], "gettext", "{verb}");
assert_eq!(cmd["selector"], ".price", "{verb}");
}
}
#[test]
fn test_open_wait_until_after_url() {
let cmd = parse_command(
&args("open https://x.com --wait-until domcontentloaded"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "navigate");
assert_eq!(cmd["url"], "https://x.com");
assert_eq!(cmd["waitUntil"], "domcontentloaded");
}
#[test]
fn test_open_wait_until_before_url_not_mistaken_for_url() {
// The --wait-until value must not be picked up as the URL.
let cmd = parse_command(
&args("open --wait-until domcontentloaded https://x.com"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["url"], "https://x.com");
assert_eq!(cmd["waitUntil"], "domcontentloaded");
}
#[test]
fn test_open_wait_until_rejects_bogus_value() {
let err = parse_command(
&args("open https://x.com --wait-until wat"),
&default_flags(),
)
.unwrap_err();
assert!(
err.format().contains("--wait-until"),
"got: {}",
err.format()
);
}
#[test]
fn test_find_bare_value_suggests_text_locator() {
// `find "I'm not a robot" click` — value where a locator keyword was
// expected. Error must steer to the corrected `find text ...` form.
let input: Vec<String> = vec![
"find".to_string(),
"I'm not a robot".to_string(),
"click".to_string(),
];
let err = parse_command(&input, &default_flags()).unwrap_err();
let msg = err.format();
assert!(msg.contains("find text"), "got: {msg}");
assert!(msg.contains("I'm not a robot"), "got: {msg}");
}
// === Core Actions ===
#[test]
+110 -47
View File
@@ -1,4 +1,4 @@
//! `agent-browser connect` — zero-confirmation control of the user's real,
//! `chrome-use connect` — zero-confirmation control of the user's real,
//! logged-in Chrome via the `ab-connect` MV3 extension over Chrome **native
//! messaging** (no localhost port, no token; Chrome authenticates the extension
//! to this host by id).
@@ -16,9 +16,19 @@ use std::io::Write;
use std::path::PathBuf;
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
/// manifest filename.
/// manifest filename. `com.agent_browser.connect` is the original name, used by
/// every shipped extension up to ab-connect 0.4.2.
pub const HOST_NAME: &str = "com.agent_browser.connect";
/// Alternate host name for the chrome-use rebrand era (ab-connect 0.5.0+). We
/// install AND recognize both names so the relay works regardless of which
/// extension version a user has — old (0.4.2) or new — with no forced
/// re-install. See [`install_native_host`] / [`host_installed`].
pub const HOST_NAME_ALT: &str = "com.leeguoo.chrome_use";
/// Every native-messaging host name this CLI installs and accepts.
pub const HOST_NAMES: &[&str] = &[HOST_NAME, HOST_NAME_ALT];
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
/// that extension talk to this host, and the force-install policy references it.
@@ -44,11 +54,11 @@ pub const STORE_URL: &str =
/// Stable identifiers for the generated Chrome configuration profile, so a
/// re-install replaces (rather than duplicates) it in System Settings.
const PROFILE_ID: &str = "work.pwtk.agent-browser.ab-connect";
const PROFILE_ID: &str = "work.pwtk.chrome-use.ab-connect";
const PROFILE_UUID: &str = "A1B2C3D4-AB00-4CCE-9E10-AAAABBBBCCCC";
const PROFILE_PAYLOAD_UUID: &str = "A1B2C3D4-AB01-4CCE-9E10-DDDDEEEEFFFF";
/// `agent-browser extension <install|uninstall|status>` (local; no daemon).
/// `chrome-use extension <install|uninstall|status>` (local; no daemon).
/// `args` is the cleaned argv including the leading "extension".
pub fn run_connect(args: &[String], json: bool) {
let install = args.iter().any(|a| a == "--install" || a == "install");
@@ -66,11 +76,11 @@ pub fn run_connect(args: &[String], json: bool) {
} else {
println!("✓ removed {removed} native-host manifest(s).");
if profile_removed {
println!("✓ removed ~/.agent-browser/ab-connect.mobileconfig");
println!("✓ removed ~/.chrome-use/ab-connect.mobileconfig");
}
if cfg!(target_os = "macos") {
println!(
" To fully remove the extension, delete the \"agent-browser connect\" profile\n\
" To fully remove the extension, delete the \"chrome-use connect\" profile\n\
in System Settings → Profiles (or run: profiles remove -identifier {PROFILE_ID})."
);
}
@@ -114,7 +124,7 @@ pub fn run_connect(args: &[String], json: bool) {
A) One click: open {STORE_URL}\n and press \"Add to Chrome\".\n\
B) Silent: approve the profile, then restart Chrome —\n \
System Settings → General → Device Management → double-click\n \
\"agent-browser connect\" → Install. Chrome then force-installs +\n \
\"chrome-use connect\" → Install. Chrome then force-installs +\n \
auto-updates it (no token, no per-use confirmation).\n\
Both need the extension published to the Web Store; until then use\n \
chrome://extensions → Developer mode → Load unpacked → extensions/ab-connect."
@@ -156,23 +166,23 @@ pub fn run_connect(args: &[String], json: bool) {
println!("✓ native-messaging host installed ({HOST_NAME}).");
println!(" Load the ab-connect extension and it connects automatically.");
} else {
println!("✗ not installed. Run: agent-browser connect --install");
println!("✗ not installed. Run: chrome-use connect --install");
}
}
/// Write the launcher script + native-messaging host manifest(s).
fn install_native_host() -> Result<Vec<String>, String> {
let home = dirs::home_dir().ok_or("no home dir")?;
let ab_dir = home.join(".agent-browser");
let ab_dir = home.join(".chrome-use");
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
// Chrome execs the manifest `path` directly with the calling extension's
// origin as argv[1]; a launcher lets us run the binary in __nm-host mode
// regardless of how/where agent-browser is installed.
// regardless of how/where chrome-use is installed.
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let launcher = ab_dir.join("nm-host.sh");
let script = format!(
"#!/bin/sh\n# agent-browser native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
"#!/bin/sh\n# chrome-use native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
exe.display()
);
std::fs::write(&launcher, script).map_err(|e| e.to_string())?;
@@ -182,18 +192,9 @@ fn install_native_host() -> Result<Vec<String>, String> {
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
}
let manifest = serde_json::json!({
"name": HOST_NAME,
"description": "agent-browser connect — native messaging host",
"path": launcher.display().to_string(),
"type": "stdio",
"allowed_origins": [
format!("chrome-extension://{EXTENSION_ID}/"),
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
],
});
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
// Write a manifest under EVERY accepted host name (both point to the same
// launcher + allowed extensions), so any extension version's
// `connectNative(<its host name>)` finds a matching host json.
let mut written = Vec::new();
for dir in native_messaging_dirs() {
if let Some(parent) = dir.parent() {
@@ -202,9 +203,22 @@ fn install_native_host() -> Result<Vec<String>, String> {
}
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{HOST_NAME}.json"));
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
written.push(path.display().to_string());
for host in HOST_NAMES {
let manifest = serde_json::json!({
"name": host,
"description": "chrome-use connect — native messaging host",
"path": launcher.display().to_string(),
"type": "stdio",
"allowed_origins": [
format!("chrome-extension://{EXTENSION_ID}/"),
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
],
});
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
let path = dir.join(format!("{host}.json"));
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
written.push(path.display().to_string());
}
}
if written.is_empty() {
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
@@ -223,7 +237,7 @@ fn install_force_install_profile(no_open: bool) -> Result<PathBuf, String> {
.into());
}
let home = dirs::home_dir().ok_or("no home dir")?;
let ab_dir = home.join(".agent-browser");
let ab_dir = home.join(".chrome-use");
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
let path = ab_dir.join("ab-connect.mobileconfig");
std::fs::write(&path, force_install_mobileconfig()).map_err(|e| e.to_string())?;
@@ -254,7 +268,7 @@ fn force_install_mobileconfig() -> String {
<key>PayloadIdentifier</key><string>{PROFILE_ID}.chrome</string>
<key>PayloadUUID</key><string>{PROFILE_PAYLOAD_UUID}</string>
<key>PayloadEnabled</key><true/>
<key>PayloadDisplayName</key><string>agent-browser connect (Chrome)</string>
<key>PayloadDisplayName</key><string>chrome-use connect (Chrome)</string>
<key>ExtensionInstallForcelist</key>
<array>
<string>{forcelist}</string>
@@ -265,9 +279,9 @@ fn force_install_mobileconfig() -> String {
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>{PROFILE_ID}</string>
<key>PayloadUUID</key><string>{PROFILE_UUID}</string>
<key>PayloadDisplayName</key><string>agent-browser connect</string>
<key>PayloadDescription</key><string>Force-installs the agent-browser connect extension so agent-browser can drive your logged-in Chrome. No token, no per-use confirmation.</string>
<key>PayloadOrganization</key><string>agent-browser-stealth</string>
<key>PayloadDisplayName</key><string>chrome-use connect</string>
<key>PayloadDescription</key><string>Force-installs the chrome-use connect extension so chrome-use can drive your logged-in Chrome. No token, no per-use confirmation.</string>
<key>PayloadOrganization</key><string>chrome-use</string>
<key>PayloadScope</key><string>User</string>
<key>PayloadRemovalDisallowed</key><false/>
</dict>
@@ -280,7 +294,7 @@ fn force_install_mobileconfig() -> String {
/// the user from System Settings, or via `profiles remove`).
fn remove_force_install_profile() -> bool {
dirs::home_dir()
.map(|h| h.join(".agent-browser").join("ab-connect.mobileconfig"))
.map(|h| h.join(".chrome-use").join("ab-connect.mobileconfig"))
.filter(|p| p.exists())
.map(|p| std::fs::remove_file(&p).is_ok())
.unwrap_or(false)
@@ -289,9 +303,11 @@ fn remove_force_install_profile() -> bool {
fn remove_host_manifests() -> usize {
let mut n = 0;
for dir in native_messaging_dirs() {
let path = dir.join(format!("{HOST_NAME}.json"));
if path.exists() && std::fs::remove_file(&path).is_ok() {
n += 1;
for host in HOST_NAMES {
let path = dir.join(format!("{host}.json"));
if path.exists() && std::fs::remove_file(&path).is_ok() {
n += 1;
}
}
}
n
@@ -334,7 +350,7 @@ fn native_messaging_dirs() -> Vec<PathBuf> {
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
native_messaging_dirs()
.into_iter()
.map(|d| d.join(format!("{HOST_NAME}.json")))
.flat_map(|d| HOST_NAMES.iter().map(move |h| d.join(format!("{h}.json"))))
.find(|p| p.exists())
.or_else(|| {
native_messaging_dirs()
@@ -352,9 +368,11 @@ fn host_manifest_path_for_chrome() -> Option<PathBuf> {
/// service worker; this manifest is the durable signal that the extension is
/// the chosen path.
pub fn host_installed() -> bool {
native_messaging_dirs()
.into_iter()
.any(|d| d.join(format!("{HOST_NAME}.json")).exists())
native_messaging_dirs().into_iter().any(|d| {
HOST_NAMES
.iter()
.any(|h| d.join(format!("{h}.json")).exists())
})
}
fn report(json: bool, ok: bool, msg: &str) {
@@ -378,7 +396,7 @@ fn report(json: bool, ok: bool, msg: &str) {
fn nm_log(line: &str) {
let path = dirs::home_dir()
.map(|h| h.join(".agent-browser").join("nm-host.log"))
.map(|h| h.join(".chrome-use").join("nm-host.log"))
.unwrap_or_else(|| PathBuf::from("/tmp/ab-nm-host.log"));
if let Some(p) = path.parent() {
let _ = std::fs::create_dir_all(p);
@@ -399,15 +417,28 @@ fn random_guid() -> String {
}
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
///
/// Cross-binary handoff: the native-messaging *host* writes it and the CLI reads
/// it, but the two may be different binaries under different brand dirs after
/// the agent-browser → chrome-use rename. Read from whichever brand dir actually
/// has the file (an old `agent-browser` host writes `~/.agent-browser`; a
/// `chrome-use` host writes `~/.chrome-use`); default to [`config_home`].
fn relay_url_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".agent-browser").join("relay-cdp-url"))
.unwrap_or_else(|| PathBuf::from("/tmp/ab-relay-cdp-url"))
if let Some(home) = dirs::home_dir() {
for base in [".chrome-use", ".agent-browser"] {
let p = home.join(base).join("relay-cdp-url");
if p.exists() {
return p;
}
}
return crate::connection::config_home().join("relay-cdp-url");
}
PathBuf::from("/tmp/ab-relay-cdp-url")
}
/// The live relay CDP WebSocket URL, if the native-messaging host is running
/// (it writes the file on connect and removes it on exit). Used by
/// `agent-browser extension connect` to attach without the user copying a URL.
/// `chrome-use extension connect` to attach without the user copying a URL.
pub fn relay_url() -> Option<String> {
let s = std::fs::read_to_string(relay_url_path()).ok()?;
let s = s.trim().to_string();
@@ -418,13 +449,35 @@ pub fn relay_url() -> Option<String> {
}
}
/// Sidecar recording the connected extension's version, written by the host when
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
/// `doctor` surface which extension build is live without a CDP round-trip.
fn relay_ext_version_path() -> PathBuf {
relay_url_path().with_file_name("relay-ext-version")
}
/// Version of the connected `ab-connect` extension, if the host learned it from
/// the extension's `hello`. `None` when no extension has connected since the
/// host started, or the extension predates version reporting.
pub fn relay_ext_version() -> Option<String> {
let s = std::fs::read_to_string(relay_ext_version_path())
.ok()?
.trim()
.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
///
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
/// **CDP WebSocket endpoint** that agent-browser connects to like any Chrome.
/// **CDP WebSocket endpoint** that chrome-use connects to like any Chrome.
/// `relay::RelayState` translates envelope ⇄ raw CDP and emulates browser-level
/// Target discovery. The ws URL carries an unguessable guid (written to a 600
/// file) so only this user's agent-browser — not arbitrary local processes —
/// file) so only this user's chrome-use — not arbitrary local processes —
/// can drive the browser. No token, no user interaction.
pub fn run_nm_host() {
let rt = match tokio::runtime::Builder::new_multi_thread()
@@ -496,7 +549,7 @@ async fn nm_host_main() {
}
});
// Accept agent-browser CDP clients on the guid-scoped ws endpoint.
// Accept chrome-use CDP clients on the guid-scoped ws endpoint.
{
let state = state.clone();
let clients = clients.clone();
@@ -539,6 +592,15 @@ async fn nm_host_main() {
Ok(v) => v,
Err(_) => continue,
};
// Extension version handshake: record it next to the relay URL so
// `doctor` can report which extension build is live (and whether it's
// behind). Best-effort; the message carries no CDP payload.
if v.get("method").and_then(|m| m.as_str()) == Some("hello") {
if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
let _ = std::fs::write(relay_ext_version_path(), ver);
}
continue;
}
let outs = {
let mut s = state.lock().await;
s.handle_ext_message(&v, "")
@@ -571,6 +633,7 @@ async fn nm_host_main() {
}
nm_log("[nm-host] stdin EOF — Chrome closed the port");
let _ = std::fs::remove_file(relay_url_path());
let _ = std::fs::remove_file(relay_ext_version_path());
}
#[allow(clippy::too_many_arguments)]
+109 -17
View File
@@ -88,8 +88,39 @@ impl Connection {
}
}
/// Brand-compat config directory basename. The project renamed
/// `agent-browser` → `chrome-use`, but this dotfile dir is invisible internal
/// plumbing: it's shared with the native-messaging host (the `relay-cdp-url`
/// handoff) and holds saved auth/daemon state. Renaming it would break existing
/// installs and re-pop the "Allow remote debugging?" dialog when the relay
/// can't be located. So decide ONCE per run: prefer the new `.chrome-use`, but
/// keep using an existing `.agent-browser` install if that's the only one
/// present; fresh installs get `.chrome-use`. `dotted` picks the home-dir form
/// (`.chrome-use`) vs the XDG/tmp subdir form (`chrome-use`); both agree.
pub fn config_dir_basename(dotted: bool) -> &'static str {
let prefer_old = dirs::home_dir()
.map(|h| !h.join(".chrome-use").exists() && h.join(".agent-browser").exists())
.unwrap_or(false);
match (prefer_old, dotted) {
(true, true) => ".agent-browser",
(true, false) => "agent-browser",
(false, true) => ".chrome-use",
(false, false) => "chrome-use",
}
}
/// The home-based config dir (`~/.chrome-use`, or `~/.agent-browser` on an
/// existing install — see [`config_dir_basename`]). Single source of truth so
/// sockets, auth, and the relay handoff all agree within one run.
pub fn config_home() -> PathBuf {
match dirs::home_dir() {
Some(home) => home.join(config_dir_basename(true)),
None => env::temp_dir().join(config_dir_basename(false)),
}
}
/// Get the base directory for socket/pid files.
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > config_home() > tmpdir
pub fn get_socket_dir() -> PathBuf {
// 1. Explicit override (ignore empty string)
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
@@ -101,17 +132,17 @@ pub fn get_socket_dir() -> PathBuf {
// 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string)
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
if !runtime_dir.is_empty() {
return PathBuf::from(runtime_dir).join("agent-browser");
return PathBuf::from(runtime_dir).join(config_dir_basename(false));
}
}
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
if let Some(home) = dirs::home_dir() {
return home.join(".agent-browser");
if dirs::home_dir().is_some() {
return config_home();
}
// 4. Last resort: temp dir
env::temp_dir().join("agent-browser")
env::temp_dir().join(config_dir_basename(false))
}
#[cfg(unix)]
@@ -611,6 +642,22 @@ fn kill_stale_daemon(session: &str) {
cleanup_stale_files(session);
}
/// Kill every per-session daemon worker (SIGTERM→SIGKILL + sidecar cleanup),
/// leaving the Chrome-launched `__nm-host` native-messaging bridge alone — it's
/// not a tracked session daemon, so the extension relay stays up. Returns the
/// session names that were stopped. Powers `chrome-use daemon restart`, which
/// clears corrupted/cross-leaked daemon state (e.g. after a version-mismatch
/// restart) without the user resorting to `pgrep`/`kill` (issue #20).
pub fn restart_all_daemons() -> Vec<String> {
let inventory = walk_daemons();
let mut stopped = Vec::new();
for session in &inventory.sessions {
kill_stale_daemon(&session.name);
stopped.push(session.name.clone());
}
stopped
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
// Socket connectivity is the sole liveness check — no PID check — so
// callers in a different PID namespace (e.g. unshare) can still reuse
@@ -625,7 +672,10 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
// version (e.g. after an upgrade), kill it and start a fresh one.
if !daemon_version_matches(session) {
eprintln!(
"{} Daemon version mismatch detected, restarting...",
"{} Daemon version mismatch detected, restarting... \
In-memory context (active tab, refs, captured requests) is reset. \
If the next read looks blank or lands on the wrong page, re-open \
your target URL before retrying (issue #8.2).",
crate::color::warning_indicator()
);
// Best-effort: ask the old daemon for its current URL so the
@@ -946,9 +996,7 @@ mod tests {
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.remove("XDG_RUNTIME_DIR");
assert!(get_socket_dir()
.to_string_lossy()
.ends_with(".agent-browser"));
assert!(get_socket_dir().to_string_lossy().ends_with(".chrome-use"));
}
#[test]
@@ -958,10 +1006,7 @@ mod tests {
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
assert_eq!(
get_socket_dir(),
PathBuf::from("/run/user/1000/agent-browser")
);
assert_eq!(get_socket_dir(), PathBuf::from("/run/user/1000/chrome-use"));
}
#[test]
@@ -971,9 +1016,7 @@ mod tests {
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
_guard.set("XDG_RUNTIME_DIR", "");
assert!(get_socket_dir()
.to_string_lossy()
.ends_with(".agent-browser"));
assert!(get_socket_dir().to_string_lossy().ends_with(".chrome-use"));
}
#[test]
@@ -984,7 +1027,7 @@ mod tests {
_guard.remove("XDG_RUNTIME_DIR");
let result = get_socket_dir();
assert!(result.to_string_lossy().ends_with(".agent-browser"));
assert!(result.to_string_lossy().ends_with(".chrome-use"));
assert!(
result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")
);
@@ -1155,6 +1198,55 @@ mod tests {
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_restart_all_daemons_empty_dir() {
let dir = std::env::temp_dir().join("ab-test-restart-empty");
let _ = fs::create_dir_all(&dir);
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
// No daemons registered → nothing to stop, and it must not blow up.
assert!(restart_all_daemons().is_empty());
let _ = fs::remove_dir(&dir);
}
#[cfg(unix)]
#[test]
fn test_restart_all_daemons_kills_live_session() {
let dir = std::env::temp_dir().join("ab-test-restart-live");
let _ = fs::create_dir_all(&dir);
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
// Spawn a real, killable child and register it as a session daemon.
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let _ = fs::write(dir.join("rktest.pid"), pid.to_string());
let _ = fs::write(get_socket_path("rktest"), b"");
let stopped = restart_all_daemons();
assert!(
stopped.contains(&"rktest".to_string()),
"stopped: {:?}",
stopped
);
// Reap the killed child first — until the parent waits, it lingers as a
// zombie that still answers `kill(pid, 0)`, so is_pid_alive would lie.
let _ = child.wait();
assert!(!is_pid_alive(pid));
// Sidecars are cleaned up.
assert!(!dir.join("rktest.pid").exists());
assert!(!get_socket_path("rktest").exists());
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_cleanup_stale_files_removes_version() {
let dir = std::env::temp_dir().join("ab-test-cleanup-version");
+332
View File
@@ -0,0 +1,332 @@
//! Offline export of a Chrome profile's cookies.
//!
//! Reads a profile's on-disk cookie store, decrypts the values with the OS
//! credential-store key, and returns CDP `Network.setCookie`-shaped objects —
//! the same shape `cookies set --curl` accepts. This is what powers
//! `cookies transfer`: it moves a logged-in session (whose auth cookies are
//! httpOnly + secure and span several hosts) from one profile to another
//! without the source profile being reachable over CDP, and without restarting
//! Chrome.
//!
//! Currently macOS-only. There, value encryption uses the `v10` scheme:
//! AES-128-CBC with a key derived (PBKDF2-HMAC-SHA1, 1003 iterations) from the
//! "Chrome Safe Storage" Keychain entry, shared by every profile of one Chrome
//! install. Other platforms return a clear error.
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
/// Resolve, read, and decrypt a Chrome profile's cookies.
///
/// `profile` accepts a directory name ("Default", "Profile 14"), a display name
/// ("Davian", case-insensitive), or "auto" (last-used profile). `domain`, when
/// set, is a comma-separated host-suffix filter (e.g. "claude.ai,anthropic.com")
/// matched against `host_key`; pass `None` to export every cookie.
pub fn export_cookies(profile: &str, domain: Option<&str>) -> Result<Vec<Value>, String> {
let db = resolve_cookie_db(profile)?;
let rows = read_cookie_rows(&db, domain)?;
let key = safe_storage_key()?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
if let Some(value) = decrypt_value(&r.encrypted_value, &key) {
out.push(to_cdp_cookie(r, value));
}
}
Ok(out)
}
fn resolve_cookie_db(profile: &str) -> Result<PathBuf, String> {
use crate::native::cdp::chrome::{find_chrome_user_data_dir, resolve_chrome_profile};
let udd = find_chrome_user_data_dir()
.ok_or_else(|| "No Chrome user data directory found".to_string())?;
let dir = resolve_chrome_profile(&udd, profile)?;
let base = udd.join(&dir);
// Chrome >=96 keeps cookies under Network/; older builds at the profile root.
let net = base.join("Network").join("Cookies");
if net.is_file() {
return Ok(net);
}
let root = base.join("Cookies");
if root.is_file() {
return Ok(root);
}
Err(format!(
"no cookie store found for profile \"{}\" (looked in {} and {})",
profile,
net.display(),
root.display()
))
}
struct CookieRow {
host_key: String,
name: String,
encrypted_value: Vec<u8>,
path: String,
is_secure: bool,
is_httponly: bool,
samesite: i64,
expires_utc: i64,
}
/// Removes a temp directory when dropped.
struct TempGuard(PathBuf);
impl Drop for TempGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn read_cookie_rows(db: &Path, domain: Option<&str>) -> Result<Vec<CookieRow>, String> {
// Copy the store (plus any -wal/-shm) to a temp file so a running Chrome's
// lock / hot journal can't block the read or be disturbed by it.
let tmp_dir = std::env::temp_dir().join(format!("chrome-use-cookies-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("temp dir: {}", e))?;
let _guard = TempGuard(tmp_dir.clone());
let tmp_db = tmp_dir.join("Cookies");
copy_db(db, &tmp_db)?;
let where_clause = build_where(domain)?;
let sql = format!(
"SELECT json_group_array(json_object(\
'h',host_key,'n',name,'e',hex(encrypted_value),'p',path,\
'sec',is_secure,'ho',is_httponly,'ss',samesite,'x',expires_utc)) \
FROM cookies{};",
where_clause
);
let output = std::process::Command::new("sqlite3")
.arg(tmp_db.to_string_lossy().to_string())
.arg(&sql)
.output()
.map_err(|e| {
format!(
"could not run sqlite3 (required to read the cookie store): {}",
e
)
})?;
if !output.status.success() {
return Err(format!(
"sqlite3 failed reading the cookie store: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() || trimmed == "null" {
return Ok(Vec::new());
}
let arr: Vec<Value> =
serde_json::from_str(trimmed).map_err(|e| format!("parsing cookie rows: {}", e))?;
let mut rows = Vec::with_capacity(arr.len());
for v in arr {
let enc_hex = v.get("e").and_then(|x| x.as_str()).unwrap_or("");
let path = v.get("p").and_then(|x| x.as_str()).unwrap_or("/");
rows.push(CookieRow {
host_key: v
.get("h")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
name: v
.get("n")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
encrypted_value: hex::decode(enc_hex).unwrap_or_default(),
path: if path.is_empty() {
"/".to_string()
} else {
path.to_string()
},
is_secure: v.get("sec").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
is_httponly: v.get("ho").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
samesite: v.get("ss").and_then(|x| x.as_i64()).unwrap_or(-1),
expires_utc: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0),
});
}
Ok(rows)
}
fn copy_db(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::copy(src, dst).map_err(|e| format!("copying cookie store: {}", e))?;
for suffix in ["-wal", "-shm"] {
let s = path_with_suffix(src, suffix);
if s.is_file() {
let _ = std::fs::copy(&s, path_with_suffix(dst, suffix));
}
}
Ok(())
}
fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
let mut s = p.as_os_str().to_os_string();
s.push(suffix);
PathBuf::from(s)
}
/// Build a `WHERE host_key LIKE '%domain'` clause from a comma-separated filter.
/// Domains are validated (alnum/./-) so they can be inlined without injection.
fn build_where(domain: Option<&str>) -> Result<String, String> {
let Some(domain) = domain else {
return Ok(String::new());
};
let mut clauses = Vec::new();
for d in domain.split(',') {
let d = d.trim();
if d.is_empty() {
continue;
}
if !d
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Err(format!("invalid domain filter \"{}\"", d));
}
clauses.push(format!("host_key LIKE '%{}'", d));
}
if clauses.is_empty() {
Ok(String::new())
} else {
Ok(format!(" WHERE {}", clauses.join(" OR ")))
}
}
fn to_cdp_cookie(r: &CookieRow, value: String) -> Value {
let mut o = serde_json::Map::new();
o.insert("name".into(), json!(r.name));
o.insert("value".into(), json!(value));
o.insert("domain".into(), json!(r.host_key));
o.insert("path".into(), json!(r.path));
o.insert("secure".into(), json!(r.is_secure));
o.insert("httpOnly".into(), json!(r.is_httponly));
// Chrome SameSite: -1 unspecified, 0 None, 1 Lax, 2 Strict.
let same_site = match r.samesite {
0 => Some("None"),
1 => Some("Lax"),
2 => Some("Strict"),
_ => None,
};
if let Some(ss) = same_site {
// CDP rejects SameSite=None without Secure; downgrade rather than fail.
if ss == "None" && !r.is_secure {
o.insert("sameSite".into(), json!("Lax"));
} else {
o.insert("sameSite".into(), json!(ss));
}
}
if let Some(unix) = chrome_epoch_to_unix(r.expires_utc) {
o.insert("expires".into(), json!(unix));
}
Value::Object(o)
}
/// Chrome stores `expires_utc` as microseconds since 1601-01-01 (0 = session
/// cookie). CDP wants seconds since the Unix epoch. Returns None for session
/// cookies and anything that converts to a non-positive time.
fn chrome_epoch_to_unix(expires_utc: i64) -> Option<f64> {
if expires_utc <= 0 {
return None;
}
let unix = expires_utc as f64 / 1_000_000.0 - 11_644_473_600.0;
if unix > 0.0 {
Some(unix)
} else {
None
}
}
/// Decrypt a Chrome `v10` cookie value (AES-128-CBC, IV = 16 spaces, PKCS7).
/// Returns None for unrecognized schemes or undecryptable values.
fn decrypt_value(enc: &[u8], key: &[u8; 16]) -> Option<String> {
if enc.len() < 3 || &enc[0..3] != b"v10" {
return None;
}
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
type Dec = cbc::Decryptor<aes::Aes128>;
let iv = [0x20u8; 16];
let mut buf = enc[3..].to_vec();
let pt = Dec::new(key.into(), &iv.into())
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.ok()?;
// Chrome >=24 prepends a 32-byte SHA256(host) domain hash to the plaintext.
match std::str::from_utf8(pt) {
Ok(s) => Some(s.to_string()),
Err(_) if pt.len() > 32 => Some(String::from_utf8_lossy(&pt[32..]).into_owned()),
Err(_) => None,
}
}
#[cfg(target_os = "macos")]
fn safe_storage_key() -> Result<[u8; 16], String> {
use pbkdf2::pbkdf2_hmac;
use sha1::Sha1;
let out = std::process::Command::new("security")
.args(["find-generic-password", "-ws", "Chrome Safe Storage"])
.output()
.map_err(|e| format!("could not read Keychain (security command): {}", e))?;
if !out.status.success() {
return Err(
"could not read the 'Chrome Safe Storage' key from Keychain \
(you may be prompted to allow access approve it and retry)"
.to_string(),
);
}
let pw = String::from_utf8_lossy(&out.stdout);
let pw = pw.trim_end_matches('\n');
let mut key = [0u8; 16];
pbkdf2_hmac::<Sha1>(pw.as_bytes(), b"saltysalt", 1003, &mut key);
Ok(key)
}
#[cfg(not(target_os = "macos"))]
fn safe_storage_key() -> Result<[u8; 16], String> {
Err("cookies export/transfer is currently supported on macOS only".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn where_clause_filters_and_validates() {
assert_eq!(build_where(None).unwrap(), "");
assert_eq!(
build_where(Some("claude.ai")).unwrap(),
" WHERE host_key LIKE '%claude.ai'"
);
assert_eq!(
build_where(Some("claude.ai, anthropic.com")).unwrap(),
" WHERE host_key LIKE '%claude.ai' OR host_key LIKE '%anthropic.com'"
);
assert!(build_where(Some("evil' OR 1=1 --")).is_err());
}
#[test]
fn epoch_conversion() {
assert_eq!(chrome_epoch_to_unix(0), None);
assert_eq!(chrome_epoch_to_unix(-5), None);
// 13380163200000000 us since 1601 == 2025-01-01T00:00:00Z (1735689600 unix)
assert_eq!(
chrome_epoch_to_unix(13_380_163_200_000_000),
Some(1_735_689_600.0)
);
}
#[test]
fn to_cdp_downgrades_samesite_none_without_secure() {
let row = CookieRow {
host_key: ".claude.ai".into(),
name: "x".into(),
encrypted_value: vec![],
path: "/".into(),
is_secure: false,
is_httponly: true,
samesite: 0, // None
expires_utc: 0,
};
let c = to_cdp_cookie(&row, "v".into());
assert_eq!(c["sameSite"], "Lax");
assert_eq!(c["httpOnly"], true);
assert_eq!(c.get("expires"), None);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
Status::Fail,
"No Chrome binary found",
)
.with_fix("agent-browser install"),
.with_fix("chrome-use install"),
),
}
+4 -4
View File
@@ -1,5 +1,5 @@
//! Check user config files: `~/.agent-browser/config.json`,
//! `./agent-browser.json`, and any file referenced by
//! Check user config files: `~/.chrome-use/config.json`,
//! `./chrome-use.json`, and any file referenced by
//! `AGENT_BROWSER_CONFIG`.
use std::env;
@@ -11,7 +11,7 @@ use super::{Check, Status};
pub(super) fn check(checks: &mut Vec<Check>) {
let category = "Config";
let user_path = dirs::home_dir().map(|d| d.join(".agent-browser").join("config.json"));
let user_path = dirs::home_dir().map(|d| d.join(".chrome-use").join("config.json"));
if let Some(p) = user_path {
if p.exists() {
match parse_json_file(&p) {
@@ -34,7 +34,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
}
}
let project_path = PathBuf::from("agent-browser.json");
let project_path = PathBuf::from("chrome-use.json");
if project_path.exists() {
match parse_json_file(&project_path) {
Ok(_) => checks.push(Check::new(
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
format!("Session {} (pid {}){}", session.name, session.pid, suffix),
);
if !version_match {
check = check.with_fix(format!("agent-browser --session {} close", session.name));
check = check.with_fix(format!("chrome-use --session {} close", session.name));
}
checks.push(check);
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
let socket_dir = get_socket_dir();
// Under the default setup, state and socket dirs are the same
// (~/.agent-browser). Collapse to a single line when they match;
// (~/.chrome-use). Collapse to a single line when they match;
// split when XDG_RUNTIME_DIR or AGENT_BROWSER_SOCKET_DIR diverts
// sockets elsewhere.
if state_dir == socket_dir {
+2 -2
View File
@@ -240,8 +240,8 @@ mod tests {
"fixed summary should mention the key generation"
);
assert!(
tmp.path().join(".agent-browser/.encryption-key").exists(),
"key file should exist at ~/.agent-browser/.encryption-key"
tmp.path().join(".chrome-use/.encryption-key").exists(),
"key file should exist at ~/.chrome-use/.encryption-key"
);
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ mod tests {
};
assert!(which_exists(probe));
assert!(!which_exists(
"agent-browser-this-does-not-exist-please-dont-install-it"
"chrome-use-this-does-not-exist-please-dont-install-it"
));
}
+1 -1
View File
@@ -114,7 +114,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
Status::Fail,
format!("Browser launch failed: {}", e),
)
.with_fix("agent-browser install # or check --debug output"),
.with_fix("chrome-use install # or check --debug output"),
);
return;
}
+4 -2
View File
@@ -1,4 +1,4 @@
//! Diagnose an agent-browser installation.
//! Diagnose an chrome-use installation.
//!
//! Runs a battery of checks across environment, Chrome install, daemon
//! state, config files, encryption, providers, network reachability, and
@@ -18,6 +18,7 @@ mod launch;
mod network;
mod providers;
mod security;
mod versions;
use serde_json::{json, Value};
@@ -97,6 +98,7 @@ pub fn run_doctor(opts: DoctorOptions) -> i32 {
let mut fixed: Vec<String> = Vec::new();
environment::check(&mut checks);
versions::check(&mut checks);
chrome::check(&mut checks);
daemon::check(&mut checks);
config::check(&mut checks);
@@ -151,7 +153,7 @@ fn summarize(checks: &[Check]) -> Summary {
}
fn print_text(checks: &[Check], summary: &Summary, fixed: &[String], fix_ran: bool) {
println!("{}", color::bold("agent-browser doctor"));
println!("{}", color::bold("chrome-use doctor"));
let mut current_category = "";
for c in checks {
+1 -1
View File
@@ -27,7 +27,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
};
let client = match reqwest::Client::builder()
.user_agent(format!("agent-browser/{}", env!("CARGO_PKG_VERSION")))
.user_agent(format!("chrome-use/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(3))
.connect_timeout(Duration::from_secs(3))
.build()
+1 -1
View File
@@ -115,7 +115,7 @@ pub(super) fn check(checks: &mut Vec<Check>) {
),
)
.with_fix(format!(
"agent-browser state clean --older-than {}",
"chrome-use state clean --older-than {}",
expire_days
)),
);
+89
View File
@@ -0,0 +1,89 @@
//! Version-coherence checks across all four moving parts: the CLI binary, the
//! per-session daemons (covered by `daemon.rs`), the connected `ab-connect`
//! extension, and the bundled skill. The extension was previously a black box —
//! nothing reported which build was live — so a user could sit on an old
//! extension with no signal. The extension now reports its version over the
//! relay (`hello`), the host records it, and this surfaces it in one place.
use super::{Check, Status};
use crate::{connect, upgrade};
pub(super) fn check(checks: &mut Vec<Check>) {
let category = "Versions";
let cli_version = env!("CARGO_PKG_VERSION");
// CLI — compare against the latest seen by the background update check.
match upgrade::cached_latest_version() {
Some(latest) if upgrade::version_is_newer(&latest, cli_version) => {
checks.push(
Check::new(
"versions.cli",
category,
Status::Warn,
format!("CLI {cli_version} (newer available: {latest})"),
)
.with_fix("chrome-use upgrade".to_string()),
);
}
_ => {
checks.push(Check::new(
"versions.cli",
category,
Status::Pass,
format!("CLI {cli_version}"),
));
}
}
// Extension — the build this CLI shipped alongside (embedded at compile time
// from the extension manifest) is what we expect to be running.
let expected_ext = env!("AB_CONNECT_VERSION");
match connect::relay_ext_version() {
Some(ext) if upgrade::version_is_newer(expected_ext, &ext) => {
checks.push(
Check::new(
"versions.extension",
category,
Status::Warn,
format!("extension {ext} is behind the bundled {expected_ext}"),
)
.with_fix(
"update ab-connect in Chrome: chrome://extensions \u{2192} reload \
(or wait for the Web Store auto-update)"
.to_string(),
),
);
}
Some(ext) => {
checks.push(Check::new(
"versions.extension",
category,
Status::Pass,
format!("extension {ext}"),
));
}
None => {
checks.push(Check::new(
"versions.extension",
category,
Status::Info,
format!(
"extension not connected (or it predates version reporting — \
expected {expected_ext})"
),
));
}
}
// Skill — ships inside the same release artifact as the binary, so it's
// version-locked here. Copies made elsewhere via `skills add` aren't.
checks.push(Check::new(
"versions.skill",
category,
Status::Info,
format!(
"skills bundled with this CLI ({cli_version}); copies made via `skills add` \
elsewhere may be stale re-run to refresh"
),
));
}
+3 -3
View File
@@ -4,9 +4,9 @@ use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_DIR: &str = ".chrome-use";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
const PROJECT_CONFIG_FILENAME: &str = "chrome-use.json";
/// Parse idle timeout from user-friendly format.
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
@@ -1344,7 +1344,7 @@ mod tests {
#[test]
fn test_load_config_missing_file_returns_none() {
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
let result = read_config_file(&PathBuf::from("/nonexistent/chrome-use.json"));
assert!(result.is_none());
}
+6 -6
View File
@@ -10,7 +10,7 @@ const LAST_KNOWN_GOOD_URL: &str =
pub fn get_browsers_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agent-browser")
.join(".chrome-use")
.join("browsers")
}
@@ -238,7 +238,7 @@ fn format_reqwest_error(e: &reqwest::Error) -> String {
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent(format!("agent-browser/{}", env!("CARGO_PKG_VERSION")))
.user_agent(format!("chrome-use/{}", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(30))
.build()
@@ -406,7 +406,7 @@ pub fn run_install(with_deps: bool) {
eprintln!(" Install Chromium from your system package manager instead:");
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
eprintln!(" sudo dnf install chromium # Fedora");
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
eprintln!(" Then use: chrome-use --executable-path /usr/bin/chromium");
exit(1);
}
@@ -420,7 +420,7 @@ pub fn run_install(with_deps: bool) {
"{} Linux detected. If browser fails to launch, run:",
color::warning_indicator()
);
println!(" agent-browser install --with-deps");
println!(" chrome-use install --with-deps");
println!();
}
}
@@ -486,7 +486,7 @@ pub fn run_install(with_deps: bool) {
"{} If you see \"shared library\" errors when running, use:",
color::yellow("Note:")
);
println!(" agent-browser install --with-deps");
println!(" chrome-use install --with-deps");
}
}
Err(e) => {
@@ -930,7 +930,7 @@ mod tests {
let url = format!("http://127.0.0.1:{}/test", port);
let _ = client.get(&url).send().await;
let request_text = server.await.unwrap();
let expected_ua = format!("agent-browser/{}", env!("CARGO_PKG_VERSION"));
let expected_ua = format!("chrome-use/{}", env!("CARGO_PKG_VERSION"));
assert!(
request_text.contains(&expected_ua),
"expected User-Agent '{}' in request:\n{}",
+218 -13
View File
@@ -3,6 +3,7 @@ mod color;
mod commands;
mod connect;
mod connection;
mod cookie_export;
mod doctor;
mod findurl;
mod flags;
@@ -10,6 +11,7 @@ mod install;
mod native;
mod output;
mod skills;
mod test_runner;
#[cfg(test)]
mod test_utils;
mod upgrade;
@@ -27,8 +29,8 @@ use windows_sys::Win32::System::Threading::OpenProcess;
use commands::{gen_id, parse_command, ParseError};
use connection::{
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons,
DaemonOptions,
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, restart_all_daemons,
send_command, walk_daemons, DaemonOptions,
};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
@@ -200,6 +202,64 @@ fn run_profiles(json_mode: bool) {
}
}
fn run_cookies_export(args: &[String], flags: &Flags) {
// Source profile comes from `--from <profile>`, falling back to the global
// `--profile` (which the flag parser has already moved into flags.profile).
let from = args
.iter()
.position(|a| a == "--from")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.or(flags.profile.as_deref());
let profile = match from {
Some(p) => p,
None => {
let msg = "cookies export needs a source profile: cookies export --from <profile> [--domain <d>]";
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
};
let domain = args
.iter()
.position(|a| a == "--domain")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str());
match cookie_export::export_cookies(profile, domain) {
Ok(cookies) => {
if flags.json {
print_json_value(json!({ "success": true, "data": cookies }));
} else {
// A JSON array ready for `cookies set --curl <file>`.
println!(
"{}",
serde_json::to_string(&cookies).unwrap_or_else(|_| "[]".to_string())
);
eprintln!(
"{}",
color::dim(&format!(
"{} cookies exported from \"{}\"",
cookies.len(),
profile
))
);
}
}
Err(e) => {
if flags.json {
print_json_error(&e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -210,13 +270,19 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
.into_iter()
.map(|s| s.name)
.collect();
// The extension relay drives the user's live Chrome but isn't always
// registered as a launched daemon session — without surfacing it,
// `session list` says "No active sessions" while open/tab work fine,
// and agents misjudge the connection as down (issue #15).
let relay_up = connect::relay_url().is_some();
if json_mode {
println!(
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
serde_json::to_string(&sessions).unwrap_or_default()
r#"{{"success":true,"data":{{"sessions":{},"relay":{}}}}}"#,
serde_json::to_string(&sessions).unwrap_or_default(),
relay_up
);
} else if sessions.is_empty() {
} else if sessions.is_empty() && !relay_up {
println!("No active sessions");
} else {
println!("Active sessions:");
@@ -228,6 +294,14 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
};
println!("{} {}", marker, s);
}
if relay_up && !sessions.iter().any(|s| s == session) {
println!(
"{} {} {}",
color::cyan(""),
session,
color::dim("(relay/extension → live Chrome)")
);
}
}
}
None | Some(_) => {
@@ -246,6 +320,94 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
}
}
/// `chrome-use daemon <restart|status>` — manage the per-session daemon workers
/// without resorting to `pgrep`/`kill`. `restart` clears corrupted or
/// cross-leaked daemon state (e.g. after a mid-session `chrome-use upgrade`
/// where stale tab handles bleed across sessions, issue #20) by killing every
/// session worker. The Chrome-launched `__nm-host` native-messaging bridge is
/// NOT a tracked session daemon, so the extension relay survives a restart —
/// the next command spins up a fresh, clean daemon against the same live Chrome.
fn run_daemon(args: &[String], json_mode: bool) {
match args.get(1).map(|s| s.as_str()) {
Some("restart") => {
let stopped = restart_all_daemons();
let relay_up = connect::relay_url().is_some();
if json_mode {
print_json_value(json!({
"success": true,
"data": { "stopped": stopped, "count": stopped.len(), "relay": relay_up },
}));
} else if stopped.is_empty() {
println!("No session daemons running — nothing to restart.");
if relay_up {
println!(
"{}",
color::dim("Extension relay still up; next command starts a fresh daemon.")
);
}
} else {
for s in &stopped {
println!("{} Stopped daemon: {}", color::green(""), s);
}
println!(
"{}",
color::dim(if relay_up {
"Extension relay (__nm-host) left running; next command starts a fresh daemon."
} else {
"Next command starts a fresh daemon."
})
);
}
}
Some("status") | Some("list") => {
let inventory = walk_daemons();
let relay_up = connect::relay_url().is_some();
if json_mode {
let sessions: Vec<_> = inventory
.sessions
.iter()
.map(|s| json!({ "name": s.name, "pid": s.pid, "version": s.version }))
.collect();
print_json_value(json!({
"success": true,
"data": { "sessions": sessions, "relay": relay_up },
}));
} else if inventory.sessions.is_empty() {
println!("No session daemons running.");
if relay_up {
println!("{}", color::dim("Extension relay (__nm-host): up"));
}
} else {
println!("Session daemons:");
for s in &inventory.sessions {
let ver = s
.version
.as_deref()
.map(|v| format!(" {}", color::dim(&format!("(v{})", v))))
.unwrap_or_default();
println!(" {} pid {}{}", s.name, s.pid, ver);
}
if relay_up {
println!("{}", color::dim("Extension relay (__nm-host): up"));
}
}
}
other => {
eprintln!(
"{} usage: chrome-use daemon <restart|status>",
color::error_indicator()
);
if let Some(unknown) = other {
eprintln!(
"{}",
color::dim(&format!(" unknown subcommand: {}", unknown))
);
}
exit(2);
}
}
}
fn get_dashboard_pid_path() -> std::path::PathBuf {
get_socket_dir().join("dashboard.pid")
}
@@ -504,7 +666,7 @@ fn main() {
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native-messaging host mode: Chrome launches `agent-browser __nm-host
// Native-messaging host mode: Chrome launches `chrome-use __nm-host
// <extension-origin> [...]` for the ab-connect extension. Must run before
// ANY stdout write — stdout is the Chrome native-messaging channel.
if env::args().nth(1).as_deref() == Some("__nm-host") {
@@ -512,6 +674,17 @@ fn main() {
return;
}
// Hidden update-check worker, spawned detached by maybe_notify_update() to
// refresh the cached latest version without blocking a real command.
if env::args().nth(1).as_deref() == Some("__update-check") {
upgrade::run_update_check();
return;
}
// Non-blocking "update available" hint (stderr only; self-skips meta
// commands, daemon mode, CI, and the opt-out env vars).
upgrade::maybe_notify_update();
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
@@ -547,9 +720,13 @@ fn main() {
// 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."
"⚠ --launch opens a fresh, isolated test profile (no cookies, no login, no \
extensions). The window is labelled `chrome-use (<session>)` in Chrome's \
profile menu so you can tell it apart from your real browser.\n \
reuse your real Chrome (cookies/login/extensions): `--profile auto` \
(or set AGENT_BROWSER_PROFILE=auto once)\n \
load an unpacked extension into the test profile: \
`--args \"--load-extension=<dir>\"`"
);
}
@@ -635,6 +812,28 @@ fn main() {
return;
}
// Handle `cookies export` (doesn't need daemon): decrypt an on-disk Chrome
// profile's cookies and print them as JSON for `cookies set --curl`.
if clean.first().map(|s| s.as_str()) == Some("cookies")
&& clean.get(1).map(|s| s.as_str()) == Some("export")
{
run_cookies_export(&clean, &flags);
return;
}
// Handle `test <suite.yaml>`: run a browser test suite. It orchestrates by
// re-invoking this binary per step, so it lives outside the normal dispatch.
if clean.first().map(|s| s.as_str()) == Some("test") {
let Some(suite) = clean.get(1) else {
eprintln!(
"{} usage: chrome-use test <suite.yaml> [--launch | --session <name>]",
color::error_indicator()
);
exit(2);
};
exit(test_runner::run_test(suite, &flags));
}
// Handle skills command (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("skills") {
skills::run_skills(&clean, flags.json);
@@ -670,7 +869,7 @@ fn main() {
}
None => {
eprintln!(
"{} extension not connected. Run `agent-browser extension install`, load the\n ab-connect extension in Chrome (chrome://extensions → Developer mode →\n Load unpacked → extensions/ab-connect), then retry.",
"{} extension not connected. Run `chrome-use extension install`, load the\n ab-connect extension in Chrome (chrome://extensions → Developer mode →\n Load unpacked → extensions/ab-connect), then retry.",
color::error_indicator()
);
exit(1);
@@ -688,6 +887,12 @@ fn main() {
return;
}
// Handle daemon management (doesn't talk to a daemon — it manages them).
if clean.first().map(|s| s.as_str()) == Some("daemon") {
run_daemon(&clean, flags.json);
return;
}
// Handle close --all: close all active sessions
if matches!(
clean.first().map(|s| s.as_str()),
@@ -906,7 +1111,7 @@ fn main() {
if !ignored_flags.is_empty() && !flags.json {
// Special case: --headed is irrelevant in CDP-attach mode
// (your existing Chrome is always already visible). The
// "agent-browser close + reopen" advice doesn't help because
// "chrome-use close + reopen" advice doesn't help because
// the new daemon will attach right back to the same Chrome.
// Don't suggest a useless workaround.
if ignored_flags == ["--headed"] {
@@ -917,7 +1122,7 @@ fn main() {
);
} else {
eprintln!(
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
"{} {} ignored: daemon already running. Use 'chrome-use close' first to restart with new options.",
color::warning_indicator(),
ignored_flags.join(", ")
);
@@ -1311,7 +1516,7 @@ fn main() {
.and_then(|v| v.as_str())
.unwrap_or("");
eprintln!("[agent-browser] Action requires confirmation:");
eprintln!("[chrome-use] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
eprint!(" Allow? [y/N]: ");
+253 -59
View File
@@ -1124,7 +1124,7 @@ impl DaemonState {
}
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(n)) => {
eprintln!("[agent-browser] Warning: CDP event buffer overflowed, {} events dropped. Network requests may be missing from HAR output.", n);
eprintln!("[chrome-use] Warning: CDP event buffer overflowed, {} events dropped. Network requests may be missing from HAR output.", n);
continue;
}
Err(broadcast::error::TryRecvError::Closed) => {
@@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"content" => handle_content(state).await,
"evaluate" => handle_evaluate(cmd, state).await,
"close" => handle_close(state).await,
"stealth_status" => handle_stealth_status(state).await,
"snapshot" => handle_snapshot(cmd, state).await,
"screenshot" => handle_screenshot(cmd, state).await,
"click" => handle_click(cmd, state).await,
@@ -1363,7 +1364,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"recording_stop" => handle_recording_stop(state).await,
"recording_restart" => handle_recording_restart(cmd, state).await,
"pdf" => handle_pdf(cmd, state).await,
"tab_list" => handle_tab_list(state).await,
"tab_list" => handle_tab_list(cmd, state).await,
"tab_new" => handle_tab_new(cmd, state).await,
"tab_switch" => handle_tab_switch(cmd, state).await,
"tab_close" => handle_tab_close(cmd, state).await,
@@ -1539,7 +1540,7 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
// before returning success. Without this, a zombie CDP socket (process
// alive, websocket dead) would let `connect_auto` and `tab_new` succeed,
// we'd return Ok, the next user command would silently no-op, and
// `agent-browser open URL` would exit 0 with the browser still on
// `chrome-use open URL` would exit 0 with the browser still on
// about:blank. Failing here lets the caller surface the real error.
if let Err(e) = mgr
.client
@@ -1556,7 +1557,7 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
return Err(format!(
"CDP session is unresponsive after attaching ({}). \
The browser may have lost its DevTools connection. \
Try: agent-browser close, then re-run.",
Try: chrome-use close, then re-run.",
e
));
}
@@ -1633,11 +1634,11 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
return Err(format!(
"Could not connect to your Chrome browser.\n\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\
Allow and re-run that consent is what lets chrome-use attach.\n\n\
Otherwise, to let chrome-use reuse your logged-in Chrome (recommended):\n\
{}\n\n\
Or launch a separate browser that KEEPS your login state:\n \
agent-browser --launch --profile auto open <url>\n\
chrome-use --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 \
@@ -1776,15 +1777,15 @@ fn chrome_relaunch_hint() -> &'static str {
if cfg!(target_os = "macos") {
" 1. Quit Chrome completely\n\
2. Run: open -a \"Google Chrome\" --args --remote-debugging-port=9222\n\
3. Then retry your agent-browser command"
3. Then retry your chrome-use command"
} else if cfg!(target_os = "windows") {
" 1. Close Chrome completely\n\
2. Run: start chrome --remote-debugging-port=9222\n\
3. Then retry your agent-browser command"
3. Then retry your chrome-use command"
} else {
" 1. Close Chrome completely\n\
2. Run: google-chrome --remote-debugging-port=9222\n\
3. Then retry your agent-browser command"
3. Then retry your chrome-use command"
}
}
@@ -1853,7 +1854,7 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
/// 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
/// that URL so `chrome-use get url` after `npm i -g` upgrade still reports
/// the page the user was on. Read-and-delete: the file is removed regardless
/// of whether navigation succeeds, so a stale sidecar can't haunt later
/// auto-launches.
@@ -2203,11 +2204,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
return Err(format!(
"Could not connect to your Chrome browser.\n\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\
Allow and re-run that consent is what lets chrome-use attach.\n\n\
Otherwise, to let chrome-use reuse your logged-in Chrome (recommended):\n\
{}\n\n\
Or launch a separate browser that KEEPS your login state:\n \
agent-browser --launch --profile auto open <url>\n\
chrome-use --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 \
@@ -2530,6 +2531,20 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
// `--reuse-tab`: if a tab already shows this URL (same origin+path), switch
// to it instead of navigating — preserves any in-page state and stops
// re-`open` from piling up duplicate tabs on rebind (issue #21).
if cmd
.get("reuseTab")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
if let Ok(Some(switched)) = mgr.reuse_tab_for_url(url).await {
return Ok(switched);
}
}
let result = mgr.navigate(url, wait_until).await?;
// Adaptive humanize: sample the freshly loaded page for known behavioural
// anti-bot vendors and escalate this session to Human if any are present.
@@ -2680,6 +2695,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
Ok(json!({ "result": result, "origin": url }))
}
/// Local stealth self-check: reports the active mode, live fingerprint probes,
/// and the list of applied overrides — so an agent (or human) can confirm
/// stealth is working without driving an external detector, and audit exactly
/// what's patched on this path (issue #5).
async fn handle_stealth_status(state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let connect = mgr.is_cdp_connection();
let probe_js = r#"(() => {
const ua = navigator.userAgent || '';
return {
webdriver: navigator.webdriver === true,
hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null,
plugins: navigator.plugins ? navigator.plugins.length : 0,
languages: navigator.languages || [],
platform: navigator.platform || '',
headlessUA: /Headless/i.test(ua),
};
})()"#;
let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null);
let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true);
let has_chrome = p
.get("hasWindowChrome")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0);
let headless_ua = p
.get("headlessUA")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let checks = json!([
{ "name": "navigator.webdriver is false", "pass": !webdriver },
{ "name": "window.chrome present", "pass": has_chrome },
{ "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins },
{ "name": "userAgent has no 'Headless'", "pass": !headless_ua },
]);
let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua;
let overrides = if connect {
json!([
"navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)",
"Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)",
"zero JS patches injected — the browser's real fingerprint is used as-is",
])
} else {
let iframe_proxy =
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1");
json!([
"navigator.webdriver removed; navigator.languages/locale normalized",
"window.chrome / chrome.runtime shimmed; navigator.platform fixed",
"WebGL vendor/renderer, plugins, permissions normalized",
format!(
"srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)",
if iframe_proxy {
"ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%"
} else {
"off"
}
),
format!(
"canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)",
if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") {
"on"
} else {
"off (opt-in)"
}
),
"Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL",
])
};
Ok(json!({
"stealthStatus": {
"mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" },
"ok": ok,
"checks": checks,
"overrides": overrides,
"probe": p,
}
}))
}
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
if let Some(ref mgr) = state.browser {
if let Some(ref session_name) = state.session_name {
@@ -2790,7 +2888,41 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
})
.collect();
Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
let ref_count = refs.len();
let mut out = json!({ "snapshot": tree, "origin": url, "refs": refs });
// Canvas/WebGL apps (games, map/3D viewers, drawing tools) paint to a
// <canvas> and expose almost no accessibility tree, so `snapshot` comes back
// near-empty and agents get stuck looking for refs that will never exist
// (dogfood: the Dead Cell game). When the tree is sparse but a canvas
// dominates the viewport, tell them to switch to the screenshot-driven path.
if ref_count < 3 {
let canvas_js =
"(() => { const c = document.querySelector('canvas'); if (!c) return false; \
const r = c.getBoundingClientRect(); \
return r.width * r.height > innerWidth * innerHeight * 0.5; })()";
if let Ok(v) = mgr.evaluate(canvas_js, None).await {
if v.as_bool() == Some(true) {
out["note"] = json!(
"This page renders to a <canvas> (game / WebGL / editor) and exposes almost no \
accessibility tree refs won't help. Use `screenshot` to see it, coordinate \
`click <x> <y>` to interact, and `keydown`/`keyup`/`press` for keyboard \
(hold-to-move: `keydown d` `keyup d`)."
);
}
}
}
Ok(out)
}
/// Resolve a (possibly relative) saved-file path to an absolute one so the CLI
/// echoes a path the agent can read regardless of the process cwd (issue #16).
/// Falls back to the original string if the file can't be canonicalized.
fn absolutize_saved_path(p: &str) -> String {
std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.to_string())
}
async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
@@ -2818,7 +2950,7 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
.map_err(|e| format!("Base64 decode error: {}", e))?;
std::fs::write(p, bytes)
.map_err(|e| format!("Failed to write screenshot: {}", e))?;
return Ok(json!({ "path": p }));
return Ok(json!({ "path": absolutize_saved_path(p) }));
}
let tmp = format!(
"/tmp/screenshot-{}.png",
@@ -2892,16 +3024,37 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
)
.await?;
let mut response = json!({ "path": result.path });
let mut response = json!({ "path": absolutize_saved_path(&result.path) });
if !result.annotations.is_empty() {
response["annotations"] = serde_json::to_value(&result.annotations)
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
}
// Stamp which page was captured so a screenshot of the wrong tab is obvious
// (issue #8.1: relay sessions can drift to whatever tab the user activated).
if let Ok(url) = mgr.get_url().await {
if !url.is_empty() {
response["origin"] = json!(url);
}
}
Ok(response)
}
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
// First-class coordinate click (issue #8.4): click a raw viewport point with
// no element resolution. Parsed from `click <x> <y>` / `click --coords x,y`.
if let (Some(x), Some(y)) = (
cmd.get("x").and_then(|v| v.as_f64()),
cmd.get("y").and_then(|v| v.as_f64()),
) {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
interaction::click_at_point(&mgr.client, &session_id, x, y, button, click_count).await?;
return Ok(json!({ "clicked": { "x": x, "y": y } }));
}
let selector = cmd
.get("selector")
.and_then(|v| v.as_str())
@@ -4241,10 +4394,19 @@ async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
// Phase 5 handlers
// ---------------------------------------------------------------------------
async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
async fn handle_tab_list(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
// Re-sync with the live browser so the list reflects tabs opened by other
// sessions or re-attached after a cross-process nav, and drops gone ones
// (issue #21). Best-effort: a stale list still beats erroring the command.
mgr.resync_targets().await.ok();
let tabs = mgr.tab_list();
Ok(json!({ "tabs": tabs }))
// Echo `full` so the formatter prints untruncated URLs (issue #19).
if cmd.get("full").and_then(|v| v.as_bool()).unwrap_or(false) {
Ok(json!({ "tabs": tabs, "full": true }))
} else {
Ok(json!({ "tabs": tabs }))
}
}
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
@@ -4275,9 +4437,20 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
let tab_ref_str = cmd
.get("tabId")
.and_then(|v| v.as_str())
.ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
.ok_or("Missing 'tabId' parameter (expected `t<N>`, a label, or a targetId)")?;
// Re-sync first so a tab opened by another session, or one that re-attached
// after a cross-process nav, is adoptable from here (issue #21).
mgr.resync_targets().await.ok();
// A CDP `targetId` (shown in `tab list`) is stable across sessions, so accept
// it directly for adopting a specific pre-existing tab — falling back to the
// per-session `t<N>` / label form.
let tab_id = match mgr.tab_id_for_target(tab_ref_str) {
Some(id) => id,
None => {
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
mgr.resolve_tab_ref(&tab_ref)?
}
};
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
@@ -4792,7 +4965,7 @@ async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
None => {
let dir = dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".agent-browser")
.join(".chrome-use")
.join("tmp")
.join("pdfs");
let _ = std::fs::create_dir_all(&dir);
@@ -6330,7 +6503,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
|| el.getAttribute('alt') || el.value || el.textContent || '').trim();
if ({name_match}) {{
el.setAttribute('data-agent-browser-located', 'true');
el.setAttribute('data-chrome-use-located', 'true');
return true;
}}
}}
@@ -6364,7 +6537,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
return Err(format!("No element found: {}", desc));
}
let selector = "[data-agent-browser-located='true']";
let selector = "[data-chrome-use-located='true']";
let result = execute_subaction(cmd, state, selector).await;
// Clean up the marker attribute
@@ -6372,7 +6545,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
if browser.active_session_id().is_ok() {
let _ = browser
.evaluate(
"document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
"document.querySelector('[data-chrome-use-located]')?.removeAttribute('data-chrome-use-located')",
None,
)
.await;
@@ -6415,7 +6588,7 @@ async fn handle_semantic_locator(
if (!label) return false;
const forId = label.getAttribute('for');
const target = forId ? document.getElementById(forId) : label.querySelector('input,select,textarea');
if (target) {{ target.setAttribute('data-agent-browser-located', 'true'); return true; }}
if (target) {{ target.setAttribute('data-chrome-use-located', 'true'); return true; }}
return false;
}})()"#,
match_fn = match_fn,
@@ -6423,7 +6596,7 @@ async fn handle_semantic_locator(
"placeholder" => format!(
r#"(() => {{
const el = document.querySelector('input[placeholder={val}], textarea[placeholder={val}]');
if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
if (el) {{ el.setAttribute('data-chrome-use-located', 'true'); return true; }}
return false;
}})()"#,
val = serde_json::to_string(value).unwrap_or_default(),
@@ -6431,7 +6604,7 @@ async fn handle_semantic_locator(
"alttext" => format!(
r#"(() => {{
const el = document.querySelector('img[alt={val}], [alt={val}]');
if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
if (el) {{ el.setAttribute('data-chrome-use-located', 'true'); return true; }}
return false;
}})()"#,
val = serde_json::to_string(value).unwrap_or_default(),
@@ -6439,7 +6612,7 @@ async fn handle_semantic_locator(
"title" => format!(
r#"(() => {{
const el = document.querySelector('[title={val}]');
if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
if (el) {{ el.setAttribute('data-chrome-use-located', 'true'); return true; }}
return false;
}})()"#,
val = serde_json::to_string(value).unwrap_or_default(),
@@ -6447,7 +6620,7 @@ async fn handle_semantic_locator(
"testid" => format!(
r#"(() => {{
const el = document.querySelector('[data-testid={val}]');
if (el) {{ el.setAttribute('data-agent-browser-located', 'true'); return true; }}
if (el) {{ el.setAttribute('data-chrome-use-located', 'true'); return true; }}
return false;
}})()"#,
val = serde_json::to_string(value).unwrap_or_default(),
@@ -6459,7 +6632,7 @@ async fn handle_semantic_locator(
const all = document.querySelectorAll('*');
for (const el of all) {{
if (el.children.length === 0 && {match_fn}) {{
el.setAttribute('data-agent-browser-located', 'true');
el.setAttribute('data-chrome-use-located', 'true');
return true;
}}
}}
@@ -6493,13 +6666,13 @@ async fn handle_semantic_locator(
return Err(format!("No element found by {} '{}'", strategy, value));
}
let selector = "[data-agent-browser-located='true']";
let selector = "[data-chrome-use-located='true']";
let action_result = execute_subaction(cmd, state, selector).await;
if let Some(ref browser) = state.browser {
let _ = browser
.evaluate(
"document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
"document.querySelector('[data-chrome-use-located]')?.removeAttribute('data-chrome-use-located')",
None,
)
.await;
@@ -6549,7 +6722,7 @@ async fn handle_nth(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
const els = document.querySelectorAll({sel});
const idx = {idx} < 0 ? els.length + {idx} : {idx};
if (idx < 0 || idx >= els.length) return false;
els[idx].setAttribute('data-agent-browser-located', 'true');
els[idx].setAttribute('data-chrome-use-located', 'true');
return true;
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default(),
@@ -6582,13 +6755,13 @@ async fn handle_nth(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
));
}
let located = "[data-agent-browser-located='true']";
let located = "[data-chrome-use-located='true']";
let action_result = execute_subaction(cmd, state, located).await;
if let Some(ref browser) = state.browser {
let _ = browser
.evaluate(
"document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
"document.querySelector('[data-chrome-use-located]')?.removeAttribute('data-chrome-use-located')",
None,
)
.await;
@@ -7125,7 +7298,7 @@ async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value,
let mut log = json!({
"version": "1.2",
"creator": {
"name": "agent-browser",
"name": "chrome-use",
"version": env!("CARGO_PKG_VERSION")
},
"entries": entries
@@ -7424,9 +7597,9 @@ fn har_output_path(explicit_path: Option<&str>) -> String {
fn get_har_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("har")
home.join(".chrome-use").join("tmp").join("har")
} else {
std::env::temp_dir().join("agent-browser").join("har")
std::env::temp_dir().join("chrome-use").join("har")
}
}
@@ -7816,23 +7989,40 @@ pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
false
}
async fn enable_request_tracking(state: &mut DaemonState) {
if state.request_tracking {
return;
}
state.request_tracking = true;
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = mgr
.client
.send_command_no_params("Network.enable", Some(session_id))
.await;
}
}
}
async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
state.tracked_requests.clear();
// Enable Network capture NOW, on `--clear`, not lazily on the next read.
// `--clear` is the canonical "start capturing fresh" call, so requests
// fired between it and the following `requests` read must be tracked.
// Lazy-enabling only on read missed exactly those → intermittent
// "No requests captured" on the first try, fine on retry (issue #8.3).
enable_request_tracking(state).await;
return Ok(json!({ "cleared": true }));
}
if !state.request_tracking {
state.request_tracking = true;
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = mgr
.client
.send_command_no_params("Network.enable", Some(session_id))
.await;
}
}
}
enable_request_tracking(state).await;
// Current page URL, so a `requests` read on a drifted/wrong tab is obvious
// and "0 captured" can't be confused with "wrong page" (issues #8.1/#8.3).
let origin = match state.browser.as_ref() {
Some(mgr) => mgr.get_url().await.ok().filter(|u| !u.is_empty()),
None => None,
};
let filter = cmd.get("filter").and_then(|v| v.as_str());
let type_filter = cmd.get("type").and_then(|v| v.as_str());
@@ -7869,7 +8059,14 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
})
.collect();
Ok(json!({ "requests": requests }))
// NB: do NOT add a top-level `count` field here — the human formatter treats
// any `{count}` as a `get count` result and prints just the number, which
// would swallow the request list. The list length is self-evident.
let mut response = json!({ "requests": requests });
if let Some(o) = origin {
response["origin"] = json!(o);
}
Ok(response)
}
async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
@@ -8773,10 +8970,7 @@ mod tests {
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"agent-browser-{label}-{}-{nanos}",
std::process::id()
))
std::env::temp_dir().join(format!("chrome-use-{label}-{}-{nanos}", std::process::id()))
}
#[tokio::test]
@@ -9322,7 +9516,7 @@ mod tests {
let har: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(har["log"]["version"], "1.2");
assert_eq!(har["log"]["creator"]["name"], "agent-browser");
assert_eq!(har["log"]["creator"]["name"], "chrome-use");
assert!(har["log"].get("browser").is_none());
assert_eq!(har["log"]["entries"][0]["response"]["content"]["size"], 128);
@@ -9332,7 +9526,7 @@ mod tests {
#[tokio::test]
async fn test_execute_har_stop_skips_browser_auto_launch() {
let path = std::env::temp_dir().join(format!(
"agent-browser-har-stop-{}.har",
"chrome-use-har-stop-{}.har",
unix_timestamp_millis()
));
let mut state = DaemonState::new();
+6 -6
View File
@@ -44,9 +44,9 @@ fn validate_profile_name(name: &str) -> Result<(), String> {
fn get_auth_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("auth")
home.join(".chrome-use").join("auth")
} else {
std::env::temp_dir().join("agent-browser").join("auth")
std::env::temp_dir().join("chrome-use").join("auth")
}
}
@@ -59,9 +59,9 @@ const KEY_FILE_NAME: &str = ".encryption-key";
fn get_agent_browser_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser")
home.join(".chrome-use")
} else {
std::env::temp_dir().join("agent-browser")
std::env::temp_dir().join("chrome-use")
}
}
@@ -81,7 +81,7 @@ fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
}
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
/// ~/.chrome-use/.encryption-key file (matching the Node.js implementation).
fn get_encryption_key() -> Result<Vec<u8>, String> {
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
return parse_key_hex(&key_hex).ok_or_else(|| {
@@ -140,7 +140,7 @@ fn ensure_encryption_key() -> Result<Vec<u8>, String> {
let _ = writeln!(
std::io::stderr(),
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
"[chrome-use] Auto-generated encryption key at {} -- back up this file or set {}",
key_file.display(),
ENCRYPTION_KEY_ENV
);
+523 -10
View File
@@ -106,6 +106,18 @@ pub(crate) fn should_track_target(target: &TargetInfo) -> bool {
&& (target.url.is_empty() || !is_internal_chrome_target(&target.url))
}
/// Origin + path of a URL, dropping the query string and fragment, for
/// `--reuse-tab` matching. SPA/SSO URLs carry volatile `?client_id=…&state=…`
/// and `#/route` parts, so two opens of the "same" page rarely match
/// byte-for-byte; comparing origin+path lands the reuse on the right tab.
/// Returns the input unchanged if it doesn't parse as a URL.
fn normalize_url_for_match(url: &str) -> String {
match url::Url::parse(url) {
Ok(u) => format!("{}{}", u.origin().ascii_serialization(), u.path()),
Err(_) => url.to_string(),
}
}
fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
page.url = target.url.clone();
@@ -136,6 +148,43 @@ fn active_page_index_after_removal(
active_page_index
}
/// Resolve the session's active page index: prefer the pinned `active_target_id`
/// (stable across tab reorder / passive discovery / removal), falling back to the
/// raw `active_page_index` only when nothing is pinned or the pin is gone. Keeping
/// commands anchored to the pinned target is what stops `eval`/`get url`/`snapshot`
/// from drifting onto a foreign tab between commands (issue #14).
fn resolve_active_index(
pages: &[PageInfo],
active_target_id: Option<&str>,
active_page_index: usize,
) -> usize {
if let Some(tid) = active_target_id {
if let Some(i) = pages.iter().position(|p| p.target_id == tid) {
return i;
}
}
active_page_index
}
/// Whether the resolved active page is a tab the session created (its target_id
/// is in `created_targets`). Pure core of [`BrowserManager::active_is_session_owned`]
/// so the relay no-hijack rule is unit-testable without a live browser.
fn active_index_is_owned(
pages: &[PageInfo],
active_target_id: Option<&str>,
active_page_index: usize,
created_targets: &HashSet<String>,
) -> bool {
pages
.get(resolve_active_index(
pages,
active_target_id,
active_page_index,
))
.map(|p| created_targets.contains(&p.target_id))
.unwrap_or(false)
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -221,7 +270,7 @@ impl TabRef {
if input.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"Expected a tab id like `t{}` or a label; positional integers are not accepted \
(run `agent-browser tab` to list stable tab ids)",
(run `chrome-use tab` to list stable tab ids)",
input
));
}
@@ -265,6 +314,15 @@ impl WaitUntil {
_ => Self::Load,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Load => "load",
Self::DomContentLoaded => "domcontentloaded",
Self::NetworkIdle => "networkidle",
Self::None => "none",
}
}
}
pub enum BrowserProcess {
@@ -315,6 +373,13 @@ pub struct BrowserManager {
/// browser after it ends. Only ever holds tabs we created — never the user's
/// existing tabs or other sessions' tabs — so closing them is always safe.
created_targets: HashSet<String>,
/// The session's *intended* active tab, pinned by stable target_id rather
/// than the fragile `active_page_index`. Set on every explicit open / tab new
/// / tab switch. `active_session_id` resolves through this so a foreign tab
/// opening (passive discovery), a tab closing, or list reordering can't drift
/// the session's commands onto the wrong page — the wrong-origin-fetch hazard
/// in the dogfood reports. Falls back to the index if the pinned tab is gone.
active_target_id: Option<String>,
next_tab_id: u32,
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
@@ -440,6 +505,7 @@ impl BrowserManager {
ignore_https_errors,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -531,6 +597,7 @@ impl BrowserManager {
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -547,6 +614,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
manager.active_page_index = 0;
manager.pin_active_target();
manager.enable_domains_direct().await?;
} else {
manager.discover_and_attach_targets().await?;
@@ -621,6 +689,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.pin_active_target();
self.enable_domains(&attach_result.session_id).await?;
} else {
for target in &page_targets {
@@ -650,6 +719,7 @@ impl BrowserManager {
}
self.active_page_index = 0;
self.pin_active_target();
let session_id = self.pages[0].session_id.clone();
self.enable_domains(&session_id).await?;
}
@@ -736,14 +806,61 @@ impl BrowserManager {
Ok(())
}
/// Index of the session's active page, resolved through the pinned
/// `active_target_id` (stable across reorder/removal/passive discovery) and
/// falling back to `active_page_index` when nothing is pinned or the pin is
/// gone. This is what keeps commands on the tab the agent actually opened.
fn resolved_active_index(&self) -> usize {
resolve_active_index(
&self.pages,
self.active_target_id.as_deref(),
self.active_page_index,
)
}
/// Whether the resolved active page is a tab THIS session created (via
/// `Target.createTarget` — `tab new`, `ensure_page`, or the first `open`).
/// On the shared real browser a fresh session also passively attaches to the
/// user's existing tabs; those are NOT owned, and navigating one would
/// clobber the user's page. Used to gate `navigate` on the relay.
fn active_is_session_owned(&self) -> bool {
active_index_is_owned(
&self.pages,
self.active_target_id.as_deref(),
self.active_page_index,
&self.created_targets,
)
}
/// Pin the current active page by target_id so later commands stick to it.
/// Call after any explicit open / tab new / tab switch.
fn pin_active_target(&mut self) {
self.active_target_id = self
.pages
.get(self.active_page_index)
.map(|p| p.target_id.clone());
}
pub fn active_session_id(&self) -> Result<&str, String> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.session_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
// On the shared real browser (extension relay), a fresh session only
// passively attached to the user's existing tabs — it doesn't own any. The
// pre-fix code made one of those the active tab, so the first `open` then
// navigated (clobbered) the user's page: in dogfooding an `open` replaced a
// half-filled form with the target site. If the active tab isn't one we
// created, open our own tab in this session's group and navigate THAT, so
// the user's (and other sessions') tabs are never hijacked. Off the relay
// (a browser we launched) reusing the active tab is correct, so this is
// gated on `agent_group()`.
if self.agent_group().is_some() && !self.active_is_session_owned() {
self.tab_new(None, None).await?;
}
let session_id = self.active_session_id()?.to_string();
let mut lifecycle_rx = self.client.subscribe();
@@ -766,9 +883,38 @@ impl BrowserManager {
// Only wait for lifecycle events if Chrome created a new loader (full navigation).
// If loader_id is None, it was a same-document navigation (e.g., hash routing)
// which does not fire Page.loadEventFired or Page.domContentEventFired.
let mut nav_warning: Option<String> = None;
if nav_result.loader_id.is_some() && wait_until != WaitUntil::None {
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await?;
if let Err(e) = self
.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await
{
// The lifecycle event (e.g. `load`) didn't fire within the
// timeout. On SPAs this is common — a long-pending XHR or a stuck
// sub-resource holds `load` open long after the DOM is interactive
// and the page is usable, so `open` would hard-fail even though
// eval/screenshot work immediately (issue #10). If the DOM is
// already ready, treat navigation as done (with a warning, carried
// in the response so the CLI can surface it) instead of failing.
// Only a still-loading document is a real failure.
let ready = self
.evaluate_simple("document.readyState")
.await
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
if ready == "interactive" || ready == "complete" {
nav_warning = Some(format!(
"`{}` didn't complete within the timeout, but the DOM is ready ({}) — \
continuing. Pass `--wait-until domcontentloaded` to skip this wait on \
SPAs with long-lived requests.",
wait_until.as_str(),
ready
));
} else {
return Err(e);
}
}
}
let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
@@ -782,12 +928,26 @@ impl BrowserManager {
}
}
// An explicit `open`/navigate IS the "explicit open" the pin invariant is
// built around (see `active_target_id`). On the relay path `open` reuses an
// existing tab via this method rather than `add_page`, so without pinning
// here `active_target_id` stayed `None` and the session rode the fragile
// `active_page_index` — a later passive tab close/reorder then drifted
// `eval`/`get url`/`snapshot` onto a foreign tab between commands (issue
// #14). Sync the index to the resolved active page, then pin it by stable
// target_id so subsequent commands stick to the tab we just navigated.
self.active_page_index = self.resolved_active_index();
if let Some(page) = self.pages.get_mut(self.active_page_index) {
page.url = page_url.clone();
page.title = title.clone();
}
self.pin_active_target();
Ok(json!({ "url": page_url, "title": title }))
let mut out = json!({ "url": page_url, "title": title });
if let Some(w) = nav_warning {
out["warning"] = json!(w);
}
Ok(out)
}
async fn wait_for_lifecycle(
@@ -991,7 +1151,7 @@ impl BrowserManager {
pub fn active_target_id(&self) -> Result<&str, String> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.target_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
@@ -1048,6 +1208,9 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = 0;
// Pin this freshly-created tab (matches `add_page`) so it's a stable
// anchor from the first command, not a bare index (issue #14).
self.pin_active_target();
self.enable_domains(&attach_result.session_id).await?;
Ok(())
@@ -1078,22 +1241,168 @@ impl BrowserManager {
}
pub fn tab_list(&self) -> Vec<Value> {
let active = self.resolved_active_index();
self.pages
.iter()
.enumerate()
.map(|(i, p)| {
json!({
"tabId": format_tab_id(p.tab_id),
// Stable CDP target id. Unlike `t<N>` (per-session, reassigned
// each connect) this is the same handle across every session
// attached to the relayed Chrome, so it's how you adopt a
// specific pre-existing tab from another session (issue #21).
"targetId": p.target_id,
"label": p.label,
"title": p.title,
"url": p.url,
"type": p.target_type,
"active": i == self.active_page_index,
"active": i == active,
})
})
.collect()
}
/// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked.
/// Lets callers adopt a tab by the cross-session-stable target id.
pub fn tab_id_for_target(&self, target_id: &str) -> Option<u32> {
self.pages
.iter()
.find(|p| p.target_id == target_id)
.map(|p| p.tab_id)
}
/// Re-pull the live target set and reconcile `self.pages`: adopt tabs that
/// appeared since connect (another session's tab, or one that just
/// re-attached after a cross-process nav), refresh url/title on known tabs,
/// and drop tabs that are gone (clearing phantom rows). Never steals focus —
/// the active tab is preserved, and re-pinned if it was pruned. Powers a live
/// `tab list` and adopt-by-targetId so a fresh session can reach a stranded,
/// still-filled tab without reloading it (issue #21).
pub async fn resync_targets(&mut self) -> Result<(), String> {
self.client
.send_command_typed::<_, Value>(
"Target.setDiscoverTargets",
&SetDiscoverTargetsParams { discover: true },
None,
)
.await?;
let result: GetTargetsResult = self
.client
.send_command_typed("Target.getTargets", &json!({}), None)
.await?;
let live: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(should_track_target)
.collect();
let live_ids: HashSet<String> = live.iter().map(|t| t.target_id.clone()).collect();
for target in &live {
if self.update_page_target_info(target) {
continue;
}
// A target this session hasn't tracked yet — attach and add it in the
// background so it's listable/adoptable without stealing the active tab.
let attach_result: AttachToTargetResult = match self
.client
.send_command_typed(
"Target.attachToTarget",
&AttachToTargetParams {
target_id: target.target_id.clone(),
flatten: true,
},
None,
)
.await
{
Ok(r) => r,
// The tab may have closed between getTargets and attach, or be a
// restricted page — skip it rather than failing the whole resync.
Err(_) => continue,
};
let tab_id = self.assign_tab_id();
self.add_background_page(PageInfo {
tab_id,
label: None,
target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
target_type: target.target_type.clone(),
});
let _ = self.enable_domains(&attach_result.session_id).await;
}
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows.
let gone: Vec<String> = self
.pages
.iter()
.map(|p| p.target_id.clone())
.filter(|tid| !live_ids.contains(tid))
.collect();
for tid in gone {
self.remove_page_by_target_id(&tid);
}
// Refresh url/title from each live tab. The relay only stamps target_info
// on attach, so after a navigation its cached url/title go stale (or stay
// blank for a tab attached at about:blank) — which made `tab list` show
// blank rows you couldn't tell apart, defeating the point of listing them
// to pick a tab to adopt (issue #21). `Target.getTargetInfo` is a plain
// CDP read (no Runtime fingerprint), one cheap call per tab.
let sessions: Vec<(usize, String)> = self
.pages
.iter()
.enumerate()
.map(|(i, p)| (i, p.session_id.clone()))
.collect();
for (i, sid) in sessions {
if sid.is_empty() {
continue;
}
if let Ok(resp) = self
.client
.send_command("Target.getTargetInfo", None, Some(&sid))
.await
{
if let Some(ti) = resp.get("targetInfo") {
if let Some(page) = self.pages.get_mut(i) {
if let Some(u) = ti.get("url").and_then(|v| v.as_str()) {
if !u.is_empty() {
page.url = u.to_string();
}
}
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
page.title = t.to_string();
}
}
}
}
}
Ok(())
}
/// If `--reuse-tab` and a tracked tab already shows `url`, switch to it
/// (without reloading, so any in-page state survives) and return its info.
/// Returns `None` when no tab matches and the caller should navigate/create.
/// Matches on exact URL or the same origin+path (ignoring query/fragment) so
/// a re-`open` of a stable entry URL lands on the existing tab instead of
/// piling up duplicates (issue #21).
pub async fn reuse_tab_for_url(&mut self, url: &str) -> Result<Option<Value>, String> {
self.resync_targets().await.ok();
let want = normalize_url_for_match(url);
let tab_id = self
.pages
.iter()
.find(|p| !want.is_empty() && (p.url == url || normalize_url_for_match(&p.url) == want))
.map(|p| p.tab_id);
match tab_id {
Some(id) => Ok(Some(self.tab_switch_by_id(id).await?)),
None => Ok(None),
}
}
/// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
@@ -1103,7 +1412,7 @@ impl BrowserManager {
Ok(*id)
} else {
Err(format!(
"Tab {} not found; run `agent-browser tab` to list open tabs",
"Tab {} not found; run `chrome-use tab` to list open tabs",
format_tab_id(*id)
))
}
@@ -1115,7 +1424,7 @@ impl BrowserManager {
.map(|p| p.tab_id)
.ok_or_else(|| {
format!(
"No tab with label `{}`; run `agent-browser tab` to list open tabs",
"No tab with label `{}`; run `chrome-use tab` to list open tabs",
name
)
}),
@@ -1219,6 +1528,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = index;
self.pin_active_target();
Ok(json!({
"tabId": format_tab_id(tab_id),
@@ -1238,6 +1548,7 @@ impl BrowserManager {
}
self.active_page_index = index;
self.pin_active_target();
let session_id = self.pages[index].session_id.clone();
self.enable_domains(&session_id).await?;
@@ -1515,7 +1826,25 @@ impl BrowserManager {
})),
Some(&effective_session_id),
)
.await?;
.await
.map_err(|e| {
// Chrome's chrome.debugger API (the extension-relay transport)
// forbids DOM.setFileInputFiles for security, surfacing as an
// opaque `-32000 "Not allowed"`. Translate it into an actionable
// message rather than leaking the raw CDP error (issue #13).
if e.contains("Not allowed") || e.contains("-32000") {
"file upload isn't supported over the extension relay — \
Chrome's chrome.debugger API forbids DOM.setFileInputFiles. \
Use a direct-CDP session instead: \
`chrome-use --session up --launch open <url>` (carry your \
login over with `cookies export` | `cookies set --curl`), \
then run `upload` in that session. \
See https://github.com/leeguooooo/chrome-use/issues/13"
.to_string()
} else {
e
}
})?;
Ok(())
}
@@ -1581,6 +1910,7 @@ impl BrowserManager {
let index = self.pages.len();
self.pages.push(page);
self.active_page_index = index;
self.pin_active_target();
}
/// Add a passively-discovered page WITHOUT changing the active tab.
@@ -1604,8 +1934,18 @@ impl BrowserManager {
pub fn remove_page_by_target_id(&mut self, target_id: &str) {
if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id);
self.pages.remove(pos);
self.update_active_page_after_removal(pos);
// If we just removed the pinned active target, the pin now dangles and
// `resolved_active_index` silently falls back to `active_page_index`.
// After a passive about:blank discovery that index can point at a blank
// tab, so `wait` → eval/snapshot lands on about:blank (issue #7). Re-pin
// to the surviving active page so the pin is never left pointing at a
// target that no longer exists.
if removed_was_pinned {
self.pin_active_target();
}
}
}
@@ -1783,6 +2123,7 @@ async fn initialize_lightpanda_manager(
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -2067,6 +2408,178 @@ mod tests {
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
}
fn page(target_id: &str) -> PageInfo {
PageInfo {
tab_id: 1,
label: None,
target_id: target_id.to_string(),
session_id: format!("session-{target_id}"),
url: String::new(),
title: String::new(),
target_type: "page".to_string(),
}
}
// --- issue #21: --reuse-tab URL matching ignores query/fragment ---
#[test]
fn normalize_url_match_strips_query_and_fragment() {
// Two opens of the "same" SSO page differ only in volatile query/hash —
// they must normalize equal so --reuse-tab lands on the existing tab.
let a = normalize_url_for_match(
"https://login.account.rakuten.com/sso/authorize?client_id=x&state=abc#/sign_in",
);
let b = normalize_url_for_match(
"https://login.account.rakuten.com/sso/authorize?client_id=y&state=zzz#/forgot",
);
assert_eq!(a, b);
assert_eq!(a, "https://login.account.rakuten.com/sso/authorize");
}
#[test]
fn normalize_url_match_distinguishes_different_paths() {
let cart = normalize_url_for_match("https://cart.step.rakuten.co.jp/cart");
let order = normalize_url_for_match("https://cart.step.rakuten.co.jp/order");
assert_ne!(cart, order);
}
#[test]
fn normalize_url_match_passes_through_unparseable() {
assert_eq!(normalize_url_for_match("not a url"), "not a url");
}
// --- issue #14: a pinned target must keep commands on the right tab ---
#[test]
fn resolve_active_index_prefers_pin_over_stale_index() {
// The tab we opened ("A") is at index 0, but `active_page_index` is stale
// and points at a foreign tab ("B"). With the pin set, resolution sticks
// to A — the drift that bit issue #14 (eval landing on /notifications).
let pages = vec![page("A"), page("B")];
assert_eq!(resolve_active_index(&pages, Some("A"), 1), 0);
}
#[test]
fn resolve_active_index_unpinned_drifts_with_index() {
// Documents the pre-fix hazard: with no pin, resolution blindly trusts
// `active_page_index`, so a clamp/reorder from passive tab discovery lands
// commands on a foreign tab. This is exactly what pinning on `open` avoids.
let pages = vec![page("A"), page("B")];
assert_eq!(resolve_active_index(&pages, None, 1), 1);
}
#[test]
fn resolve_active_index_falls_back_when_pin_is_gone() {
// If the pinned tab was closed (target_id no longer present), fall back to
// the index rather than panicking or returning a bogus slot.
let pages = vec![page("A"), page("B")];
assert_eq!(resolve_active_index(&pages, Some("CLOSED"), 1), 1);
}
// --- issue: `open` must not hijack a user's tab on the relay (dogfood) ---
#[test]
fn active_not_owned_when_only_user_tabs_discovered() {
// A fresh relay session passively attached to the user's tabs but created
// none — so navigate must NOT reuse the active tab (it'd clobber the
// user's page); it has to open its own first.
let pages = vec![page("USER_A"), page("USER_B")];
let created = HashSet::new();
assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created));
}
#[test]
fn active_owned_when_session_created_the_tab() {
let pages = vec![page("USER_A"), page("OURS")];
let mut created = HashSet::new();
created.insert("OURS".to_string());
// Active pinned to the tab we created → safe to navigate it.
assert!(active_index_is_owned(&pages, Some("OURS"), 1, &created));
// But pinned to the user's tab → not owned, even though we own another.
assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created));
}
#[test]
fn active_not_owned_when_no_pages() {
let created = HashSet::new();
assert!(!active_index_is_owned(&[], None, 0, &created));
}
#[test]
fn resolve_active_index_pin_survives_passive_background_tab() {
// A foreign tab ("Z") gets appended by passive discovery after we pinned
// "A". The append doesn't shift A's position, and the pin keeps us on A
// regardless of what `active_page_index` happens to be.
let pages = vec![page("A"), page("B"), page("Z")];
assert_eq!(resolve_active_index(&pages, Some("A"), 2), 0);
}
// issue #7: removing the pinned active target must re-anchor the pin to a
// surviving page. Models `remove_page_by_target_id`'s index + re-pin steps
// purely (BrowserManager needs a live CDP client, so the method itself can't
// be unit-constructed). The invariant: after removal the pin never dangles
// and never silently resolves to a passively-discovered about:blank tab.
fn simulate_remove(
target_ids: &[&str],
active_index: usize,
pinned: &str,
remove_id: &str,
) -> (Vec<String>, usize, Option<String>) {
let pos = target_ids.iter().position(|t| *t == remove_id).unwrap();
let removed_was_pinned = pinned == remove_id;
let mut pages: Vec<String> = target_ids.iter().map(|s| s.to_string()).collect();
pages.remove(pos);
let new_active = active_page_index_after_removal(active_index, pos, pages.len());
let new_pin = if removed_was_pinned {
pages.get(new_active).cloned()
} else {
Some(pinned.to_string())
};
(pages, new_active, new_pin)
}
fn resolve_active<'a>(
pages: &'a [String],
active_index: usize,
pin: &Option<String>,
) -> &'a str {
if let Some(tid) = pin {
if let Some(p) = pages.iter().find(|p| *p == tid) {
return p;
}
}
pages.get(active_index).map(|s| s.as_str()).unwrap_or("")
}
#[test]
fn test_removing_unpinned_blank_keeps_pin_on_real_page() {
// pages = [creepjs(pinned, active), about:blank]; a passive blank closes.
let (pages, active, pin) = simulate_remove(&["creepjs", "blank"], 0, "creepjs", "blank");
assert_eq!(resolve_active(&pages, active, &pin), "creepjs");
}
#[test]
fn test_removing_pinned_page_repins_to_survivor_not_dangling() {
// pages = [blank, creepjs(pinned, active)]; the pinned page itself closes.
let (pages, active, pin) = simulate_remove(&["blank", "creepjs"], 1, "creepjs", "creepjs");
// pin must point at a page that still exists (no dangling fallback).
let resolved = resolve_active(&pages, active, &pin);
assert!(
pages.iter().any(|p| p == resolved),
"resolved a dangling target"
);
assert_eq!(resolved, "blank");
}
#[test]
fn test_resolve_falls_back_cleanly_when_pin_dangles() {
// A stale pin (target already gone) must resolve to a real surviving page,
// never panic or return the missing id.
let pages = vec!["creepjs".to_string(), "blank".to_string()];
let pin = Some("gone".to_string());
assert_eq!(resolve_active(&pages, 0, &pin), "creepjs");
}
#[test]
fn test_validate_launch_options_extensions_and_cdp() {
let ext = vec!["/path/to/ext".to_string()];
+136 -20
View File
@@ -180,6 +180,30 @@ fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
}
}
/// Seed a throwaway `--launch` profile with a human-readable name
/// (`chrome-use (<session>)`) so Chrome's toolbar profile chip identifies the
/// window as an agent's test profile rather than an anonymous empty profile
/// (issue #9). The name lives in `Local State`'s `profile.info_cache.<dir>.name`
/// — the same field `resolve_chrome_profile("auto")` reads. Best-effort: any
/// write error is ignored (the profile still works, just unlabeled).
fn write_temp_profile_label(dir: &std::path::Path) {
let session = std::env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string());
let label = format!("chrome-use ({session})");
let local_state = serde_json::json!({
"profile": {
"info_cache": {
"Default": { "name": label, "is_using_default_name": false }
}
}
});
let _ = std::fs::write(dir.join("Local State"), local_state.to_string());
let default_dir = dir.join("Default");
if std::fs::create_dir_all(&default_dir).is_ok() {
let prefs = serde_json::json!({ "profile": { "name": label } });
let _ = std::fs::write(default_dir.join("Preferences"), prefs.to_string());
}
}
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
let mut args = vec![
"--remote-debugging-port=0".to_string(),
@@ -222,7 +246,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
args.push("--headless=new".to_string());
// Linux paints native scrollbars into viewport screenshots unless
// Chrome is launched with this flag. `--hide-scrollbars` is
// presence-based, so agent-browser exposes --hide-scrollbars false
// presence-based, so chrome-use exposes --hide-scrollbars false
// as the public opt-out instead of forwarding a fake inverse switch.
if options.hide_scrollbars {
args.push("--hide-scrollbars".to_string());
@@ -262,10 +286,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
args.push(format!("--user-data-dir={}", expanded));
(dir, None)
} else {
let dir =
std::env::temp_dir().join(format!("agent-browser-chrome-{}", uuid::Uuid::new_v4()));
let dir = std::env::temp_dir().join(format!("chrome-use-chrome-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
// Label the throwaway profile so a human watching the desktop can tell
// which agent session owns this otherwise-anonymous empty-profile window,
// instead of "which profile is this? where did it come from?" (issue #9).
write_temp_profile_label(&dir);
args.push(format!("--user-data-dir={}", dir.display()));
(dir.clone(), Some(dir))
};
@@ -314,6 +341,46 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
})
}
/// Cross-process advisory lock that serializes concurrent launches of the SAME
/// Chrome profile (issue #11). Held via `flock` on a per-profile lock file; the
/// kernel releases it automatically when the holding process exits, so a crash
/// can't wedge the queue. Best-effort: if the lock can't be acquired the launch
/// proceeds unlocked rather than failing.
struct ProfileLaunchLock {
#[cfg(unix)]
_file: std::fs::File,
}
impl ProfileLaunchLock {
fn acquire(profile: &str) -> Option<Self> {
let safe: String = profile
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
let path = std::env::temp_dir().join(format!("chrome-use-launch-{safe}.lock"));
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.ok()?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
// Blocking exclusive lock: concurrent same-profile launches queue.
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
return None;
}
Some(ProfileLaunchLock { _file: file })
}
#[cfg(not(unix))]
{
let _ = file;
Some(ProfileLaunchLock {})
}
}
}
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let chrome_path = match &options.executable_path {
Some(p) => PathBuf::from(p),
@@ -321,11 +388,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
let cache_dir = crate::install::get_browsers_dir();
format!(
"Chrome not found. Checked:\n \
- agent-browser cache: {}\n \
- chrome-use cache: {}\n \
- System Chrome installations\n \
- Puppeteer browser cache\n \
- Playwright browser cache\n\
Run `agent-browser install` to download Chrome, or use --executable-path.",
Run `chrome-use install` to download Chrome, or use --executable-path.",
cache_dir.display()
)
})?,
@@ -336,6 +403,13 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
// rewrite options so the retry loop uses the copied profile.
let mut resolved_options: Option<LaunchOptions> = None;
let mut profile_temp_dir: Option<PathBuf> = None;
// Serialize concurrent launches of the SAME named profile across processes
// (issue #11). Without this, N parallel `open --profile <same>` collide on
// the profile-copy disk I/O / Chrome's profile lock, every candidate burns
// its full launch timeout, and all fail. The flock queues them instead and
// auto-releases on process exit, so a crash can't wedge the queue. Held
// until Chrome is up (function return).
let mut _launch_lock: Option<ProfileLaunchLock> = None;
if let Some(ref profile) = options.profile {
if is_chrome_profile_name(profile) {
@@ -345,6 +419,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
.to_string()
})?;
let resolved = resolve_chrome_profile(&user_data_dir, profile)?;
_launch_lock = ProfileLaunchLock::acquire(&resolved);
let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?;
let mut opts = options.clone();
@@ -611,7 +686,7 @@ fn chrome_launch_error(message: &str, stderr_lines: &[String]) -> String {
}
pub fn find_chrome() -> Option<PathBuf> {
// 1. Check Chrome downloaded by `agent-browser install`
// 1. Check Chrome downloaded by `chrome-use install`
if let Some(p) = crate::install::find_installed_chrome() {
return Some(p);
}
@@ -623,7 +698,7 @@ pub fn find_chrome() -> Option<PathBuf> {
let _ = writeln!(
std::io::stderr(),
"Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
Falling back to system Chrome. Run `agent-browser install` to re-download.",
Falling back to system Chrome. Run `chrome-use install` to re-download.",
cache_dir.display()
);
}
@@ -734,7 +809,7 @@ pub fn cleanup_orphaned_chrome_profiles() {
};
for entry in entries.flatten() {
let name = entry.file_name();
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
if !name.to_string_lossy().starts_with("chrome-use-chrome-") {
continue;
}
let path = entry.path();
@@ -778,7 +853,7 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
// `chrome.debugger` permission, which — unlike a raw `--remote-debugging-port`
// CDP attach — never triggers Chrome 136+'s per-connection
// "Allow remote debugging?" consent modal. The native-messaging host writes
// ~/.agent-browser/relay-cdp-url while connected and removes it on exit, so a
// ~/.chrome-use/relay-cdp-url while connected and removes it on exit, so a
// present URL means the relay is up. This must win over the DevToolsActivePort
// / :9222 probes below: if the user's Chrome happens to also be listening on a
// debug port, attaching there would pop the consent dialog and defeat the
@@ -814,15 +889,13 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
}
if host_installed {
return Err(
"The agent-browser-stealth extension is installed, but its relay \
return Err("The chrome-use extension is installed, but its relay \
isn't connected right now. Wake it up click the extension's \
toolbar icon, or reload it at chrome://extensions — then retry. \
(agent-browser will not attach to a raw --remote-debugging-port \
(chrome-use will not attach to a raw --remote-debugging-port \
while the extension is set up, because that pops Chrome's \"Allow \
remote debugging?\" dialog. Use --cdp <port> to force the raw path.)"
.to_string(),
);
.to_string());
}
let user_data_dirs = get_chrome_user_data_dirs();
@@ -848,7 +921,7 @@ pub async fn auto_connect_cdp() -> Result<String, 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 \
--remote-debugging-port=9222 (then chrome-use auto-connects), or pass \
--cdp <port>/--launch."
.to_string(),
)
@@ -1149,7 +1222,7 @@ pub fn copy_chrome_profile(
profile_directory: &str,
) -> Result<PathBuf, String> {
let temp_dir =
std::env::temp_dir().join(format!("agent-browser-profile-{}", uuid::Uuid::new_v4()));
std::env::temp_dir().join(format!("chrome-use-profile-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir)
.map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
@@ -1583,7 +1656,7 @@ mod tests {
guard.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent/path");
let temp_home = std::env::temp_dir().join(format!(
"agent-browser-test-home-{}-{}",
"chrome-use-test-home-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1713,7 +1786,7 @@ mod tests {
let result = build_chrome_args(&opts).unwrap();
assert!(
!result.args.iter().any(|a| a == "--hide-scrollbars"),
"--hide-scrollbars false should suppress agent-browser's default hide switch"
"--hide-scrollbars false should suppress chrome-use's default hide switch"
);
if let Some(ref dir) = result.temp_user_data_dir {
let _ = std::fs::remove_dir_all(dir);
@@ -1830,7 +1903,7 @@ mod tests {
#[test]
fn test_chrome_process_drop_cleans_temp_dir() {
let dir = std::env::temp_dir().join(format!(
"agent-browser-chrome-drop-test-{}",
"chrome-use-chrome-drop-test-{}",
uuid::Uuid::new_v4()
));
let _ = std::fs::create_dir_all(&dir);
@@ -1861,6 +1934,17 @@ mod tests {
assert!(is_chrome_profile_name(""));
}
#[test]
fn test_profile_launch_lock_acquires_and_sanitizes() {
// Uncontended acquire succeeds and writes a sanitized per-profile lock
// file (issue #11: serialize concurrent same-profile launches).
let lock = ProfileLaunchLock::acquire("Profile 5/weird:name");
assert!(lock.is_some(), "uncontended lock should acquire");
let expected = std::env::temp_dir().join("chrome-use-launch-Profile_5_weird_name.lock");
assert!(expected.exists(), "lock file should exist at {expected:?}");
drop(lock);
}
#[test]
fn test_is_chrome_profile_name_paths() {
assert!(!is_chrome_profile_name("/tmp/dir"));
@@ -1890,6 +1974,38 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_write_temp_profile_label_names_the_profile() {
// issue #9: a throwaway --launch profile must carry a human-readable name
// in Local State (the field Chrome's profile chip reads) + Preferences.
let tmp = std::env::temp_dir().join("ab-label-test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
write_temp_profile_label(&tmp);
let ls: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(tmp.join("Local State")).unwrap())
.unwrap();
let name = ls["profile"]["info_cache"]["Default"]["name"]
.as_str()
.unwrap();
assert!(name.starts_with("chrome-use ("), "got: {name}");
assert_eq!(
ls["profile"]["info_cache"]["Default"]["is_using_default_name"],
serde_json::json!(false)
);
let prefs: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(tmp.join("Default/Preferences")).unwrap(),
)
.unwrap();
assert!(prefs["profile"]["name"]
.as_str()
.unwrap()
.starts_with("chrome-use ("));
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");
@@ -1939,7 +2055,7 @@ mod tests {
impl TempDir {
fn new(name: &str) -> Self {
Self(std::env::temp_dir().join(format!(
"agent-browser-test-{}-{}-{}",
"chrome-use-test-{}-{}-{}",
name,
std::process::id(),
std::time::SystemTime::now()
+1 -1
View File
@@ -61,7 +61,7 @@ pub async fn discover_cdp_url_with_timeout(
"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 \
use the default auto-connect (just `chrome-use open <url>`), which reads \
DevToolsActivePort and attaches over WebSocket. \
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
)),
+3 -3
View File
@@ -498,15 +498,15 @@ fn get_daemon_socket_dir() -> PathBuf {
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("agent-browser");
return PathBuf::from(xdg).join("chrome-use");
}
}
if let Some(home) = dirs::home_dir() {
return home.join(".agent-browser");
return home.join(".chrome-use");
}
std::env::temp_dir().join("agent-browser")
std::env::temp_dir().join("chrome-use")
}
#[cfg(windows)]
+18 -20
View File
@@ -306,7 +306,7 @@ async fn e2e_lightpanda_auto_launch_can_open_page() {
async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() {
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
let socket_dir = std::env::temp_dir().join(format!(
"agent-browser-e2e-stream-{}-{}",
"chrome-use-e2e-stream-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -609,7 +609,7 @@ async fn e2e_screenshot() {
// Named screenshot
let tmp_path = std::env::temp_dir()
.join("agent-browser-e2e-test-screenshot.png")
.join("chrome-use-e2e-test-screenshot.png")
.to_string_lossy()
.to_string();
let resp = execute_command(
@@ -2202,7 +2202,7 @@ async fn e2e_state_management() {
// Save state
let tmp_state = std::env::temp_dir()
.join("agent-browser-e2e-state.json")
.join("chrome-use-e2e-state.json")
.to_string_lossy()
.to_string();
let resp = execute_command(
@@ -2314,7 +2314,7 @@ async fn e2e_save_state_cross_domain() {
// Save state (currently on example.com)
let tmp_state = std::env::temp_dir()
.join("agent-browser-e2e-cross-domain-state.json")
.join("chrome-use-e2e-cross-domain-state.json")
.to_string_lossy()
.to_string();
let resp = execute_command(
@@ -2718,10 +2718,8 @@ async fn e2e_error_handling() {
#[tokio::test]
#[ignore]
async fn e2e_profile_cookie_persistence() {
let profile_dir = std::env::temp_dir().join(format!(
"agent-browser-e2e-profile-{}",
uuid::Uuid::new_v4()
));
let profile_dir =
std::env::temp_dir().join(format!("chrome-use-e2e-profile-{}", uuid::Uuid::new_v4()));
// Session 1: launch with profile, set a cookie, close
{
@@ -4254,7 +4252,7 @@ async fn e2e_headers_case_insensitive_no_duplicates() {
// Regression: externally opened tabs must appear in tab_list (#1037)
//
// When connected to Chrome (launched or via --cdp), a tab opened outside of
// agent-browser (e.g. by the user or another CDP client) should be detected
// chrome-use (e.g. by the user or another CDP client) should be detected
// and listed. Previously, chrome://newtab/ was filtered by
// is_internal_chrome_target, and Target.targetInfoChanged for untracked
// targets was silently ignored.
@@ -4280,7 +4278,7 @@ async fn e2e_externally_opened_tab_detected() {
// Simulate an external client opening a new tab via the browser-level CDP
// session (no sessionId). This mirrors what happens when a user manually
// opens a tab while agent-browser is connected via --cdp.
// opens a tab while chrome-use is connected via --cdp.
let browser = state.browser.as_ref().expect("browser should be launched");
let _: Value = browser
.client
@@ -4373,7 +4371,7 @@ async fn e2e_relaunch_on_options_change() {
"id": "3",
"action": "launch",
"headless": true,
"userAgent": "agent-browser-test/1.0"
"userAgent": "chrome-use-test/1.0"
}),
&mut state,
)
@@ -4397,7 +4395,7 @@ async fn e2e_relaunch_on_options_change() {
async fn e2e_stream_frame_metadata_respects_custom_viewport() {
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
let socket_dir = std::env::temp_dir().join(format!(
"agent-browser-e2e-stream-viewport-{}-{}",
"chrome-use-e2e-stream-viewport-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -4703,7 +4701,7 @@ async fn e2e_recording_inherits_viewport() {
/// Verify that launching with `storageState` in the launch command restores
/// cookies that were previously saved with `state_save`.
///
/// This is the e2e equivalent of `agent-browser --state ./auth.json open <url>`.
/// This is the e2e equivalent of `chrome-use --state ./auth.json open <url>`.
/// The launch command accepts a `storageState` field that should load the
/// state file (cookies + localStorage) before the first navigation.
#[tokio::test]
@@ -4711,7 +4709,7 @@ async fn e2e_recording_inherits_viewport() {
async fn e2e_state_flag_restores_cookies() {
let state_path = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-state-flag-{}.json",
"chrome-use-e2e-state-flag-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
@@ -4819,7 +4817,7 @@ async fn e2e_state_flag_missing_file_fails_launch() {
let missing_path = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-missing-state-{}.json",
"chrome-use-e2e-missing-state-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
@@ -4859,14 +4857,14 @@ async fn e2e_state_flag_missing_file_fails_launch() {
async fn e2e_storage_state_launch_restarts_clean_browser() {
let state_one = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-storage-reuse-1-{}.json",
"chrome-use-e2e-storage-reuse-1-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
.to_string();
let state_two = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-storage-reuse-2-{}.json",
"chrome-use-e2e-storage-reuse-2-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
@@ -4967,7 +4965,7 @@ async fn e2e_storage_state_launch_restarts_clean_browser() {
async fn e2e_state_env_restores_cookies_on_auto_launch() {
let state_path = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-state-env-{}.json",
"chrome-use-e2e-state-env-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
@@ -5149,7 +5147,7 @@ async fn e2e_session_name_auto_restores_cookies() {
// Clean up auto-saved state files
let sessions_dir = dirs::home_dir()
.unwrap()
.join(".agent-browser")
.join(".chrome-use")
.join("sessions");
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
@@ -5168,7 +5166,7 @@ async fn e2e_session_name_auto_restores_cookies() {
async fn e2e_explicit_state_load_restores_cookies() {
let state_path = std::env::temp_dir()
.join(format!(
"agent-browser-e2e-explicit-load-{}.json",
"chrome-use-e2e-explicit-load-{}.json",
uuid::Uuid::new_v4()
))
.to_string_lossy()
+9 -5
View File
@@ -378,7 +378,7 @@ pub async fn resolve_element_object_id(
&DomResolveNodeParams {
backend_node_id: Some(active_id),
node_id: None,
object_group: Some("agent-browser".to_string()),
object_group: Some("chrome-use".to_string()),
},
Some(effective_session_id),
)
@@ -419,7 +419,7 @@ pub async fn resolve_element_object_id(
&DomResolveNodeParams {
backend_node_id: Some(fresh_id),
node_id: None,
object_group: Some("agent-browser".to_string()),
object_group: Some("chrome-use".to_string()),
},
Some(effective_session_id),
)
@@ -556,8 +556,12 @@ async fn verify_ref_identity(
Err(format!(
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
The DOM mutated between snapshot and interaction (typical with React/Vue \
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
reusing nodes during re-render). Fix: take a fresh `snapshot` and re-target \
with the new ref. For SPAs where refs churn every interaction, drive the \
element directly with `eval` (e.g. `eval \"document.querySelector(...).click()\"`), \
which doesn't depend on refs.\n\
(Last resort: AGENT_BROWSER_VERIFY_REF=0 disables this safety check only \
if you accept clicks may land on a re-rendered/wrong node.)",
ref_id, expected_role, expected_name, actual_role, actual_name,
))
}
@@ -587,7 +591,7 @@ async fn verify_click_target(
let resolve_params = DomResolveNodeParams {
backend_node_id: Some(backend_node_id),
node_id: None,
object_group: Some("agent-browser-occlusion".to_string()),
object_group: Some("chrome-use-occlusion".to_string()),
};
let resolve_fut = client.send_command_typed::<_, serde_json::Value>(
"DOM.resolveNode",
+1 -1
View File
@@ -1,6 +1,6 @@
//! Human-like input behaviour for stealth.
//!
//! When agent-browser drives a real Chrome over CDP, the input events it
//! When chrome-use drives a real Chrome over CDP, the input events it
//! dispatches are already `isTrusted` — but a click that teleports the cursor
//! straight to an element's exact centre, with no approach path and zero delay
//! between move/press/release, is a behavioural tell that advanced anti-bot
+1 -1
View File
@@ -13,7 +13,7 @@ use super::cdp::client::InspectProxyHandle;
/// Counter for unique attach IDs so concurrent connections don't collide.
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
/// Lightweight HTTP + WebSocket server for `chrome-use inspect`.
///
/// Serves two purposes:
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
+14
View File
@@ -1143,6 +1143,20 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
.await;
}
/// Click at a raw viewport coordinate, bypassing element/selector resolution
/// (issue #8.4 first-class coordinate click). Honors the humanize trajectory and
/// press dwell exactly like a selector click — it shares `dispatch_click`.
pub async fn click_at_point(
client: &CdpClient,
session_id: &str,
x: f64,
y: f64,
button: &str,
click_count: i32,
) -> Result<(), String> {
dispatch_click(client, session_id, x, y, button, click_count).await
}
async fn dispatch_click(
client: &CdpClient,
session_id: &str,
+1 -1
View File
@@ -425,7 +425,7 @@ mod agentcore {
let url = format!("https://{}{}", host, path);
// Generate a unique session name
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let session_name = format!("chrome-use-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut body_json = json!({
"name": session_name,
+41 -2
View File
@@ -12,7 +12,7 @@
//!
//! ## Multiple clients (concurrent agents on one shared browser)
//!
//! Several agent-browser daemons (one per `--session`) can connect to the same
//! Several chrome-use daemons (one per `--session`) can connect to the same
//! relay/Chrome at once. The extension is a single peer, so the relay must
//! demultiplex: every forwarded command is re-keyed to a relay-global id mapped
//! back to the originating client, and the extension's reply is routed to **only
@@ -31,7 +31,7 @@ use serde_json::{json, Value};
/// Protocol version advertised in the connect handshake (matches the extension).
pub const RELAY_PROTOCOL: i64 = 3;
/// Identifies one connected CDP client (agent-browser daemon) for routing.
/// Identifies one connected CDP client (chrome-use daemon) for routing.
pub type ClientId = u64;
/// One target (tab) the extension has attached, as the relay tracks it.
@@ -330,6 +330,45 @@ mod tests {
}
}
#[test]
fn reattach_with_same_session_restores_target() {
// Issue #17 recovery contract. A tab's chrome.debugger session is torn
// down (cross-process nav, SW restart, …) then re-attached. The fix has
// the extension reuse the SAME `cb-tab-<tabId>` id across that churn, so
// after detach+reattach the relay must expose the NEW target under the
// SAME session — which is exactly the session the daemon is still bound
// to, so its eval/snapshot auto-follow the new page instead of going stale.
let mut s = RelayState::new();
s.handle_ext_message(&attached_event("T_old", "cb-tab-42"), "tok");
s.handle_ext_message(
&json!({
"method": "forwardCDPEvent",
"params": { "method": "Target.detachedFromTarget", "params": { "sessionId": "cb-tab-42" } }
}),
"tok",
);
s.handle_ext_message(&attached_event("T_new", "cb-tab-42"), "tok");
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
match route {
ClientRoute::Local(v) => {
let infos = v["result"]["targetInfos"].as_array().unwrap();
assert_eq!(infos.len(), 1, "only the new target should remain");
assert_eq!(infos[0]["targetId"], "T_new");
}
_ => panic!("getTargets must be local"),
}
// The daemon's existing session id still resolves — to the new target.
let route = s.route_client_command(
1,
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "T_new" } }),
);
assert_eq!(
route,
ClientRoute::Local(json!({ "id": 2, "result": { "sessionId": "cb-tab-42" } }))
);
}
#[test]
fn browser_get_version_is_answered_locally() {
// Liveness probe must NOT be forwarded (the extension can't do
+3 -5
View File
@@ -260,7 +260,7 @@ async fn collect_annotations(
"DOM.resolveNode",
Some(serde_json::json!({
"backendNodeId": backend_node_id,
"objectGroup": "agent-browser-annotate"
"objectGroup": "chrome-use-annotate"
})),
Some(session_id),
)
@@ -589,11 +589,9 @@ fn round(value: f64) -> i64 {
fn get_screenshot_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("screenshots")
home.join(".chrome-use").join("tmp").join("screenshots")
} else {
std::env::temp_dir()
.join("agent-browser")
.join("screenshots")
std::env::temp_dir().join("chrome-use").join("screenshots")
}
}
+35 -2
View File
@@ -927,7 +927,7 @@ async fn find_cursor_interactive_elements(
)
.await
{
eprintln!("[agent-browser] Warning: failed to clean up data-__ab-ci attributes: {e}");
eprintln!("[chrome-use] Warning: failed to clean up data-__ab-ci attributes: {e}");
}
// Build the map
@@ -1305,6 +1305,39 @@ fn render_tree(
}
}
/// True if a snapshot line names an interactive ARIA role. Compaction keeps
/// these even without a `ref=`/`": "` marker, so a clickable control never gets
/// dropped from `-c` output (the dogfood reports saw a button present in the full
/// snapshot vanish from compact, leaving the agent clicking an empty ref).
fn is_interactive_line(line: &str) -> bool {
const ROLES: &[&str] = &[
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"switch",
"slider",
"spinbutton",
"searchbox",
"tab ",
"clickable",
"focusable",
"editable",
];
let t = line.trim_start();
// Lines look like `- button "Label" [ref=e1]`; match the role token after the
// leading "- " marker.
let t = t.strip_prefix("- ").unwrap_or(t);
ROLES.iter().any(|r| t.starts_with(r))
}
fn compact_tree(tree: &str, interactive: bool) -> String {
let lines: Vec<&str> = tree.lines().collect();
if lines.is_empty() {
@@ -1314,7 +1347,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
let mut keep = vec![false; lines.len()];
for (i, line) in lines.iter().enumerate() {
if line.contains("ref=") || line.contains(": ") {
if line.contains("ref=") || line.contains(": ") || is_interactive_line(line) {
keep[i] = true;
// Mark ancestors
let my_indent = count_indent(line);
+7 -7
View File
@@ -717,14 +717,14 @@ pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
}
}
/// Return the agent-browser state root (`~/.agent-browser`, falling back to
/// `<tempdir>/agent-browser` when the home directory can't be resolved).
/// Return the chrome-use state root (`~/.chrome-use`, falling back to
/// `<tempdir>/chrome-use` when the home directory can't be resolved).
/// This is the parent of `sessions/`, auth storage, and the encryption key.
pub fn get_state_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser")
home.join(".chrome-use")
} else {
std::env::temp_dir().join("agent-browser")
std::env::temp_dir().join("chrome-use")
}
}
@@ -783,19 +783,19 @@ mod tests {
#[test]
fn test_state_show_nonexistent_file() {
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
let result = state_show("/tmp/nonexistent-chrome-use-state-file.json");
assert!(result.is_err());
}
#[test]
fn test_state_clear_nonexistent_file() {
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
let result = state_clear(Some("/tmp/nonexistent-chrome-use-state-file.json"));
assert!(result.is_err());
}
#[test]
fn test_state_rename_nonexistent() {
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
let result = state_rename("/tmp/nonexistent-chrome-use-state-file.json", "new-name");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
+15 -2
View File
@@ -51,18 +51,19 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
vec![locale, base_lang]
};
let config_line = format!(
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
hide_canvas_enabled(),
canvas_noise_seed(),
disable_iframe_proxy_enabled(),
);
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
// otherwise the fallback below prepends a SECOND `const __abStealth`
// declaration and the whole script dies with a redeclaration SyntaxError.
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };"#,
) {
format!("{}{}", config_line, rest)
} else {
@@ -81,6 +82,18 @@ fn hide_canvas_enabled() -> bool {
.unwrap_or(false)
}
/// Whether to DROP the srcdoc-iframe `contentWindow` Proxy patch (FullLaunch).
/// That patch masks automation in srcdoc iframes, but the JS `Proxy` is itself a
/// fingerprintable tell (CreepJS `hasIframeProxy` → ~20% stealth). Off by default
/// (keep the patch); `AGENT_BROWSER_DISABLE_IFRAME_PROXY=1` drops it for a clean
/// 0% CreepJS at the cost of that niche srcdoc-iframe masking.
fn disable_iframe_proxy_enabled() -> bool {
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY")
.ok()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
/// device returns the same hash on repeated reads) but differs from the
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
+33 -2
View File
@@ -1,4 +1,4 @@
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
// the way real Chrome exposes these — as prototype getters, NOT instance own
// properties. Adding an own property to the `navigator` instance is itself a
@@ -289,6 +289,10 @@ const __abRedefineNavProto = (name, getterImpl) => {
})();
(function(){
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
// The srcdoc-iframe contentWindow Proxy below is itself a fingerprintable tell
// (CreepJS `hasIframeProxy`). Honor the opt-out so callers can trade the niche
// srcdoc masking for a clean 0% CreepJS fingerprint.
if (typeof __abStealth !== 'undefined' && __abStealth.disableIframeProxy) return;
const nativeCreateElement = document.createElement.bind(document);
const nativeSrcdocDescriptor =
typeof HTMLIFrameElement !== 'undefined'
@@ -304,12 +308,39 @@ const __abRedefineNavProto = (name, getterImpl) => {
try {
if (iframe.contentWindow) return;
} catch {}
// Native window methods are bound to the real Window via an internal slot;
// calling them with the Proxy as `this` throws "Illegal invocation". Wrap
// each function in an apply/construct trap that swaps the Proxy receiver for
// the real window, while passing `.prototype`/`.name`/`.toString`/identity
// straight through (a plain `.bind()` would drop `.prototype` and break
// `instanceof`). Cached so repeated reads return the same function.
const fnProxyCache = new WeakMap();
const bindToRealWindow = (fn) => {
let wrapped = fnProxyCache.get(fn);
if (wrapped) return wrapped;
try {
wrapped = new Proxy(fn, {
apply(target, thisArg, args) {
return Reflect.apply(target, thisArg === proxy ? window : thisArg, args);
},
construct(target, args, newTarget) {
return Reflect.construct(target, args, newTarget);
},
});
} catch {
wrapped = fn;
}
fnProxyCache.set(fn, wrapped);
return wrapped;
};
const proxy = new Proxy(window, {
get(target, key) {
if (key === 'self') return proxy;
if (key === 'frameElement') return iframe;
if (key === '0') return undefined;
return Reflect.get(target, key, target);
const value = Reflect.get(target, key, target);
if (typeof value === 'function') return bindToRealWindow(value);
return value;
},
});
iframeProxyMap.set(iframe, proxy);
+10 -10
View File
@@ -76,7 +76,7 @@ pub(super) async fn handle_models_request(
let _ = stream.write_all(body.as_bytes()).await;
}
const SKILL_NAMES: &[&str] = &["agent-browser", "slack", "electron", "dogfood", "agentcore"];
const SKILL_NAMES: &[&str] = &["chrome-use", "slack", "electron", "dogfood", "agentcore"];
/// Locate the `skills/` directory by walking up from the executable.
/// Works for npm installs (binary in `bin/`, skills at `../skills/`) and
@@ -87,7 +87,7 @@ fn find_skills_dir() -> Option<std::path::PathBuf> {
let mut dir = real.parent();
while let Some(d) = dir {
let candidate = d.join("skills");
if candidate.join("agent-browser").join("SKILL.md").exists() {
if candidate.join("chrome-use").join("SKILL.md").exists() {
return Some(candidate);
}
dir = d.parent();
@@ -133,7 +133,7 @@ pub(crate) fn get_system_prompt() -> &'static str {
}
format!(
r#"You are an AI assistant that controls a browser through agent-browser. You have an active browser session, but you can also create new sessions.
r#"You are an AI assistant that controls a browser through chrome-use. You have an active browser session, but you can also create new sessions.
RULES:
- You MUST use the agent_browser tool for every browser action. NEVER claim you performed an action without calling the tool.
@@ -141,19 +141,19 @@ RULES:
- If a request is outside your capabilities (e.g. system operations), say so honestly. Do not improvise or pretend.
- One tool call per command. Do not chain with `&&` or `;`.
- Do not add `--json`.
- Do not run non-agent-browser programs.
- Do not run non-chrome-use programs.
- Keep responses concise.
- For screenshots, omit the path argument so they save to the default location (which will be displayed inline). Screenshots from tool calls are ALREADY shown to the user. Do NOT re-display them with markdown image syntax in your text response. Never use `![...]()` to reference screenshots.
- To create a new session: add `--session <name>` to any command (e.g. `agent-browser --session my-session open https://example.com`). If the session does not exist, it will be created automatically.
- To use a different browser engine: add `--engine <engine>` (e.g. `agent-browser --session lp-session --engine lightpanda open https://example.com`). Supported engines: chrome (default), lightpanda.
- To create a new session: add `--session <name>` to any command (e.g. `chrome-use --session my-session open https://example.com`). If the session does not exist, it will be created automatically.
- To use a different browser engine: add `--engine <engine>` (e.g. `chrome-use --session lp-session --engine lightpanda open https://example.com`). Supported engines: chrome (default), lightpanda.
The following skill references describe agent-browser capabilities in detail. Use them when deciding which commands to run and how to approach tasks.
The following skill references describe chrome-use capabilities in detail. Use them when deciding which commands to run and how to approach tasks.
{sections}"#,
)
})
}
pub(crate) const CHAT_TOOLS: &str = r#"[{"type":"function","function":{"name":"agent_browser","description":"Execute an agent-browser command. Runs against the active session by default. Add --session <name> to target or create a different session, and --engine <engine> to choose a browser engine.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The command to execute, e.g. 'agent-browser open https://google.com' or 'agent-browser --session new-session open https://example.com' or 'agent-browser snapshot -i' or 'agent-browser click @e3'"}},"required":["command"]}}}]"#;
pub(crate) const CHAT_TOOLS: &str = r#"[{"type":"function","function":{"name":"agent_browser","description":"Execute an chrome-use command. Runs against the active session by default. Add --session <name> to target or create a different session, and --engine <engine> to choose a browser engine.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The command to execute, e.g. 'chrome-use open https://google.com' or 'chrome-use --session new-session open https://example.com' or 'chrome-use snapshot -i' or 'chrome-use click @e3'"}},"required":["command"]}}}]"#;
pub(crate) const COMPACT_THRESHOLD_CHARS: usize = 200_000;
pub(crate) const KEEP_RECENT_MESSAGES: usize = 6;
@@ -462,7 +462,7 @@ pub(crate) async fn execute_chat_tool(session: &str, command: &str) -> String {
let single = command.split("&&").next().unwrap_or(command);
let single = single.split(';').next().unwrap_or(single).trim();
let stripped = single.strip_prefix("agent-browser ").unwrap_or(single);
let stripped = single.strip_prefix("chrome-use ").unwrap_or(single);
let words = crate::commands::shell_words_split(stripped);
let mut global_flags: Vec<String> = Vec::new();
@@ -490,7 +490,7 @@ pub(crate) async fn execute_chat_tool(session: &str, command: &str) -> String {
let first_cmd = cmd_words.first().map(|s| s.as_str()).unwrap_or("");
if !ALLOWED_COMMANDS.contains(&first_cmd) {
return format!(
"Blocked: '{}' is not a valid agent-browser command.",
"Blocked: '{}' is not a valid chrome-use command.",
first_cmd
);
}
+5 -5
View File
@@ -815,21 +815,21 @@ mod tests {
#[test]
fn test_same_origin_ws_request_proxied() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.chrome-use.localhost\r\nOrigin: https://dashboard.chrome-use.localhost\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_normalize_origin_authority_https_without_port() {
assert_eq!(
normalize_origin_authority("https://dashboard.agent-browser.localhost"),
Some("dashboard.agent-browser.localhost".to_string())
normalize_origin_authority("https://dashboard.chrome-use.localhost"),
Some("dashboard.chrome-use.localhost".to_string())
);
}
#[test]
fn test_same_origin_ws_request_default_https_port() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.chrome-use.localhost:443\r\nOrigin: https://dashboard.chrome-use.localhost\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
@@ -841,7 +841,7 @@ mod tests {
#[test]
fn test_same_origin_http_request_matching_referer() {
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nReferer: https://dashboard.agent-browser.localhost/sessions\r\n\r\n";
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.chrome-use.localhost:443\r\nReferer: https://dashboard.chrome-use.localhost/sessions\r\n\r\n";
assert!(is_same_origin_http_request(req));
}
+4 -4
View File
@@ -358,16 +358,16 @@ fn get_clock_domain() -> Option<&'static str> {
fn get_traces_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("traces")
home.join(".chrome-use").join("tmp").join("traces")
} else {
std::env::temp_dir().join("agent-browser").join("traces")
std::env::temp_dir().join("chrome-use").join("traces")
}
}
fn get_profiles_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".agent-browser").join("tmp").join("profiles")
home.join(".chrome-use").join("tmp").join("profiles")
} else {
std::env::temp_dir().join("agent-browser").join("profiles")
std::env::temp_dir().join("chrome-use").join("profiles")
}
}
+594 -477
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -47,7 +47,7 @@ fn find_package_root() -> Option<PathBuf> {
if let Ok(exe) = env::current_exe() {
let exe = exe.canonicalize().unwrap_or(exe);
if let Some(parent) = exe.parent() {
// npm install layout: bin/agent-browser-* -> ../
// npm install layout: bin/chrome-use-* -> ../
let candidate = parent.join("..");
if candidate.join("skills").is_dir() {
return Some(candidate.canonicalize().unwrap_or(candidate));
@@ -77,7 +77,7 @@ fn find_package_root() -> Option<PathBuf> {
/// upgraded binary re-extracts fresh content.
fn embedded_skills_root() -> Option<PathBuf> {
let base = dirs::cache_dir()?
.join("agent-browser")
.join("chrome-use")
.join(concat!("skills-", env!("CARGO_PKG_VERSION")));
let marker = base.join(".extracted");
if !marker.exists() {
@@ -344,13 +344,13 @@ fn run_get(skills_dirs: &[PathBuf], names: &[String], get_all: bool, full: bool,
"{}",
serde_json::to_string(&json!({
"success": false,
"error": "No skill name provided. Usage: agent-browser skills get <name>",
"error": "No skill name provided. Usage: chrome-use skills get <name>",
}))
.unwrap_or_default()
);
} else {
eprintln!(
"{} No skill name provided. Usage: agent-browser skills get <name>",
"{} No skill name provided. Usage: chrome-use skills get <name>",
color::error_indicator()
);
}
+522
View File
@@ -0,0 +1,522 @@
//! `chrome-use test <suite.yaml>` — a tiny, re-runnable browser test runner.
//!
//! Turns repetitive browser checks into unit-test-style suites for the frontend.
//! A suite is a YAML file of cases; each case is a list of `steps` (which reuse
//! chrome-use's own commands) followed by `assert`s (which compile to a single
//! `eval` expression read back as a boolean). The runner drives the session by
//! re-invoking the chrome-use binary per step, so it inherits every flag /
//! launch / daemon / `@ref` semantic for free; the daemon stays up for the
//! session, so each step is just a fast socket round-trip.
//!
//! ```yaml
//! suite: chatgpt smoke
//! setup:
//! - account: chatgpt/huayue # cookie-use injects this login (optional)
//! cases:
//! - name: home loads logged in
//! steps:
//! - open: https://chatgpt.com/
//! - wait: { load: networkidle }
//! assert:
//! - url: { contains: chatgpt.com }
//! - visible: "#prompt-textarea"
//! ```
use crate::flags::Flags;
use serde_json::Value;
use std::process::Command;
use std::time::Instant;
pub fn run_test(suite_path: &str, flags: &Flags) -> i32 {
let text = match std::fs::read_to_string(suite_path) {
Ok(t) => t,
Err(e) => {
eprintln!("{} cannot read suite '{}': {}", err(), suite_path, e);
return 2;
}
};
// YAML deserializes straight into serde_json::Value (maps→objects, etc.).
let suite: Value = match serde_yaml::from_str(&text) {
Ok(v) => v,
Err(e) => {
eprintln!("{} invalid YAML in '{}': {}", err(), suite_path, e);
return 2;
}
};
let cases = match suite.get("cases").and_then(|c| c.as_array()) {
Some(c) if !c.is_empty() => c.clone(),
_ => {
eprintln!("{} suite has no `cases`", err());
return 2;
}
};
let suite_name = suite
.get("suite")
.and_then(|s| s.as_str())
.unwrap_or("suite");
let exe = match std::env::current_exe() {
Ok(p) => p.to_string_lossy().into_owned(),
Err(e) => {
eprintln!("{} cannot find own binary: {}", err(), e);
return 2;
}
};
// A dedicated launched browser by default (deterministic, re-runnable). If
// the user named a --session, target that existing one instead.
let (session, do_launch) = if flags.session == "default" {
("cu-test".to_string(), true)
} else {
(flags.session.clone(), flags.force_launch)
};
let owns_session = session == "cu-test";
let mut base: Vec<String> = vec!["--session".into(), session.clone()];
if do_launch {
base.push("--launch".into());
}
if let Some(p) = &flags.profile {
base.push("--profile".into());
base.push(p.clone());
}
let artifacts_dir = flags
.download_path
.clone()
.unwrap_or_else(|| "cu-test-artifacts".to_string());
let runner = Runner {
exe,
base,
artifacts_dir,
};
// --- setup (runs once) ---
if let Some(setup) = suite.get("setup").and_then(|s| s.as_array()) {
for item in setup {
if let Err(e) = runner.run_setup_item(item, &session) {
eprintln!("{} setup failed: {}", err(), e);
if owns_session {
runner.close();
}
return 2;
}
}
}
// --- cases ---
println!("suite: {} (session {})", suite_name, session);
let mut passed = 0usize;
let mut failed = 0usize;
for case in &cases {
let name = case
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("(unnamed)");
let start = Instant::now();
let outcome = runner.run_case(case);
let secs = start.elapsed().as_secs_f64();
match outcome {
Ok(()) => {
passed += 1;
println!(" {} {} {:.1}s", ok(), name, secs);
}
Err(failure) => {
failed += 1;
println!(" {} {} {:.1}s", cross(), name, secs);
println!(" {}", failure.reason);
if let Some(shot) = runner.capture_artifact(name) {
println!("{}", shot);
}
}
}
}
if owns_session {
runner.close();
}
println!(
"{} cases · {} passed · {} failed",
cases.len(),
passed,
failed
);
i32::from(failed > 0)
}
struct Failure {
reason: String,
}
struct Runner {
exe: String,
base: Vec<String>,
artifacts_dir: String,
}
impl Runner {
/// Run one chrome-use sub-command. Returns the `data` object on success.
fn cli(&self, args: &[String]) -> Result<Option<Value>, String> {
let out = Command::new(&self.exe)
.args(&self.base)
.args(args)
.arg("--json")
.output()
.map_err(|e| format!("spawning chrome-use: {}", e))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if let Ok(v) = serde_json::from_str::<Value>(stdout.trim()) {
let success = v
.get("success")
.and_then(|b| b.as_bool())
.unwrap_or(out.status.success());
if !success {
return Err(v
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("command failed")
.to_string());
}
return Ok(v.get("data").cloned());
}
if out.status.success() {
Ok(None)
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn close(&self) {
let _ = self.cli(&["close".to_string()]);
}
fn run_setup_item(&self, item: &Value, session: &str) -> Result<(), String> {
// `account: <id>` injects a stored cookie-use login into this session.
if let Some(acct) = item.get("account").and_then(|a| a.as_str()) {
let target = format!("session:{}", session);
let out = Command::new("cookie-use")
.args(["use", acct, "--target", &target, "--no-open"])
.output();
return match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(format!(
"cookie-use use {} failed: {}",
acct,
String::from_utf8_lossy(&o.stderr).trim()
)),
Err(e) => Err(format!(
"cookie-use not available ({}); skip `account:` or install it",
e
)),
};
}
// Otherwise it's a normal step.
let args = step_to_args(item)?;
self.cli(&args).map(|_| ())
}
fn run_case(&self, case: &Value) -> Result<(), Failure> {
if let Some(steps) = case.get("steps").and_then(|s| s.as_array()) {
for step in steps {
let args = step_to_args(step).map_err(|e| Failure {
reason: format!("bad step: {}", e),
})?;
self.cli(&args).map_err(|e| Failure {
reason: format!(
"step `{}` failed: {}",
args.first().cloned().unwrap_or_default(),
e
),
})?;
}
}
if let Some(asserts) = case.get("assert").and_then(|a| a.as_array()) {
for a in asserts {
let (expr, describe) = assert_to_eval(a).map_err(|e| Failure {
reason: format!("bad assert: {}", e),
})?;
let data = self.cli(&["eval".to_string(), expr]).map_err(|e| Failure {
reason: format!("assert `{}` could not run: {}", describe, e),
})?;
let result = data.as_ref().and_then(|d| d.get("result"));
if !is_truthy(result) {
let got = result
.map(value_short)
.unwrap_or_else(|| "undefined".into());
return Err(Failure {
reason: format!("assert {} → got {}", describe, got),
});
}
}
}
Ok(())
}
/// Best-effort screenshot of the failing state. Returns the saved path.
fn capture_artifact(&self, case_name: &str) -> Option<String> {
let _ = std::fs::create_dir_all(&self.artifacts_dir);
let path = format!("{}/{}.png", self.artifacts_dir, slug(case_name));
match self.cli(&["screenshot".to_string(), path.clone()]) {
Ok(Some(d)) => d
.get("path")
.and_then(|p| p.as_str())
.map(String::from)
.or(Some(path)),
Ok(None) => Some(path),
Err(_) => None,
}
}
}
/// Map a YAML step (a one-key object) to chrome-use CLI args.
fn step_to_args(step: &Value) -> Result<Vec<String>, String> {
let obj = step
.as_object()
.ok_or_else(|| "step must be a key: value mapping".to_string())?;
let (key, val) = obj.iter().next().ok_or_else(|| "empty step".to_string())?;
let s = |v: &Value| v.as_str().map(String::from);
match key.as_str() {
"open" | "goto" | "navigate" => {
let url = s(val).ok_or("open: expected a URL string")?;
Ok(vec!["open".into(), url])
}
"click" => Ok(vec![
"click".into(),
s(val).ok_or("click: expected a selector")?,
]),
"press" => Ok(vec!["press".into(), s(val).ok_or("press: expected a key")?]),
"eval" => Ok(vec![
"eval".into(),
s(val).ok_or("eval: expected JS string")?,
]),
"fill" | "type" => {
let sel = field(val, &["sel", "selector"]).ok_or("fill/type: need sel")?;
let text = field(val, &["text", "value"]).ok_or("fill/type: need text")?;
Ok(vec![key.clone(), sel, text])
}
"scroll" => {
if let Some(dir) = s(val) {
Ok(vec!["scroll".into(), dir])
} else {
let dir = field(val, &["dir", "direction"]).ok_or("scroll: need dir")?;
let mut a = vec!["scroll".into(), dir];
if let Some(px) = field(val, &["px", "pixels"]) {
a.push(px);
}
Ok(a)
}
}
"wait" => {
if let Some(n) = val.as_i64() {
Ok(vec!["wait".into(), n.to_string()])
} else if let Some(load) = field(val, &["load"]) {
Ok(vec!["wait".into(), "--load".into(), load])
} else if let Some(sel) = s(val) {
Ok(vec!["wait".into(), sel])
} else {
Err("wait: expected ms, a selector, or { load: <state> }".into())
}
}
other => Err(format!("unknown step `{}`", other)),
}
}
/// Compile a YAML assert (one-key object) into (js-bool-expr, human-describe).
fn assert_to_eval(a: &Value) -> Result<(String, String), String> {
let obj = a
.as_object()
.ok_or_else(|| "assert must be a key: value mapping".to_string())?;
let (key, val) = obj
.iter()
.next()
.ok_or_else(|| "empty assert".to_string())?;
match key.as_str() {
"url" => {
let (op, want) = str_op(val).ok_or("url: need contains/equals/matches")?;
Ok((
cmp_expr("location.href", &op, &want),
format!("url {} {:?}", op, want),
))
}
"visible" => {
let sel = val.as_str().ok_or("visible: expected a selector")?;
Ok((visible_expr(sel), format!("visible {:?}", sel)))
}
"hidden" => {
let sel = val.as_str().ok_or("hidden: expected a selector")?;
Ok((
format!("!({})", visible_expr(sel)),
format!("hidden {:?}", sel),
))
}
"text" => {
let sel = field(val, &["sel", "selector"]).ok_or("text: need sel")?;
let (op, want) = str_op(val).ok_or("text: need contains/equals/matches")?;
let base = format!(
"((document.querySelector({})||{{}}).textContent||\"\")",
js(&sel)
);
Ok((
cmp_expr(&base, &op, &want),
format!("text {:?} {} {:?}", sel, op, want),
))
}
"count" => {
let sel = field(val, &["sel", "selector"]).ok_or("count: need sel")?;
let n = val
.get("eq")
.or_else(|| val.get("equals"))
.and_then(|v| v.as_i64())
.ok_or("count: need eq: <n>")?;
Ok((
format!("document.querySelectorAll({}).length==={}", js(&sel), n),
format!("count {:?} == {}", sel, n),
))
}
"eval" => {
let expr = val.as_str().ok_or("eval: expected JS string")?;
Ok((format!("!!({})", expr), format!("eval {:?}", expr)))
}
other => Err(format!("unknown assert `{}`", other)),
}
}
fn visible_expr(sel: &str) -> String {
format!(
"(function(){{var e=document.querySelector({});return !!(e&&(e.offsetWidth||e.offsetHeight||e.getClientRects().length));}})()",
js(sel)
)
}
/// Extract (op, want) from `{contains|equals|matches: <str>}`.
fn str_op(val: &Value) -> Option<(String, String)> {
for op in ["contains", "equals", "matches"] {
if let Some(s) = val.get(op).and_then(|v| v.as_str()) {
return Some((op.to_string(), s.to_string()));
}
}
None
}
fn cmp_expr(base: &str, op: &str, want: &str) -> String {
match op {
"equals" => format!("({})==={}", base, js(want)),
"matches" => format!("new RegExp({}).test({})", js(want), base),
_ => format!("({}).includes({})", base, js(want)), // contains
}
}
/// First present field among `keys`, as a string.
fn field(val: &Value, keys: &[&str]) -> Option<String> {
for k in keys {
if let Some(v) = val.get(*k) {
return match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
};
}
}
None
}
/// JSON-encode a string so it embeds safely as a JS literal.
fn js(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
}
fn is_truthy(v: Option<&Value>) -> bool {
match v {
Some(Value::Bool(b)) => *b,
Some(Value::Null) | None => false,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
fn value_short(v: &Value) -> String {
let s = v.to_string();
if s.len() > 60 {
format!("{}", &s[..60])
} else {
s
}
}
fn slug(name: &str) -> String {
let s: String = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
s.trim_matches('-').to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn step_mapping() {
assert_eq!(
step_to_args(&json!({"open": "https://x.com"})).unwrap(),
vec!["open", "https://x.com"]
);
assert_eq!(
step_to_args(&json!({"fill": {"sel": "#a", "text": "hi"}})).unwrap(),
vec!["fill", "#a", "hi"]
);
assert_eq!(
step_to_args(&json!({"wait": {"load": "networkidle"}})).unwrap(),
vec!["wait", "--load", "networkidle"]
);
assert_eq!(
step_to_args(&json!({"wait": 500})).unwrap(),
vec!["wait", "500"]
);
assert!(step_to_args(&json!({"bogus": 1})).is_err());
}
#[test]
fn assert_compilation() {
let (e, _) = assert_to_eval(&json!({"url": {"contains": "x.com"}})).unwrap();
assert!(e.contains("location.href") && e.contains(".includes("));
let (e, _) = assert_to_eval(&json!({"count": {"sel": ".a", "eq": 3}})).unwrap();
assert!(e.contains("querySelectorAll") && e.ends_with("===3"));
let (e, _) = assert_to_eval(&json!({"hidden": "#x"})).unwrap();
assert!(e.starts_with("!("));
let (e, _) = assert_to_eval(&json!({"eval": "window.ok"})).unwrap();
assert_eq!(e, "!!(window.ok)");
assert!(assert_to_eval(&json!({"bogus": 1})).is_err());
}
#[test]
fn truthiness() {
assert!(is_truthy(Some(&json!(true))));
assert!(!is_truthy(Some(&json!(false))));
assert!(!is_truthy(None));
assert!(!is_truthy(Some(&json!(""))));
assert!(is_truthy(Some(&json!("x"))));
assert!(!is_truthy(Some(&json!(0))));
}
#[test]
fn js_escaping() {
// Selectors with quotes must embed safely.
assert_eq!(js(r#"a"b"#), r#""a\"b""#);
}
}
fn ok() -> &'static str {
"\x1b[32m✓\x1b[0m"
}
fn cross() -> &'static str {
"\x1b[31m✗\x1b[0m"
}
fn err() -> &'static str {
"\x1b[31merror:\x1b[0m"
}
+169 -10
View File
@@ -1,26 +1,185 @@
use crate::color;
use std::process::{exit, Command};
use std::path::PathBuf;
use std::process::{exit, Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 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";
const INSTALL_URL: &str = "https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh";
/// GitHub API for the latest published release (used by the update check).
const LATEST_RELEASE_API: &str =
"https://api.github.com/repos/leeguooooo/chrome-use/releases/latest";
/// Re-check the latest version at most this often (seconds).
const UPDATE_CHECK_INTERVAL_SECS: u64 = 86_400; // once a day
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn update_cache_path() -> PathBuf {
crate::connection::config_home().join("update-check.json")
}
fn write_update_cache(checked_at: u64, latest: &str) {
let path = update_cache_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let body = serde_json::json!({ "checked_at": checked_at, "latest": latest }).to_string();
let _ = std::fs::write(&path, body);
}
/// Parse a dotted version (`1.2.1`, `v1.2.1`, `1.2.1-fork.3`) into a comparable
/// `(major, minor, patch)`, ignoring any pre-release/build suffix.
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let core = v.trim().trim_start_matches('v');
let core = core.split(['-', '+']).next().unwrap_or(core);
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
fn is_newer(latest: &str, current: &str) -> bool {
matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c)
}
/// Public semver-ish comparison (`latest` strictly newer than `current`), so
/// `doctor` can flag a stale extension/CLI without re-implementing parsing.
pub fn version_is_newer(latest: &str, current: &str) -> bool {
is_newer(latest, current)
}
/// The latest CLI version recorded by the background update check, if any.
/// `doctor` uses it to show "a newer chrome-use is available" without a network
/// call (the `__update-check` worker refreshes the cache out of band).
pub fn cached_latest_version() -> Option<String> {
std::fs::read_to_string(update_cache_path())
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|j| {
j.get("latest")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
}
/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it.
/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a
/// real command. Uses `curl` (no extra deps, matches `upgrade`).
pub fn run_update_check() {
let latest = Command::new("curl")
.args([
"-fsSL",
"--max-time",
"8",
"-H",
"User-Agent: chrome-use-update-check",
LATEST_RELEASE_API,
])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
.and_then(|j| {
j.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.trim_start_matches('v').to_string())
});
if let Some(latest) = latest {
write_update_cache(now_secs(), &latest);
}
}
/// Non-blocking "update available" notice. Called once per command run:
/// - prints a one-line hint to **stderr** (never stdout, so `--json` is clean)
/// when a cached release is newer than the running binary;
/// - refreshes the cached latest version at most once a day via a **detached**
/// background process, so the current command never waits on the network.
///
/// Skipped for meta commands (upgrade/install/doctor/`__*`/--version/--help),
/// in CI, in daemon mode, and when CHROME_USE_NO_UPDATE_CHECK /
/// AGENT_BROWSER_NO_UPDATE_CHECK is set.
pub fn maybe_notify_update() {
if std::env::var_os("CHROME_USE_NO_UPDATE_CHECK").is_some()
|| std::env::var_os("AGENT_BROWSER_NO_UPDATE_CHECK").is_some()
|| std::env::var_os("CI").is_some()
|| std::env::var_os("AGENT_BROWSER_DAEMON").is_some()
{
return;
}
let first = std::env::args().nth(1).unwrap_or_default();
if first.starts_with("__")
|| matches!(
first.as_str(),
"upgrade" | "install" | "doctor" | "dashboard" | "daemon"
)
{
return;
}
if std::env::args().any(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) {
return;
}
let (checked_at, latest) = std::fs::read_to_string(update_cache_path())
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.map(|j| {
(
j.get("checked_at").and_then(|v| v.as_u64()).unwrap_or(0),
j.get("latest")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
)
})
.unwrap_or((0, String::new()));
if is_newer(&latest, CURRENT_VERSION) {
eprintln!(
"{} chrome-use {latest} is available (you have {CURRENT_VERSION}) — run `chrome-use upgrade`",
color::warning_indicator()
);
}
// Refresh in the background at most once a day. Bump the timestamp first
// (keeping the last-known latest) so concurrent runs don't all spawn a
// checker, then fire a detached child that does the network fetch.
if now_secs().saturating_sub(checked_at) >= UPDATE_CHECK_INTERVAL_SECS {
write_update_cache(now_secs(), &latest);
if let Ok(exe) = std::env::current_exe() {
let _ = Command::new(exe)
.arg("__update-check")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
}
/// 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.
/// ran `npm/pnpm install -g chrome-use@latest`, which installed the
/// UNRELATED upstream `chrome-use` 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() {
println!(
"{}",
color::cyan(&format!(
"Upgrading agent-browser-stealth (currently v{}) from the latest GitHub Release...",
"Upgrading chrome-use (currently v{}) from the latest GitHub Release...",
CURRENT_VERSION
))
);
@@ -31,9 +190,9 @@ pub fn run_upgrade() {
"{} Automatic upgrade isn't supported on Windows.",
color::warning_indicator()
);
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.");
eprintln!(" Download the latest chrome-use-win32-x64.tar.gz from:");
eprintln!(" https://github.com/leeguooooo/chrome-use/releases/latest");
eprintln!(" and replace chrome-use.exe on your PATH.");
exit(1);
}
@@ -58,7 +217,7 @@ pub fn run_upgrade() {
let ok = cmd.status().map(|s| s.success()).unwrap_or(false);
if ok {
println!(
"{} Upgrade complete — run `agent-browser-stealth --version` to confirm.",
"{} Upgrade complete — run `chrome-use --version` to confirm.",
color::success_indicator()
);
} else {
+5 -5
View File
@@ -1,4 +1,4 @@
//! Integration tests for `agent-browser doctor`.
//! Integration tests for `chrome-use doctor`.
//!
//! These tests spawn the real CLI binary via `env!("CARGO_BIN_EXE_*")` and
//! verify the doctor command produces sane output. They override
@@ -8,7 +8,7 @@
use std::process::Command;
use tempfile::TempDir;
const BIN: &str = env!("CARGO_BIN_EXE_agent-browser");
const BIN: &str = env!("CARGO_BIN_EXE_chrome-use");
fn build_doctor_cmd(tmp: &TempDir, args: &[&str]) -> Command {
let socket_dir = tmp.path().join("sockets");
@@ -45,7 +45,7 @@ fn doctor_offline_quick_json_emits_valid_payload() {
let output = build_doctor_cmd(&tmp, &["doctor", "--offline", "--quick", "--json"])
.output()
.expect("failed to invoke agent-browser doctor");
.expect("failed to invoke chrome-use doctor");
let code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
@@ -123,7 +123,7 @@ fn doctor_help_describes_flags_and_examples() {
let output = build_doctor_cmd(&tmp, &["doctor", "--help"])
.output()
.expect("failed to invoke agent-browser doctor --help");
.expect("failed to invoke chrome-use doctor --help");
assert!(
output.status.success(),
@@ -134,7 +134,7 @@ fn doctor_help_describes_flags_and_examples() {
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
for needle in [
"agent-browser doctor",
"chrome-use doctor",
"--offline",
"--quick",
"--fix",
+8 -8
View File
@@ -1,4 +1,4 @@
# Docker Compose for building agent-browser
# Docker Compose for building chrome-use
# Usage: docker compose -f docker/docker-compose.yml run build-linux
# docker compose -f docker/docker-compose.yml run build-windows
#
@@ -19,10 +19,10 @@ services:
echo "Building for Linux platforms (parallel)..."
# Build both targets in parallel
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/chrome-use /output/chrome-use-linux-x64 && chmod +x /output/chrome-use-linux-x64 && echo "✓ Linux x64 done") &
PID1=$$!
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/chrome-use /output/chrome-use-linux-arm64 && chmod +x /output/chrome-use-linux-arm64 && echo "✓ Linux ARM64 done") &
PID2=$$!
# Wait for both and check exit codes individually — without this
@@ -36,7 +36,7 @@ services:
echo ""
echo "✓ Linux platforms built successfully!"
ls -la /output/agent-browser-linux-*
ls -la /output/chrome-use-linux-*
'
# Build for Windows
@@ -53,11 +53,11 @@ services:
echo "Building for Windows x64..."
cargo build --release --target x86_64-pc-windows-gnu
cp /build/target/x86_64-pc-windows-gnu/release/agent-browser.exe /output/agent-browser-win32-x64.exe
cp /build/target/x86_64-pc-windows-gnu/release/chrome-use.exe /output/chrome-use-win32-x64.exe
echo ""
echo "✓ Windows build completed!"
ls -la /output/agent-browser-win32-*
ls -la /output/chrome-use-win32-*
'
# Build for a single target (override with TARGET env var)
@@ -70,7 +70,7 @@ services:
- ../bin:/output
environment:
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
- OUTPUT_NAME=${OUTPUT_NAME:-chrome-use-linux-x64}
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
# interpolated by docker compose at YAML parse time against the *host*
# environment, which silently drops script-local variables like SRC
@@ -83,7 +83,7 @@ services:
-c '
set -e
cargo zigbuild --release --target $$TARGET
SRC="/build/target/$$TARGET/release/agent-browser"
SRC="/build/target/$$TARGET/release/chrome-use"
if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
cp "$$SRC" "/output/$$OUTPUT_NAME"
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ The chrome.debugger attach + CDP Target handling in `background.js` is adapted
from **openclaw-browser-relay** by chengyixu
(https://github.com/chengyixu/openclaw-browser-relay, MIT per its README).
Changes for agent-browser-stealth: rebranded to "agent-browser connect"; the
Changes for chrome-use: rebranded to "chrome-use connect"; the
transport is rewritten from a localhost WebSocket + shared token to Chrome
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
Chrome authenticates the extension to the host by id. WebSocket/token/options
+101 -12
View File
@@ -1,6 +1,6 @@
// agent-browser connect — MV3 service worker.
// chrome-use connect — MV3 service worker.
//
// Bridges the user's real Chrome tabs to the local agent-browser daemon over a
// Bridges the user's real Chrome tabs to the local chrome-use daemon over a
// Chrome **native messaging** channel (no localhost port, no token: Chrome
// authenticates this extension to the host by id). It attaches chrome.debugger
// to eligible tabs and relays CDP both ways via a tiny envelope:
@@ -21,10 +21,9 @@ const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about
/** @type {chrome.runtime.Port|null} */
let port = null
/** Whether the native-messaging host (the local agent-browser CLI) is linked.
/** Whether the native-messaging host (the local chrome-use CLI) is linked.
* Read by the popup status page. */
let hostConnected = false
let nextSession = 1
/** tabId -> { sessionId, targetId } */
const tabs = new Map()
/** sessionId -> tabId (main session per tab) */
@@ -109,6 +108,12 @@ function connectHost() {
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
})
// Report our version so the host can tell the CLI/`doctor` which extension
// build is live (otherwise the extension version is a black box — the user
// can't tell they're on an old one). Best-effort; ignored by older hosts.
try {
postToHost({ method: 'hello', version: chrome.runtime.getManifest().version })
} catch {}
// Tell the daemon about everything we already have attached, then attach
// anything new.
reannounceAttachedTabs()
@@ -150,6 +155,25 @@ function tabForTarget(targetId) {
return null
}
// Best-effort recovery for a stale `cb-tab-<tabId>` session: the handle is gone
// from our maps, but if the underlying Chrome tab still exists and is eligible,
// re-attach to it and return its id so the in-flight command can be retried.
// Returns null when the tab is genuinely gone (closed / restricted), in which
// case the caller surfaces the stale-session error. (issue #20.1)
async function recoverSessionTab(sessionId) {
const m = /^cb-tab-(\d+)$/.exec(sessionId)
if (!m) return null
const tabId = Number(m[1])
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!eligible(tab)) return null
try {
await attachTab(tabId)
} catch {
return null
}
return tabs.has(tabId) ? tabId : null
}
function anyConnectedTab() {
const it = tabs.keys().next()
return it.done ? null : it.value
@@ -199,10 +223,42 @@ async function handleForwardCdpCommand(msg) {
}
// Everything else → chrome.debugger on the resolved tab.
const tabId =
(sessionId ? tabForSession(sessionId) : null) ??
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
anyConnectedTab()
//
// A daemon-supplied sessionId/targetId MUST resolve to a real attached tab.
// The old code fell through to anyConnectedTab() when it didn't, which
// silently ran the command (eval/screenshot/network) on an arbitrary tab —
// exactly the "ran on the wrong page with no warning" failure in issue #8.1,
// and the blank-screenshot symptom after a service-worker restart (#8.2).
// Fail loudly instead so the agent sees an actionable error, not bad data.
let tabId
if (sessionId) {
tabId = tabForSession(sessionId)
if (!tabId) {
// The session's debugger handle is gone, but `cb-tab-<tabId>` encodes the
// STABLE Chrome tabId (#17). A cross-process navigation (e.g. an SSO
// redirect to another origin), a service-worker restart, or DevTools
// briefly stealing the debugger all tear the handle down while the tab
// itself lives on. Before failing, try to transparently re-attach to that
// same tab and retry — so `open`/`navigate`/`eval` self-heal instead of
// dead-ending the agent (issue #20.1). attachTab re-mints the identical
// `cb-tab-<tabId>` session, so the daemon's binding stays valid.
tabId = await recoverSessionTab(sessionId)
if (!tabId) {
throw new Error(
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
`navigated across processes, or lost after an extension restart). ` +
`Re-attach by re-opening your target URL before retrying.`,
)
}
}
} else if (typeof params?.targetId === 'string') {
tabId = tabForTarget(params.targetId)
if (!tabId) throw new Error(`no attached tab for targetId ${params.targetId} (${method})`)
} else {
// No session/target specified — a browser-level command that legitimately
// applies to any attached tab.
tabId = anyConnectedTab()
}
if (!tabId) throw new Error(`no attached tab for ${method}`)
const dbg = { tabId }
@@ -240,7 +296,16 @@ async function attachTab(tabId) {
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '')
if (!targetId) throw new Error('attachTab: no targetId')
const sessionId = `cb-tab-${nextSession++}`
// Derive the session id from the STABLE Chrome tabId, not a monotonic counter
// (issue #17). A tab's chrome.debugger session can be torn down and
// re-established — cross-process navigation, a service-worker restart wiping
// these in-memory maps, DevTools stealing the debugger — and each time the tab
// re-attaches. With a counter, re-attach minted a BRAND-NEW `cb-tab-N`, which
// orphaned the daemon's binding (it's still pinned to the old id and the relay
// never tells it to rebind) → permanent "stale sessionId / tab is gone". The
// tabId is stable across all of that, so `cb-tab-<tabId>` restores the SAME
// session the daemon already holds → eval/snapshot auto-follow the new page.
const sessionId = `cb-tab-${tabId}`
const entry = { sessionId, targetId }
tabs.set(tabId, entry)
sessionToTab.set(sessionId, tabId)
@@ -326,9 +391,33 @@ chrome.debugger.onEvent.addListener((source, method, params) =>
}),
)
chrome.debugger.onDetach.addListener((source) =>
void whenReady(() => {
if (source.tabId) detachTab(source.tabId, true)
chrome.debugger.onDetach.addListener((source, reason) =>
void whenReady(async () => {
const tabId = source.tabId
if (!tabId) return
detachTab(tabId, true)
// A cross-process navigation (e.g. an SSO redirect like
// login.account.rakuten.com that swaps the render process / spawns OOPIFs)
// detaches the debugger, but the TAB survives. Without re-attaching, the
// session goes permanently stale and even open/navigate fails — exactly the
// #19 follow-up. So proactively re-attach (the stable `cb-tab-<tabId>`
// session id then restores the daemon's binding). Don't fight a detach the
// user or DevTools initiated.
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') return
if (!port) return
// The swapped-in process needs a moment to settle; retry with backoff.
for (let i = 0; i < 6; i++) {
await new Promise((r) => setTimeout(r, 250 + i * 200))
if (tabs.has(tabId)) return // already re-attached (e.g. via onUpdated)
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!tab || !eligible(tab)) return // tab gone or now a restricted page
try {
await attachTab(tabId)
return
} catch (e) {
console.warn(`ab-connect: reattach attempt ${i + 1} for tab ${tabId} failed:`, e)
}
}
}),
)
+4 -4
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "agent-browser-stealth",
"version": "0.4.1",
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"name": "chrome-use",
"version": "0.4.7",
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": {
"16": "icons/icon16.png",
@@ -24,7 +24,7 @@
"type": "module"
},
"action": {
"default_title": "agent-browser-stealth",
"default_title": "chrome-use",
"default_popup": "popup.html"
}
}
+4 -4
View File
@@ -90,7 +90,7 @@
<header>
<img src="icons/icon128.png" alt="" />
<div>
<div class="title">agent-browser-stealth</div>
<div class="title">chrome-use</div>
<div class="ver">local automation bridge</div>
</div>
</header>
@@ -105,20 +105,20 @@
</div>
<p class="desc">
Lets your locally-installed <strong>agent-browser</strong> command-line tool
Lets your locally-installed <strong>chrome-use</strong> command-line tool
drive your own logged-in Chrome tabs — entirely on this machine, only when
you run a command. No remote server, no data collection.
</p>
<div class="hint" id="hint">
Not linked yet. Install &amp; pair the CLI, then reopen this popup:
<code>agent-browser extension install</code>
<code>chrome-use extension install</code>
</div>
</main>
<footer>
<span class="privacy">No tracking · no remote server</span>
<a id="repo" data-href="https://github.com/leeguooooo/agent-browser-stealth">GitHub ↗</a>
<a id="repo" data-href="https://github.com/leeguooooo/chrome-use">GitHub ↗</a>
</footer>
<script src="popup.js"></script>
+3 -3
View File
@@ -1,6 +1,6 @@
// Popup status page for agent-browser-stealth.
// Popup status page for chrome-use.
// Asks the service worker whether the native-messaging link to the local
// agent-browser CLI is live, and renders a paired / not-paired indicator.
// chrome-use CLI is live, and renders a paired / not-paired indicator.
const dot = document.getElementById('dot')
const label = document.getElementById('statusLabel')
@@ -25,7 +25,7 @@ function render(state) {
} else {
dot.classList.add('off')
label.textContent = 'Not paired'
sub.textContent = 'no local agent-browser CLI linked'
sub.textContent = 'no local chrome-use CLI linked'
hint.style.display = 'block'
}
}
+28 -20
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chrome Web Store 提交指南 — agent-browser-stealth</title>
<title>Chrome Web Store 提交指南 — chrome-use</title>
<style>
:root{--fg:#1a1a1a;--muted:#5c5c5c;--accent:#2563eb;--warn:#b45309;--ok:#15803d;--border:#e2e2e2;--bg:#fff;--code:#f5f5f7}
*{box-sizing:border-box}
@@ -28,7 +28,7 @@
<body>
<header>
<h1>Chrome Web Store 提交指南</h1>
<div class="sub">agent-browser-stealth · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
<div class="sub">chrome-use · <strong>更新现有商店条目</strong> <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code> · 上传 <strong>key 已删</strong> 的包(纯改名,保住老用户/评分)</div>
</header>
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
@@ -44,27 +44,31 @@
<li>(隐私政策需要一个公开 URL,见第四节 —— 我可以帮你开 GitHub Pages 托管 <code>privacy.html</code>)</li>
</ol>
<h2>二、上传</h2>
<h2>二、上传(更新现有条目,纯改名)</h2>
<p>你已经有一个上架条目(原名 <em>agent-browser-stealth</em>,Item ID <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code>)。这次只是把它<strong>改名成 chrome-use</strong>,所以走 <span class="field">更新版本</span>,<u>不要</u> New item —— 这样老用户自动更新、评分/安装量都保留。</p>
<ol>
<li>devconsole → <span class="field">New item</span> → 上传 <code>extensions/ab-connect.zip</code></li>
<li>上传后确认分配到的 Item ID = <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>(因为 manifest 里保留了 <code>key</code>,id 会被锁成这个,native messaging 的 allowed_origins 才对得上)。<strong>若 id 不是这个,告诉我,我重签。</strong></li>
<li>devconsole → 打开 <strong>现有的 agent-browser-stealth 条目</strong>(id <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code>)→ <span class="field">Package → Upload new package</span></li>
<li>上传 <strong>key 已删</strong> 的包 <code>chrome-use-store-vX.Y.Z.zip</code>(<em>必须删掉 manifest <code>key</code> 字段</em>,否则商店报"key 字段不符";仓库里 <code>ab-connect/manifest.json</code> 带 key 是给本地 Load-unpacked 用的,别直接传那个)。上传后 Item ID <strong>保持 <code>knfcmbam…</code> 不变</strong>;用户看到的扩展名变成 <strong>chrome-use</strong></li>
<li>native messaging 的 <code>allowed_origins</code> 同时放行 <code>knfcmbam…</code><code>ciiljdl…</code> 两个 id,所以改名后 relay 照常连得上,<strong>不会断现有用户</strong></li>
<li><strong>不要</strong>在这次发布里改 <code>background.js</code> 的 native host 名(保持 <code>com.agent_browser.connect</code>);<code>com.leeguoo.chrome_use</code> 是给将来真迁移用的。</li>
</ol>
<div class="warn"><strong>若你确实想另开一个全新的 "chrome-use" 条目(新 id、评分清零、用户需重装)</strong>:那才用保留 key 的包,id 会锁成 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>。仅在你想彻底脱离旧 <em>stealth</em> 品牌时才这么做 —— 默认按上面"更新现有条目"走。</div>
<h2>三、商店信息(直接复制以下文案)</h2>
<h3>名称 / Name</h3>
<pre>agent-browser-stealth</pre>
<pre>chrome-use</pre>
<h3>简介 / Summary(≤132 字符)</h3>
<pre>Let your own agent-browser CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
<pre>Let your own chrome-use CLI drive your logged-in Chrome — a local automation bridge. No remote server, no token.</pre>
<h3>详细描述 / Description</h3>
<pre>agent-browser-stealth is the in-browser half of the open-source agent-browser CLI. It lets the
<pre>chrome-use is the in-browser half of the open-source chrome-use CLI. It lets the
command-line tool you installed on this same computer automate the Chrome you're already logged
into — opening pages, clicking, filling forms, reading the DOM — driven entirely by you.
How it works
- The extension talks ONLY to the local agent-browser CLI over Chrome native messaging (a local
- The extension talks ONLY to the local chrome-use CLI over Chrome native messaging (a local
inter-process channel — no network socket, no token, no remote server).
- When you run an automation command, the extension relays Chrome DevTools Protocol operations to
the tab you target, then returns the result to the CLI.
@@ -72,9 +76,9 @@ How it works
Privacy
- No analytics, no trackers, no data collection.
- Nothing is sent to any remote server. The only message peer is the local CLI.
- Source is open (Apache-2.0): https://github.com/leeguooooo/agent-browser-stealth
- Source is open (Apache-2.0): https://github.com/leeguooooo/chrome-use
You need the agent-browser CLI installed and paired (run: agent-browser extension install) for this
You need the chrome-use CLI installed and paired (run: chrome-use extension install) for this
extension to do anything.</pre>
<h3>类别 / Category</h3>
@@ -86,16 +90,16 @@ extension to do anything.</pre>
<h2>四、隐私实践(Privacy practices 标签页 —— 必填)</h2>
<h3>Single purpose(单一用途)</h3>
<pre>Bridge the user's locally-installed agent-browser CLI to their own logged-in Chrome so the CLI can
<pre>Bridge the user's locally-installed chrome-use CLI to their own logged-in Chrome so the CLI can
automate pages the user is working with, entirely on the user's machine and at the user's command.</pre>
<h3>各权限理由 / Permission justifications</h3>
<table>
<tr><th>权限</th><th>理由(复制到对应输入框)</th></tr>
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local agent-browser CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
<tr><td class="field">debugger</td><td>Attaches the Chrome DevTools Protocol to the user's own active tab so the paired local chrome-use CLI can automate it (navigate, click, read DOM) only while the user is running a command. Commands arrive solely from the local CLI via native messaging; there is no remote endpoint.</td></tr>
<tr><td class="field">tabs</td><td>Enumerate and target the correct open tab to attach automation to.</td></tr>
<tr><td class="field">tabGroups</td><td>Organizes the tabs the local agent-browser CLI drives into a labeled, colored Chrome tab group per automation session, so the user can see at a glance which tabs are under automation and they stay visually separated from the user's own tabs.</td></tr>
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the agent-browser CLI installed on the same machine. No network is used.</td></tr>
<tr><td class="field">tabGroups</td><td>Organizes the tabs the local chrome-use CLI drives into a labeled, colored Chrome tab group per automation session, so the user can see at a glance which tabs are under automation and they stay visually separated from the user's own tabs.</td></tr>
<tr><td class="field">nativeMessaging</td><td>The sole communication channel: a local native-messaging connection to the chrome-use CLI installed on the same machine. No network is used.</td></tr>
<tr><td class="field">storage</td><td>Persist small local pairing/configuration state for the extension.</td></tr>
<tr><td class="field">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
<tr><td class="field">webNavigation</td><td>Detect page loads/navigations so automation can wait for the right moment before acting.</td></tr>
@@ -111,11 +115,15 @@ automate pages the user is working with, entirely on the user's machine and at t
<h2>五、隐私政策 URL</h2>
<p>商店要求一个公开可访问的隐私政策地址。GitHub Pages <strong>已开启</strong>,直接填这个(渲染好看):</p>
<pre>https://leeguooooo.github.io/agent-browser-stealth/extensions/store/privacy.html</pre>
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/extensions/store/privacy.html</code>。)</p>
<pre>https://leeguooooo.github.io/chrome-use/extensions/store/privacy.html</pre>
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/chrome-use/main/extensions/store/privacy.html</code>。)</p>
<h2>六、截图 / Screenshots(至少 1 张,1280×800 或 640×400</h2>
<p>可以截一张 CLI + Chrome 并排的演示图。<em>需要的话我用 cua-driver 截一张合规尺寸的图给你</em></p>
<h2>六、图标 + 截图 / Icon &amp; Screenshots</h2>
<p><strong>已生成,涂鸦风(和 cookie-use README 同一套)</strong>上传到对应字段即可:</p>
<ul>
<li><span class="field">Store icon(128×128)</span>:<code>chrome-use-store-icon-128.png</code></li>
<li><span class="field">Screenshots(每张正好 1280×800)</span>:<code>chrome-use-store-shot1-1280x800.png</code>(CMD 牵线操控已登录浏览器)、<code>shot2</code>(机械臂抓浏览器方向盘)、<code>shot3</code>(浏览器插线连终端 CONNECTED)。</li>
</ul>
<h2>七、提交后</h2>
<ol>
@@ -127,6 +135,6 @@ automate pages the user is working with, entirely on the user's machine and at t
<strong>今天的临时可用方案:</strong> 在你这台 Mac 上 <code>chrome://extensions</code> → 打开开发者模式 → Load unpacked → 选 <code>extensions/ab-connect</code>,30 秒手动装一次,native messaging + <code>extension connect</code> 立即可用。等商店过审再切静默路径。
</div>
<footer>agent-browser-stealth · 提交包与文案随扩展版本更新;改扩展后重跑 <code>scripts/pack-extension.sh</code> 并重打 <code>ab-connect.zip</code></footer>
<footer>chrome-use · 更新现有条目 <code>knfcmbam…</code>(纯改名);上传包必须删 key。改扩展后重打 key-stripped 的 <code>chrome-use-store-vX.Y.Z.zip</code> 再传</footer>
</body>
</html>
+8 -8
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — agent-browser-stealth</title>
<title>Privacy Policy — chrome-use</title>
<style>
:root{
--fg:#1a1a1a; --muted:#5c5c5c; --accent:#2563eb; --border:#e2e2e2; --bg:#fff; --code:#f5f5f5;
@@ -26,17 +26,17 @@
</head>
<body>
<header>
<h1>Privacy Policy — agent-browser-stealth</h1>
<h1>Privacy Policy — chrome-use</h1>
<div class="sub">Chrome extension (id <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>) · Last updated 2026-06-09</div>
</header>
<p><strong>Summary: this extension collects no personal data, contains no analytics or
trackers, and sends nothing to any remote server.</strong> It is a local bridge that lets the
user's own <code>agent-browser</code> command-line tool, running on the same computer, drive the
user's own <code>chrome-use</code> command-line tool, running on the same computer, drive the
user's logged-in Chrome.</p>
<h2>What the extension does</h2>
<p>agent-browser-stealth pairs Chrome with the locally-installed <code>agent-browser</code> CLI over
<p>chrome-use pairs Chrome with the locally-installed <code>chrome-use</code> CLI over
Chrome <em>native messaging</em> (a local inter-process channel; no network socket, no token). When
the user issues an automation command in the CLI, the extension relays Chrome DevTools Protocol
operations to the tab the user targets. Everything happens on the user's machine, initiated by the
@@ -49,7 +49,7 @@ user.</p>
<tr><td class="key">Browsing history</td><td>No</td><td>Not collected. Page content is acted on transiently only while the user is running an automation command, and is never stored or sent off-device.</td></tr>
<tr><td class="key">Authentication / cookies / credentials</td><td>No</td><td>Not read or exported by the extension.</td></tr>
<tr><td class="key">Analytics / telemetry</td><td>No</td><td>The extension contains no analytics, tracking, or crash-reporting code.</td></tr>
<tr><td class="key">Remote transmission</td><td>No</td><td>The extension's only message peer is the local <code>agent-browser</code> CLI via native messaging. It makes no outbound network requests of its own.</td></tr>
<tr><td class="key">Remote transmission</td><td>No</td><td>The extension's only message peer is the local <code>chrome-use</code> CLI via native messaging. It makes no outbound network requests of its own.</td></tr>
</table>
<h2>Permissions &amp; why they are needed</h2>
@@ -57,7 +57,7 @@ user.</p>
<tr><th>Permission</th><th>Purpose</th></tr>
<tr><td class="key">debugger</td><td>Attach the Chrome DevTools Protocol to the user's own tab so the local CLI can automate it, only while the user is actively running a command.</td></tr>
<tr><td class="key">tabs</td><td>Enumerate and target the correct open tab to automate.</td></tr>
<tr><td class="key">nativeMessaging</td><td>The local transport to the paired <code>agent-browser</code> CLI — the extension's sole communication channel.</td></tr>
<tr><td class="key">nativeMessaging</td><td>The local transport to the paired <code>chrome-use</code> CLI — the extension's sole communication channel.</td></tr>
<tr><td class="key">storage</td><td>Persist small local pairing/state values.</td></tr>
<tr><td class="key">alarms</td><td>Keep the MV3 service worker alive during longer automation sessions.</td></tr>
<tr><td class="key">webNavigation</td><td>Detect page loads so automation can wait for the right moment.</td></tr>
@@ -68,10 +68,10 @@ user.</p>
extension talks only to a program the user installed on the same computer.</p>
<h2>Contact</h2>
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/agent-browser-stealth</code></p>
<p>Source code, issues, and contact: <code>https://github.com/leeguooooo/chrome-use</code></p>
<footer>
agent-browser-stealth is open source (Apache-2.0). This policy applies to the extension only.
chrome-use is open source (Apache-2.0). This policy applies to the extension only.
</footer>
</body>
</html>
+7 -7
View File
@@ -23,17 +23,17 @@
border-radius:999px;margin-left:14px;vertical-align:middle;font-weight:600}
</style></head>
<body><div class="wrap">
<h1>agent-browser&nbsp;connect <span class="pill">local · no token · no remote</span></h1>
<p class="tag">Let your own <span class="accent">agent-browser</span> CLI drive the Chrome you're already logged into.</p>
<h1>chrome-use&nbsp;connect <span class="pill">local · no token · no remote</span></h1>
<p class="tag">Let your own <span class="accent">chrome-use</span> CLI drive the Chrome you're already logged into.</p>
<div class="term">
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span><span class="bartitle">zsh — agent-browser</span></div>
<pre><span class="p">$</span> <span class="c">agent-browser extension install</span>
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span><span class="bartitle">zsh — chrome-use</span></div>
<pre><span class="p">$</span> <span class="c">chrome-use extension install</span>
<span class="ok"></span> <span class="o">native-messaging host installed (com.agent_browser.connect)</span>
<span class="ok"></span> <span class="o">extension ready — add it from the Chrome Web Store</span>
<span class="p">$</span> <span class="c">agent-browser open</span> <span class="o">"https://mail.google.com"</span> <span class="dim"># your logged-in tab</span>
<span class="p">$</span> <span class="c">agent-browser snapshot -i</span> <span class="dim"># read the page</span>
<span class="p">$</span> <span class="c">agent-browser click</span> <span class="o">@e42</span> <span class="dim"># act on it</span>
<span class="p">$</span> <span class="c">chrome-use open</span> <span class="o">"https://mail.google.com"</span> <span class="dim"># your logged-in tab</span>
<span class="p">$</span> <span class="c">chrome-use snapshot -i</span> <span class="dim"># read the page</span>
<span class="p">$</span> <span class="c">chrome-use click</span> <span class="o">@e42</span> <span class="dim"># act on it</span>
<span class="ok"></span> <span class="o">driving your real session — no re-login, no confirmation</span>
</pre>
</div>
+3 -3
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "agent-browser-stealth",
"name": "chrome-use",
"version": "0.2.0",
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
"description": "Session-aware tab grouping and coordination for CDP-driven chrome-use workflows.",
"icons": {
"128": "icons/icon.svg"
},
@@ -12,7 +12,7 @@
"service_worker": "service-worker.js"
},
"action": {
"default_title": "agent-browser-stealth"
"default_title": "chrome-use"
},
"side_panel": {
"default_path": "sidepanel.html"
+2 -2
View File
@@ -26,7 +26,7 @@ const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE';
const CONTENT_PING = 'AB_CONTENT_PING';
const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth';
const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth';
const DOWNLOAD_ARCHIVE_ROOT = 'chrome-use';
const STORAGE_POLICY_KEY = 'abSessionPoliciesV1';
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
const STORAGE_WORKFLOWS_KEY = 'abWorkflowsV1';
@@ -932,7 +932,7 @@ async function enforceSessionWindowAffinity(tabId) {
async function updateRiskBadge(tabId) {
let text = '';
let title = 'agent-browser-stealth';
let title = 'chrome-use';
const session = getManagedSessionForTab(tabId);
if (session) {
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agent-browser-stealth panel</title>
<title>chrome-use panel</title>
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<header>
<h1>agent-browser-stealth</h1>
<h1>chrome-use</h1>
<div class="actions">
<button id="refresh-btn" type="button">Refresh</button>
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
+6 -12
View File
@@ -1,16 +1,16 @@
#!/bin/sh
# agent-browser-stealth installer — downloads the prebuilt binary from the
# chrome-use 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
# curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/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"
REPO="leeguooooo/chrome-use"
BIN_NAME="chrome-use"
err() { printf '\033[31merror:\033[0m %s\n' "$1" >&2; exit 1; }
info() { printf '\033[36m==>\033[0m %s\n' "$1" >&2; }
@@ -39,7 +39,7 @@ if [ "$plat" = "linux" ] && ! ldd /bin/sh 2>/dev/null | grep -qi 'gnu\|glibc'; t
libc="-musl"
fi
fi
asset="agent-browser-${plat}${libc}-${cpu}"
asset="chrome-use-${plat}${libc}-${cpu}"
# --- resolve release tag --------------------------------------------------
tag="${AGENT_BROWSER_VERSION:-}"
@@ -95,14 +95,8 @@ fi
mkdir -p "$bindir"
mv "$tmp/${BIN_NAME}" "$bindir/${BIN_NAME}"
# Aliases pointing at the same binary: `abs` (short) and `agent-browser-stealth`
# (the fork's package name). All three names work, and an upgrade refreshes
# whichever name you actually run.
for alias_name in abs agent-browser-stealth; do
ln -sf "$bindir/${BIN_NAME}" "$bindir/${alias_name}" 2>/dev/null || true
done
info "installed -> ${bindir}/ (agent-browser, agent-browser-stealth, abs)"
info "installed -> ${bindir}/${BIN_NAME}"
"$bindir/${BIN_NAME}" --version 2>/dev/null || true
case ":$PATH:" in
+9 -11
View File
@@ -1,7 +1,7 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.45",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"name": "chrome-use",
"version": "1.4.0",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module",
"packageManager": "pnpm@11.1.3",
"files": [
@@ -12,9 +12,7 @@
"extensions"
],
"bin": {
"agent-browser-stealth": "bin/agent-browser.js",
"agent-browser": "bin/agent-browser.js",
"abs": "bin/agent-browser.js"
"chrome-use": "bin/chrome-use.js"
},
"scripts": {
"prepare": "husky || true",
@@ -22,10 +20,10 @@
"version": "npm run version:sync && git add cli/Cargo.toml",
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
"build:macos": "npm run version:sync && bash -c 'cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & PID1=$!; cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1' && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
"build:macos": "npm run version:sync && bash -c 'cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & PID1=$!; cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & PID2=$!; wait $PID1 || exit 1; wait $PID2 || exit 1' && cp cli/target/aarch64-apple-darwin/release/chrome-use bin/chrome-use-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/chrome-use bin/chrome-use-darwin-x64",
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
"build:all-platforms": "npm run version:sync && npm run build:linux && npm run build:windows && npm run build:macos",
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
"build:docker": "docker build -t chrome-use-builder -f docker/Dockerfile.build .",
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
"postinstall": "node scripts/postinstall.js"
},
@@ -43,12 +41,12 @@
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/leeguooooo/agent-browser-stealth.git"
"url": "git+https://github.com/leeguooooo/chrome-use.git"
},
"bugs": {
"url": "https://github.com/leeguooooo/agent-browser-stealth/issues"
"url": "https://github.com/leeguooooo/chrome-use/issues"
},
"homepage": "https://github.com/leeguooooo/agent-browser-stealth",
"homepage": "https://github.com/leeguooooo/chrome-use",
"devDependencies": {
"husky": "^9.0.11"
}
+13 -13
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
# Build agent-browser for all platforms using Docker
# Build chrome-use for all platforms using Docker
# Usage: ./scripts/build-all-platforms.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -14,7 +14,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Building agent-browser for all platforms...${NC}"
echo -e "${YELLOW}Building chrome-use for all platforms...${NC}"
echo ""
# Ensure output directory exists
@@ -22,7 +22,7 @@ mkdir -p "$OUTPUT_DIR"
# Build the Docker image if needed
echo -e "${YELLOW}Building Docker cross-compilation image...${NC}"
docker build -t agent-browser-builder -f "$PROJECT_ROOT/docker/Dockerfile.build" "$PROJECT_ROOT"
docker build -t chrome-use-builder -f "$PROJECT_ROOT/docker/Dockerfile.build" "$PROJECT_ROOT"
# Function to build for a target
build_target() {
@@ -34,8 +34,8 @@ build_target() {
docker run --rm \
-v "$PROJECT_ROOT/cli:/build" \
-v "$OUTPUT_DIR:/output" \
agent-browser-builder \
-c "cargo zigbuild --release --target ${target} && cp /build/target/${target}/release/agent-browser* /output/${output_name} && chmod +x /output/${output_name} 2>/dev/null || true"
chrome-use-builder \
-c "cargo zigbuild --release --target ${target} && cp /build/target/${target}/release/chrome-use* /output/${output_name} && chmod +x /output/${output_name} 2>/dev/null || true"
if [ -f "$OUTPUT_DIR/$output_name" ]; then
echo -e "${GREEN}✓ Built ${output_name}${NC}"
@@ -47,28 +47,28 @@ build_target() {
# Build for each platform
# Linux x64
build_target "x86_64-unknown-linux-gnu" "agent-browser-linux-x64"
build_target "x86_64-unknown-linux-gnu" "chrome-use-linux-x64"
# Linux ARM64
build_target "aarch64-unknown-linux-gnu" "agent-browser-linux-arm64"
build_target "aarch64-unknown-linux-gnu" "chrome-use-linux-arm64"
# Windows x64
build_target "x86_64-pc-windows-gnu" "agent-browser-win32-x64.exe"
build_target "x86_64-pc-windows-gnu" "chrome-use-win32-x64.exe"
# macOS x64 (via zig for cross-compilation)
build_target "x86_64-apple-darwin" "agent-browser-darwin-x64"
build_target "x86_64-apple-darwin" "chrome-use-darwin-x64"
# macOS ARM64 (via zig for cross-compilation)
build_target "aarch64-apple-darwin" "agent-browser-darwin-arm64"
build_target "aarch64-apple-darwin" "chrome-use-darwin-arm64"
# Linux musl x64 (Alpine)
build_target "x86_64-unknown-linux-musl" "agent-browser-linux-musl-x64"
build_target "x86_64-unknown-linux-musl" "chrome-use-linux-musl-x64"
# Linux musl ARM64 (Alpine)
build_target "aarch64-unknown-linux-musl" "agent-browser-linux-musl-arm64"
build_target "aarch64-unknown-linux-musl" "chrome-use-linux-musl-arm64"
echo ""
echo -e "${GREEN}Build complete!${NC}"
echo ""
echo "Binaries are in: $OUTPUT_DIR"
ls -la "$OUTPUT_DIR"/agent-browser-*
ls -la "$OUTPUT_DIR"/chrome-use-*
+2 -2
View File
@@ -13,13 +13,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
const sourceExt = platform() === 'win32' ? '.exe' : '';
const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
const sourcePath = join(projectRoot, `cli/target/release/chrome-use${sourceExt}`);
const binDir = join(projectRoot, 'bin');
// Determine platform suffix
const platformKey = `${platform()}-${arch()}`;
const ext = platform() === 'win32' ? '.exe' : '';
const targetName = `agent-browser-${platformKey}${ext}`;
const targetName = `chrome-use-${platformKey}${ext}`;
const targetPath = join(binDir, targetName);
if (!existsSync(sourcePath)) {
+12 -12
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/**
* Postinstall script for agent-browser
* Postinstall script for chrome-use
*
* Downloads the platform-specific native binary if not present.
* On global installs, patches npm's bin entry to use the native binary directly:
@@ -35,7 +35,7 @@ function isMusl() {
const osKey = platform() === 'linux' && isMusl() ? 'linux-musl' : platform();
const platformKey = `${osKey}-${arch()}`;
const ext = platform() === 'win32' ? '.exe' : '';
const binaryName = `agent-browser-${platformKey}${ext}`;
const binaryName = `chrome-use-${platformKey}${ext}`;
const binaryPath = join(binDir, binaryName);
// Package info
@@ -82,7 +82,7 @@ async function downloadFile(url, dest) {
/**
* Detect which package manager ran this postinstall and write a marker file
* next to the binary so `agent-browser upgrade` can use the correct one
* next to the binary so `chrome-use upgrade` can use the correct one
* without fragile path heuristics or slow subprocess probing.
*
* npm_config_user_agent is set by npm/pnpm/yarn/bun during lifecycle scripts,
@@ -193,7 +193,7 @@ function showInstallReminder() {
if (systemChrome) {
console.log('');
console.log(` ✓ System Chrome found: ${systemChrome}`);
console.log(' agent-browser will use it automatically.');
console.log(' chrome-use will use it automatically.');
console.log('');
return;
}
@@ -202,12 +202,12 @@ function showInstallReminder() {
console.log(' ⚠ No Chrome installation detected.');
console.log(' If you plan to use a local browser, run:');
console.log('');
console.log(' agent-browser install');
console.log(' chrome-use install');
if (platform() === 'linux') {
console.log('');
console.log(' On Linux, include system dependencies with:');
console.log('');
console.log(' agent-browser install --with-deps');
console.log(' chrome-use install --with-deps');
}
console.log('');
console.log(' You can skip this if you use --cdp, --provider, --engine, or --executable-path.');
@@ -240,7 +240,7 @@ async function fixUnixSymlink() {
return; // npm not available
}
const symlinkPath = join(npmBinDir, 'agent-browser');
const symlinkPath = join(npmBinDir, 'chrome-use');
// Check if symlink exists (indicates global install)
try {
@@ -277,19 +277,19 @@ async function fixWindowsShims() {
return;
}
const cmdShim = join(npmBinDir, 'agent-browser.cmd');
const ps1Shim = join(npmBinDir, 'agent-browser.ps1');
const cmdShim = join(npmBinDir, 'chrome-use.cmd');
const ps1Shim = join(npmBinDir, 'chrome-use.ps1');
// Shims may not exist yet during postinstall (npm creates them after
// lifecycle scripts). If missing, fall back: the JS wrapper at
// bin/agent-browser.js handles Windows correctly via child_process.spawn.
// bin/chrome-use.js handles Windows correctly via child_process.spawn.
if (!existsSync(cmdShim)) {
return;
}
// Point the shims at the binary's ABSOLUTE path. The previous code rebuilt a
// relative `node_modules\agent-browser\bin\...` path, but this fork's package
// is `agent-browser-stealth`, so that path never existed → the rewrite was
// relative `node_modules\chrome-use\bin\...` path, but this fork's package
// is `chrome-use`, so that path never existed → the rewrite was
// skipped and the shim stayed the (slower) JS wrapper. `binaryPath` is the
// real absolute path to the native binary inside this package.
if (!existsSync(binaryPath)) {
+1 -1
View File
@@ -44,7 +44,7 @@ let cargoToml = readFileSync(cargoTomlPath, "utf-8");
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
const newCargoVersion = `version = "${version}"`;
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
const cargoPackageName = cargoNameMatch?.[1] ?? "chrome-use";
let cargoTomlUpdated = false;
if (cargoVersionRegex.test(cargoToml)) {
+9 -9
View File
@@ -3,7 +3,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTANCE_FILE="$SCRIPT_DIR/.instance"
NAME_PREFIX="agent-browser-debug"
NAME_PREFIX="chrome-use-debug"
INSTANCE_TYPE="${INSTANCE_TYPE:-t3.xlarge}"
if [[ -f "$INSTANCE_FILE" ]]; then
@@ -103,7 +103,7 @@ if [[ "$SG_ID" == "None" || -z "$SG_ID" ]]; then
echo "Creating security group: $SG_NAME"
SG_ID=$(aws ec2 create-security-group \
--group-name "$SG_NAME" \
--description "agent-browser Windows debug instance (SSM only, no inbound)" \
--description "chrome-use Windows debug instance (SSM only, no inbound)" \
--vpc-id "$VPC_ID" \
--query "GroupId" --output text)
@@ -161,19 +161,19 @@ Start-Process -FilePath $vsInstaller -ArgumentList "--quiet --wait --norestart -
Log "Build tools installed."
# Clone repo
Log "Cloning agent-browser..."
git clone https://github.com/vercel-labs/agent-browser.git C:\agent-browser
Set-Location C:\agent-browser
Log "Cloning chrome-use..."
git clone https://github.com/vercel-labs/agent-browser.git C:\chrome-use
Set-Location C:\chrome-use
Log "Repo cloned."
# Build CLI
Log "Building agent-browser CLI..."
Log "Building chrome-use CLI..."
cargo build --release --manifest-path cli\Cargo.toml
Log "Build complete."
# Install Chrome
Log "Installing Chrome via agent-browser..."
.\cli\target\release\agent-browser.exe install
Log "Installing Chrome via chrome-use..."
.\cli\target\release\chrome-use.exe install
Log "Chrome installed."
Log "--- Bootstrap complete ---"
@@ -214,7 +214,7 @@ echo " ./scripts/windows-debug/run.sh \"Get-Content C:\\bootstrap.log\""
echo ""
echo "Once ready, sync your branch and start debugging:"
echo " ./scripts/windows-debug/sync.sh"
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test\""
echo " ./scripts/windows-debug/run.sh \"cd C:\\chrome-use && cargo test\""
echo ""
echo "Stop when done to save costs:"
echo " ./scripts/windows-debug/stop.sh"
+2 -2
View File
@@ -13,9 +13,9 @@ if [[ $# -eq 0 ]]; then
echo "Usage: ./scripts/windows-debug/run.sh \"<powershell-command>\""
echo ""
echo "Examples:"
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test\""
echo " ./scripts/windows-debug/run.sh \"cd C:\\chrome-use && cargo test\""
echo " ./scripts/windows-debug/run.sh \"Get-Content C:\\bootstrap.log\""
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test e2e -- --ignored --test-threads=1\""
echo " ./scripts/windows-debug/run.sh \"cd C:\\chrome-use && cargo test e2e -- --ignored --test-threads=1\""
exit 1
fi
+2 -2
View File
@@ -10,7 +10,7 @@ REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "https://github.com/v
echo "Syncing branch '$BRANCH' on Windows instance..."
"$RUN" "
cd C:\agent-browser
cd C:\chrome-use
git remote set-url origin '$REMOTE_URL'
git fetch origin
git checkout -B '$BRANCH' 'origin/$BRANCH'
@@ -21,7 +21,7 @@ echo ""
echo "Branch synced. Rebuilding..."
"$RUN" "
cd C:\agent-browser
cd C:\chrome-use
cargo build --release --manifest-path cli\Cargo.toml
Write-Host 'Build complete.'
"
+24 -24
View File
@@ -1,12 +1,12 @@
---
name: agentcore
description: Run agent-browser on AWS Bedrock AgentCore cloud browsers. Use when the user wants to use AgentCore, run browser automation on AWS, use a cloud browser with AWS credentials, or needs a managed browser session backed by AWS infrastructure. Triggers include "use agentcore", "run on AWS", "cloud browser with AWS", "bedrock browser", "agentcore session", or any task requiring AWS-hosted browser automation.
allowed-tools: Bash(agent-browser:*), Bash(agent-browser-stealth:*), Bash(abs:*), Bash(npx agent-browser:*), Bash(npx agent-browser-stealth:*)
description: Run chrome-use on AWS Bedrock AgentCore cloud browsers. Use when the user wants to use AgentCore, run browser automation on AWS, use a cloud browser with AWS credentials, or needs a managed browser session backed by AWS infrastructure. Triggers include "use agentcore", "run on AWS", "cloud browser with AWS", "bedrock browser", "agentcore session", or any task requiring AWS-hosted browser automation.
allowed-tools: Bash(chrome-use:*), Bash(chrome-use:*), Bash(abs:*), Bash(npx chrome-use:*), Bash(npx chrome-use:*)
---
# AWS Bedrock AgentCore
Run agent-browser on cloud browser sessions hosted by AWS Bedrock AgentCore. All standard agent-browser commands work identically; the only difference is where the browser runs.
Run chrome-use on cloud browser sessions hosted by AWS Bedrock AgentCore. All standard chrome-use commands work identically; the only difference is where the browser runs.
## Setup
@@ -21,13 +21,13 @@ No additional setup is needed if the user already has working AWS credentials.
```bash
# Open a page on an AgentCore cloud browser
agent-browser -p agentcore open https://example.com
chrome-use -p agentcore open https://example.com
# Everything else is the same as local Chrome
agent-browser snapshot -i
agent-browser click @e1
agent-browser screenshot page.png
agent-browser close
chrome-use snapshot -i
chrome-use click @e1
chrome-use screenshot page.png
chrome-use close
```
## Environment Variables
@@ -46,15 +46,15 @@ Use `AGENTCORE_PROFILE_ID` to persist browser state across sessions. This is use
```bash
# First run: log in
AGENTCORE_PROFILE_ID=my-app agent-browser -p agentcore open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser close
AGENTCORE_PROFILE_ID=my-app chrome-use -p agentcore open https://app.example.com/login
chrome-use snapshot -i
chrome-use fill @e1 "user@example.com"
chrome-use fill @e2 "password"
chrome-use click @e3
chrome-use close
# Future runs: already authenticated
AGENTCORE_PROFILE_ID=my-app agent-browser -p agentcore open https://app.example.com/dashboard
AGENTCORE_PROFILE_ID=my-app chrome-use -p agentcore open https://app.example.com/dashboard
```
## Live View
@@ -70,10 +70,10 @@ Live View: https://us-east-1.console.aws.amazon.com/bedrock-agentcore/browser/aw
```bash
# Default: us-east-1
agent-browser -p agentcore open https://example.com
chrome-use -p agentcore open https://example.com
# Explicit region
AGENTCORE_REGION=eu-west-1 agent-browser -p agentcore open https://example.com
AGENTCORE_REGION=eu-west-1 chrome-use -p agentcore open https://example.com
```
## Credential Patterns
@@ -82,14 +82,14 @@ AGENTCORE_REGION=eu-west-1 agent-browser -p agentcore open https://example.com
# Explicit credentials (CI/CD, scripts)
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
agent-browser -p agentcore open https://example.com
chrome-use -p agentcore open https://example.com
# SSO (interactive)
aws sso login --profile my-profile
AWS_PROFILE=my-profile agent-browser -p agentcore open https://example.com
AWS_PROFILE=my-profile chrome-use -p agentcore open https://example.com
# IAM role / default credential chain
agent-browser -p agentcore open https://example.com
chrome-use -p agentcore open https://example.com
```
## Using with AGENT_BROWSER_PROVIDER
@@ -100,10 +100,10 @@ Set the provider via environment variable to avoid passing `-p agentcore` on eve
export AGENT_BROWSER_PROVIDER=agentcore
export AGENTCORE_REGION=us-east-2
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser click @e1
agent-browser close
chrome-use open https://example.com
chrome-use snapshot -i
chrome-use click @e1
chrome-use close
```
## Common Issues
+306 -170
View File
@@ -1,10 +1,10 @@
---
name: core
description: Core agent-browser usage guide. Read this before running any agent-browser commands. Covers the snapshot-and-ref workflow, navigating pages, interacting with elements (click, fill, type, select), extracting text and data, taking screenshots, managing tabs, handling forms and auth, waiting for content, running multiple browser sessions in parallel, and troubleshooting common failures. Use when the user asks to interact with a website, fill a form, click something, extract data, take a screenshot, log into a site, test a web app, or automate any browser task.
allowed-tools: Bash(agent-browser:*), Bash(agent-browser-stealth:*), Bash(abs:*), Bash(npx agent-browser:*), Bash(npx agent-browser-stealth:*)
description: Core chrome-use usage guide. Read this before running any chrome-use commands. Covers the snapshot-and-ref workflow, navigating pages, interacting with elements (click, fill, type, select), extracting text and data, taking screenshots, managing tabs, handling forms and auth, waiting for content, running multiple browser sessions in parallel, and troubleshooting common failures. Use when the user asks to interact with a website, fill a form, click something, extract data, take a screenshot, log into a site, test a web app, or automate any browser task.
allowed-tools: Bash(chrome-use:*), Bash(chrome-use:*), Bash(abs:*), Bash(npx chrome-use:*), Bash(npx chrome-use:*)
---
# agent-browser core
# chrome-use core
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP, no
Playwright or Puppeteer dependency. Accessibility-tree snapshots with compact
@@ -18,17 +18,17 @@ web pages — see [When to load another skill](#when-to-load-another-skill).
> **Hit a rough edge? Please report it.** If a command surprised you — a
> confusing error, a stale `@ref`, an occluded click, a flaky wait, a missing
> feature, or anything that cost you extra turns — open a quick issue at
> **<https://github.com/leeguooooo/agent-browser-stealth/issues>** with the exact
> **<https://github.com/leeguooooo/chrome-use/issues>** with the exact
> command and what happened vs. what you expected. Agent-filed friction reports
> are how this tool gets sharper; a 30-second issue is genuinely valuable.
## The core loop
```bash
agent-browser open <url> # 1. Open a page
agent-browser snapshot -i # 2. See what's on it (interactive elements only)
agent-browser click @e3 # 3. Act on refs from the snapshot
agent-browser snapshot -i # 4. Re-snapshot after any page change
chrome-use open <url> # 1. Open a page
chrome-use snapshot -i # 2. See what's on it (interactive elements only)
chrome-use click @e3 # 3. Act on refs from the snapshot
chrome-use snapshot -i # 4. Re-snapshot after any page change
```
Refs (`@e1`, `@e2`, ...) are assigned fresh on every snapshot. They become
@@ -38,15 +38,15 @@ next ref interaction.
## Before you automate: pick the cheapest tool
Driving a browser is the heavy option. agent-browser earns its keep when you
Driving a browser is the heavy option. chrome-use earns its keep when you
need a **real, logged-in browser** — not for reading text off a public page.
| You need | Use |
|---|---|
| Discover what exists / find sources | `WebSearch` |
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
| Login state, interaction, JS-rendered or anti-bot pages | **agent-browser** (this skill) |
| A page the user saved before / an internal system | `agent-browser find-url <keywords>` (their bookmarks), then open it |
| Login state, interaction, JS-rendered or anti-bot pages | **chrome-use** (this skill) |
| A page the user saved before / an internal system | `chrome-use find-url <keywords>` (their bookmarks), then open it |
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
Don't hand-build deep URLs with query params — links discovered by *interacting*
@@ -58,36 +58,47 @@ hand-constructed URL often doesn't.
When the task needs the user's *live* logged-in window (their real session, the
window they're looking at — not a fresh browser), use the extension connect flow.
One-time setup:
1. `agent-browser extension install` — registers the native-messaging host.
2. Install the **agent-browser-stealth** extension. Easiest (and restart-stable):
1. `chrome-use extension install` — registers the native-messaging host.
2. Install the **chrome-use** extension. Easiest (and restart-stable):
the **Chrome Web Store**, one-click *Add to Chrome*:
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
<https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk>
(Dev fallback: `chrome://extensions` → Developer mode → *Load unpacked*
`extensions/ab-connect`. Load-unpacked can be disabled on Chrome restart, so
prefer the Store build for unattended setups.)
Once installed, plain `agent-browser open <url>` auto-connects through the
Once installed, plain `chrome-use open <url>` auto-connects through the
extension relay — `auto_connect_cdp` **prefers the live relay over a raw
`--remote-debugging-port`**, so Chrome 136+'s "Allow remote debugging?" consent
popup never fires. `agent-browser extension connect` is the explicit form of the
popup never fires. `chrome-use extension connect` is the explicit form of the
same path. Zero-confirmation, zero-token. Use `--launch` instead when a fresh,
isolated browser is fine.
`--launch` opens an **isolated, empty test profile** — no cookies, no login, no
extensions (so the extension-relay path is off). Its window is labelled
`chrome-use (<session>)` in Chrome's profile menu so a human watching the
desktop knows which session owns it. If a launched session needs more:
- **Real cookies / login / extensions** → drop `--launch`, use `--profile auto`
(reuses the user's real Chrome profile), or set `AGENT_BROWSER_PROFILE=auto`
once so every call does it by default.
- **A specific unpacked extension in the test profile**
`--launch --args "--load-extension=<dir>"`.
**If you DO hit the "Allow remote debugging?" dialog**, don't keep retrying (every
attempt re-pops it). One of two things is true:
1. **You're on a stale build.** The relay-preference that avoids this dialog
landed in **fork.30**. Run `agent-browser --version`: if it's below
landed in **fork.30**. Run `chrome-use --version`: if it's below
`0.27.0-fork.30`, upgrade and retry:
```bash
curl -fsSL https://raw.githubusercontent.com/leeguooooo/agent-browser-stealth/main/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh
```
If `which -a agent-browser` shows more than one install, an old **npm/pnpm**
If `which -a chrome-use` shows more than one install, an old **npm/pnpm**
copy (the npm registry lags behind — Releases are the source of truth) may be
shadowing the upgraded one; remove the stale copy
(`npm rm -g agent-browser-stealth` / `pnpm rm -g agent-browser-stealth`) so the
(`npm rm -g chrome-use` / `pnpm rm -g chrome-use`) so the
`install.sh` build wins. A tool that bundles its *own* pinned copy
(e.g. `node .../agent-browser-stealth@0.24.x/.../agent-browser`) needs that
(e.g. `node .../chrome-use@0.24.x/.../chrome-use`) needs that
copy upgraded too.
2. **The extension/relay isn't live.** Tell the user to install the Store
extension (one click, above); after that the relay stays up and the dialog
@@ -124,7 +135,7 @@ You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
readable; best for straightforward forms and navigation. But the a11y view is
*lossy and fragile*: refs go stale on any change, hidden inputs never show up,
overlays can block coordinate clicks.
2. **eval-first** (`agent-browser eval "<js>"`) — your eyes and hands on the real
2. **eval-first** (`chrome-use eval "<js>"`) — your eyes and hands on the real
DOM: read hidden inputs, reach into Shadow DOM / iframes, inspect
`form.elements` and `.validity`, extract the exact shape you want, or call
`el.click()` directly. **The moment the structured path fights you, drop to
@@ -133,47 +144,47 @@ You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
```bash
# "what's actually in this form / why won't it submit?"
agent-browser eval "[...document.forms[0].elements].map(e=>[e.name,e.type,e.value,e.checked])"
agent-browser eval "document.querySelector('[name=point_choice]')?.value"
agent-browser eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).map(e=>e.name+': '+e.validationMessage)"
agent-browser eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
chrome-use eval "[...document.forms[0].elements].map(e=>[e.name,e.type,e.value,e.checked])"
chrome-use eval "document.querySelector('[name=point_choice]')?.value"
chrome-use eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).map(e=>e.name+': '+e.validationMessage)"
chrome-use eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
```
## Quickstart
```bash
# Install once
npm i -g agent-browser && agent-browser install
npm i -g chrome-use && chrome-use install
# Take a screenshot of a page
agent-browser open https://example.com
agent-browser screenshot home.png
agent-browser close
chrome-use open https://example.com
chrome-use screenshot home.png
chrome-use close
# Search, click a result, and capture it
agent-browser open https://duckduckgo.com
agent-browser snapshot -i # find the search box ref
agent-browser fill @e1 "agent-browser cli"
agent-browser press Enter
agent-browser wait --load networkidle
agent-browser snapshot -i # refs now reflect results
agent-browser click @e5 # click a result
agent-browser screenshot result.png
chrome-use open https://duckduckgo.com
chrome-use snapshot -i # find the search box ref
chrome-use fill @e1 "chrome-use cli"
chrome-use press Enter
chrome-use wait --load networkidle
chrome-use snapshot -i # refs now reflect results
chrome-use click @e5 # click a result
chrome-use screenshot result.png
```
The browser stays running across commands so these feel like a single
session. Use `agent-browser close` (or `close --all`) when you're done.
session. Use `chrome-use close` (or `close --all`) when you're done.
## Reading a page
```bash
agent-browser snapshot # full tree (verbose)
agent-browser snapshot -i # interactive elements only (preferred)
agent-browser snapshot -i -u # include href urls on links
agent-browser snapshot -i -c # compact (no empty structural nodes)
agent-browser snapshot -i -d 3 # cap depth at 3 levels
agent-browser snapshot -s "#main" # scope to a CSS selector
agent-browser snapshot -i --json # machine-readable output
chrome-use snapshot # full tree (verbose)
chrome-use snapshot -i # interactive elements only (preferred)
chrome-use snapshot -i -u # include href urls on links
chrome-use snapshot -i -c # compact (no empty structural nodes)
chrome-use snapshot -i -d 3 # cap depth at 3 levels
chrome-use snapshot -s "#main" # scope to a CSS selector
chrome-use snapshot -i --json # machine-readable output
```
Snapshot output looks like:
@@ -196,32 +207,35 @@ assigned fresh on every snapshot.
For unstructured reading (no refs needed):
```bash
agent-browser get text @e1 # visible text of an element
agent-browser get html @e1 # innerHTML
agent-browser get attr @e1 href # any attribute
agent-browser get value @e1 # input value
agent-browser get title # page title
agent-browser get url # current URL
agent-browser get count ".item" # count matching elements
chrome-use get text @e1 # visible text of an element
chrome-use get html @e1 # innerHTML
chrome-use get attr @e1 href # any attribute
chrome-use get value @e1 # input value
chrome-use get title # page title
chrome-use get url # current URL
chrome-use get count ".item" # count matching elements
```
## Interacting
```bash
agent-browser click @e1 # click
agent-browser click @e1 --new-tab # open link in new tab instead of navigating
agent-browser dblclick @e1 # double-click
agent-browser hover @e1 # hover
agent-browser focus @e1 # focus (useful before keyboard input)
agent-browser fill @e2 "hello" # clear then type
agent-browser type @e2 " world" # type without clearing
agent-browser press Enter # press a key at current focus
agent-browser press Control+a # key combination
agent-browser check @e3 # check checkbox
agent-browser uncheck @e3 # uncheck
agent-browser select @e4 "option-value" # native <select> only
agent-browser select @e4 "a" "b" # select multiple
agent-browser pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
chrome-use click @e1 # click
chrome-use click @e1 --new-tab # open link in new tab instead of navigating
chrome-use dblclick @e1 # double-click
chrome-use hover @e1 # hover
chrome-use focus @e1 # focus (useful before keyboard input)
chrome-use fill @e2 "hello" # clear then type
chrome-use type @e2 " world" # type without clearing
chrome-use press Enter # press a key at current focus (down+up)
chrome-use press Control+a # key combination
chrome-use keydown d # HOLD a key down (no auto-release)
chrome-use keyup d # release it — pair them to hold-to-move
# in a game: `keydown d; sleep; keyup d`
chrome-use check @e3 # check checkbox
chrome-use uncheck @e3 # uncheck
chrome-use select @e4 "option-value" # native <select> only
chrome-use select @e4 "a" "b" # select multiple
chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
# native): opens it, waits for the menu
# (incl. portal-rendered), matches by
# visible text, fires the right events,
@@ -229,10 +243,14 @@ agent-browser pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
# (no silent no-op). Use this for custom
# dropdowns where `select` returns ✓ but
# changes nothing.
agent-browser upload @e5 file1.pdf # upload file(s)
agent-browser scroll down 500 # scroll page (up/down/left/right)
agent-browser scrollintoview @e1 # scroll element into view
agent-browser drag @e1 @e2 # drag and drop
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP
# session. Over the extension relay it CANNOT work
# (Chrome's chrome.debugger forbids it); chrome-use
# errors with a hint. Carry your login into a launched
# session via `cookies export` | `cookies set --curl`.
chrome-use scroll down 500 # scroll page (up/down/left/right)
chrome-use scrollintoview @e1 # scroll element into view
chrome-use drag @e1 @e2 # drag and drop
```
### When refs don't work or you don't want to snapshot
@@ -240,22 +258,22 @@ agent-browser drag @e1 @e2 # drag and drop
Use semantic locators:
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find text "Sign In" click --exact # exact match only
agent-browser find label "Email" fill "user@test.com"
agent-browser find placeholder "Search" type "query"
agent-browser find testid "submit-btn" click
agent-browser find first ".card" click
agent-browser find nth 2 ".card" hover
chrome-use find role button click --name "Submit"
chrome-use find text "Sign In" click
chrome-use find text "Sign In" click --exact # exact match only
chrome-use find label "Email" fill "user@test.com"
chrome-use find placeholder "Search" type "query"
chrome-use find testid "submit-btn" click
chrome-use find first ".card" click
chrome-use find nth 2 ".card" hover
```
Or a raw CSS selector:
```bash
agent-browser click "#submit"
agent-browser fill "input[name=email]" "user@test.com"
agent-browser click "button.primary"
chrome-use click "#submit"
chrome-use fill "input[name=email]" "user@test.com"
chrome-use click "button.primary"
```
Escalation ladder: snapshot + `@eN` refs are quickest for straightforward
@@ -267,8 +285,36 @@ occluded clicks). Don't retry a flaky structured locator three times; drop to
`click` auto-scrolls into view and, if the coordinate click is occluded, falls
back to a DOM `.click()`. If a click *reports success but nothing happened*
classic for an autocomplete/menu `<li>` that closes on the input's blur — retry
that one with `AGENT_BROWSER_CLICK_MODE=dom agent-browser click ...`, or just
`agent-browser eval "<select the item via JS>"`.
that one with `AGENT_BROWSER_CLICK_MODE=dom chrome-use click ...`, or just
`chrome-use eval "<select the item via JS>"`.
Click a raw pixel point when the only handle you have is a coordinate (canvas,
a marker from a screenshot, a target with no stable selector):
```bash
chrome-use click 449 320 # click viewport point (x y)
chrome-use click 449,320 # same, comma form
chrome-use click --coords 449,320 # same, explicit flag
```
A bare-number argument is always a coordinate, never a selector.
### Canvas / WebGL apps (games, map & 3D viewers, drawing tools)
These paint everything to a `<canvas>` and expose **almost no accessibility
tree**, so `snapshot` comes back near-empty and refs are a dead end. `snapshot`
detects this and prints a one-line hint. Drive them the screenshot way:
```bash
chrome-use screenshot /tmp/s.png # SEE the state (your only read path —
# eval/get text return nothing useful)
chrome-use click 640 360 # interact by viewport coordinate
chrome-use keydown d; sleep 0.6; chrome-use keyup d # hold-to-move
chrome-use press Space # discrete actions (jump/attack/confirm)
```
Each command is a ~250ms round-trip, so this is fine for turn-based / canvas
*apps* but too slow to play a real-time 60fps action game frame-by-frame.
## Waiting (read this)
@@ -276,13 +322,13 @@ Agents fail more often from bad waits than from bad selectors. Pick the
right wait for the situation:
```bash
agent-browser wait @e1 # until an element appears
agent-browser wait 2000 # dumb wait, milliseconds (last resort)
agent-browser wait --text "Success" # until the text appears on the page
agent-browser wait --url "**/dashboard" # until URL matches pattern (glob)
agent-browser wait --load networkidle # until network idle (post-navigation)
agent-browser wait --load domcontentloaded # until DOMContentLoaded
agent-browser wait --fn "window.myApp.ready === true" # until JS condition
chrome-use wait @e1 # until an element appears
chrome-use wait 2000 # dumb wait, milliseconds (last resort)
chrome-use wait --text "Success" # until the text appears on the page
chrome-use wait --url "**/dashboard" # until URL matches pattern (glob)
chrome-use wait --load networkidle # until network idle (post-navigation)
chrome-use wait --load domcontentloaded # until DOMContentLoaded
chrome-use wait --fn "window.myApp.ready === true" # until JS condition
```
After any page-changing action, pick one:
@@ -299,42 +345,42 @@ flaky. Timeouts default to 25 seconds.
### Log in
```bash
agent-browser open https://app.example.com/login
agent-browser snapshot -i
chrome-use open https://app.example.com/login
chrome-use snapshot -i
# Pick the email/password refs out of the snapshot, then:
agent-browser fill @e3 "user@example.com"
agent-browser fill @e4 "hunter2"
agent-browser click @e5
agent-browser wait --url "**/dashboard"
agent-browser snapshot -i
chrome-use fill @e3 "user@example.com"
chrome-use fill @e4 "hunter2"
chrome-use click @e5
chrome-use wait --url "**/dashboard"
chrome-use snapshot -i
```
Credentials in shell history are a leak. For anything sensitive, use the
auth vault (see [references/authentication.md](references/authentication.md)):
```bash
agent-browser auth save my-app --url https://app.example.com/login \
chrome-use auth save my-app --url https://app.example.com/login \
--username user@example.com --password-stdin
# (type password, Ctrl+D)
agent-browser auth login my-app # fills + clicks, waits for form
chrome-use auth login my-app # fills + clicks, waits for form
```
### Persist session across runs
```bash
# Log in once, save cookies + localStorage
agent-browser state save ./auth.json
chrome-use state save ./auth.json
# Later runs start already-logged-in
agent-browser --state ./auth.json open https://app.example.com
chrome-use --state ./auth.json open https://app.example.com
```
Or use `--session-name` for auto-save/restore:
```bash
AGENT_BROWSER_SESSION_NAME=my-app agent-browser open https://app.example.com
AGENT_BROWSER_SESSION_NAME=my-app chrome-use open https://app.example.com
# State is auto-saved and restored on subsequent runs with the same name.
```
@@ -347,7 +393,7 @@ re-discover it.** Keep one markdown file per domain (these are your own notes,
not shipped with the skill):
```
~/.agent-browser/site-patterns/<domain>.md
~/.chrome-use/site-patterns/<domain>.md
```
**Before** working on a domain, read its file if it exists (use your normal file
@@ -380,15 +426,15 @@ page every time.
```bash
# Structured snapshot (best for AI reasoning over page content)
agent-browser snapshot -i --json > page.json
chrome-use snapshot -i --json > page.json
# Targeted extraction with refs
agent-browser snapshot -i
agent-browser get text @e5
agent-browser get attr @e10 href
chrome-use snapshot -i
chrome-use get text @e5
chrome-use get attr @e10 href
# Arbitrary shape via JavaScript
cat <<'EOF' | agent-browser eval --stdin
cat <<'EOF' | chrome-use eval --stdin
const rows = document.querySelectorAll("table tbody tr");
Array.from(rows).map(r => ({
name: r.cells[0].innerText,
@@ -399,7 +445,7 @@ EOF
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
large scripts — inline `agent-browser eval "..."` is shell-mangled and works
large scripts — inline `chrome-use eval "..."` is shell-mangled and works
only for simple ASCII expressions.
**`eval` runs in the page's MAIN world and state persists across calls**, so a
@@ -408,13 +454,20 @@ top-level `const x`/`let x`/`var x` in one call collides with the next
names, assign to `window.x`, or wrap the body in an IIFE
(`(() => { const x = …; return x; })()`).
**For array/object results, use `eval --json`** — the plain renderer
pretty-prints across multiple lines, which `tail`/`head`/pipes mangle; `--json`
emits one parseable line. Also note **`type`/`fill` insert text without firing
`keydown`/`keyup`** (CDP insertText) — the value lands, but a page that gates on
key events (some search-as-you-type widgets) won't react; use `keyboard type` (or
`press` per key) when real keystrokes are required.
### Screenshot
```bash
agent-browser screenshot # temp path, printed on stdout
agent-browser screenshot page.png # specific path
agent-browser screenshot --full full.png # full scroll height
agent-browser screenshot --annotate map.png # numbered labels + legend keyed to snapshot refs
chrome-use screenshot # temp path, printed on stdout
chrome-use screenshot page.png # specific path
chrome-use screenshot --full full.png # full scroll height
chrome-use screenshot --annotate map.png # numbered labels + legend keyed to snapshot refs
```
Headless Chromium screenshots hide native scrollbars for consistent image output.
@@ -425,12 +478,16 @@ Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
### Handle multiple pages via tabs
```bash
agent-browser tab # list open tabs (with stable tabId)
agent-browser tab new https://docs... # open a new tab (and switch to it)
agent-browser tab t2 # switch to tab t2
agent-browser tab close t2 # close tab t2
chrome-use tab # list open tabs (with stable tabId)
chrome-use tabs # alias for `tab` (lists too)
chrome-use tab new https://docs... # open a new tab (and switch to it)
chrome-use tab t2 # switch to tab t2
chrome-use tab close t2 # close tab t2
```
(`tabs` → the `tab` subcommand tree, and `get-text <sel>``get text <sel>`
common-guess aliases so you don't waste a round on the wrong spelling.)
Tab ids are stable strings (`t1`, `t2`, …), never reused within a session, so
the same id keeps referring to the same tab across commands. Positional
integers are **not** accepted — use `t2`, not `2`. After switching, refs from a
@@ -442,34 +499,85 @@ Each `--session <name>` is an isolated browser with its own cookies, tabs,
and refs. Useful for testing multi-user flows or parallel scraping:
```bash
agent-browser --session a open https://app.example.com
agent-browser --session b open https://app.example.com
agent-browser --session a fill @e1 "alice@test.com"
agent-browser --session b fill @e1 "bob@test.com"
chrome-use --session a open https://app.example.com
chrome-use --session b open https://app.example.com
chrome-use --session a fill @e1 "alice@test.com"
chrome-use --session b fill @e1 "bob@test.com"
```
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
shell.
**Concurrent agents MUST each use a distinct `--session <name>`.** Within one
session, commands are pinned to the tab you opened (by target_id, so a foreign
tab can't drift your `eval`/`screenshot`). Two agents sharing the *same* session
(e.g. both on the bare default) share one daemon and one active tab and will
clobber each other.
True multi-agent isolation requires the **extension-connect path**: each
`--session` gets its own colored Chrome tab group, so sessions never touch each
other's tabs. **Raw `--cdp <port>` does NOT isolate** — every session attaches to
the same browser's existing targets, so a second session's first `open` can
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
extension (each with a distinct `--session`), not raw `--cdp`.
Each session owns its own tab group and assigns its own `t<N>` indices (the same
physical tab is `t8` in one session, `t1` in another), so `t<N>` is **not** a
stable cross-session handle. To reach a *specific* tab from another session — e.g.
a tab that was filled in a session whose handle later died — use the **stable CDP
`targetId`**:
```bash
chrome-use tab list --full --session B # re-syncs live tabs; prints `target: <id>` per row
chrome-use tab <targetId> --session B # adopt that exact tab, NO reload (state preserved)
```
`tab list` re-discovers the live tab set on every call, so a fresh session sees
tabs other sessions opened (and re-attached ones), not just its own. Adopting by
`targetId` lands session B on the stranded tab without reloading it, so a
half-filled form survives. Still, the simplest recovery for a session whose own
tab died is to recover *that* session (reload / re-`open` / `daemon restart`).
To avoid piling up duplicate tabs when you re-`open` the same entry URL on
rebind, pass **`--reuse-tab`**: if a tab already shows that URL (matched by
origin+path), it switches to it instead of spawning a new one.
### Reset stuck daemon state
Each session runs a background daemon worker that holds the page handles. If a
session starts misbehaving — commands hit the wrong tab, refs/handles look stale,
or you upgraded `chrome-use` mid-session and old workers linger — restart the
daemons instead of hunting PIDs with `pgrep`/`kill`:
```bash
chrome-use daemon status # list running session daemons (+ relay state)
chrome-use daemon restart # kill every session daemon worker
```
`daemon restart` leaves the extension's native-messaging bridge (`__nm-host`)
alone, so the relay to your live Chrome stays up — the next command just spins up
a fresh, clean daemon against the same browser. It does **not** close any tabs.
### Mock network requests
```bash
agent-browser network route "**/api/users" --body '{"users":[]}' # stub a response
agent-browser network route "**/analytics" --abort # block entirely
agent-browser network requests # inspect what fired
agent-browser network har start # record all traffic
chrome-use network route "**/api/users" --body '{"users":[]}' # stub a response
chrome-use network route "**/analytics" --abort # block entirely
chrome-use network requests --clear # start capturing fresh
chrome-use network requests # inspect what fired
chrome-use network har start # record all traffic
# ... perform actions ...
agent-browser network har stop /tmp/trace.har
chrome-use network har stop /tmp/trace.har
```
### Record a video of the workflow
```bash
agent-browser record start demo.webm
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser click @e3
agent-browser record stop
chrome-use record start demo.webm
chrome-use open https://example.com
chrome-use snapshot -i
chrome-use click @e3
chrome-use record stop
```
See [references/video-recording.md](references/video-recording.md) for
@@ -480,21 +588,21 @@ codec options, GIF export, and more.
Iframes are auto-inlined in the snapshot — their refs work transparently:
```bash
agent-browser snapshot -i
chrome-use snapshot -i
# @e3 [Iframe] "payment-frame"
# @e4 [input] "Card number"
# @e5 [button] "Pay"
agent-browser fill @e4 "4111111111111111"
agent-browser click @e5
chrome-use fill @e4 "4111111111111111"
chrome-use click @e5
```
To scope a snapshot to an iframe (for focus or deep nesting):
```bash
agent-browser frame @e3 # switch context to the iframe
agent-browser snapshot -i
agent-browser frame main # back to main frame
chrome-use frame @e3 # switch context to the iframe
chrome-use snapshot -i
chrome-use frame main # back to main frame
```
### Dialogs
@@ -503,10 +611,10 @@ agent-browser frame main # back to main frame
`confirm` and `prompt`:
```bash
agent-browser dialog status # is there a pending dialog?
agent-browser dialog accept # accept
agent-browser dialog accept "text" # accept with prompt input
agent-browser dialog dismiss # cancel
chrome-use dialog status # is there a pending dialog?
chrome-use dialog accept # accept
chrome-use dialog accept "text" # accept with prompt input
chrome-use dialog dismiss # cancel
```
## Diagnosing install issues
@@ -516,10 +624,14 @@ stale daemons, version mismatches after `upgrade`, missing Chrome, etc.)
run `doctor` before anything else:
```bash
agent-browser doctor # full diagnosis (env, Chrome, daemons, config, providers, network, launch test)
agent-browser doctor --offline --quick # fast, local-only
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
agent-browser doctor --json # structured output for programmatic consumption
chrome-use doctor # full diagnosis (env, Chrome, daemons, config, providers, network, launch test)
chrome-use doctor --offline --quick # fast, local-only
chrome-use doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
chrome-use doctor --json # structured output for programmatic consumption
chrome-use stealth status # stealth self-check: mode + live probes
chrome-use stealth status --json # (webdriver/chrome/plugins/UA) + applied
# overrides. Gate a sensitive flow on this
# instead of driving an external detector.
```
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
@@ -529,39 +641,61 @@ Destructive actions require `--fix`. Exit code is `0` if all checks pass
## Troubleshooting
**"Ref not found" / "Element not found: @eN"**
Page changed since the snapshot. Run `agent-browser snapshot -i` again,
Page changed since the snapshot. Run `chrome-use snapshot -i` again,
then use the new refs.
**Element exists in the DOM but not in the snapshot**
It's probably off-screen or not yet rendered. Try:
```bash
agent-browser scroll down 1000
agent-browser snapshot -i
chrome-use scroll down 1000
chrome-use snapshot -i
# or
agent-browser wait --text "..."
agent-browser snapshot -i
chrome-use wait --text "..."
chrome-use snapshot -i
```
**Click does nothing / overlay swallows the click**
Some modals and cookie banners block other clicks. Snapshot, find the
dismiss/close button, click it, then re-snapshot.
**`stale sessionId … re-open your target URL` (extension-relay mode)**
Your tab was closed, navigated across processes, or its debugger detached
(e.g. it landed on a `chrome://` or Chrome Web Store page, which Chrome
forbids debugging). The session no longer has a live tab — re-run
`chrome-use open <your URL>` to re-attach, then retry. This loud error
replaces the old silent behaviour where the command ran on some *other*
tab and returned wrong data.
To recover, you need the tab's **exact** URL (query params and all — a long
SSO/redirect link breaks if truncated). `tab list` shortens long URLs with
`…`; use **`tab list --full`** to print them untruncated, then re-`open` the
right one. For multi-redirect SSO flows, re-open the **stable entry URL**
(not the mid-redirect one) and `wait` a few seconds for the SPA to settle
before snapshotting.
**Reads landing on the wrong page**
`eval`, `screenshot`, and `network requests` print the page they ran
against to stderr: `eval @ <url>`, `screenshot @ <url>`, `network @ <url>`.
If that URL isn't the page you expected (the active tab drifted), re-`open`
your target URL — don't trust the result. Treat the stamp as a built-in
sanity check on every read.
**Fill / type doesn't work**
Some custom input components intercept key events. Try:
```bash
agent-browser focus @e1
agent-browser keyboard inserttext "text" # bypasses key events
chrome-use focus @e1
chrome-use keyboard inserttext "text" # bypasses key events
# or
agent-browser keyboard type "text" # raw keystrokes, no selector
chrome-use keyboard type "text" # raw keystrokes, no selector
```
**Page needs JS you can't get right in one shot**
Use `eval --stdin` with a heredoc instead of inline:
```bash
cat <<'EOF' | agent-browser eval --stdin
cat <<'EOF' | chrome-use eval --stdin
// Complex script with quotes, backticks, whatever
document.querySelectorAll('[data-id]').length
EOF
@@ -599,28 +733,30 @@ and [references/authentication.md](references/authentication.md).
## When to load another skill
- **Electron desktop app** (VS Code, Slack desktop, Discord, Figma, etc.):
`agent-browser skills get electron`
- **Slack workspace automation**: `agent-browser skills get slack`
- **Exploratory testing / QA / bug hunts**: `agent-browser skills get dogfood`
- **Vercel Sandbox microVMs**: `agent-browser skills get vercel-sandbox`
- **AWS Bedrock AgentCore cloud browser**: `agent-browser skills get agentcore`
`chrome-use skills get electron`
- **Slack workspace automation**: `chrome-use skills get slack`
- **Exploratory testing / QA / bug hunts**: `chrome-use skills get dogfood`
- **Re-runnable test suites (frontend "unit tests")**: `chrome-use skills get test`
— turn repeated checks into a `chrome-use test <suite.yaml>` regression suite
- **Vercel Sandbox microVMs**: `chrome-use skills get vercel-sandbox`
- **AWS Bedrock AgentCore cloud browser**: `chrome-use skills get agentcore`
## React / Web Vitals (built-in, any React app)
agent-browser ships with first-class React introspection. Works on any
chrome-use ships with first-class React introspection. Works on any
React app — Next.js, Remix, Vite+React, CRA, TanStack Start, React Native
Web, etc. The `react …` commands require the React DevTools hook to be
installed at launch via `--enable react-devtools`:
```bash
agent-browser open --enable react-devtools http://localhost:3000
agent-browser react tree # component tree
agent-browser react inspect <fiberId> # props, hooks, state, source
agent-browser react renders start # begin re-render recording
agent-browser react renders stop # print render profile
agent-browser react suspense [--only-dynamic] # Suspense boundaries + classifier
agent-browser vitals [url] # LCP/CLS/TTFB/FCP/INP + hydration
agent-browser pushstate <url> # SPA navigation (auto-detects Next router)
chrome-use open --enable react-devtools http://localhost:3000
chrome-use react tree # component tree
chrome-use react inspect <fiberId> # props, hooks, state, source
chrome-use react renders start # begin re-render recording
chrome-use react renders stop # print render profile
chrome-use react suspense [--only-dynamic] # Suspense boundaries + classifier
chrome-use vitals [url] # LCP/CLS/TTFB/FCP/INP + hydration
chrome-use pushstate <url> # SPA navigation (auto-detects Next router)
```
Without `--enable react-devtools`, the `react …` commands error. `vitals`
@@ -640,7 +776,7 @@ instructed. See `references/trust-boundaries.md` for the full rules.
Everything covered here plus the complete command/flag/env listing:
```bash
agent-browser skills get core --full
chrome-use skills get core --full
```
That pulls in:
+70 -70
View File
@@ -44,18 +44,18 @@ Log in to your target site(s) in this Chrome window as you normally would.
```bash
# Auto-discover the running Chrome and save its cookies + localStorage
agent-browser --auto-connect state save ./my-auth.json
chrome-use --auto-connect state save ./my-auth.json
```
**Step 3: Reuse in automation**
```bash
# Load auth at launch
agent-browser --state ./my-auth.json open https://app.example.com/dashboard
chrome-use --state ./my-auth.json open https://app.example.com/dashboard
# Or load into an existing session
agent-browser state load ./my-auth.json
agent-browser open https://app.example.com/dashboard
chrome-use state load ./my-auth.json
chrome-use open https://app.example.com/dashboard
```
This works for any site, including those with complex OAuth flows, SSO, or 2FA -- as long as Chrome already has valid session cookies.
@@ -65,35 +65,35 @@ This works for any site, including those with complex OAuth flows, SSO, or 2FA -
**Tip:** Combine with `--session-name` so the imported auth auto-persists across restarts:
```bash
agent-browser --session-name myapp state load ./my-auth.json
chrome-use --session-name myapp state load ./my-auth.json
# From now on, state is auto-saved/restored for "myapp"
```
## Persistent Profiles
Use `--profile` to point agent-browser at a Chrome user data directory. This persists everything (cookies, IndexedDB, service workers, cache) across browser restarts without explicit save/load:
Use `--profile` to point chrome-use at a Chrome user data directory. This persists everything (cookies, IndexedDB, service workers, cache) across browser restarts without explicit save/load:
```bash
# First run: login once
agent-browser --profile ~/.myapp-profile open https://app.example.com/login
chrome-use --profile ~/.myapp-profile open https://app.example.com/login
# ... complete login flow ...
# All subsequent runs: already authenticated
agent-browser --profile ~/.myapp-profile open https://app.example.com/dashboard
chrome-use --profile ~/.myapp-profile open https://app.example.com/dashboard
```
Use different paths for different projects or test users:
```bash
agent-browser --profile ~/.profiles/admin open https://app.example.com
agent-browser --profile ~/.profiles/viewer open https://app.example.com
chrome-use --profile ~/.profiles/admin open https://app.example.com
chrome-use --profile ~/.profiles/viewer open https://app.example.com
```
Or set via environment variable:
```bash
export AGENT_BROWSER_PROFILE=~/.myapp-profile
agent-browser open https://app.example.com/dashboard
chrome-use open https://app.example.com/dashboard
```
## Session Persistence
@@ -102,42 +102,42 @@ Use `--session-name` to auto-save and restore cookies + localStorage by name, wi
```bash
# Auto-saves state on close, auto-restores on next launch
agent-browser --session-name twitter open https://twitter.com
chrome-use --session-name twitter open https://twitter.com
# ... login flow ...
agent-browser close # state saved to ~/.agent-browser/sessions/
chrome-use close # state saved to ~/.chrome-use/sessions/
# Next time: state is automatically restored
agent-browser --session-name twitter open https://twitter.com
chrome-use --session-name twitter open https://twitter.com
```
Encrypt state at rest:
```bash
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
agent-browser --session-name secure open https://app.example.com
chrome-use --session-name secure open https://app.example.com
```
## Basic Login Flow
```bash
# Navigate to login page
agent-browser open https://app.example.com/login
agent-browser wait --load networkidle
chrome-use open https://app.example.com/login
chrome-use wait --load networkidle
# Get form elements
agent-browser snapshot -i
chrome-use snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Sign In"
# Fill credentials
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
chrome-use fill @e1 "user@example.com"
chrome-use fill @e2 "password123"
# Submit
agent-browser click @e3
agent-browser wait --load networkidle
chrome-use click @e3
chrome-use wait --load networkidle
# Verify login succeeded
agent-browser get url # Should be dashboard, not login
chrome-use get url # Should be dashboard, not login
```
## Saving Authentication State
@@ -146,15 +146,15 @@ After logging in, save state for reuse:
```bash
# Login first (see above)
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
chrome-use open https://app.example.com/login
chrome-use snapshot -i
chrome-use fill @e1 "user@example.com"
chrome-use fill @e2 "password123"
chrome-use click @e3
chrome-use wait --url "**/dashboard"
# Save authenticated state
agent-browser state save ./auth-state.json
chrome-use state save ./auth-state.json
```
## Restoring Authentication
@@ -163,13 +163,13 @@ Skip login by loading saved state:
```bash
# Load saved auth state
agent-browser state load ./auth-state.json
chrome-use state load ./auth-state.json
# Navigate directly to protected page
agent-browser open https://app.example.com/dashboard
chrome-use open https://app.example.com/dashboard
# Verify authenticated
agent-browser snapshot -i
chrome-use snapshot -i
```
## OAuth / SSO Flows
@@ -178,23 +178,23 @@ For OAuth redirects:
```bash
# Start OAuth flow
agent-browser open https://app.example.com/auth/google
chrome-use open https://app.example.com/auth/google
# Handle redirects automatically
agent-browser wait --url "**/accounts.google.com**"
agent-browser snapshot -i
chrome-use wait --url "**/accounts.google.com**"
chrome-use snapshot -i
# Fill Google credentials
agent-browser fill @e1 "user@gmail.com"
agent-browser click @e2 # Next button
agent-browser wait 2000
agent-browser snapshot -i
agent-browser fill @e3 "password"
agent-browser click @e4 # Sign in
chrome-use fill @e1 "user@gmail.com"
chrome-use click @e2 # Next button
chrome-use wait 2000
chrome-use snapshot -i
chrome-use fill @e3 "password"
chrome-use click @e4 # Sign in
# Wait for redirect back
agent-browser wait --url "**/app.example.com**"
agent-browser state save ./oauth-state.json
chrome-use wait --url "**/app.example.com**"
chrome-use state save ./oauth-state.json
```
## Two-Factor Authentication
@@ -203,18 +203,18 @@ Handle 2FA with manual intervention:
```bash
# Login with credentials
agent-browser open https://app.example.com/login --headed # Show browser
agent-browser snapshot -i
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
chrome-use open https://app.example.com/login --headed # Show browser
chrome-use snapshot -i
chrome-use fill @e1 "user@example.com"
chrome-use fill @e2 "password123"
chrome-use click @e3
# Wait for user to complete 2FA manually
echo "Complete 2FA in the browser window..."
agent-browser wait --url "**/dashboard" --timeout 120000
chrome-use wait --url "**/dashboard" --timeout 120000
# Save state after 2FA
agent-browser state save ./2fa-state.json
chrome-use state save ./2fa-state.json
```
## HTTP Basic Auth
@@ -223,10 +223,10 @@ For sites using HTTP Basic Authentication:
```bash
# Set credentials before navigation
agent-browser set credentials username password
chrome-use set credentials username password
# Navigate to protected resource
agent-browser open https://protected.example.com/api
chrome-use open https://protected.example.com/api
```
## Cookie-Based Auth
@@ -235,10 +235,10 @@ Manually set authentication cookies:
```bash
# Set auth cookie
agent-browser cookies set session_token "abc123xyz"
chrome-use cookies set session_token "abc123xyz"
# Navigate to protected page
agent-browser open https://app.example.com/dashboard
chrome-use open https://app.example.com/dashboard
```
## Token Refresh Handling
@@ -253,24 +253,24 @@ STATE_FILE="./auth-state.json"
# Try loading existing state
if [[ -f "$STATE_FILE" ]]; then
agent-browser state load "$STATE_FILE"
agent-browser open https://app.example.com/dashboard
chrome-use state load "$STATE_FILE"
chrome-use open https://app.example.com/dashboard
# Check if session is still valid
URL=$(agent-browser get url)
URL=$(chrome-use get url)
if [[ "$URL" == *"/login"* ]]; then
echo "Session expired, re-authenticating..."
# Perform fresh login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save "$STATE_FILE"
chrome-use snapshot -i
chrome-use fill @e1 "$USERNAME"
chrome-use fill @e2 "$PASSWORD"
chrome-use click @e3
chrome-use wait --url "**/dashboard"
chrome-use state save "$STATE_FILE"
fi
else
# First-time login
agent-browser open https://app.example.com/login
chrome-use open https://app.example.com/login
# ... login flow ...
fi
```
@@ -284,20 +284,20 @@ fi
2. **Use environment variables for credentials**
```bash
agent-browser fill @e1 "$APP_USERNAME"
agent-browser fill @e2 "$APP_PASSWORD"
chrome-use fill @e1 "$APP_USERNAME"
chrome-use fill @e2 "$APP_PASSWORD"
```
3. **Clean up after automation**
```bash
agent-browser cookies clear
chrome-use cookies clear
rm -f ./auth-state.json
```
4. **Use short-lived sessions for CI/CD**
```bash
# Don't persist state in CI
agent-browser open https://app.example.com/login
chrome-use open https://app.example.com/login
# ... login and perform actions ...
agent-browser close # Session ends, nothing persisted
chrome-use close # Session ends, nothing persisted
```
+198 -198
View File
@@ -1,30 +1,30 @@
# Command Reference
Complete reference for all agent-browser commands. For quick start and common patterns, see SKILL.md.
Complete reference for all chrome-use commands. For quick start and common patterns, see SKILL.md.
## Navigation
```bash
agent-browser open # Launch browser (no navigation); stays on about:blank.
chrome-use open # Launch browser (no navigation); stays on about:blank.
# Pair with `network route`, `cookies set --curl`, or
# `addinitscript` to stage state before the first navigation.
agent-browser open <url> # Launch + navigate (aliases: goto, navigate)
chrome-use open <url> # Launch + navigate (aliases: goto, navigate)
# Supports: https://, http://, file://, about:, data://
# Auto-prepends https:// if no protocol given
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser pushstate <url> # SPA client-side navigation. Auto-detects
chrome-use back # Go back
chrome-use forward # Go forward
chrome-use reload # Reload page
chrome-use pushstate <url> # SPA client-side navigation. Auto-detects
# window.next.router.push (triggers RSC fetch on Next.js);
# falls back to history.pushState + popstate/navigate events.
agent-browser close # Close browser (aliases: quit, exit)
agent-browser connect 9222 # Connect to browser via CDP port
chrome-use close # Close browser (aliases: quit, exit)
chrome-use connect 9222 # Connect to browser via CDP port
```
### Pre-navigation setup (one-turn batch)
```bash
agent-browser batch \
chrome-use batch \
'["open"]' \
'["network","route","*","--abort","--resource-type","script"]' \
'["cookies","set","--curl","cookies.curl","--domain","localhost"]' \
@@ -40,67 +40,67 @@ prior page.
## Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
chrome-use snapshot # Full accessibility tree
chrome-use snapshot -i # Interactive elements only (recommended)
chrome-use snapshot -c # Compact output
chrome-use snapshot -d 3 # Limit depth to 3
chrome-use snapshot -s "#main" # Scope to CSS selector
```
## Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key (alias: key)
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown option
agent-browser select @e1 "a" "b" # Select multiple options
agent-browser scroll down 500 # Scroll page (default: down 300px)
agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto)
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
chrome-use click @e1 # Click
chrome-use click @e1 --new-tab # Click and open in new tab
chrome-use dblclick @e1 # Double-click
chrome-use focus @e1 # Focus element
chrome-use fill @e2 "text" # Clear and type
chrome-use type @e2 "text" # Type without clearing
chrome-use press Enter # Press key (alias: key)
chrome-use press Control+a # Key combination
chrome-use keydown Shift # Hold key down
chrome-use keyup Shift # Release key
chrome-use hover @e1 # Hover
chrome-use check @e1 # Check checkbox
chrome-use uncheck @e1 # Uncheck checkbox
chrome-use select @e1 "value" # Select dropdown option
chrome-use select @e1 "a" "b" # Select multiple options
chrome-use scroll down 500 # Scroll page (default: down 300px)
chrome-use scrollintoview @e1 # Scroll element into view (alias: scrollinto)
chrome-use drag @e1 @e2 # Drag and drop
chrome-use upload @e1 file.pdf # Upload files
```
## Get Information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get cdp-url # Get CDP WebSocket URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.)
chrome-use get text @e1 # Get element text
chrome-use get html @e1 # Get innerHTML
chrome-use get value @e1 # Get input value
chrome-use get attr @e1 href # Get attribute
chrome-use get title # Get page title
chrome-use get url # Get current URL
chrome-use get cdp-url # Get CDP WebSocket URL
chrome-use get count ".item" # Count matching elements
chrome-use get box @e1 # Get bounding box
chrome-use get styles @e1 # Get computed styles (font, color, bg, etc.)
```
## Check State
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
chrome-use is visible @e1 # Check if visible
chrome-use is enabled @e1 # Check if enabled
chrome-use is checked @e1 # Check if checked
```
## Screenshots and PDF
```bash
agent-browser screenshot # Save to temporary directory
agent-browser screenshot path.png # Save to specific path
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
chrome-use screenshot # Save to temporary directory
chrome-use screenshot path.png # Save to specific path
chrome-use screenshot --full # Full page
chrome-use pdf output.pdf # Save as PDF
```
Headless Chromium screenshots hide native scrollbars for consistent image output.
@@ -109,97 +109,97 @@ Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
## Video Recording
```bash
agent-browser record start ./demo.webm # Start recording
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new
chrome-use record start ./demo.webm # Start recording
chrome-use click @e1 # Perform actions
chrome-use record stop # Stop and save video
chrome-use record restart ./take2.webm # Stop current + start new
```
## Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text (or -t)
agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u)
agent-browser wait --load networkidle # Wait for network idle (or -l)
agent-browser wait --fn "window.ready" # Wait for JS condition (or -f)
chrome-use wait @e1 # Wait for element
chrome-use wait 2000 # Wait milliseconds
chrome-use wait --text "Success" # Wait for text (or -t)
chrome-use wait --url "**/dashboard" # Wait for URL pattern (or -u)
chrome-use wait --load networkidle # Wait for network idle (or -l)
chrome-use wait --fn "window.ready" # Wait for JS condition (or -f)
```
## Mouse Control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
chrome-use mouse move 100 200 # Move mouse
chrome-use mouse down left # Press button
chrome-use mouse up left # Release button
chrome-use mouse wheel 100 # Scroll wheel
```
## Semantic Locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find text "Sign In" click --exact # Exact match only
agent-browser find label "Email" fill "user@test.com"
agent-browser find placeholder "Search" type "query"
agent-browser find alt "Logo" click
agent-browser find title "Close" click
agent-browser find testid "submit-btn" click
agent-browser find first ".item" click
agent-browser find last ".item" click
agent-browser find nth 2 "a" hover
chrome-use find role button click --name "Submit"
chrome-use find text "Sign In" click
chrome-use find text "Sign In" click --exact # Exact match only
chrome-use find label "Email" fill "user@test.com"
chrome-use find placeholder "Search" type "query"
chrome-use find alt "Logo" click
chrome-use find title "Close" click
chrome-use find testid "submit-btn" click
chrome-use find first ".item" click
chrome-use find last ".item" click
chrome-use find nth 2 "a" hover
```
## Browser Settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation)
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth (alias: auth)
agent-browser set media dark # Emulate color scheme
agent-browser set media light reduced-motion # Light mode + reduced motion
chrome-use set viewport 1920 1080 # Set viewport size
chrome-use set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
chrome-use set device "iPhone 14" # Emulate device
chrome-use set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation)
chrome-use set offline on # Toggle offline mode
chrome-use set headers '{"X-Key":"v"}' # Extra HTTP headers
chrome-use set credentials user pass # HTTP basic auth (alias: auth)
chrome-use set media dark # Emulate color scheme
chrome-use set media light reduced-motion # Light mode + reduced motion
```
## Cookies and Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
chrome-use cookies # Get all cookies
chrome-use cookies set name value # Set cookie
chrome-use cookies clear # Clear cookies
chrome-use storage local # Get all localStorage
chrome-use storage local key # Get specific key
chrome-use storage local set k v # Set value
chrome-use storage local clear # Clear all
```
## Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
chrome-use network route <url> # Intercept requests
chrome-use network route <url> --abort # Block requests
chrome-use network route <url> --body '{}' # Mock response
chrome-use network unroute [url] # Remove routes
chrome-use network requests # View tracked requests
chrome-use network requests --filter api # Filter requests
```
## Tabs and Windows
```bash
agent-browser tab # List tabs with tabId and label
agent-browser tab new [url] # New tab
agent-browser tab new --label docs [url] # New tab with a memorable label
agent-browser tab t2 # Switch to tab by id
agent-browser tab docs # Switch to tab by label
agent-browser tab close # Close current tab
agent-browser tab close t2 # Close tab by id
agent-browser tab close docs # Close tab by label
agent-browser window new # New window
chrome-use tab # List tabs with tabId and label
chrome-use tab new [url] # New tab
chrome-use tab new --label docs [url] # New tab with a memorable label
chrome-use tab t2 # Switch to tab by id
chrome-use tab docs # Switch to tab by label
chrome-use tab close # Close current tab
chrome-use tab close t2 # Close tab by id
chrome-use tab close docs # Close tab by label
chrome-use window new # New window
```
Tab ids are stable strings of the form `t1`, `t2`, `t3`. They're never reused
@@ -212,13 +212,13 @@ everywhere a tab ref is accepted. Labels are the agent-friendly way to write
multi-tab workflows:
```bash
agent-browser tab new --label docs https://docs.example.com
agent-browser tab new --label app https://app.example.com
agent-browser tab docs # switch to docs
agent-browser snapshot # populate refs for docs
agent-browser click @e1 # ref click on docs
agent-browser tab app # switch to app
agent-browser tab close docs # close by label
chrome-use tab new --label docs https://docs.example.com
chrome-use tab new --label app https://app.example.com
chrome-use tab docs # switch to docs
chrome-use snapshot # populate refs for docs
chrome-use click @e1 # ref click on docs
chrome-use tab app # switch to app
chrome-use tab close docs # close by label
```
Labels are never auto-generated, never rewritten on navigation, and must be
@@ -229,9 +229,9 @@ that was active when the snapshot ran.
## Frames
```bash
agent-browser frame "#iframe" # Switch to iframe by CSS selector
agent-browser frame @e3 # Switch to iframe by element ref
agent-browser frame main # Back to main frame
chrome-use frame "#iframe" # Switch to iframe by CSS selector
chrome-use frame @e3 # Switch to iframe by element ref
chrome-use frame main # Back to main frame
```
### Iframe support
@@ -239,19 +239,19 @@ agent-browser frame main # Back to main frame
Iframes are detected automatically during snapshots. When the main-frame snapshot runs, `Iframe` nodes are resolved and their content is inlined beneath the iframe element in the output (one level of nesting; iframes within iframes are not expanded).
```bash
agent-browser snapshot -i
chrome-use snapshot -i
# @e3 [Iframe] "payment-frame"
# @e4 [input] "Card number"
# @e5 [button] "Pay"
# Interact directly — refs inside iframes already work
agent-browser fill @e4 "4111111111111111"
agent-browser click @e5
chrome-use fill @e4 "4111111111111111"
chrome-use click @e5
# Or switch frame context for scoped snapshots
agent-browser frame @e3 # Switch using element ref
agent-browser snapshot -i # Snapshot scoped to that iframe
agent-browser frame main # Return to main frame
chrome-use frame @e3 # Switch using element ref
chrome-use snapshot -i # Snapshot scoped to that iframe
chrome-use frame main # Return to main frame
```
The `frame` command accepts:
@@ -264,27 +264,27 @@ The `frame` command accepts:
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` to disable this behavior.
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
agent-browser dialog status # Check if a dialog is currently open
chrome-use dialog accept [text] # Accept dialog
chrome-use dialog dismiss # Dismiss dialog
chrome-use dialog status # Check if a dialog is currently open
```
## JavaScript
```bash
agent-browser eval "document.title" # Simple expressions only
agent-browser eval -b "<base64>" # Any JavaScript (base64 encoded)
agent-browser eval --stdin # Read script from stdin
chrome-use eval "document.title" # Simple expressions only
chrome-use eval -b "<base64>" # Any JavaScript (base64 encoded)
chrome-use eval --stdin # Read script from stdin
```
Use `-b`/`--base64` or `--stdin` for reliable execution. Shell escaping with nested quotes and special characters is error-prone.
```bash
# Base64 encode your script, then:
agent-browser eval -b "ZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW3NyYyo9Il9uZXh0Il0nKQ=="
chrome-use eval -b "ZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW3NyYyo9Il9uZXh0Il0nKQ=="
# Or use stdin with heredoc for multiline scripts:
cat <<'EOF' | agent-browser eval --stdin
cat <<'EOF' | chrome-use eval --stdin
const links = document.querySelectorAll('a');
Array.from(links).map(a => a.href);
EOF
@@ -293,49 +293,49 @@ EOF
## State Management
```bash
agent-browser state save auth.json # Save cookies, storage, auth state
agent-browser state load auth.json # Restore saved state
chrome-use state save auth.json # Save cookies, storage, auth state
chrome-use state load auth.json # Restore saved state
```
## Global Options
```bash
agent-browser --session <name> ... # Isolated browser session
agent-browser --json ... # JSON output for parsing
agent-browser --headed ... # Default & always-on (stealth). Headless is FORBIDDEN
chrome-use --session <name> ... # Isolated browser session
chrome-use --json ... # JSON output for parsing
chrome-use --headed ... # Default & always-on (stealth). Headless is FORBIDDEN
# (bot tell); display-less servers: AGENT_BROWSER_ALLOW_HEADLESS=1
agent-browser --full ... # Full page screenshot (-f)
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
agent-browser -p <provider> ... # Cloud browser provider (--provider)
agent-browser --proxy <url> ... # Use proxy server
agent-browser --proxy-bypass <hosts> # Hosts to bypass proxy
agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
agent-browser --executable-path <p> # Custom browser executable
agent-browser --extension <path> ... # Load browser extension (repeatable)
agent-browser --ignore-https-errors # Ignore SSL certificate errors
agent-browser --hide-scrollbars false # Keep native scrollbars visible in headless Chromium screenshots
agent-browser --help # Show help (-h)
agent-browser --version # Show version (-V)
agent-browser <command> --help # Show detailed help for a command
chrome-use --full ... # Full page screenshot (-f)
chrome-use --cdp <port> ... # Connect via Chrome DevTools Protocol
chrome-use -p <provider> ... # Cloud browser provider (--provider)
chrome-use --proxy <url> ... # Use proxy server
chrome-use --proxy-bypass <hosts> # Hosts to bypass proxy
chrome-use --headers <json> ... # HTTP headers scoped to URL's origin
chrome-use --executable-path <p> # Custom browser executable
chrome-use --extension <path> ... # Load browser extension (repeatable)
chrome-use --ignore-https-errors # Ignore SSL certificate errors
chrome-use --hide-scrollbars false # Keep native scrollbars visible in headless Chromium screenshots
chrome-use --help # Show help (-h)
chrome-use --version # Show version (-V)
chrome-use <command> --help # Show detailed help for a command
```
## Drive your real, logged-in Chrome (extension — zero confirmation)
Chrome 136 blocked `--remote-debugging-port` on the default profile, so to drive
the user's *existing* logged-in window, agent-browser uses a Chrome **extension**
the user's *existing* logged-in window, chrome-use uses a Chrome **extension**
over native messaging — no port, no token, no per-use confirmation (the
codex/claude approach).
One-time setup:
```bash
agent-browser extension install # writes the native-messaging host manifest
chrome-use extension install # writes the native-messaging host manifest
```
The native-messaging host accepts **both** extension origins, so either install
works — but prefer the Store build:
1. **Chrome Web Store (recommended)** — one-click *Add to Chrome*:
<https://chromewebstore.google.com/detail/agent-browser-stealth/knfcmbamhjmaonkfnjhldjedeobeafmk>
<https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk>
Restart-stable and auto-updating (store id `knfcmbamhjmaonkfnjhldjedeobeafmk`).
2. **Load unpacked (dev)** — load `<repo>/extensions/ab-connect` from source;
its pinned `key` gives the stable id `ciiljdlhd…`. NOTE: Load-unpacked
@@ -347,12 +347,12 @@ CLI can't load an unpacked extension):
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked**
> select `<repo>/extensions/ab-connect` (it appears in the list as
> **agent-browser-stealth**)
> **chrome-use**)
Once loaded, the relay goes live and plain `agent-browser open <url>` connects
Once loaded, the relay goes live and plain `chrome-use open <url>` connects
through it automatically — `auto_connect_cdp` prefers the live extension relay
over a raw `--remote-debugging-port`, so Chrome 136+'s "Allow remote debugging?"
consent popup never appears. `agent-browser extension connect` is the explicit
consent popup never appears. `chrome-use extension connect` is the explicit
form of the same path.
**You can do this load step yourself with a computer-use / GUI-automation tool**
@@ -368,35 +368,35 @@ the folder (one click). After it loads, Chrome assigns the extension a fixed id
Then, any time (pure CLI, zero confirmation):
```bash
agent-browser extension connect # auto-attaches to the live, logged-in tabs
agent-browser tab # list the real tabs it now controls
agent-browser tab t3 # switch the session to one of them
agent-browser snapshot -i / eval / click ... # drive it like any session
agent-browser extension status # is the host installed?
agent-browser extension uninstall # remove the host manifest
chrome-use extension connect # auto-attaches to the live, logged-in tabs
chrome-use tab # list the real tabs it now controls
chrome-use tab t3 # switch the session to one of them
chrome-use snapshot -i / eval / click ... # drive it like any session
chrome-use extension status # is the host installed?
chrome-use extension uninstall # remove the host manifest
```
Security: the extension↔host link is authenticated by Chrome (extension id); the
host↔agent-browser CDP link uses an unguessable URL in a 0600 file. Use this when
host↔chrome-use CDP link uses an unguessable URL in a 0600 file. Use this when
you need the user's real cookies/login on their actual machine. (`--extension
<path>` is unrelated — that loads an extension into a *launched* browser.)
## Debugging
```bash
agent-browser --headed open example.com # Show browser window
agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser connect 9222 # Alternative: connect command
agent-browser console # View console messages (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
agent-browser console --clear # Clear console
agent-browser errors # View page errors (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser inspect # Open Chrome DevTools for this session
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop trace.json # Stop and save profile
chrome-use --headed open example.com # Show browser window
chrome-use --cdp 9222 snapshot # Connect via CDP port
chrome-use connect 9222 # Alternative: connect command
chrome-use console # View console messages (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
chrome-use console --clear # Clear console
chrome-use errors # View page errors (needs AGENT_BROWSER_CAPTURE_CONSOLE=1)
chrome-use errors --clear # Clear errors
chrome-use highlight @e1 # Highlight element
chrome-use inspect # Open Chrome DevTools for this session
chrome-use trace start # Start recording trace
chrome-use trace stop trace.zip # Stop and save trace
chrome-use profiler start # Start Chrome DevTools profiling
chrome-use profiler stop trace.json # Stop and save profile
```
### Finding a page the user saved (`find-url`)
@@ -406,10 +406,10 @@ systems or previously-saved pages that public search can't reach. Local read, no
browser/daemon needed.
```bash
agent-browser find-url jira board # all keywords must match (name or url)
agent-browser find-url --limit 10 invoices
agent-browser find-url --browser edge --profile "Profile 1" wiki
agent-browser find-url grafana --json # {results:[{name,url,folder}], count}
chrome-use find-url jira board # all keywords must match (name or url)
chrome-use find-url --limit 10 invoices
chrome-use find-url --browser edge --profile "Profile 1" wiki
chrome-use find-url grafana --json # {results:[{name,url,folder}], count}
```
Results are most-recently-added first. `javascript:`/`data:` bookmarklets are
@@ -425,13 +425,13 @@ problem (e.g. a hidden `point_choice=none` that the visible UI never exposes):
```bash
# Dump every field's name → value, including hidden inputs and unchecked radios
agent-browser eval "JSON.stringify([...document.forms[0].elements].map(e=>({name:e.name,type:e.type,value:e.value,checked:e.checked})).filter(e=>e.name))"
chrome-use eval "JSON.stringify([...document.forms[0].elements].map(e=>({name:e.name,type:e.type,value:e.value,checked:e.checked})).filter(e=>e.name))"
# Inspect one hidden field directly
agent-browser eval "document.querySelector('[name=point_choice]')?.value"
chrome-use eval "document.querySelector('[name=point_choice]')?.value"
# Why won't it submit? Ask the browser's own validity API
agent-browser eval "[...document.forms[0].elements].filter(e=>!e.validity?.valid).map(e=>e.name+': '+e.validationMessage)"
chrome-use eval "[...document.forms[0].elements].filter(e=>!e.validity?.valid).map(e=>e.name+': '+e.validationMessage)"
```
## React / Web Vitals
@@ -440,30 +440,30 @@ Requires `--enable react-devtools` at launch for the `react ...` commands.
`vitals` and `pushstate` are framework-agnostic.
```bash
agent-browser open --enable react-devtools <url> # Launch with React hook installed
agent-browser react tree # Full component tree
agent-browser react inspect <fiberId> # Props, hooks, state, source
agent-browser react renders start # Begin re-render recording
agent-browser react renders stop [--json] # Stop and print render profile
agent-browser react suspense [--only-dynamic] [--json] # Suspense boundaries + classifier
chrome-use open --enable react-devtools <url> # Launch with React hook installed
chrome-use react tree # Full component tree
chrome-use react inspect <fiberId> # Props, hooks, state, source
chrome-use react renders start # Begin re-render recording
chrome-use react renders stop [--json] # Stop and print render profile
chrome-use react suspense [--only-dynamic] [--json] # Suspense boundaries + classifier
# --only-dynamic hides the "static" list
agent-browser vitals [url] [--json] # LCP/CLS/TTFB/FCP/INP + hydration
agent-browser pushstate <url> # SPA client-side nav (auto-detects Next router)
chrome-use vitals [url] [--json] # LCP/CLS/TTFB/FCP/INP + hydration
chrome-use pushstate <url> # SPA client-side nav (auto-detects Next router)
```
## Init scripts
```bash
agent-browser open --init-script <path> # Register before first navigation (repeatable)
agent-browser addinitscript <js> # Register at runtime (returns identifier)
agent-browser removeinitscript <identifier> # Remove a previously registered init script
chrome-use open --init-script <path> # Register before first navigation (repeatable)
chrome-use addinitscript <js> # Register at runtime (returns identifier)
chrome-use removeinitscript <identifier> # Remove a previously registered init script
```
## cURL cookie import
```bash
agent-browser cookies set --curl <file> # Auto-detects JSON/cURL/Cookie-header
agent-browser cookies set --curl <file> --domain example.com # Scope to a domain
chrome-use cookies set --curl <file> # Auto-detects JSON/cURL/Cookie-header
chrome-use cookies set --curl <file> --domain example.com # Scope to a domain
```
Supported formats: JSON array of `{name, value}`, a cURL dump from
@@ -473,8 +473,8 @@ echo cookie values.
## Network route by resource type
```bash
agent-browser network route '*' --abort --resource-type script # Block scripts only (SSR-lock pattern)
agent-browser network route '*' --resource-type image,font --body '' # Stub images and fonts
chrome-use network route '*' --abort --resource-type script # Block scripts only (SSR-lock pattern)
chrome-use network route '*' --resource-type image,font --body '' # Stub images and fonts
```
## Environment Variables
@@ -488,7 +488,7 @@ AGENT_BROWSER_ENABLE="react-devtools" # Comma-separated built-in init scr
AGENT_BROWSER_HIDE_SCROLLBARS="false" # Keep native scrollbars visible in headless Chromium screenshots
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
AGENT_BROWSER_HOME="/path/to/chrome-use" # Custom install location
AGENT_BROWSER_CLICK_MODE="dom" # Click strategy: "" (default: scroll-in + coordinate
# click, DOM-dispatch fallback), "coord" (strict
# coordinate only), "dom" (always element.click())
+21 -21
View File
@@ -18,28 +18,28 @@ Capture Chrome DevTools performance profiles during browser automation for perfo
```bash
# Start profiling
agent-browser profiler start
chrome-use profiler start
# Perform actions
agent-browser navigate https://example.com
agent-browser click "#button"
agent-browser wait 1000
chrome-use navigate https://example.com
chrome-use click "#button"
chrome-use wait 1000
# Stop and save
agent-browser profiler stop ./trace.json
chrome-use profiler stop ./trace.json
```
## Profiler Commands
```bash
# Start profiling with default categories
agent-browser profiler start
chrome-use profiler start
# Start with custom trace categories
agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing"
chrome-use profiler start --categories "devtools.timeline,v8.execute,blink.user_timing"
# Stop profiling and save to file
agent-browser profiler stop ./trace.json
chrome-use profiler stop ./trace.json
```
## Categories
@@ -61,30 +61,30 @@ Several `disabled-by-default-*` categories are also included for detailed timeli
### Diagnosing Slow Page Loads
```bash
agent-browser profiler start
agent-browser navigate https://app.example.com
agent-browser wait --load networkidle
agent-browser profiler stop ./page-load-profile.json
chrome-use profiler start
chrome-use navigate https://app.example.com
chrome-use wait --load networkidle
chrome-use profiler stop ./page-load-profile.json
```
### Profiling User Interactions
```bash
agent-browser navigate https://app.example.com
agent-browser profiler start
agent-browser click "#submit"
agent-browser wait 2000
agent-browser profiler stop ./interaction-profile.json
chrome-use navigate https://app.example.com
chrome-use profiler start
chrome-use click "#submit"
chrome-use wait 2000
chrome-use profiler stop ./interaction-profile.json
```
### CI Performance Regression Checks
```bash
#!/bin/bash
agent-browser profiler start
agent-browser navigate https://app.example.com
agent-browser wait --load networkidle
agent-browser profiler stop "./profiles/build-${BUILD_ID}.json"
chrome-use profiler start
chrome-use navigate https://app.example.com
chrome-use wait --load networkidle
chrome-use profiler stop "./profiles/build-${BUILD_ID}.json"
```
## Output Format
+21 -21
View File
@@ -21,20 +21,20 @@ Use the `--proxy` flag or set proxy via environment variable:
```bash
# Via CLI flag
agent-browser --proxy "http://proxy.example.com:8080" open https://example.com
chrome-use --proxy "http://proxy.example.com:8080" open https://example.com
# Via environment variable
export HTTP_PROXY="http://proxy.example.com:8080"
agent-browser open https://example.com
chrome-use open https://example.com
# HTTPS proxy
export HTTPS_PROXY="https://proxy.example.com:8080"
agent-browser open https://example.com
chrome-use open https://example.com
# Both
export HTTP_PROXY="http://proxy.example.com:8080"
export HTTPS_PROXY="http://proxy.example.com:8080"
agent-browser open https://example.com
chrome-use open https://example.com
```
## Authenticated Proxy
@@ -44,7 +44,7 @@ For proxies requiring authentication:
```bash
# Include credentials in URL
export HTTP_PROXY="http://username:password@proxy.example.com:8080"
agent-browser open https://example.com
chrome-use open https://example.com
```
## SOCKS Proxy
@@ -52,11 +52,11 @@ agent-browser open https://example.com
```bash
# SOCKS5 proxy
export ALL_PROXY="socks5://proxy.example.com:1080"
agent-browser open https://example.com
chrome-use open https://example.com
# SOCKS5 with auth
export ALL_PROXY="socks5://user:pass@proxy.example.com:1080"
agent-browser open https://example.com
chrome-use open https://example.com
```
## Proxy Bypass
@@ -65,12 +65,12 @@ Skip proxy for specific domains using `--proxy-bypass` or `NO_PROXY`:
```bash
# Via CLI flag
agent-browser --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com
chrome-use --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com
# Via environment variable
export NO_PROXY="localhost,127.0.0.1,.internal.company.com"
agent-browser open https://internal.company.com # Direct connection
agent-browser open https://external.com # Via proxy
chrome-use open https://internal.company.com # Direct connection
chrome-use open https://external.com # Via proxy
```
## Common Use Cases
@@ -94,9 +94,9 @@ for proxy in "${PROXIES[@]}"; do
region=$(echo "$proxy" | grep -oP '^\w+-\w+')
echo "Testing from: $region"
agent-browser --session "$region" open https://example.com
agent-browser --session "$region" screenshot "./screenshots/$region.png"
agent-browser --session "$region" close
chrome-use --session "$region" open https://example.com
chrome-use --session "$region" screenshot "./screenshots/$region.png"
chrome-use --session "$region" close
done
```
@@ -123,9 +123,9 @@ for i in "${!URLS[@]}"; do
export HTTP_PROXY="${PROXY_LIST[$proxy_index]}"
export HTTPS_PROXY="${PROXY_LIST[$proxy_index]}"
agent-browser open "${URLS[$i]}"
agent-browser get text body > "output-$i.txt"
agent-browser close
chrome-use open "${URLS[$i]}"
chrome-use get text body > "output-$i.txt"
chrome-use close
sleep 1 # Polite delay
done
@@ -142,18 +142,18 @@ export HTTPS_PROXY="http://corpproxy.company.com:8080"
export NO_PROXY="localhost,127.0.0.1,.company.com"
# External sites go through proxy
agent-browser open https://external-vendor.com
chrome-use open https://external-vendor.com
# Internal sites bypass proxy
agent-browser open https://intranet.company.com
chrome-use open https://intranet.company.com
```
## Verifying Proxy Connection
```bash
# Check your apparent IP
agent-browser open https://httpbin.org/ip
agent-browser get text body
chrome-use open https://httpbin.org/ip
chrome-use get text body
# Should show proxy's IP, not your real IP
```
@@ -175,7 +175,7 @@ Some proxies perform SSL inspection. If you encounter certificate errors:
```bash
# For testing only - not recommended for production
agent-browser open https://example.com --ignore-https-errors
chrome-use open https://example.com --ignore-https-errors
```
### Slow Performance
@@ -20,14 +20,14 @@ Use `--session` flag to isolate browser contexts:
```bash
# Session 1: Authentication flow
agent-browser --session auth open https://app.example.com/login
chrome-use --session auth open https://app.example.com/login
# Session 2: Public browsing (separate cookies, storage)
agent-browser --session public open https://example.com
chrome-use --session public open https://example.com
# Commands are isolated by session
agent-browser --session auth fill @e1 "user@example.com"
agent-browser --session public get text body
chrome-use --session auth fill @e1 "user@example.com"
chrome-use --session public get text body
```
## Session Isolation Properties
@@ -46,17 +46,17 @@ Each session has independent:
```bash
# Save cookies, storage, and auth state
agent-browser state save /path/to/auth-state.json
chrome-use state save /path/to/auth-state.json
```
### Load Session State
```bash
# Restore saved state
agent-browser state load /path/to/auth-state.json
chrome-use state load /path/to/auth-state.json
# Continue with authenticated session
agent-browser open https://app.example.com/dashboard
chrome-use open https://app.example.com/dashboard
```
### State File Contents
@@ -82,19 +82,19 @@ STATE_FILE="/tmp/auth-state.json"
# Check if we have saved state
if [[ -f "$STATE_FILE" ]]; then
agent-browser state load "$STATE_FILE"
agent-browser open https://app.example.com/dashboard
chrome-use state load "$STATE_FILE"
chrome-use open https://app.example.com/dashboard
else
# Perform login
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --load networkidle
chrome-use open https://app.example.com/login
chrome-use snapshot -i
chrome-use fill @e1 "$USERNAME"
chrome-use fill @e2 "$PASSWORD"
chrome-use click @e3
chrome-use wait --load networkidle
# Save for future use
agent-browser state save "$STATE_FILE"
chrome-use state save "$STATE_FILE"
fi
```
@@ -105,32 +105,32 @@ fi
# Scrape multiple sites concurrently
# Start all sessions
agent-browser --session site1 open https://site1.com &
agent-browser --session site2 open https://site2.com &
agent-browser --session site3 open https://site3.com &
chrome-use --session site1 open https://site1.com &
chrome-use --session site2 open https://site2.com &
chrome-use --session site3 open https://site3.com &
wait
# Extract from each
agent-browser --session site1 get text body > site1.txt
agent-browser --session site2 get text body > site2.txt
agent-browser --session site3 get text body > site3.txt
chrome-use --session site1 get text body > site1.txt
chrome-use --session site2 get text body > site2.txt
chrome-use --session site3 get text body > site3.txt
# Cleanup
agent-browser --session site1 close
agent-browser --session site2 close
agent-browser --session site3 close
chrome-use --session site1 close
chrome-use --session site2 close
chrome-use --session site3 close
```
### A/B Testing Sessions
```bash
# Test different user experiences
agent-browser --session variant-a open "https://app.com?variant=a"
agent-browser --session variant-b open "https://app.com?variant=b"
chrome-use --session variant-a open "https://app.com?variant=a"
chrome-use --session variant-b open "https://app.com?variant=b"
# Compare
agent-browser --session variant-a screenshot /tmp/variant-a.png
agent-browser --session variant-b screenshot /tmp/variant-b.png
chrome-use --session variant-a screenshot /tmp/variant-a.png
chrome-use --session variant-b screenshot /tmp/variant-b.png
```
## Default Session
@@ -139,19 +139,19 @@ When `--session` is omitted, commands use the default session:
```bash
# These use the same default session
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser close # Closes default session
chrome-use open https://example.com
chrome-use snapshot -i
chrome-use close # Closes default session
```
## Session Cleanup
```bash
# Close specific session
agent-browser --session auth close
chrome-use --session auth close
# List active sessions
agent-browser session list
chrome-use session list
```
## Best Practices
@@ -160,19 +160,19 @@ agent-browser session list
```bash
# GOOD: Clear purpose
agent-browser --session github-auth open https://github.com
agent-browser --session docs-scrape open https://docs.example.com
chrome-use --session github-auth open https://github.com
chrome-use --session docs-scrape open https://docs.example.com
# AVOID: Generic names
agent-browser --session s1 open https://github.com
chrome-use --session s1 open https://github.com
```
### 2. Always Clean Up
```bash
# Close sessions when done
agent-browser --session auth close
agent-browser --session scrape close
chrome-use --session auth close
chrome-use --session scrape close
```
### 3. Handle State Files Securely
@@ -189,5 +189,5 @@ rm /tmp/auth-state.json
```bash
# Set timeout for automated scripts
timeout 60 agent-browser --session long-task get text body
timeout 60 chrome-use --session long-task get text body
```
+33 -33
View File
@@ -21,7 +21,7 @@ Traditional approach:
Full DOM/HTML → AI parses → CSS selector → Action (~3000-5000 tokens)
```
agent-browser approach:
chrome-use approach:
```
Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens)
```
@@ -30,10 +30,10 @@ Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens)
```bash
# Basic snapshot (shows page structure)
agent-browser snapshot
chrome-use snapshot
# Interactive snapshot (-i flag) - RECOMMENDED
agent-browser snapshot -i
chrome-use snapshot -i
```
### Snapshot Output Format
@@ -66,16 +66,16 @@ Once you have refs, interact directly:
```bash
# Click the "Sign In" button
agent-browser click @e6
chrome-use click @e6
# Fill email input
agent-browser fill @e10 "user@example.com"
chrome-use fill @e10 "user@example.com"
# Fill password
agent-browser fill @e11 "password123"
chrome-use fill @e11 "password123"
# Submit the form
agent-browser click @e12
chrome-use click @e12
```
## Ref Lifecycle
@@ -84,14 +84,14 @@ agent-browser click @e12
```bash
# Get initial snapshot
agent-browser snapshot -i
chrome-use snapshot -i
# @e1 [button] "Next"
# Click triggers page change
agent-browser click @e1
chrome-use click @e1
# MUST re-snapshot to get new refs!
agent-browser snapshot -i
chrome-use snapshot -i
# @e1 [h1] "Page 2" ← Different element now!
```
@@ -101,29 +101,29 @@ agent-browser snapshot -i
```bash
# CORRECT
agent-browser open https://example.com
agent-browser snapshot -i # Get refs first
agent-browser click @e1 # Use ref
chrome-use open https://example.com
chrome-use snapshot -i # Get refs first
chrome-use click @e1 # Use ref
# WRONG
agent-browser open https://example.com
agent-browser click @e1 # Ref doesn't exist yet!
chrome-use open https://example.com
chrome-use click @e1 # Ref doesn't exist yet!
```
### 2. Re-Snapshot After Navigation
```bash
agent-browser click @e5 # Navigates to new page
agent-browser snapshot -i # Get new refs
agent-browser click @e1 # Use new refs
chrome-use click @e5 # Navigates to new page
chrome-use snapshot -i # Get new refs
chrome-use click @e1 # Use new refs
```
### 3. Re-Snapshot After Dynamic Changes
```bash
agent-browser click @e1 # Opens dropdown
agent-browser snapshot -i # See dropdown items
agent-browser click @e7 # Select item
chrome-use click @e1 # Opens dropdown
chrome-use snapshot -i # See dropdown items
chrome-use click @e7 # Select item
```
### 4. Snapshot Specific Regions
@@ -132,7 +132,7 @@ For complex pages, snapshot specific areas:
```bash
# Snapshot just the form
agent-browser snapshot @e9
chrome-use snapshot @e9
```
## Ref Notation Details
@@ -167,7 +167,7 @@ agent-browser snapshot @e9
Snapshots automatically detect and inline iframe content. When the main-frame snapshot runs, each `Iframe` node is resolved and its child accessibility tree is included directly beneath it in the output. Refs assigned to elements inside iframes carry frame context, so interactions like `click`, `fill`, and `type` work without manually switching frames.
```bash
agent-browser snapshot -i
chrome-use snapshot -i
# @e1 [heading] "Checkout"
# @e2 [Iframe] "payment-frame"
# @e3 [input] "Card number"
@@ -176,9 +176,9 @@ agent-browser snapshot -i
# @e6 [button] "Cancel"
# Interact with iframe elements directly using their refs
agent-browser fill @e3 "4111111111111111"
agent-browser fill @e4 "12/28"
agent-browser click @e5
chrome-use fill @e3 "4111111111111111"
chrome-use fill @e4 "12/28"
chrome-use click @e5
```
**Key details:**
@@ -193,27 +193,27 @@ agent-browser click @e5
```bash
# Ref may have changed - re-snapshot
agent-browser snapshot -i
chrome-use snapshot -i
```
### Element Not Visible in Snapshot
```bash
# Scroll down to reveal element
agent-browser scroll down 1000
agent-browser snapshot -i
chrome-use scroll down 1000
chrome-use snapshot -i
# Or wait for dynamic content
agent-browser wait 1000
agent-browser snapshot -i
chrome-use wait 1000
chrome-use snapshot -i
```
### Too Many Elements
```bash
# Snapshot specific container
agent-browser snapshot @e5
chrome-use snapshot @e5
# Or use get text for content-only extraction
agent-browser get text @e5
chrome-use get text @e5
```
@@ -1,6 +1,6 @@
# Trust boundaries
Safety rules that apply to every agent-browser task, across all sites and
Safety rules that apply to every chrome-use task, across all sites and
frameworks. Read before driving a real user's browser session.
**Related**: [SKILL.md](../SKILL.md), [authentication.md](authentication.md).
+42 -42
View File
@@ -17,29 +17,29 @@ Capture browser automation as video for debugging, documentation, or verificatio
```bash
# Start recording
agent-browser record start ./demo.webm
chrome-use record start ./demo.webm
# Perform actions
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser click @e1
agent-browser fill @e2 "test input"
chrome-use open https://example.com
chrome-use snapshot -i
chrome-use click @e1
chrome-use fill @e2 "test input"
# Stop and save
agent-browser record stop
chrome-use record stop
```
## Recording Commands
```bash
# Start recording to file
agent-browser record start ./output.webm
chrome-use record start ./output.webm
# Stop current recording
agent-browser record stop
chrome-use record stop
# Restart with new file (stops current + starts new)
agent-browser record restart ./take2.webm
chrome-use record restart ./take2.webm
```
## Use Cases
@@ -50,18 +50,18 @@ agent-browser record restart ./take2.webm
#!/bin/bash
# Record automation for debugging
agent-browser record start ./debug-$(date +%Y%m%d-%H%M%S).webm
chrome-use record start ./debug-$(date +%Y%m%d-%H%M%S).webm
# Run your automation
agent-browser open https://app.example.com
agent-browser snapshot -i
agent-browser click @e1 || {
chrome-use open https://app.example.com
chrome-use snapshot -i
chrome-use click @e1 || {
echo "Click failed - check recording"
agent-browser record stop
chrome-use record stop
exit 1
}
agent-browser record stop
chrome-use record stop
```
### Documentation Generation
@@ -70,23 +70,23 @@ agent-browser record stop
#!/bin/bash
# Record workflow for documentation
agent-browser record start ./docs/how-to-login.webm
chrome-use record start ./docs/how-to-login.webm
agent-browser open https://app.example.com/login
agent-browser wait 1000 # Pause for visibility
chrome-use open https://app.example.com/login
chrome-use wait 1000 # Pause for visibility
agent-browser snapshot -i
agent-browser fill @e1 "demo@example.com"
agent-browser wait 500
chrome-use snapshot -i
chrome-use fill @e1 "demo@example.com"
chrome-use wait 500
agent-browser fill @e2 "password"
agent-browser wait 500
chrome-use fill @e2 "password"
chrome-use wait 500
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser wait 1000 # Show result
chrome-use click @e3
chrome-use wait --load networkidle
chrome-use wait 1000 # Show result
agent-browser record stop
chrome-use record stop
```
### CI/CD Test Evidence
@@ -99,7 +99,7 @@ TEST_NAME="${1:-e2e-test}"
RECORDING_DIR="./test-recordings"
mkdir -p "$RECORDING_DIR"
agent-browser record start "$RECORDING_DIR/$TEST_NAME-$(date +%s).webm"
chrome-use record start "$RECORDING_DIR/$TEST_NAME-$(date +%s).webm"
# Run test
if run_e2e_test; then
@@ -108,7 +108,7 @@ else
echo "Test failed - recording saved"
fi
agent-browser record stop
chrome-use record stop
```
## Best Practices
@@ -117,16 +117,16 @@ agent-browser record stop
```bash
# Slow down for human viewing
agent-browser click @e1
agent-browser wait 500 # Let viewer see result
chrome-use click @e1
chrome-use wait 500 # Let viewer see result
```
### 2. Use Descriptive Filenames
```bash
# Include context in filename
agent-browser record start ./recordings/login-flow-2024-01-15.webm
agent-browser record start ./recordings/checkout-test-run-42.webm
chrome-use record start ./recordings/login-flow-2024-01-15.webm
chrome-use record start ./recordings/checkout-test-run-42.webm
```
### 3. Handle Recording in Error Cases
@@ -136,12 +136,12 @@ agent-browser record start ./recordings/checkout-test-run-42.webm
set -e
cleanup() {
agent-browser record stop 2>/dev/null || true
agent-browser close 2>/dev/null || true
chrome-use record stop 2>/dev/null || true
chrome-use close 2>/dev/null || true
}
trap cleanup EXIT
agent-browser record start ./automation.webm
chrome-use record start ./automation.webm
# ... automation steps ...
```
@@ -149,15 +149,15 @@ agent-browser record start ./automation.webm
```bash
# Record video AND capture key frames
agent-browser record start ./flow.webm
chrome-use record start ./flow.webm
agent-browser open https://example.com
agent-browser screenshot ./screenshots/step1-homepage.png
chrome-use open https://example.com
chrome-use screenshot ./screenshots/step1-homepage.png
agent-browser click @e1
agent-browser screenshot ./screenshots/step2-after-click.png
chrome-use click @e1
chrome-use screenshot ./screenshots/step2-after-click.png
agent-browser record stop
chrome-use record stop
```
## Output Format
@@ -4,8 +4,8 @@
# Usage: ./authenticated-session.sh <login-url> [state-file]
#
# RECOMMENDED: Use the auth vault instead of this template:
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --password-stdin
# agent-browser auth login myapp
# echo "<pass>" | chrome-use auth save myapp --url <login-url> --username <user> --password-stdin
# chrome-use auth login myapp
# The auth vault stores credentials securely and the LLM never sees passwords.
#
# Environment variables:
@@ -34,17 +34,17 @@ echo "Authentication workflow: $LOGIN_URL"
# ================================================================
if [[ -f "$STATE_FILE" ]]; then
echo "Loading saved state from $STATE_FILE..."
if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then
agent-browser wait --load networkidle
if chrome-use --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then
chrome-use wait --load networkidle
CURRENT_URL=$(agent-browser get url)
CURRENT_URL=$(chrome-use get url)
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
echo "Session restored successfully"
agent-browser snapshot -i
chrome-use snapshot -i
exit 0
fi
echo "Session expired, performing fresh login..."
agent-browser close 2>/dev/null || true
chrome-use close 2>/dev/null || true
else
echo "Failed to load state, re-authenticating..."
fi
@@ -55,13 +55,13 @@ fi
# DISCOVERY MODE: Shows form structure (delete after setup)
# ================================================================
echo "Opening login page..."
agent-browser open "$LOGIN_URL"
agent-browser wait --load networkidle
chrome-use open "$LOGIN_URL"
chrome-use wait --load networkidle
echo ""
echo "Login form structure:"
echo "---"
agent-browser snapshot -i
chrome-use snapshot -i
echo "---"
echo ""
echo "Next steps:"
@@ -70,7 +70,7 @@ echo " 2. Update the LOGIN FLOW section below with your refs"
echo " 3. Set: export APP_USERNAME='...' APP_PASSWORD='...'"
echo " 4. Delete this DISCOVERY MODE section"
echo ""
agent-browser close
chrome-use close
exit 0
# ================================================================
@@ -79,27 +79,27 @@ exit 0
# : "${APP_USERNAME:?Set APP_USERNAME environment variable}"
# : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}"
#
# agent-browser open "$LOGIN_URL"
# agent-browser wait --load networkidle
# agent-browser snapshot -i
# chrome-use open "$LOGIN_URL"
# chrome-use wait --load networkidle
# chrome-use snapshot -i
#
# # Fill credentials (update refs to match your form)
# agent-browser fill @e1 "$APP_USERNAME"
# agent-browser fill @e2 "$APP_PASSWORD"
# agent-browser click @e3
# agent-browser wait --load networkidle
# chrome-use fill @e1 "$APP_USERNAME"
# chrome-use fill @e2 "$APP_PASSWORD"
# chrome-use click @e3
# chrome-use wait --load networkidle
#
# # Verify login succeeded
# FINAL_URL=$(agent-browser get url)
# FINAL_URL=$(chrome-use get url)
# if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then
# echo "Login failed - still on login page"
# agent-browser screenshot /tmp/login-failed.png
# agent-browser close
# chrome-use screenshot /tmp/login-failed.png
# chrome-use close
# exit 1
# fi
#
# # Save state for future runs
# echo "Saving state to $STATE_FILE"
# agent-browser state save "$STATE_FILE"
# chrome-use state save "$STATE_FILE"
# echo "Login successful"
# agent-browser snapshot -i
# chrome-use snapshot -i
+14 -14
View File
@@ -22,47 +22,47 @@ mkdir -p "$OUTPUT_DIR"
# Optional: Load authentication state
# if [[ -f "./auth-state.json" ]]; then
# echo "Loading authentication state..."
# agent-browser state load "./auth-state.json"
# chrome-use state load "./auth-state.json"
# fi
# Navigate to target
agent-browser open "$TARGET_URL"
agent-browser wait --load networkidle
chrome-use open "$TARGET_URL"
chrome-use wait --load networkidle
# Get metadata
TITLE=$(agent-browser get title)
URL=$(agent-browser get url)
TITLE=$(chrome-use get title)
URL=$(chrome-use get url)
echo "Title: $TITLE"
echo "URL: $URL"
# Capture full page screenshot
agent-browser screenshot --full "$OUTPUT_DIR/page-full.png"
chrome-use screenshot --full "$OUTPUT_DIR/page-full.png"
echo "Saved: $OUTPUT_DIR/page-full.png"
# Get page structure with refs
agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt"
chrome-use snapshot -i > "$OUTPUT_DIR/page-structure.txt"
echo "Saved: $OUTPUT_DIR/page-structure.txt"
# Extract all text content
agent-browser get text body > "$OUTPUT_DIR/page-text.txt"
chrome-use get text body > "$OUTPUT_DIR/page-text.txt"
echo "Saved: $OUTPUT_DIR/page-text.txt"
# Save as PDF
agent-browser pdf "$OUTPUT_DIR/page.pdf"
chrome-use pdf "$OUTPUT_DIR/page.pdf"
echo "Saved: $OUTPUT_DIR/page.pdf"
# Optional: Extract specific elements using refs from structure
# agent-browser get text @e5 > "$OUTPUT_DIR/main-content.txt"
# chrome-use get text @e5 > "$OUTPUT_DIR/main-content.txt"
# Optional: Handle infinite scroll pages
# for i in {1..5}; do
# agent-browser scroll down 1000
# agent-browser wait 1000
# chrome-use scroll down 1000
# chrome-use wait 1000
# done
# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png"
# chrome-use screenshot --full "$OUTPUT_DIR/page-scrolled.png"
# Cleanup
agent-browser close
chrome-use close
echo ""
echo "Capture complete:"
+20 -20
View File
@@ -18,45 +18,45 @@ FORM_URL="${1:?Usage: $0 <form-url>}"
echo "Form automation: $FORM_URL"
# Step 1: Navigate to form
agent-browser open "$FORM_URL"
agent-browser wait --load networkidle
chrome-use open "$FORM_URL"
chrome-use wait --load networkidle
# Step 2: Snapshot to discover form elements
echo ""
echo "Form structure:"
agent-browser snapshot -i
chrome-use snapshot -i
# Step 3: Fill form fields (customize these refs based on snapshot output)
#
# Common field types:
# agent-browser fill @e1 "John Doe" # Text input
# agent-browser fill @e2 "user@example.com" # Email input
# agent-browser fill @e3 "SecureP@ss123" # Password input
# agent-browser select @e4 "Option Value" # Dropdown
# agent-browser check @e5 # Checkbox
# agent-browser click @e6 # Radio button
# agent-browser fill @e7 "Multi-line text" # Textarea
# agent-browser upload @e8 /path/to/file.pdf # File upload
# chrome-use fill @e1 "John Doe" # Text input
# chrome-use fill @e2 "user@example.com" # Email input
# chrome-use fill @e3 "SecureP@ss123" # Password input
# chrome-use select @e4 "Option Value" # Dropdown
# chrome-use check @e5 # Checkbox
# chrome-use click @e6 # Radio button
# chrome-use fill @e7 "Multi-line text" # Textarea
# chrome-use upload @e8 /path/to/file.pdf # File upload
#
# Uncomment and modify:
# agent-browser fill @e1 "Test User"
# agent-browser fill @e2 "test@example.com"
# agent-browser click @e3 # Submit button
# chrome-use fill @e1 "Test User"
# chrome-use fill @e2 "test@example.com"
# chrome-use click @e3 # Submit button
# Step 4: Wait for submission
# agent-browser wait --load networkidle
# agent-browser wait --url "**/success" # Or wait for redirect
# chrome-use wait --load networkidle
# chrome-use wait --url "**/success" # Or wait for redirect
# Step 5: Verify result
echo ""
echo "Result:"
agent-browser get url
agent-browser snapshot -i
chrome-use get url
chrome-use snapshot -i
# Optional: Capture evidence
agent-browser screenshot /tmp/form-result.png
chrome-use screenshot /tmp/form-result.png
echo "Screenshot saved: /tmp/form-result.png"
# Cleanup
agent-browser close
chrome-use close
echo "Done"
+25 -25
View File
@@ -1,7 +1,7 @@
---
name: dogfood
description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams.
allowed-tools: Bash(agent-browser:*), Bash(agent-browser-stealth:*), Bash(abs:*), Bash(npx agent-browser:*), Bash(npx agent-browser-stealth:*)
allowed-tools: Bash(chrome-use:*), Bash(chrome-use:*), Bash(abs:*), Bash(npx chrome-use:*), Bash(npx chrome-use:*)
---
# Dogfood
@@ -22,7 +22,7 @@ Only the **Target URL** is required. Everything else has sensible defaults -- us
If the user says something like "dogfood vercel.com", start immediately with defaults. Do not ask clarifying questions unless authentication is mentioned but credentials are missing.
Always use `agent-browser` directly -- never `npx agent-browser`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower.
Always use `chrome-use` directly -- never `npx chrome-use`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower.
## Workflow
@@ -50,8 +50,8 @@ cp {SKILL_DIR}/templates/dogfood-report-template.md {OUTPUT_DIR}/report.md
Start a named session:
```bash
agent-browser --session {SESSION} open {TARGET_URL}
agent-browser --session {SESSION} wait --load networkidle
chrome-use --session {SESSION} open {TARGET_URL}
chrome-use --session {SESSION} wait --load networkidle
```
### 2. Authenticate
@@ -59,12 +59,12 @@ agent-browser --session {SESSION} wait --load networkidle
If the app requires login:
```bash
agent-browser --session {SESSION} snapshot -i
chrome-use --session {SESSION} snapshot -i
# Identify login form refs, fill credentials
agent-browser --session {SESSION} fill @e1 "{EMAIL}"
agent-browser --session {SESSION} fill @e2 "{PASSWORD}"
agent-browser --session {SESSION} click @e3
agent-browser --session {SESSION} wait --load networkidle
chrome-use --session {SESSION} fill @e1 "{EMAIL}"
chrome-use --session {SESSION} fill @e2 "{PASSWORD}"
chrome-use --session {SESSION} click @e3
chrome-use --session {SESSION} wait --load networkidle
```
For OTP/email codes: ask the user, wait for their response, then enter the code.
@@ -72,7 +72,7 @@ For OTP/email codes: ask the user, wait for their response, then enter the code.
After successful login, save state for potential reuse:
```bash
agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json
chrome-use --session {SESSION} state save {OUTPUT_DIR}/auth-state.json
```
### 3. Orient
@@ -80,8 +80,8 @@ agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json
Take an initial annotated screenshot and snapshot to understand the app structure:
```bash
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png
agent-browser --session {SESSION} snapshot -i
chrome-use --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png
chrome-use --session {SESSION} snapshot -i
```
Identify the main navigation elements and map out the sections to visit.
@@ -96,15 +96,15 @@ Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full l
- Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals.
- Check edge cases: empty states, error handling, boundary inputs.
- Try realistic end-to-end workflows (create, edit, delete flows).
- Check the browser console for errors periodically. **Console/error capture is off by default in this stealth fork** — start the dogfood session with `AGENT_BROWSER_CAPTURE_CONSOLE=1` (e.g. `AGENT_BROWSER_CAPTURE_CONSOLE=1 agent-browser --session {SESSION} open <url>`) or `console`/`errors` will return empty.
- Check the browser console for errors periodically. **Console/error capture is off by default in this stealth fork** — start the dogfood session with `AGENT_BROWSER_CAPTURE_CONSOLE=1` (e.g. `AGENT_BROWSER_CAPTURE_CONSOLE=1 chrome-use --session {SESSION} open <url>`) or `console`/`errors` will return empty.
**At each page:**
```bash
agent-browser --session {SESSION} snapshot -i
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png
agent-browser --session {SESSION} errors
agent-browser --session {SESSION} console
chrome-use --session {SESSION} snapshot -i
chrome-use --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png
chrome-use --session {SESSION} errors
chrome-use --session {SESSION} console
```
Use your judgment on how deep to go. Spend more time on core features and less on peripheral pages. If you find a cluster of issues in one area, investigate deeper.
@@ -124,17 +124,17 @@ These require user interaction to reproduce -- use full repro with video and ste
1. **Start a repro video** _before_ reproducing:
```bash
agent-browser --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm
chrome-use --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm
```
2. **Walk through the steps at human pace.** Pause 1-2 seconds between actions so the video is watchable. Take a screenshot at each step:
```bash
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png
chrome-use --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png
sleep 1
# Perform action (click, fill, etc.)
sleep 1
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png
chrome-use --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png
sleep 1
# ...continue until the issue manifests
```
@@ -143,13 +143,13 @@ sleep 1
```bash
sleep 2
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png
chrome-use --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png
```
4. **Stop the video:**
```bash
agent-browser --session {SESSION} record stop
chrome-use --session {SESSION} record stop
```
5. Write numbered repro steps in the report, each referencing its screenshot.
@@ -159,7 +159,7 @@ agent-browser --session {SESSION} record stop
These are visible without interaction -- a single annotated screenshot is sufficient. No video, no multi-step repro:
```bash
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png
chrome-use --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png
```
Write a brief description and reference the screenshot in the report. Set **Repro Video** to `N/A`.
@@ -182,7 +182,7 @@ After exploring:
2. Close the session:
```bash
agent-browser --session {SESSION} close
chrome-use --session {SESSION} close
```
3. Tell the user the report is ready and summarize findings: total issues, breakdown by severity, and the most critical items.
@@ -205,7 +205,7 @@ agent-browser --session {SESSION} close
- **Test like a user, not a robot.** Try common workflows end-to-end. Click things a real user would click. Enter realistic data.
- **Type like a human.** When filling form fields during video recording, use `type` instead of `fill` -- it types character-by-character. Use `fill` only outside of video recording when speed matters.
- **Pace repro videos for humans.** Add `sleep 1` between actions and `sleep 2` before the final result screenshot. Videos should be watchable at 1x speed -- a human reviewing the report needs to see what happened, not a blur of instant state changes.
- **Be efficient with commands.** Batch multiple `agent-browser` commands in a single shell call when they are independent (e.g., `agent-browser ... screenshot ... && agent-browser ... console`). Use `agent-browser --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll.
- **Be efficient with commands.** Batch multiple `chrome-use` commands in a single shell call when they are independent (e.g., `chrome-use ... screenshot ... && chrome-use ... console`). Use `chrome-use --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll.
## References
+50 -50
View File
@@ -1,17 +1,17 @@
---
name: electron
description: Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include "automate Slack app", "control VS Code", "interact with Discord app", "test this Electron app", "connect to desktop app", or any task requiring automation of a native Electron application.
allowed-tools: Bash(agent-browser:*), Bash(agent-browser-stealth:*), Bash(abs:*), Bash(npx agent-browser:*), Bash(npx agent-browser-stealth:*)
description: Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using chrome-use via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include "automate Slack app", "control VS Code", "interact with Discord app", "test this Electron app", "connect to desktop app", or any task requiring automation of a native Electron application.
allowed-tools: Bash(chrome-use:*), Bash(chrome-use:*), Bash(abs:*), Bash(npx chrome-use:*), Bash(npx chrome-use:*)
---
# Electron App Automation
Automate any Electron desktop app using agent-browser. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.
Automate any Electron desktop app using chrome-use. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that chrome-use can connect to, enabling the same snapshot-interact workflow used for web pages.
## Core Workflow
1. **Launch** the Electron app with remote debugging enabled
2. **Connect** agent-browser to the CDP port
2. **Connect** chrome-use to the CDP port
3. **Snapshot** to discover interactive elements
4. **Interact** using element refs
5. **Re-snapshot** after navigation or state changes
@@ -20,13 +20,13 @@ Automate any Electron desktop app using agent-browser. Electron apps are built o
# Launch an Electron app with remote debugging
open -a "Slack" --args --remote-debugging-port=9222
# Connect agent-browser to the app
agent-browser connect 9222
# Connect chrome-use to the app
chrome-use connect 9222
# Standard workflow from here
agent-browser snapshot -i
agent-browser click @e5
agent-browser screenshot slack-desktop.png
chrome-use snapshot -i
chrome-use click @e5
chrome-use screenshot slack-desktop.png
```
## Launching Electron Apps with CDP
@@ -76,13 +76,13 @@ discord --remote-debugging-port=9224
```bash
# Connect to a specific port
agent-browser connect 9222
chrome-use connect 9222
# Or use --cdp on each command
agent-browser --cdp 9222 snapshot -i
chrome-use --cdp 9222 snapshot -i
# Auto-discover a running Chromium-based app
agent-browser --auto-connect snapshot -i
chrome-use --auto-connect snapshot -i
```
After `connect`, all subsequent commands target the connected app without needing `--cdp`.
@@ -93,13 +93,13 @@ Electron apps often have multiple windows or webviews. Use tab commands to list
```bash
# List all available targets (windows, webviews, etc.)
agent-browser tab
chrome-use tab
# Switch to a specific tab by id (t1, t2, …; integers not accepted)
agent-browser tab t2
chrome-use tab t2
# Switch by URL pattern
agent-browser tab --url "*settings*"
chrome-use tab --url "*settings*"
```
## Webview Support
@@ -108,21 +108,21 @@ Electron `<webview>` elements are automatically discovered and can be controlled
```bash
# Connect to running Electron app
agent-browser connect 9222
chrome-use connect 9222
# List targets -- webviews appear alongside pages
agent-browser tab
chrome-use tab
# Example output:
# 0: [page] Slack - Main Window https://app.slack.com/
# 1: [webview] Embedded Content https://example.com/widget
# Switch to a webview
agent-browser tab t1
chrome-use tab t1
# Interact with the webview normally
agent-browser snapshot -i
agent-browser click @e3
agent-browser screenshot webview.png
chrome-use snapshot -i
chrome-use click @e3
chrome-use screenshot webview.png
```
**Note:** Webview support works via raw CDP connection.
@@ -134,40 +134,40 @@ agent-browser screenshot webview.png
```bash
open -a "Slack" --args --remote-debugging-port=9222
sleep 3 # Wait for app to start
agent-browser connect 9222
agent-browser snapshot -i
chrome-use connect 9222
chrome-use snapshot -i
# Read the snapshot output to identify UI elements
agent-browser click @e10 # Navigate to a section
agent-browser snapshot -i # Re-snapshot after navigation
chrome-use click @e10 # Navigate to a section
chrome-use snapshot -i # Re-snapshot after navigation
```
### Take Screenshots of Desktop Apps
```bash
agent-browser connect 9222
agent-browser screenshot app-state.png
agent-browser screenshot --full full-app.png
agent-browser screenshot --annotate annotated-app.png
chrome-use connect 9222
chrome-use screenshot app-state.png
chrome-use screenshot --full full-app.png
chrome-use screenshot --annotate annotated-app.png
```
### Extract Data from a Desktop App
```bash
agent-browser connect 9222
agent-browser snapshot -i
agent-browser get text @e5
agent-browser snapshot --json > app-state.json
chrome-use connect 9222
chrome-use snapshot -i
chrome-use get text @e5
chrome-use snapshot --json > app-state.json
```
### Fill Forms in Desktop Apps
```bash
agent-browser connect 9222
agent-browser snapshot -i
agent-browser fill @e3 "search query"
agent-browser press Enter
agent-browser wait 1000
agent-browser snapshot -i
chrome-use connect 9222
chrome-use snapshot -i
chrome-use fill @e3 "search query"
chrome-use press Enter
chrome-use wait 1000
chrome-use snapshot -i
```
### Run Multiple Apps Simultaneously
@@ -176,14 +176,14 @@ Use named sessions to control multiple Electron apps at the same time:
```bash
# Connect to Slack
agent-browser --session slack connect 9222
chrome-use --session slack connect 9222
# Connect to VS Code
agent-browser --session vscode connect 9223
chrome-use --session vscode connect 9223
# Interact with each independently
agent-browser --session slack snapshot -i
agent-browser --session vscode snapshot -i
chrome-use --session slack snapshot -i
chrome-use --session vscode snapshot -i
```
## Color Scheme
@@ -191,14 +191,14 @@ agent-browser --session vscode snapshot -i
The default color scheme when connecting via CDP may be `light`. To preserve dark mode:
```bash
agent-browser connect 9222
agent-browser --color-scheme dark snapshot -i
chrome-use connect 9222
chrome-use --color-scheme dark snapshot -i
```
Or set it globally:
```bash
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser connect 9222
AGENT_BROWSER_COLOR_SCHEME=dark chrome-use connect 9222
```
## Troubleshooting
@@ -216,12 +216,12 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser connect 9222
### Elements not appearing in snapshot
- The app may use multiple webviews. Use `agent-browser tab` to list targets and switch to the right one
- The app may use multiple webviews. Use `chrome-use tab` to list targets and switch to the right one
### Cannot type in input fields
- Try `agent-browser keyboard type "text"` to type at the current focus without a selector
- Some Electron apps use custom input components; use `agent-browser keyboard inserttext "text"` to bypass key events
- Try `chrome-use keyboard type "text"` to type at the current focus without a selector
- Some Electron apps use custom input components; use `chrome-use keyboard inserttext "text"` to bypass key events
## Supported Apps
@@ -233,4 +233,4 @@ Any app built on Electron works, including:
- **Media:** Spotify, Tidal
- **Productivity:** Todoist, Linear, 1Password
If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with agent-browser.
If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with chrome-use.

Some files were not shown because too many files have changed in this diff Show More