Compare commits

...
13 Commits
Author SHA1 Message Date
leeguooooo 5b4ffdb2bb chore(release): 1.4.0 — no-hijack open + tab adopt-by-targetId + keydown/keyup docs + canvas hint + all-component version coherence (doctor)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-13 23:45:30 +09:00
leeguooooo 62e7229b47 feat(version): extension reports its version; doctor shows all-component coherence
The upgrade story spanned four parts (CLI, daemon, extension, skill) with no
single view and — worst — the extension was a total black box: nothing reported
which build was live, so a user could sit on a stale extension with zero signal.

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

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

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

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

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

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

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

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

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

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

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

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

The stale-session itself auto-recovers via the stable per-tab relay session id
(#17, extension 0.4.4). Parse tests for both new forms; verified live.
2026-06-13 15:44:25 +09:00
leeguooooo 7bb50d54b3 ci(release): fetch tags before building changelog (was empty)
v1.2.2's auto-changelog came out empty: in a detached-HEAD tag checkout the
tag refs git describe/git log need aren't reliably present even with
fetch-depth:0. Fetch them explicitly first.
2026-06-12 22:55:47 +09:00
20 changed files with 911 additions and 32 deletions
+13 -1
View File
@@ -39,7 +39,6 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0 # full history so the changelog step can diff tags
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -117,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:
@@ -133,6 +142,9 @@ jobs:
- 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)"
{
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrome-use"
version = "1.2.2"
version = "1.4.0"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chrome-use"
version = "1.2.2"
version = "1.4.0"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+18
View File
@@ -3,6 +3,23 @@ use std::env;
use std::fs;
use std::path::Path;
/// Embed the version of the `ab-connect` extension this CLI ships alongside, so
/// `doctor` can tell a connected extension "you're older than what this CLI
/// expects, update it." Read from the extension manifest at build time so it
/// stays in sync with whatever extension version is in the same checkout/release
/// (the ext is on its own 0.4.x line, separate from the CLI version). Falls back
/// to "unknown" if the manifest can't be read.
fn embed_extension_version() {
let manifest = Path::new("../extensions/ab-connect/manifest.json");
println!("cargo:rerun-if-changed=../extensions/ab-connect/manifest.json");
let version = fs::read_to_string(manifest)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=AB_CONNECT_VERSION={}", version);
}
/// Ensure `packages/dashboard/out/` exists so `rust-embed` doesn't fail during
/// Rust-only dev builds where the dashboard hasn't been built. The placeholder
/// `index.html` is only written when the directory is completely absent.
@@ -20,6 +37,7 @@ fn ensure_dashboard_dir() {
fn main() {
ensure_dashboard_dir();
embed_extension_version();
let protocol_dir = Path::new("cdp-protocol");
let out_dir = env::var("OUT_DIR").unwrap();
+74 -3
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" => {
@@ -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"] {
+32
View File
@@ -449,6 +449,28 @@ pub fn relay_url() -> Option<String> {
}
}
/// Sidecar recording the connected extension's version, written by the host when
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
/// `doctor` surface which extension build is live without a CDP round-trip.
fn relay_ext_version_path() -> PathBuf {
relay_url_path().with_file_name("relay-ext-version")
}
/// Version of the connected `ab-connect` extension, if the host learned it from
/// the extension's `hello`. `None` when no extension has connected since the
/// host started, or the extension predates version reporting.
pub fn relay_ext_version() -> Option<String> {
let s = std::fs::read_to_string(relay_ext_version_path())
.ok()?
.trim()
.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
///
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
@@ -570,6 +592,15 @@ async fn nm_host_main() {
Ok(v) => v,
Err(_) => continue,
};
// Extension version handshake: record it next to the relay URL so
// `doctor` can report which extension build is live (and whether it's
// behind). Best-effort; the message carries no CDP payload.
if v.get("method").and_then(|m| m.as_str()) == Some("hello") {
if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
let _ = std::fs::write(relay_ext_version_path(), ver);
}
continue;
}
let outs = {
let mut s = state.lock().await;
s.handle_ext_message(&v, "")
@@ -602,6 +633,7 @@ async fn nm_host_main() {
}
nm_log("[nm-host] stdin EOF — Chrome closed the port");
let _ = std::fs::remove_file(relay_url_path());
let _ = std::fs::remove_file(relay_ext_version_path());
}
#[allow(clippy::too_many_arguments)]
+65
View File
@@ -642,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
@@ -1182,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");
+2
View File
@@ -18,6 +18,7 @@ mod launch;
mod network;
mod providers;
mod security;
mod versions;
use serde_json::{json, Value};
@@ -97,6 +98,7 @@ pub fn run_doctor(opts: DoctorOptions) -> i32 {
let mut fixed: Vec<String> = Vec::new();
environment::check(&mut checks);
versions::check(&mut checks);
chrome::check(&mut checks);
daemon::check(&mut checks);
config::check(&mut checks);
+89
View File
@@ -0,0 +1,89 @@
//! Version-coherence checks across all four moving parts: the CLI binary, the
//! per-session daemons (covered by `daemon.rs`), the connected `ab-connect`
//! extension, and the bundled skill. The extension was previously a black box —
//! nothing reported which build was live — so a user could sit on an old
//! extension with no signal. The extension now reports its version over the
//! relay (`hello`), the host records it, and this surfaces it in one place.
use super::{Check, Status};
use crate::{connect, upgrade};
pub(super) fn check(checks: &mut Vec<Check>) {
let category = "Versions";
let cli_version = env!("CARGO_PKG_VERSION");
// CLI — compare against the latest seen by the background update check.
match upgrade::cached_latest_version() {
Some(latest) if upgrade::version_is_newer(&latest, cli_version) => {
checks.push(
Check::new(
"versions.cli",
category,
Status::Warn,
format!("CLI {cli_version} (newer available: {latest})"),
)
.with_fix("chrome-use upgrade".to_string()),
);
}
_ => {
checks.push(Check::new(
"versions.cli",
category,
Status::Pass,
format!("CLI {cli_version}"),
));
}
}
// Extension — the build this CLI shipped alongside (embedded at compile time
// from the extension manifest) is what we expect to be running.
let expected_ext = env!("AB_CONNECT_VERSION");
match connect::relay_ext_version() {
Some(ext) if upgrade::version_is_newer(expected_ext, &ext) => {
checks.push(
Check::new(
"versions.extension",
category,
Status::Warn,
format!("extension {ext} is behind the bundled {expected_ext}"),
)
.with_fix(
"update ab-connect in Chrome: chrome://extensions \u{2192} reload \
(or wait for the Web Store auto-update)"
.to_string(),
),
);
}
Some(ext) => {
checks.push(Check::new(
"versions.extension",
category,
Status::Pass,
format!("extension {ext}"),
));
}
None => {
checks.push(Check::new(
"versions.extension",
category,
Status::Info,
format!(
"extension not connected (or it predates version reporting — \
expected {expected_ext})"
),
));
}
}
// Skill — ships inside the same release artifact as the binary, so it's
// version-locked here. Copies made elsewhere via `skills add` aren't.
checks.push(Check::new(
"versions.skill",
category,
Status::Info,
format!(
"skills bundled with this CLI ({cli_version}); copies made via `skills add` \
elsewhere may be stale — re-run to refresh"
),
));
}
+96 -2
View File
@@ -29,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;
@@ -320,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")
}
@@ -799,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()),
+67 -8
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.
@@ -2874,7 +2888,32 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
})
.collect();
Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
let ref_count = refs.len();
let mut out = json!({ "snapshot": tree, "origin": url, "refs": refs });
// Canvas/WebGL apps (games, map/3D viewers, drawing tools) paint to a
// <canvas> and expose almost no accessibility tree, so `snapshot` comes back
// near-empty and agents get stuck looking for refs that will never exist
// (dogfood: the Dead Cell game). When the tree is sparse but a canvas
// dominates the viewport, tell them to switch to the screenshot-driven path.
if ref_count < 3 {
let canvas_js =
"(() => { const c = document.querySelector('canvas'); if (!c) return false; \
const r = c.getBoundingClientRect(); \
return r.width * r.height > innerWidth * innerHeight * 0.5; })()";
if let Ok(v) = mgr.evaluate(canvas_js, None).await {
if v.as_bool() == Some(true) {
out["note"] = json!(
"This page renders to a <canvas> (game / WebGL / editor) and exposes almost no \
accessibility tree refs won't help. Use `screenshot` to see it, coordinate \
`click <x> <y>` to interact, and `keydown`/`keyup`/`press` for keyboard \
(hold-to-move: `keydown d` `keyup d`)."
);
}
}
}
Ok(out)
}
/// Resolve a (possibly relative) saved-file path to an absolute one so the CLI
@@ -4355,10 +4394,19 @@ async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
// Phase 5 handlers
// ---------------------------------------------------------------------------
async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
async fn handle_tab_list(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
// Re-sync with the live browser so the list reflects tabs opened by other
// sessions or re-attached after a cross-process nav, and drops gone ones
// (issue #21). Best-effort: a stale list still beats erroring the command.
mgr.resync_targets().await.ok();
let tabs = mgr.tab_list();
Ok(json!({ "tabs": tabs }))
// Echo `full` so the formatter prints untruncated URLs (issue #19).
if cmd.get("full").and_then(|v| v.as_bool()).unwrap_or(false) {
Ok(json!({ "tabs": tabs, "full": true }))
} else {
Ok(json!({ "tabs": tabs }))
}
}
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
@@ -4389,9 +4437,20 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
let tab_ref_str = cmd
.get("tabId")
.and_then(|v| v.as_str())
.ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
.ok_or("Missing 'tabId' parameter (expected `t<N>`, a label, or a targetId)")?;
// Re-sync first so a tab opened by another session, or one that re-attached
// after a cross-process nav, is adoptable from here (issue #21).
mgr.resync_targets().await.ok();
// A CDP `targetId` (shown in `tab list`) is stable across sessions, so accept
// it directly for adopting a specific pre-existing tab — falling back to the
// per-session `t<N>` / label form.
let tab_id = match mgr.tab_id_for_target(tab_ref_str) {
Some(id) => id,
None => {
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
mgr.resolve_tab_ref(&tab_ref)?
}
};
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
+261 -1
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();
@@ -154,6 +166,25 @@ fn resolve_active_index(
active_page_index
}
/// Whether the resolved active page is a tab the session created (its target_id
/// is in `created_targets`). Pure core of [`BrowserManager::active_is_session_owned`]
/// so the relay no-hijack rule is unit-testable without a live browser.
fn active_index_is_owned(
pages: &[PageInfo],
active_target_id: Option<&str>,
active_page_index: usize,
created_targets: &HashSet<String>,
) -> bool {
pages
.get(resolve_active_index(
pages,
active_target_id,
active_page_index,
))
.map(|p| created_targets.contains(&p.target_id))
.unwrap_or(false)
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -787,6 +818,20 @@ impl BrowserManager {
)
}
/// Whether the resolved active page is a tab THIS session created (via
/// `Target.createTarget` — `tab new`, `ensure_page`, or the first `open`).
/// On the shared real browser a fresh session also passively attaches to the
/// user's existing tabs; those are NOT owned, and navigating one would
/// clobber the user's page. Used to gate `navigate` on the relay.
fn active_is_session_owned(&self) -> bool {
active_index_is_owned(
&self.pages,
self.active_target_id.as_deref(),
self.active_page_index,
&self.created_targets,
)
}
/// Pin the current active page by target_id so later commands stick to it.
/// Call after any explicit open / tab new / tab switch.
fn pin_active_target(&mut self) {
@@ -804,6 +849,18 @@ impl BrowserManager {
}
pub async fn navigate(&mut self, url: &str, wait_until: WaitUntil) -> Result<Value, String> {
// On the shared real browser (extension relay), a fresh session only
// passively attached to the user's existing tabs — it doesn't own any. The
// pre-fix code made one of those the active tab, so the first `open` then
// navigated (clobbered) the user's page: in dogfooding an `open` replaced a
// half-filled form with the target site. If the active tab isn't one we
// created, open our own tab in this session's group and navigate THAT, so
// the user's (and other sessions') tabs are never hijacked. Off the relay
// (a browser we launched) reusing the active tab is correct, so this is
// gated on `agent_group()`.
if self.agent_group().is_some() && !self.active_is_session_owned() {
self.tab_new(None, None).await?;
}
let session_id = self.active_session_id()?.to_string();
let mut lifecycle_rx = self.client.subscribe();
@@ -1184,22 +1241,168 @@ impl BrowserManager {
}
pub fn tab_list(&self) -> Vec<Value> {
let active = self.resolved_active_index();
self.pages
.iter()
.enumerate()
.map(|(i, p)| {
json!({
"tabId": format_tab_id(p.tab_id),
// Stable CDP target id. Unlike `t<N>` (per-session, reassigned
// each connect) this is the same handle across every session
// attached to the relayed Chrome, so it's how you adopt a
// specific pre-existing tab from another session (issue #21).
"targetId": p.target_id,
"label": p.label,
"title": p.title,
"url": p.url,
"type": p.target_type,
"active": i == self.active_page_index,
"active": i == active,
})
})
.collect()
}
/// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked.
/// Lets callers adopt a tab by the cross-session-stable target id.
pub fn tab_id_for_target(&self, target_id: &str) -> Option<u32> {
self.pages
.iter()
.find(|p| p.target_id == target_id)
.map(|p| p.tab_id)
}
/// Re-pull the live target set and reconcile `self.pages`: adopt tabs that
/// appeared since connect (another session's tab, or one that just
/// re-attached after a cross-process nav), refresh url/title on known tabs,
/// and drop tabs that are gone (clearing phantom rows). Never steals focus —
/// the active tab is preserved, and re-pinned if it was pruned. Powers a live
/// `tab list` and adopt-by-targetId so a fresh session can reach a stranded,
/// still-filled tab without reloading it (issue #21).
pub async fn resync_targets(&mut self) -> Result<(), String> {
self.client
.send_command_typed::<_, Value>(
"Target.setDiscoverTargets",
&SetDiscoverTargetsParams { discover: true },
None,
)
.await?;
let result: GetTargetsResult = self
.client
.send_command_typed("Target.getTargets", &json!({}), None)
.await?;
let live: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(should_track_target)
.collect();
let live_ids: HashSet<String> = live.iter().map(|t| t.target_id.clone()).collect();
for target in &live {
if self.update_page_target_info(target) {
continue;
}
// A target this session hasn't tracked yet — attach and add it in the
// background so it's listable/adoptable without stealing the active tab.
let attach_result: AttachToTargetResult = match self
.client
.send_command_typed(
"Target.attachToTarget",
&AttachToTargetParams {
target_id: target.target_id.clone(),
flatten: true,
},
None,
)
.await
{
Ok(r) => r,
// The tab may have closed between getTargets and attach, or be a
// restricted page — skip it rather than failing the whole resync.
Err(_) => continue,
};
let tab_id = self.assign_tab_id();
self.add_background_page(PageInfo {
tab_id,
label: None,
target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
target_type: target.target_type.clone(),
});
let _ = self.enable_domains(&attach_result.session_id).await;
}
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows.
let gone: Vec<String> = self
.pages
.iter()
.map(|p| p.target_id.clone())
.filter(|tid| !live_ids.contains(tid))
.collect();
for tid in gone {
self.remove_page_by_target_id(&tid);
}
// Refresh url/title from each live tab. The relay only stamps target_info
// on attach, so after a navigation its cached url/title go stale (or stay
// blank for a tab attached at about:blank) — which made `tab list` show
// blank rows you couldn't tell apart, defeating the point of listing them
// to pick a tab to adopt (issue #21). `Target.getTargetInfo` is a plain
// CDP read (no Runtime fingerprint), one cheap call per tab.
let sessions: Vec<(usize, String)> = self
.pages
.iter()
.enumerate()
.map(|(i, p)| (i, p.session_id.clone()))
.collect();
for (i, sid) in sessions {
if sid.is_empty() {
continue;
}
if let Ok(resp) = self
.client
.send_command("Target.getTargetInfo", None, Some(&sid))
.await
{
if let Some(ti) = resp.get("targetInfo") {
if let Some(page) = self.pages.get_mut(i) {
if let Some(u) = ti.get("url").and_then(|v| v.as_str()) {
if !u.is_empty() {
page.url = u.to_string();
}
}
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
page.title = t.to_string();
}
}
}
}
}
Ok(())
}
/// If `--reuse-tab` and a tracked tab already shows `url`, switch to it
/// (without reloading, so any in-page state survives) and return its info.
/// Returns `None` when no tab matches and the caller should navigate/create.
/// Matches on exact URL or the same origin+path (ignoring query/fragment) so
/// a re-`open` of a stable entry URL lands on the existing tab instead of
/// piling up duplicates (issue #21).
pub async fn reuse_tab_for_url(&mut self, url: &str) -> Result<Option<Value>, String> {
self.resync_targets().await.ok();
let want = normalize_url_for_match(url);
let tab_id = self
.pages
.iter()
.find(|p| !want.is_empty() && (p.url == url || normalize_url_for_match(&p.url) == want))
.map(|p| p.tab_id);
match tab_id {
Some(id) => Ok(Some(self.tab_switch_by_id(id).await?)),
None => Ok(None),
}
}
/// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
@@ -2217,6 +2420,34 @@ mod tests {
}
}
// --- 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]
@@ -2245,6 +2476,35 @@ mod tests {
assert_eq!(resolve_active_index(&pages, Some("CLOSED"), 1), 1);
}
// --- issue: `open` must not hijack a user's tab on the relay (dogfood) ---
#[test]
fn active_not_owned_when_only_user_tabs_discovered() {
// A fresh relay session passively attached to the user's tabs but created
// none — so navigate must NOT reuse the active tab (it'd clobber the
// user's page); it has to open its own first.
let pages = vec![page("USER_A"), page("USER_B")];
let created = HashSet::new();
assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created));
}
#[test]
fn active_owned_when_session_created_the_tab() {
let pages = vec![page("USER_A"), page("OURS")];
let mut created = HashSet::new();
created.insert("OURS".to_string());
// Active pinned to the tab we created → safe to navigate it.
assert!(active_index_is_owned(&pages, Some("OURS"), 1, &created));
// But pinned to the user's tab → not owned, even though we own another.
assert!(!active_index_is_owned(&pages, Some("USER_A"), 0, &created));
}
#[test]
fn active_not_owned_when_no_pages() {
let created = HashSet::new();
assert!(!active_index_is_owned(&[], None, 0, &created));
}
#[test]
fn resolve_active_index_pin_survives_passive_background_tab() {
// A foreign tab ("Z") gets appended by passive discovery after we pinned
+35 -3
View File
@@ -297,6 +297,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Snapshot
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
print_with_boundaries(snapshot, origin, opts);
// Canvas-app hint: the tree was near-empty but the page paints to a
// <canvas>, so refs are a dead end — point at the screenshot path.
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
eprintln!("{}", color::dim(note));
}
return;
}
// Title
@@ -477,6 +482,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 +499,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 +517,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;
}
@@ -3050,6 +3071,9 @@ Core Commands:
type <sel> <text> Type into element
fill <sel> <text> Clear and fill
press <key> Press key (Enter, Tab, Control+a)
keydown <key> Hold a key down (no auto-release) for games/shortcuts
keyup <key> Release a held key. Pair with keydown to hold-to-move:
`keydown d` `keyup d`
keyboard type <text> Type text with real keystrokes (no selector)
keyboard inserttext <text> Insert text without key events
hover <sel> Hover element
@@ -3108,7 +3132,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 +3199,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)
+22 -1
View File
@@ -52,6 +52,27 @@ fn is_newer(latest: &str, current: &str) -> bool {
matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c)
}
/// Public semver-ish comparison (`latest` strictly newer than `current`), so
/// `doctor` can flag a stale extension/CLI without re-implementing parsing.
pub fn version_is_newer(latest: &str, current: &str) -> bool {
is_newer(latest, current)
}
/// The latest CLI version recorded by the background update check, if any.
/// `doctor` uses it to show "a newer chrome-use is available" without a network
/// call (the `__update-check` worker refreshes the cache out of band).
pub fn cached_latest_version() -> Option<String> {
std::fs::read_to_string(update_cache_path())
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|j| {
j.get("latest")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
}
/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it.
/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a
/// real command. Uses `curl` (no extra deps, matches `upgrade`).
@@ -100,7 +121,7 @@ pub fn maybe_notify_update() {
if first.starts_with("__")
|| matches!(
first.as_str(),
"upgrade" | "install" | "doctor" | "dashboard"
"upgrade" | "install" | "doctor" | "dashboard" | "daemon"
)
{
return;
Binary file not shown.
Binary file not shown.
+68 -8
View File
@@ -108,6 +108,12 @@ function connectHost() {
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
})
// Report our version so the host can tell the CLI/`doctor` which extension
// build is live (otherwise the extension version is a black box — the user
// can't tell they're on an old one). Best-effort; ignored by older hosts.
try {
postToHost({ method: 'hello', version: chrome.runtime.getManifest().version })
} catch {}
// Tell the daemon about everything we already have attached, then attach
// anything new.
reannounceAttachedTabs()
@@ -149,6 +155,25 @@ function tabForTarget(targetId) {
return null
}
// Best-effort recovery for a stale `cb-tab-<tabId>` session: the handle is gone
// from our maps, but if the underlying Chrome tab still exists and is eligible,
// re-attach to it and return its id so the in-flight command can be retried.
// Returns null when the tab is genuinely gone (closed / restricted), in which
// case the caller surfaces the stale-session error. (issue #20.1)
async function recoverSessionTab(sessionId) {
const m = /^cb-tab-(\d+)$/.exec(sessionId)
if (!m) return null
const tabId = Number(m[1])
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!eligible(tab)) return null
try {
await attachTab(tabId)
} catch {
return null
}
return tabs.has(tabId) ? tabId : null
}
function anyConnectedTab() {
const it = tabs.keys().next()
return it.done ? null : it.value
@@ -209,11 +234,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)
@@ -355,9 +391,33 @@ chrome.debugger.onEvent.addListener((source, method, params) =>
}),
)
chrome.debugger.onDetach.addListener((source) =>
void whenReady(() => {
if (source.tabId) detachTab(source.tabId, true)
chrome.debugger.onDetach.addListener((source, reason) =>
void whenReady(async () => {
const tabId = source.tabId
if (!tabId) return
detachTab(tabId, true)
// A cross-process navigation (e.g. an SSO redirect like
// login.account.rakuten.com that swaps the render process / spawns OOPIFs)
// detaches the debugger, but the TAB survives. Without re-attaching, the
// session goes permanently stale and even open/navigate fails — exactly the
// #19 follow-up. So proactively re-attach (the stable `cb-tab-<tabId>`
// session id then restores the daemon's binding). Don't fight a detach the
// user or DevTools initiated.
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') return
if (!port) return
// The swapped-in process needs a moment to settle; retry with backoff.
for (let i = 0; i < 6; i++) {
await new Promise((r) => setTimeout(r, 250 + i * 200))
if (tabs.has(tabId)) return // already re-attached (e.g. via onUpdated)
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (!tab || !eligible(tab)) return // tab gone or now a restricted page
try {
await attachTab(tabId)
return
} catch (e) {
console.warn(`ab-connect: reattach attempt ${i + 1} for tab ${tabId} failed:`, e)
}
}
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "chrome-use",
"version": "0.4.4",
"version": "0.4.7",
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chrome-use",
"version": "1.2.2",
"version": "1.4.0",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module",
"packageManager": "pnpm@11.1.3",
+65 -1
View File
@@ -226,8 +226,11 @@ chrome-use hover @e1 # hover
chrome-use focus @e1 # focus (useful before keyboard input)
chrome-use fill @e2 "hello" # clear then type
chrome-use type @e2 " world" # type without clearing
chrome-use press Enter # press a key at current focus
chrome-use press Enter # press a key at current focus (down+up)
chrome-use press Control+a # key combination
chrome-use keydown d # HOLD a key down (no auto-release)
chrome-use keyup d # release it — pair them to hold-to-move
# in a game: `keydown d; sleep; keyup d`
chrome-use check @e3 # check checkbox
chrome-use uncheck @e3 # uncheck
chrome-use select @e4 "option-value" # native <select> only
@@ -296,6 +299,23 @@ chrome-use click --coords 449,320 # same, explicit flag
A bare-number argument is always a coordinate, never a selector.
### Canvas / WebGL apps (games, map & 3D viewers, drawing tools)
These paint everything to a `<canvas>` and expose **almost no accessibility
tree**, so `snapshot` comes back near-empty and refs are a dead end. `snapshot`
detects this and prints a one-line hint. Drive them the screenshot way:
```bash
chrome-use screenshot /tmp/s.png # SEE the state (your only read path —
# eval/get text return nothing useful)
chrome-use click 640 360 # interact by viewport coordinate
chrome-use keydown d; sleep 0.6; chrome-use keyup d # hold-to-move
chrome-use press Space # discrete actions (jump/attack/confirm)
```
Each command is a ~250ms round-trip, so this is fine for turn-based / canvas
*apps* but too slow to play a real-time 60fps action game frame-by-frame.
## Waiting (read this)
Agents fail more often from bad waits than from bad selectors. Pick the
@@ -501,6 +521,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
@@ -610,6 +667,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>`.