Compare commits

...
28 Commits
Author SHA1 Message Date
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
25 changed files with 1840 additions and 88 deletions
+40 -1
View File
@@ -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
+41
View File
@@ -205,6 +205,47 @@ chrome-use --launch --profile auto open https://x.com/home
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" />
+21 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrome-use"
version = "1.1.0"
version = "1.3.0"
dependencies = [
"aes",
"aes-gcm",
@@ -312,6 +312,7 @@ dependencies = [
"rust-embed",
"serde",
"serde_json",
"serde_yaml",
"sha1",
"sha2",
"similar",
@@ -1982,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"
@@ -2419,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"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chrome-use"
version = "1.1.0"
version = "1.3.0"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
@@ -45,6 +45,7 @@ sha1 = "0.10"
chrono = "0.4"
urlencoding = "2"
rust-embed = "8"
serde_yaml = "0.9"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+76 -5
View File
@@ -370,6 +370,12 @@ 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
@@ -408,6 +414,12 @@ 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" => {
@@ -1328,11 +1340,11 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
usage: "cookies transfer --from <profile> [--domain <domain>]",
});
}
return Ok(json!({
Ok(json!({
"id": id,
"action": "cookies_set",
"cookies": cookies,
}));
}))
}
"set" => {
// --curl <file> mode: import cookies from a JSON array,
@@ -1496,7 +1508,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// `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" => {
match rest.first().copied() {
// `--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]
@@ -1528,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) {
@@ -1541,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)
}
}
}
@@ -3552,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();
@@ -3716,6 +3763,30 @@ mod tests {
);
}
#[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"] {
+58 -27
View File
@@ -16,8 +16,18 @@ use std::io::Write;
use std::path::PathBuf;
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
/// manifest filename.
pub const HOST_NAME: &str = "com.leeguoo.chrome_use";
/// 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
@@ -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": "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())?;
// 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());
@@ -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) {
@@ -399,10 +417,23 @@ 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(".chrome-use").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
+101 -5
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 > ~/.chrome-use > 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("chrome-use");
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(".chrome-use");
if dirs::home_dir().is_some() {
return config_home();
}
// 4. Last resort: temp dir
env::temp_dir().join("chrome-use")
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
@@ -1151,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");
+138 -5
View File
@@ -11,6 +11,7 @@ mod install;
mod native;
mod output;
mod skills;
mod test_runner;
#[cfg(test)]
mod test_utils;
mod upgrade;
@@ -28,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;
@@ -269,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:");
@@ -287,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(_) => {
@@ -305,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")
}
@@ -571,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
@@ -707,6 +821,19 @@ fn main() {
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);
@@ -760,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()),
+52 -9
View File
@@ -1364,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,
@@ -2531,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.
@@ -2877,6 +2891,15 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
}
/// 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> {
let annotate = cmd
.get("annotate")
@@ -2902,7 +2925,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",
@@ -2976,7 +2999,7 @@ 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))?;
@@ -4346,10 +4369,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> {
@@ -4380,9 +4412,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;
+291 -8
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,24 @@ 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
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -762,12 +792,11 @@ impl BrowserManager {
/// 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 {
if let Some(tid) = &self.active_target_id {
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
return i;
}
}
self.active_page_index
resolve_active_index(
&self.pages,
self.active_target_id.as_deref(),
self.active_page_index,
)
}
/// Pin the current active page by target_id so later commands stick to it.
@@ -854,10 +883,20 @@ 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();
let mut out = json!({ "url": page_url, "title": title });
if let Some(w) = nav_warning {
@@ -1124,6 +1163,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(())
@@ -1154,22 +1196,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> {
@@ -1593,7 +1781,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(())
}
@@ -2157,6 +2363,83 @@ 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);
}
#[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
+59
View File
@@ -341,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),
@@ -363,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) {
@@ -372,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();
@@ -1886,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"));
+39
View File
@@ -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
+27 -3
View File
@@ -477,6 +477,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
// Tabs
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
// `tab list --full` prints untruncated URLs so a long SSO/redirect
// URL can actually be re-opened after a stale session (issue #19).
let full = data.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
for tab in tabs {
let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
let tab_label = tab.get("label").and_then(|v| v.as_str());
@@ -491,8 +494,13 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
let title = title.as_str();
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal.
let url = truncate_middle(url, 120);
// the list stays readable instead of flooding the terminal
// unless `--full` was asked for (to re-open the exact URL).
let url = if full {
url.to_string()
} else {
truncate_middle(url, 120)
};
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active {
color::cyan("")
@@ -504,6 +512,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
} else {
println!("{} [{}] {} - {}", marker, tab_id, title, url);
}
// `--full` also surfaces the stable cross-session CDP targetId so
// a stranded tab can be adopted from another session via
// `tab <targetId>` (issue #21).
if full {
if let Some(target_id) = tab.get("targetId").and_then(|v| v.as_str()) {
println!(" {}", color::dim(&format!("target: {}", target_id)));
}
}
}
return;
}
@@ -3108,7 +3124,12 @@ Storage:
storage <local|session> Manage web storage
Tabs:
tab [new|list|close|<n>] Manage tabs
tab [new|list|close|<ref>] Manage tabs (<ref> = t<N>, a label, or a CDP targetId)
tab list --full Full URLs + stable cross-session targetId per tab
tab <targetId> Adopt a specific tab (incl. another session's) by its
stable targetId, no reload preserves in-page state
open <url> --reuse-tab Reuse an existing tab on that URL instead of spawning
a duplicate (matches origin+path; preserves state)
Diff:
diff snapshot Compare current vs last snapshot
@@ -3170,6 +3191,9 @@ Confirmation:
Sessions:
session Show current session name
session list List active sessions
daemon status List running session daemons (+ relay state)
daemon restart Kill all session daemons; keeps the extension relay
up. Clears stale/cross-leaked state after an upgrade.
Chat (AI):
chat <message> Send a natural language instruction (single-shot)
+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"
}
+140 -1
View File
@@ -1,5 +1,7 @@
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");
@@ -7,6 +9,143 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// upgrade path and the install path are identical (GitHub Release, no npm).
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)
}
/// 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 —
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -6,6 +6,6 @@ from **openclaw-browser-relay** by chengyixu
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.leeguoo.chrome_use`) — no port, no token,
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
Chrome authenticates the extension to the host by id. WebSocket/token/options
code removed.
+73 -11
View File
@@ -16,7 +16,7 @@
// attach + Target handling; the transport is rewritten from WebSocket+token to
// native messaging.
const HOST_NAME = 'com.leeguoo.chrome_use'
const HOST_NAME = 'com.agent_browser.connect'
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
/** @type {chrome.runtime.Port|null} */
@@ -24,7 +24,6 @@ let port = null
/** 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) */
@@ -150,6 +149,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
@@ -210,11 +228,22 @@ async function handleForwardCdpCommand(msg) {
if (sessionId) {
tabId = tabForSession(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.`,
)
// 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)
@@ -261,7 +290,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)
@@ -347,9 +385,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)
}
}
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "chrome-use",
"version": "0.5.0",
"version": "0.4.6",
"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": {
+15 -7
View File
@@ -28,7 +28,7 @@
<body>
<header>
<h1>Chrome Web Store 提交指南</h1>
<div class="sub">chrome-use · 上传包 <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,11 +44,15 @@
<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>
@@ -114,8 +118,12 @@ automate pages the user is working with, entirely on the user's machine and at t
<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>chrome-use · 提交包与文案随扩展版本更新;改扩展后重跑 <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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chrome-use",
"version": "1.1.0",
"version": "1.3.0",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module",
"packageManager": "pnpm@11.1.3",
+51 -1
View File
@@ -240,7 +240,11 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
# (no silent no-op). Use this for custom
# dropdowns where `select` returns ✓ but
# changes nothing.
chrome-use upload @e5 file1.pdf # upload file(s)
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
@@ -497,6 +501,43 @@ 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
@@ -606,6 +647,13 @@ forbids debugging). The session no longer has a live tab — re-run
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>`.
@@ -668,6 +716,8 @@ and [references/authentication.md](references/authentication.md).
`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`
+90
View File
@@ -0,0 +1,90 @@
---
name: test
description: Write and run re-runnable, unit-test-style browser test suites with `chrome-use test <suite.yaml>`. Use when repetitive manual browser checks (does the page load logged in? is this element there? did the flow work?) should become a fixed, repeatable regression suite instead of being re-done by hand each time — frontend automated testing on top of chrome-use.
---
# chrome-use test — browser test suites
Turn the repetitive "open it, click around, check it's right" work into a
**re-runnable suite**, like unit tests for the frontend. Every time you find a
regression, add a case — the suite gets more valuable the more you use it.
```
chrome-use test <suite.yaml> [--launch | --session <name>] [--json]
```
- Exit code **0** if all cases pass, **1** if any fail → drop it straight into CI.
- Default: launches a fresh isolated browser (deterministic, repeatable) in a
`cu-test` session and closes it after. Pass `--session <name>` to run against an
already-connected session (e.g. the live Chrome via `chrome-use extension connect`).
- Failed cases auto-save a screenshot to `cu-test-artifacts/<case>.png`.
## Suite format (YAML)
```yaml
suite: chatgpt smoke # label (optional)
setup: # runs once before all cases (optional)
- account: chatgpt/huayue # inject a cookie-use stored login (optional)
- open: https://chatgpt.com/ # …or any normal step
cases:
- name: home loads logged in
steps: # steps reuse chrome-use's own commands
- open: https://chatgpt.com/
- wait: { load: networkidle }
assert: # all asserts must hold or the case fails
- url: { contains: chatgpt.com }
- visible: "#prompt-textarea"
- name: composer takes text
steps:
- fill: { sel: "#prompt-textarea", text: "hi" }
assert:
- text: { sel: "#prompt-textarea", contains: hi }
- eval: "!!window.__NEXT_DATA__"
```
## Steps (the verbs)
Each step is a one-key mapping; the key is a chrome-use command:
| Step | Meaning |
|---|---|
| `open: <url>` | navigate |
| `click: <selector\|@ref>` | click |
| `fill: { sel: <s>, text: <t> }` | clear + type |
| `type: { sel: <s>, text: <t> }` | type (no clear) |
| `press: <key>` | key press (e.g. `Enter`) |
| `wait: <ms>` / `wait: { load: networkidle }` / `wait: <selector>` | wait |
| `scroll: <up\|down\|...>` or `{ dir: down, px: 500 }` | scroll |
| `eval: "<js>"` | run JS |
## Assertions (the checks) — all compile to one truthy `eval`
| Assert | Passes when |
|---|---|
| `url: { contains\|equals\|matches: <v> }` | the page URL matches |
| `visible: <selector>` | element exists and is laid out |
| `hidden: <selector>` | element is absent / not laid out |
| `text: { sel: <s>, contains\|equals\|matches: <v> }` | element text matches |
| `count: { sel: <s>, eq: <n> }` | exactly N elements match |
| `eval: "<js>"` | the JS expression is truthy |
## Auth
`setup: - account: <id>` injects a [cookie-use](https://github.com/leeguooooo/cookie-use)
stored login into the test session, so the suite runs authenticated. (Needs
`cookie-use` installed; skip the line if you don't use it.)
## Workflow
1. Do the check once by hand with `open`/`snapshot`/`eval` to learn the selectors.
2. Write it up as a case in a `*.yaml` suite.
3. `chrome-use test suite.yaml` — green means it works; red shows the failing
assert + a screenshot.
4. Found a regression later? Add a case. Run the whole suite in CI.
## Limits (v1)
Assertions are evaluated independently after the steps run. No per-case retries,
no parallel cases, no snapshot/screenshot baseline diffing yet (use an `eval`
assert against known content for now). Steps run sequentially; a failing step
fails the case immediately.