diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 440e1fe..c6d0840 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrome-use" -version = "1.5.22" +version = "1.5.23" dependencies = [ "aes", "aes-gcm", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 17764de..e9390ed 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chrome-use" -version = "1.5.22" +version = "1.5.23" edition = "2021" description = "Fast browser automation CLI for AI agents" license = "Apache-2.0" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index dfb5cdb..ae0071d 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -80,6 +80,7 @@ const KNOWN_COMMANDS: &[&str] = &[ "upload", "site", "box", + "adopt", ]; /// Levenshtein distance, capped — small inputs only (command names). @@ -1317,6 +1318,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result`: the adoption happens at daemon connect (driven by + // the AGENT_BROWSER_ADOPT env main.rs set + a forced-fresh daemon), so by + // the time this command runs the tab is already attached. Resolve to a + // `url` read so the response confirms which tab got adopted. + "adopt" => Ok(json!({ "id": id, "action": "url" })), + // === Stealth self-check === "stealth" => { // `stealth [status]` — local stealth self-check: mode, live probes diff --git a/cli/src/connection.rs b/cli/src/connection.rs index af4c247..f4c63b6 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -595,7 +595,7 @@ fn query_current_url(session: &str) -> Option { } /// Kill a running daemon by reading its PID file and sending a kill signal. -fn kill_stale_daemon(session: &str) { +pub fn kill_stale_daemon(session: &str) { // Remove the socket first so no new connections reach the old daemon #[cfg(unix)] { diff --git a/cli/src/main.rs b/cli/src/main.rs index da91365..b3c051c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -981,6 +981,43 @@ fn main() { } } + // `adopt `: read a PRE-EXISTING tab (the user's own, or another + // session's) WITHOUT opening a new one. Forces a fresh daemon and points it at + // the relay (like `extension connect`); the AGENT_BROWSER_ADOPT env makes the + // daemon's first connect ADOPT the matching tab instead of creating an + // about:blank. Rewrites into `connect ` BEFORE parse_command so the + // daemon attaches to the user's real Chrome. Must run before parse_command. + if clean.first().map(|s| s.as_str()) == Some("adopt") { + match clean.get(1) { + Some(spec) if !spec.trim().is_empty() => { + std::env::set_var("AGENT_BROWSER_ADOPT", spec.trim()); + connection::kill_stale_daemon(&flags.session); + match connect::relay_url() { + Some(url) => { + flags.cdp = Some(url.clone()); + flags.auto_connect = false; + clean = vec!["connect".to_string(), url]; + } + None => { + eprintln!( + "{} extension relay not connected — open Chrome with the ab-connect \ + extension first (this command reads an EXISTING tab, it won't launch one).", + color::error_indicator() + ); + exit(1); + } + } + } + _ => { + eprintln!( + "{} usage: chrome-use adopt (reads an existing tab, no new tab)", + color::error_indicator() + ); + exit(2); + } + } + } + // Handle session separately (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("session") { run_session(&clean, &flags.session, flags.json); diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 965f6cd..a32ba2c 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -824,6 +824,106 @@ impl BrowserManager { Ok(by_id.into_values().collect()) } + /// Every tab the relay knows, UNSCOPED (ignores group scoping) — for explicit + /// cross-group adoption (`chrome-use adopt`). Falls back to the scoped + /// `collect_page_targets` on a relay/browser that doesn't support the + /// unscoped query. Retries a few times over the relay (discovery is eventual). + async fn collect_all_targets(&self) -> Result, String> { + let rounds = if crate::connect::relay_url().is_some() { + 3 + } else { + 1 + }; + let mut by_id: HashMap = HashMap::new(); + let mut any_ok = false; + for i in 0..rounds { + if i > 0 { + tokio::time::sleep(Duration::from_millis(150)).await; + } + if let Ok(result) = self + .client + .send_command_typed::<_, GetTargetsResult>( + "ABRelay.getAllTargets", + &json!({}), + None, + ) + .await + { + any_ok = true; + for t in result.target_infos.into_iter().filter(should_track_target) { + by_id.entry(t.target_id.clone()).or_insert(t); + } + } + } + if any_ok { + Ok(by_id.into_values().collect()) + } else { + // Older relay without ABRelay.getAllTargets → best-effort scoped list. + self.collect_page_targets().await + } + } + + /// Adopt a specific pre-existing tab matched by `spec` (an exact CDP + /// `targetId`, or a case-insensitive substring of the tab URL) WITHOUT opening + /// a new tab — for `chrome-use adopt`. Attaches it (the relay tags it into our + /// group), tracks + pins it. Errors if nothing matches (never creates a tab). + async fn adopt_existing_target(&mut self, spec: &str) -> Result<(), String> { + let all = self.collect_all_targets().await?; + let spec_l = spec.to_lowercase(); + let target = all + .iter() + .find(|t| t.target_id == spec) + .or_else(|| all.iter().find(|t| t.url.to_lowercase().contains(&spec_l))) + .ok_or_else(|| { + let mut open: Vec = all + .iter() + .map(|t| { + let u = if t.url.len() > 80 { + &t.url[..80] + } else { + &t.url + }; + u.to_string() + }) + .collect(); + open.sort(); + open.dedup(); + format!( + "adopt: no open tab matching `{spec}` (by targetId or URL substring).\n\ + {} tab(s) the extension can see:\n {}", + open.len(), + open.join("\n ") + ) + })? + .clone(); + + let attach: AttachToTargetResult = self + .client + .send_command_typed( + "Target.attachToTarget", + &AttachToTargetParams { + target_id: target.target_id.clone(), + flatten: true, + }, + None, + ) + .await?; + let tab_id = self.assign_tab_id(); + self.pages.push(PageInfo { + tab_id, + label: None, + target_id: target.target_id.clone(), + session_id: attach.session_id.clone(), + url: target.url.clone(), + title: sanitize_title(&target.title), + target_type: target.target_type.clone(), + }); + self.active_page_index = self.pages.len() - 1; + self.pin_active_target(); + self.enable_domains(&attach.session_id).await?; + Ok(()) + } + async fn discover_and_attach_targets(&mut self) -> Result<(), String> { self.client .send_command_typed::<_, Value>( @@ -837,6 +937,17 @@ impl BrowserManager { // own tab group (issue #40). On a launched browser this is a no-op. let scoped = self.announce_group().await; + // `chrome-use adopt `: adopt a specific PRE-EXISTING tab instead of + // creating one — true zero-new-tab reading of the user's own tab. The + // directive rides in via env so it takes effect at first connect (before + // any about:blank would be made). If nothing matches, error out rather + // than fall back to creating a tab. + if let Ok(spec) = std::env::var("AGENT_BROWSER_ADOPT") { + if !spec.trim().is_empty() { + return self.adopt_existing_target(spec.trim()).await; + } + } + let page_targets: Vec = self.collect_page_targets().await?; if page_targets.is_empty() { diff --git a/cli/src/native/relay.rs b/cli/src/native/relay.rs index 3bbf41f..6339718 100644 --- a/cli/src/native/relay.rs +++ b/cli/src/native/relay.rs @@ -155,6 +155,20 @@ impl RelayState { "Target.setDiscoverTargets" | "Target.setAutoAttach" => { ClientRoute::Local(json!({ "id": id, "result": {} })) } + // Unscoped discovery for EXPLICIT cross-group adoption (`chrome-use + // adopt`): returns every target the extension has attached, ignoring + // group scoping, so an agent can find a specific pre-existing tab (the + // user's, another session's) by URL/targetId and adopt it. Isolation + // is preserved because the daemon only acts on the one tab it then + // attaches (which the relay re-tags into the adopter's group). + "ABRelay.getAllTargets" => { + let infos: Vec = self + .targets + .values() + .map(|t| t.target_info.clone()) + .collect(); + ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } })) + } "Target.getTargets" => { // Scope to the client's own group when it announced one; an // un-announced (legacy) client gets the full list (back-compat). @@ -772,6 +786,33 @@ mod tests { assert!(get_target_ids(&mut s, 2).is_empty()); } + #[test] + fn get_all_targets_is_unscoped() { + let mut s = RelayState::new(); + create_in_group(&mut s, 1, "agent-a", "ta", "sa"); + create_in_group(&mut s, 2, "agent-b", "tb", "sb"); + // Client 1's scoped getTargets sees only its own group... + assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]); + // ...but ABRelay.getAllTargets returns EVERY target regardless of group + // (for explicit cross-group adoption). + let all = match s + .route_client_command(1, &json!({ "id": 1, "method": "ABRelay.getAllTargets" })) + { + ClientRoute::Local(v) => { + let mut ids: Vec = v["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .map(|t| t["targetId"].as_str().unwrap().to_string()) + .collect(); + ids.sort(); + ids + } + _ => panic!("getAllTargets must be local"), + }; + assert_eq!(all, vec!["ta", "tb"]); + } + #[test] fn detach_clears_target_group() { let mut s = RelayState::new(); diff --git a/cli/src/output.rs b/cli/src/output.rs index b0f257b..580139c 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -3375,6 +3375,10 @@ Tabs: stable targetId, no reload — preserves in-page state open --reuse-tab Reuse an existing tab on that URL instead of spawning a duplicate (matches origin+path; preserves state) + adopt Read a PRE-EXISTING tab (the user's own, or another + session's) WITHOUT opening a new one — matches by URL + substring or stable targetId, then drives it. e.g. + `adopt "github.com/owner/repo"` Diff: diff snapshot Compare current vs last snapshot diff --git a/extensions/ab-connect.crx b/extensions/ab-connect.crx index 308338c..36eb4a2 100644 Binary files a/extensions/ab-connect.crx and b/extensions/ab-connect.crx differ diff --git a/extensions/ab-connect.zip b/extensions/ab-connect.zip index e9393d1..b38f72b 100644 Binary files a/extensions/ab-connect.zip and b/extensions/ab-connect.zip differ diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index d2d7591..82edb7f 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -471,8 +471,19 @@ async function reannounceAttachedTabs() { for (const [tabId, entry] of tabs.entries()) { // Re-send the group hint too (issue #40) so the relay can rebuild its // targetId→group map after its own restart (createTarget tagging won't - // re-run for tabs that are already open). + // re-run for tabs that are already open). Include the live url/title so the + // relay's target list stays matchable by URL after a reconnect (otherwise a + // reannounced tab shows a blank url and `adopt ` can't find it). const { openerTargetId, abGroup } = await tabScopeHints(tabId) + let url = '' + let title = '' + try { + const t = await chrome.tabs.get(tabId) + if (t) { + url = t.url || t.pendingUrl || '' + title = t.title || '' + } + } catch {} postToHost({ method: 'forwardCDPEvent', params: { @@ -480,7 +491,7 @@ async function reannounceAttachedTabs() { method: 'Target.attachedToTarget', params: { sessionId: entry.sessionId, - targetInfo: { targetId: entry.targetId, type: 'page', attached: true, openerTargetId, abGroup }, + targetInfo: { targetId: entry.targetId, type: 'page', url, title, attached: true, openerTargetId, abGroup }, }, }, }) diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index b492c0e..81d1283 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "chrome-use", - "version": "0.4.10", + "version": "0.4.11", "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": { diff --git a/package.json b/package.json index b8b38e8..1f25e98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-use", - "version": "1.5.22", + "version": "1.5.23", "description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default", "type": "module", "packageManager": "pnpm@11.1.3",