Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e272546b5c | ||
|
|
c7de19b099 | ||
|
|
6b9de10c73 | ||
|
|
63e0dd5921 | ||
|
|
c0ee65d0d8 | ||
|
|
7601919a04 | ||
|
|
345c0d62a2 | ||
|
|
0644fb2d0b | ||
|
|
1ea6b1a2c5 | ||
|
|
7bb50d54b3 |
@@ -39,7 +39,6 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
fetch-depth: 0 # full history so the changelog step can diff tags
|
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -117,6 +116,16 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
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
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
@@ -133,6 +142,9 @@ jobs:
|
|||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
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 }}"
|
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.2.2"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.2.2"
|
version = "1.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+74
-3
@@ -370,6 +370,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
if flags.provider.is_some() {
|
if flags.provider.is_some() {
|
||||||
nav_cmd["waitUntil"] = json!("none");
|
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
|
// Explicit readiness override (issue #10): SPAs whose `load` event
|
||||||
// never fires (a long-lived XHR/websocket holds it open) hang out the
|
// never fires (a long-lived XHR/websocket holds it open) hang out the
|
||||||
// load-event wait. `--wait-until domcontentloaded` returns as soon as
|
// 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" })),
|
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||||
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
"forward" => Ok(json!({ "id": id, "action": "forward" })),
|
||||||
"reload" => Ok(json!({ "id": id, "action": "reload" })),
|
"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 ===
|
// === Core Actions ===
|
||||||
"click" => {
|
"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 —
|
// `tabs` (plural) is a natural guess for the `tab` subcommand tree —
|
||||||
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
|
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
|
||||||
"tab" | "tabs" => {
|
"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") => {
|
Some("new") => {
|
||||||
// Accepted forms:
|
// Accepted forms:
|
||||||
// tab new [url]
|
// tab new [url]
|
||||||
@@ -1528,7 +1545,13 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
}
|
}
|
||||||
Ok(cmd)
|
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") => {
|
Some("close") => {
|
||||||
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
||||||
if let Some(tab_ref) = rest.get(1) {
|
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",
|
"action": "tab_switch",
|
||||||
"tabId": tab_ref,
|
"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");
|
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]
|
#[test]
|
||||||
fn test_navigate_with_headers() {
|
fn test_navigate_with_headers() {
|
||||||
let mut flags = default_flags();
|
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]
|
#[test]
|
||||||
fn test_get_text_hyphen_and_underscore_aliases() {
|
fn test_get_text_hyphen_and_underscore_aliases() {
|
||||||
for verb in ["get-text", "get_text"] {
|
for verb in ["get-text", "get_text"] {
|
||||||
|
|||||||
@@ -642,6 +642,22 @@ fn kill_stale_daemon(session: &str) {
|
|||||||
cleanup_stale_files(session);
|
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> {
|
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||||
// Socket connectivity is the sole liveness check — no PID check — so
|
// Socket connectivity is the sole liveness check — no PID check — so
|
||||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||||
@@ -1182,6 +1198,55 @@ mod tests {
|
|||||||
let _ = fs::remove_dir(&dir);
|
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]
|
#[test]
|
||||||
fn test_cleanup_stale_files_removes_version() {
|
fn test_cleanup_stale_files_removes_version() {
|
||||||
let dir = std::env::temp_dir().join("ab-test-cleanup-version");
|
let dir = std::env::temp_dir().join("ab-test-cleanup-version");
|
||||||
|
|||||||
+96
-2
@@ -29,8 +29,8 @@ use windows_sys::Win32::System::Threading::OpenProcess;
|
|||||||
|
|
||||||
use commands::{gen_id, parse_command, ParseError};
|
use commands::{gen_id, parse_command, ParseError};
|
||||||
use connection::{
|
use connection::{
|
||||||
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons,
|
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, restart_all_daemons,
|
||||||
DaemonOptions,
|
send_command, walk_daemons, DaemonOptions,
|
||||||
};
|
};
|
||||||
use flags::{clean_args, parse_flags, Flags};
|
use flags::{clean_args, parse_flags, Flags};
|
||||||
use install::run_install;
|
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 {
|
fn get_dashboard_pid_path() -> std::path::PathBuf {
|
||||||
get_socket_dir().join("dashboard.pid")
|
get_socket_dir().join("dashboard.pid")
|
||||||
}
|
}
|
||||||
@@ -799,6 +887,12 @@ fn main() {
|
|||||||
return;
|
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
|
// Handle close --all: close all active sessions
|
||||||
if matches!(
|
if matches!(
|
||||||
clean.first().map(|s| s.as_str()),
|
clean.first().map(|s| s.as_str()),
|
||||||
|
|||||||
@@ -1364,7 +1364,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"recording_stop" => handle_recording_stop(state).await,
|
"recording_stop" => handle_recording_stop(state).await,
|
||||||
"recording_restart" => handle_recording_restart(cmd, state).await,
|
"recording_restart" => handle_recording_restart(cmd, state).await,
|
||||||
"pdf" => handle_pdf(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_new" => handle_tab_new(cmd, state).await,
|
||||||
"tab_switch" => handle_tab_switch(cmd, state).await,
|
"tab_switch" => handle_tab_switch(cmd, state).await,
|
||||||
"tab_close" => handle_tab_close(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.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
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?;
|
let result = mgr.navigate(url, wait_until).await?;
|
||||||
// Adaptive humanize: sample the freshly loaded page for known behavioural
|
// Adaptive humanize: sample the freshly loaded page for known behavioural
|
||||||
// anti-bot vendors and escalate this session to Human if any are present.
|
// anti-bot vendors and escalate this session to Human if any are present.
|
||||||
@@ -4355,10 +4369,19 @@ async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
|||||||
// Phase 5 handlers
|
// Phase 5 handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
async fn handle_tab_list(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
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();
|
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> {
|
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
@@ -4389,9 +4412,20 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
let tab_ref_str = cmd
|
let tab_ref_str = cmd
|
||||||
.get("tabId")
|
.get("tabId")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
|
.ok_or("Missing 'tabId' parameter (expected `t<N>`, a label, or a targetId)")?;
|
||||||
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
|
// Re-sync first so a tab opened by another session, or one that re-attached
|
||||||
let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
|
// 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.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
|
|||||||
+187
-1
@@ -106,6 +106,18 @@ pub(crate) fn should_track_target(target: &TargetInfo) -> bool {
|
|||||||
&& (target.url.is_empty() || !is_internal_chrome_target(&target.url))
|
&& (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 {
|
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) {
|
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
|
||||||
page.url = target.url.clone();
|
page.url = target.url.clone();
|
||||||
@@ -1184,22 +1196,168 @@ impl BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tab_list(&self) -> Vec<Value> {
|
pub fn tab_list(&self) -> Vec<Value> {
|
||||||
|
let active = self.resolved_active_index();
|
||||||
self.pages
|
self.pages
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, p)| {
|
.map(|(i, p)| {
|
||||||
json!({
|
json!({
|
||||||
"tabId": format_tab_id(p.tab_id),
|
"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,
|
"label": p.label,
|
||||||
"title": p.title,
|
"title": p.title,
|
||||||
"url": p.url,
|
"url": p.url,
|
||||||
"type": p.target_type,
|
"type": p.target_type,
|
||||||
"active": i == self.active_page_index,
|
"active": i == active,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.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
|
/// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
|
||||||
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
|
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
|
||||||
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
|
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
|
||||||
@@ -2217,6 +2375,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 ---
|
// --- issue #14: a pinned target must keep commands on the right tab ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+27
-3
@@ -477,6 +477,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
// Tabs
|
// Tabs
|
||||||
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
|
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 {
|
for tab in tabs {
|
||||||
let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
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());
|
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 title = title.as_str();
|
||||||
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
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
|
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
|
||||||
// the list stays readable instead of flooding the terminal.
|
// the list stays readable instead of flooding the terminal —
|
||||||
let url = truncate_middle(url, 120);
|
// 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 active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let marker = if active {
|
let marker = if active {
|
||||||
color::cyan("→")
|
color::cyan("→")
|
||||||
@@ -504,6 +512,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
} else {
|
} else {
|
||||||
println!("{} [{}] {} - {}", marker, tab_id, title, url);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3108,7 +3124,12 @@ Storage:
|
|||||||
storage <local|session> Manage web storage
|
storage <local|session> Manage web storage
|
||||||
|
|
||||||
Tabs:
|
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:
|
||||||
diff snapshot Compare current vs last snapshot
|
diff snapshot Compare current vs last snapshot
|
||||||
@@ -3170,6 +3191,9 @@ Confirmation:
|
|||||||
Sessions:
|
Sessions:
|
||||||
session Show current session name
|
session Show current session name
|
||||||
session list List active sessions
|
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 (AI):
|
||||||
chat <message> Send a natural language instruction (single-shot)
|
chat <message> Send a natural language instruction (single-shot)
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ pub fn maybe_notify_update() {
|
|||||||
if first.starts_with("__")
|
if first.starts_with("__")
|
||||||
|| matches!(
|
|| matches!(
|
||||||
first.as_str(),
|
first.as_str(),
|
||||||
"upgrade" | "install" | "doctor" | "dashboard"
|
"upgrade" | "install" | "doctor" | "dashboard" | "daemon"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -149,6 +149,25 @@ function tabForTarget(targetId) {
|
|||||||
return null
|
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() {
|
function anyConnectedTab() {
|
||||||
const it = tabs.keys().next()
|
const it = tabs.keys().next()
|
||||||
return it.done ? null : it.value
|
return it.done ? null : it.value
|
||||||
@@ -209,11 +228,22 @@ async function handleForwardCdpCommand(msg) {
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
tabId = tabForSession(sessionId)
|
tabId = tabForSession(sessionId)
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
throw new Error(
|
// The session's debugger handle is gone, but `cb-tab-<tabId>` encodes the
|
||||||
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
|
// STABLE Chrome tabId (#17). A cross-process navigation (e.g. an SSO
|
||||||
`navigated across processes, or lost after an extension restart). ` +
|
// redirect to another origin), a service-worker restart, or DevTools
|
||||||
`Re-attach by re-opening your target URL before retrying.`,
|
// 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') {
|
} else if (typeof params?.targetId === 'string') {
|
||||||
tabId = tabForTarget(params.targetId)
|
tabId = tabForTarget(params.targetId)
|
||||||
@@ -355,9 +385,33 @@ chrome.debugger.onEvent.addListener((source, method, params) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
chrome.debugger.onDetach.addListener((source) =>
|
chrome.debugger.onDetach.addListener((source, reason) =>
|
||||||
void whenReady(() => {
|
void whenReady(async () => {
|
||||||
if (source.tabId) detachTab(source.tabId, true)
|
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "0.4.4",
|
"version": "0.4.6",
|
||||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
"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",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -501,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
|
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
|
||||||
extension (each with a distinct `--session`), not raw `--cdp`.
|
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
|
### Mock network requests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -610,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*
|
replaces the old silent behaviour where the command ran on some *other*
|
||||||
tab and returned wrong data.
|
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**
|
**Reads landing on the wrong page**
|
||||||
`eval`, `screenshot`, and `network requests` print the page they ran
|
`eval`, `screenshot`, and `network requests` print the page they ran
|
||||||
against to stderr: `eval @ <url>`, `screenshot @ <url>`, `network @ <url>`.
|
against to stderr: `eval @ <url>`, `screenshot @ <url>`, `network @ <url>`.
|
||||||
|
|||||||
Reference in New Issue
Block a user