feat(adopt): read a pre-existing tab without opening a new one
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
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
New `chrome-use adopt <url-substring|targetId>`: drive a tab the user (or another session) already has open, with ZERO new tabs. After group-scoped isolation (#40) a session can't see foreign tabs, so adopt adds an explicit, opt-in path: - Relay (relay.rs): `ABRelay.getAllTargets` returns every attached target UNSCOPED (ignores group scoping), so the agent can find a specific tab by URL or targetId. +1 unit test. - Daemon (browser.rs): `collect_all_targets` (unscoped, falls back to scoped on older relays) + `adopt_existing_target` — matches by exact targetId or case-insensitive URL substring, attaches it (the relay re-tags it into the adopter's group, so isolation holds), pins it; never creates a tab. On no match it errors AND lists the open tabs it can see, rather than launching. discover_and_attach_targets honors AGENT_BROWSER_ADOPT at first connect, so no about:blank is ever created. - CLI (main.rs): `adopt` sets the env, forces a fresh daemon, and rewrites into `connect <relay-url>` (like `extension connect`) so the daemon attaches to the user's real Chrome before parse_command. Extension (ab-connect 0.4.11): `reannounceAttachedTabs` now re-sends each tab's url/title (it previously sent neither) so the relay's target list stays matchable by URL after the MV3 service worker reconnects — otherwise reannounced tabs show a blank url and `adopt <url>` can't find them. Repacked upload zip + crx. Mechanism verified live (enumerated all 11 of the user's open tabs incl. the target). 862 tests pass.
This commit is contained in:
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.22"
|
||||
version = "1.5.23"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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<Value, ParseErr
|
||||
Ok(json!({ "id": id, "action": "site", "domain": domain, "script": script }))
|
||||
}
|
||||
|
||||
// `adopt <url|targetId>`: 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
|
||||
|
||||
@@ -595,7 +595,7 @@ fn query_current_url(session: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
{
|
||||
|
||||
@@ -981,6 +981,43 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// `adopt <url|targetId>`: 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 <relay-url>` 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 <url-substring|targetId> (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);
|
||||
|
||||
@@ -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<Vec<TargetInfo>, String> {
|
||||
let rounds = if crate::connect::relay_url().is_some() {
|
||||
3
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut by_id: HashMap<String, TargetInfo> = 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<String> = 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 <spec>`: 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<TargetInfo> = self.collect_page_targets().await?;
|
||||
|
||||
if page_targets.is_empty() {
|
||||
|
||||
@@ -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<Value> = 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<String> = 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();
|
||||
|
||||
@@ -3375,6 +3375,10 @@ Tabs:
|
||||
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)
|
||||
adopt <url|targetId> 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 <url>` 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user