diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 880aaee..440e1fe 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrome-use" -version = "1.5.21" +version = "1.5.22" dependencies = [ "aes", "aes-gcm", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1e425ac..17764de 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chrome-use" -version = "1.5.21" +version = "1.5.22" edition = "2021" description = "Fast browser automation CLI for AI agents" license = "Apache-2.0" diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index de57509..965f6cd 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -509,6 +509,13 @@ pub struct BrowserManager { /// for `RELAY_PRUNE_MISSES` consecutive snapshots; any snapshot that includes /// it resets the counter. Keyed by stable target_id. relay_target_misses: HashMap, + /// Whether the relay accepted this session's group announcement and is + /// therefore scoping `Target.getTargets` to our own tab group (issue #40). + /// When true the daemon can safely adopt new targets again (follow-popup, + /// cross-session adopt) — the relay has already filtered out foreign tabs. + /// When false (launch-on-real-CDP, or an older relay that didn't answer the + /// announce) the daemon keeps strict daemon-side isolation. + relay_scoped: bool, next_tab_id: u32, /// Whether to enable the CDP `Runtime` domain (console / error / exception capture). /// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal @@ -646,6 +653,7 @@ impl BrowserManager { created_targets: HashSet::new(), active_target_id: None, relay_target_misses: HashMap::new(), + relay_scoped: false, next_tab_id: 1, capture_console: console_capture_enabled(), }; @@ -749,6 +757,7 @@ impl BrowserManager { created_targets: HashSet::new(), active_target_id: None, relay_target_misses: HashMap::new(), + relay_scoped: false, next_tab_id: 1, capture_console: console_capture_enabled(), }; @@ -824,6 +833,10 @@ impl BrowserManager { ) .await?; + // Announce our group FIRST so the relay scopes the getTargets below to our + // own tab group (issue #40). On a launched browser this is a no-op. + let scoped = self.announce_group().await; + let page_targets: Vec = self.collect_page_targets().await?; if page_targets.is_empty() { @@ -870,19 +883,19 @@ impl BrowserManager { self.active_page_index = 0; self.pin_active_target(); self.enable_domains(&attach_result.session_id).await?; - } else if self.agent_group().is_some() { - // STRICT MULTI-AGENT ISOLATION (relay / the user's real Chrome). - // `page_targets` here are the USER's and OTHER agents' tabs. A tab - // group belongs to exactly ONE agent, so this session must NOT adopt - // any of them — it tracks ONLY tabs it creates (its own colored group) - // plus popups it opens. Adopting foreign tabs is precisely what let - // another concurrent agent's tab churn drop the tab we were driving and - // drift eval/click onto the wrong page (multi-agent failure). Open our - // own dedicated background tab in the session's group and pin it; the - // user's / other agents' tabs stay invisible to us. + } else if self.agent_group().is_some() && !scoped { + // STRICT MULTI-AGENT ISOLATION fallback (relay, but the group announce + // didn't take — e.g. an older relay). Without relay-side scoping, + // `page_targets` could be the USER's and OTHER agents' tabs, so this + // session must NOT adopt any of them — adopting foreign tabs is what let + // another agent's tab churn drop the tab we were driving (multi-agent + // failure). Open our own dedicated background tab and pin it instead. self.tab_new(None, None).await?; } else { - // A browser WE launched: every tab is ours, so adopt them all. + // Either a browser WE launched (every tab is ours) or the relay has + // scoped getTargets to our own tab group (#40) — so `page_targets` are + // all ours: adopt them (this restores follow-popup + cross-session + // adopt under isolation, since foreign tabs were already filtered out). for target in &page_targets { let attach_result: AttachToTargetResult = self .client @@ -1580,7 +1593,11 @@ impl BrowserManager { // in the synthesized targetInfo), so don't adopt anything: the agent drives // only tabs it explicitly created, and pop-ups (e.g. an OAuth/login window) // are the user's. A launched browser (every tab ours) still follows pop-ups. - if self.agent_group().is_some() { + // Strict isolation only when on the relay WITHOUT group scoping: there a + // pop-up can't be told apart from a foreign tab, so adopt nothing. When the + // relay IS scoping (#40), getTargets returns only our group, so a tab that + // appeared after our own action is genuinely ours (a pop-up) — adopt it. + if self.agent_group().is_some() && !self.relay_scoped { return None; } let result: GetTargetsResult = self @@ -1658,16 +1675,17 @@ impl BrowserManager { .collect(); let live_ids: HashSet = live.iter().map(|t| t.target_id.clone()).collect(); let on_relay = self.agent_group().is_some(); + // When the relay scopes getTargets to our group (#40), `live` is already + // only our own tabs, so adopting unknown ones is safe (a freshly-opened + // pop-up). Without scoping, keep strict isolation: never adopt a tab we + // didn't create — it belongs to the user or another agent. + let strict_isolation = on_relay && !self.relay_scoped; for target in &live { if self.update_page_target_info(target) { continue; } - // STRICT MULTI-AGENT ISOLATION: on the relay (the user's real Chrome, - // shared with other agents), NEVER adopt a tab this session didn't - // create — it belongs to the user or another agent's group. Only a - // browser we launched (every tab ours) adopts unknown targets. - if on_relay { + if strict_isolation { continue; } let attach_result: AttachToTargetResult = match self @@ -1837,6 +1855,25 @@ impl BrowserManager { } } + /// Tell the relay which tab group this session owns so it can scope + /// `Target.getTargets` to us (issue #40). Only meaningful on the relay; a + /// no-op (returns false) on a launched/real-CDP connection. Sets and returns + /// `relay_scoped`: when true, the daemon can trust getTargets to contain only + /// our group and re-enable adopting new tabs (pop-ups, cross-session adopt). + async fn announce_group(&mut self) -> bool { + let Some(group) = self.agent_group() else { + self.relay_scoped = false; + return false; + }; + let ok = self + .client + .send_command_typed::<_, Value>("ABRelay.setGroup", &json!({ "group": group }), None) + .await + .is_ok(); + self.relay_scoped = ok; + ok + } + pub async fn tab_new( &mut self, url: Option<&str>, @@ -2630,6 +2667,7 @@ async fn initialize_lightpanda_manager( created_targets: HashSet::new(), active_target_id: None, relay_target_misses: HashMap::new(), + relay_scoped: false, next_tab_id: 1, capture_console: console_capture_enabled(), }; diff --git a/cli/src/native/relay.rs b/cli/src/native/relay.rs index 65b887d..3bbf41f 100644 --- a/cli/src/native/relay.rs +++ b/cli/src/native/relay.rs @@ -52,6 +52,22 @@ pub struct RelayState { pending: HashMap, /// monotonic source of relay-global command ids next_global_id: i64, + /// Group-scoped isolation (issue #40). A tab group belongs to exactly one + /// agent/session; the relay scopes `Target.getTargets` per client to its own + /// group so the daemon can safely adopt new tabs (follow-popup, cross-session + /// adopt) without ever seeing the user's or another agent's tabs. + /// + /// clientId -> group name. A client that never announced a group (older + /// daemon) is absent here and gets the full, UNSCOPED target list — so this + /// is fully backward-compatible. + client_groups: HashMap, + /// targetId -> group name. Created tabs are tagged from `Target.createTarget`'s + /// `agentGroup`; an explicitly adopted tab is tagged to the adopter; a pop-up + /// inherits its opener's group (needs the extension to report `openerTargetId`). + target_group: HashMap, + /// relay-global id of an in-flight `Target.createTarget` -> the `agentGroup` + /// it carried, so the reply's `targetId` can be tagged with that group. + pending_create: HashMap, } /// What to do with a raw CDP command received from a `CdpClient`. @@ -95,6 +111,7 @@ impl RelayState { /// `pending` entries don't leak. pub fn drop_client(&mut self, client_id: ClientId) { self.pending.retain(|_, (cid, _)| *cid != client_id); + self.client_groups.remove(&client_id); } /// Route a raw CDP command `{id, method, params?, sessionId?}` from a @@ -123,16 +140,37 @@ impl RelayState { "jsVersion": "" } })), + // Non-CDP control message: a daemon announces which tab group + // (session) it owns, so getTargets can be scoped to it (issue #40). + "ABRelay.setGroup" => { + if let Some(g) = params.get("group").and_then(|g| g.as_str()) { + if !g.is_empty() { + self.client_groups.insert(client_id, g.to_string()); + } + } + ClientRoute::Local(json!({ "id": id, "result": {} })) + } // Discovery is best-effort and event-driven in real CDP; abs only // reads the getTargets result, so an empty ack is enough here. "Target.setDiscoverTargets" | "Target.setAutoAttach" => { ClientRoute::Local(json!({ "id": id, "result": {} })) } "Target.getTargets" => { + // Scope to the client's own group when it announced one; an + // un-announced (legacy) client gets the full list (back-compat). + let scoped = self.client_groups.get(&client_id).cloned(); let infos: Vec = self .targets - .values() - .map(|t| t.target_info.clone()) + .iter() + .filter(|(tid, _)| match &scoped { + Some(g) => self + .target_group + .get(*tid) + .map(|tg| tg == g) + .unwrap_or(false), + None => true, + }) + .map(|(_, t)| t.target_info.clone()) .collect(); ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } })) } @@ -142,9 +180,19 @@ impl RelayState { .and_then(|t| t.as_str()) .unwrap_or(""); match self.targets.get(target_id) { - Some(entry) => ClientRoute::Local( - json!({ "id": id, "result": { "sessionId": entry.session_id } }), - ), + Some(entry) => { + let session_id = entry.session_id.clone(); + // Explicitly adopting a target makes it this client's + // (cross-session adopt, #21) — tag it into the adopter's + // group so it stays in that client's scoped getTargets and + // isn't churn-pruned. + if let Some(g) = self.client_groups.get(&client_id).cloned() { + self.target_group.insert(target_id.to_string(), g); + } + ClientRoute::Local( + json!({ "id": id, "result": { "sessionId": session_id } }), + ) + } None => ClientRoute::Local(json!({ "id": id, "error": { "code": -32602, "message": format!("No such target {target_id}") } @@ -157,6 +205,18 @@ impl RelayState { self.next_global_id += 1; let gid = self.next_global_id; self.pending.insert(gid, (client_id, id)); + // Remember the group a createTarget carries so the reply's + // targetId can be tagged to the creating session (issue #40). + if method == "Target.createTarget" { + if let Some(g) = params.get("agentGroup").and_then(|g| g.as_str()) { + if !g.is_empty() { + self.pending_create.insert(gid, g.to_string()); + self.client_groups + .entry(client_id) + .or_insert_with(|| g.to_string()); + } + } + } ClientRoute::Forward(json!({ "id": gid, "method": "forwardCDPCommand", @@ -201,6 +261,17 @@ impl RelayState { && msg.get("method").is_none() { let gid = msg.get("id").and_then(|i| i.as_i64()); + // A createTarget reply: tag the new tab's targetId with the group the + // command carried, so it lands in the creating session's scope (#40). + if let Some(g) = gid.and_then(|g| self.pending_create.remove(&g)) { + if let Some(tid) = msg + .get("result") + .and_then(|r| r.get("targetId")) + .and_then(|t| t.as_str()) + { + self.target_group.insert(tid.to_string(), g); + } + } let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) { Some((client_id, orig)) => (Some(client_id), orig), // No mapping (stale/unknown id) — fall back to broadcasting with @@ -241,6 +312,27 @@ impl RelayState { .and_then(|s| s.as_str()) .unwrap_or("") .to_string(); + // Attribute the tab to a group for scoping (issue #40), + // unless we already know it (createTarget tag). An + // explicit `abGroup` from the extension wins; otherwise a + // pop-up inherits its opener's group via `openerTargetId`. + if !self.target_group.contains_key(tid) { + if let Some(g) = info + .get("abGroup") + .and_then(|g| g.as_str()) + .filter(|g| !g.is_empty()) + { + self.target_group.insert(tid.to_string(), g.to_string()); + } else if let Some(opener) = info + .get("openerTargetId") + .and_then(|o| o.as_str()) + .filter(|o| !o.is_empty()) + { + if let Some(g) = self.target_group.get(opener).cloned() { + self.target_group.insert(tid.to_string(), g); + } + } + } self.targets.insert( tid.to_string(), TargetEntry { @@ -255,6 +347,15 @@ impl RelayState { "Target.detachedFromTarget" => { let gone = inner_params.get("sessionId").and_then(|s| s.as_str()); if let Some(gone) = gone { + let gone_tids: Vec = self + .targets + .iter() + .filter(|(_, e)| e.session_id == gone) + .map(|(tid, _)| tid.clone()) + .collect(); + for tid in gone_tids { + self.target_group.remove(&tid); + } self.targets.retain(|_, e| e.session_id != gone); } return vec![]; @@ -557,4 +658,131 @@ mod tests { _ => panic!("expected ToExt"), } } + + // === Group-scoped isolation (issue #40) === + + /// Drive the real create path: announce group, createTarget(agentGroup), feed + /// the ext reply (tags target→group) + the attachedToTarget event (creates the + /// entry). Returns nothing; mutates `s`. + fn create_in_group(s: &mut RelayState, client: ClientId, group: &str, tid: &str, sid: &str) { + s.route_client_command( + client, + &json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": group } }), + ); + let route = s.route_client_command( + client, + &json!({ "id": 2, "method": "Target.createTarget", + "params": { "url": "about:blank", "agentGroup": group } }), + ); + let gid = match route { + ClientRoute::Forward(env) => env["id"].as_i64().unwrap(), + _ => panic!("createTarget must forward"), + }; + s.handle_ext_message(&json!({ "id": gid, "result": { "targetId": tid } }), ""); + s.handle_ext_message( + &json!({ "method": "forwardCDPEvent", "params": { + "method": "Target.attachedToTarget", + "params": { "sessionId": sid, "targetInfo": { + "targetId": tid, "type": "page", "url": "about:blank", "attached": true } } } }), + "", + ); + } + + fn get_target_ids(s: &mut RelayState, client: ClientId) -> Vec { + match s.route_client_command(client, &json!({ "id": 9, "method": "Target.getTargets" })) { + ClientRoute::Local(v) => v["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .map(|t| t["targetId"].as_str().unwrap().to_string()) + .collect(), + _ => panic!("getTargets must be local"), + } + } + + #[test] + fn get_targets_is_scoped_to_each_clients_group() { + 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"); + // Each client sees ONLY its own group's tab — never the other agent's. + assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]); + assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]); + } + + #[test] + fn legacy_client_without_group_sees_all_targets() { + 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 3 never announced a group → full, unscoped list (back-compat). + let mut all = get_target_ids(&mut s, 3); + all.sort(); + assert_eq!(all, vec!["ta", "tb"]); + } + + #[test] + fn popup_inherits_opener_group_and_is_visible_to_that_client_only() { + 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"); + // A pop-up that agent-a's tab opened: extension reports openerTargetId=ta. + s.handle_ext_message( + &json!({ "method": "forwardCDPEvent", "params": { + "method": "Target.attachedToTarget", + "params": { "sessionId": "sp", "targetInfo": { + "targetId": "tp", "type": "page", "url": "https://oauth.example/", + "attached": true, "openerTargetId": "ta" } } } }), + "", + ); + // Only agent-a sees the pop-up; agent-b never does. + let mut a = get_target_ids(&mut s, 1); + a.sort(); + assert_eq!(a, vec!["ta", "tp"]); + assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]); + } + + #[test] + fn explicit_attach_tags_target_into_adopter_group() { + let mut s = RelayState::new(); + // A pre-existing, ungrouped tab the extension reported (e.g. user's tab). + s.handle_ext_message( + &json!({ "method": "forwardCDPEvent", "params": { + "method": "Target.attachedToTarget", + "params": { "sessionId": "su", "targetInfo": { + "targetId": "tu", "type": "page", "url": "https://user.example/", "attached": true } } } }), + "", + ); + // Client 1 (group agent-a) explicitly adopts it by targetId (#21). + s.route_client_command( + 1, + &json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-a" } }), + ); + s.route_client_command( + 1, + &json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "tu" } }), + ); + // Now it's in agent-a's scope and survives the scoped getTargets. + assert_eq!(get_target_ids(&mut s, 1), vec!["tu"]); + // A different agent still doesn't see it. + s.route_client_command( + 2, + &json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-b" } }), + ); + assert!(get_target_ids(&mut s, 2).is_empty()); + } + + #[test] + fn detach_clears_target_group() { + let mut s = RelayState::new(); + create_in_group(&mut s, 1, "agent-a", "ta", "sa"); + assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]); + s.handle_ext_message( + &json!({ "method": "forwardCDPEvent", "params": { + "method": "Target.detachedFromTarget", "params": { "sessionId": "sa" } } }), + "", + ); + assert!(get_target_ids(&mut s, 1).is_empty()); + assert!(!s.target_group.contains_key("ta")); + } } diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index 13aa2ec..d2d7591 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -81,6 +81,35 @@ async function groupTabInto(tabId, name) { groupIdByName.set(name, gid) } +// Group-scoped relay isolation hints (issue #40). The relay scopes +// Target.getTargets per agent by tab group; report two things in the synthesized +// targetInfo so it can attribute each tab: +// - abGroup: the tab's Chrome tab-group TITLE (= the owning session name), so +// the relay can re-attribute existing tabs after a restart (createTarget +// tagging won't re-run for already-open tabs). +// - openerTargetId: the targetId of the tab that opened this one, so a pop-up +// (window.open / target=_blank / OAuth result) inherits its opener's group +// and the agent that opened it can follow it — without foreign tabs leaking. +// Best-effort: any failure yields empty strings, which the relay ignores. +async function tabScopeHints(tabId) { + let openerTargetId = '' + let abGroup = '' + try { + const t = await chrome.tabs.get(tabId) + if (t) { + if (typeof t.openerTabId === 'number') { + const op = tabs.get(t.openerTabId) + if (op) openerTargetId = op.targetId + } + if (t.groupId != null && t.groupId >= 0 && chrome.tabGroups) { + const g = await chrome.tabGroups.get(t.groupId).catch(() => null) + if (g && g.title) abGroup = g.title + } + } + } catch {} + return { openerTargetId, abGroup } +} + function postToHost(msg) { try { if (port) port.postMessage(msg) @@ -126,7 +155,7 @@ function connectHost() { } catch {} // Tell the daemon about everything we already have attached, then attach // anything new. - reannounceAttachedTabs() + void reannounceAttachedTabs() void attachAllTabs() } @@ -140,7 +169,7 @@ async function onHostMessage(msg) { // Daemon (re)connected — (re)attach and announce every tab so it discovers // the user's existing tabs rather than racing an empty target list. if (msg.method === 'attachAll') { - reannounceAttachedTabs() + void reannounceAttachedTabs() await attachAllTabs() return } @@ -387,12 +416,16 @@ async function attachTab(tabId) { sessionToTab.set(sessionId, tabId) rememberSessionTarget(sessionId, targetId) setBadge(tabId, port ? 'on' : 'connecting') + const { openerTargetId, abGroup } = await tabScopeHints(tabId) postToHost({ method: 'forwardCDPEvent', params: { sessionId, method: 'Target.attachedToTarget', - params: { sessionId, targetInfo: { ...targetInfo, attached: true } }, + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true, openerTargetId, abGroup }, + }, }, }) return entry @@ -434,14 +467,21 @@ async function attachAllTabs() { } } -function reannounceAttachedTabs() { - for (const [, entry] of tabs.entries()) { +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). + const { openerTargetId, abGroup } = await tabScopeHints(tabId) postToHost({ method: 'forwardCDPEvent', params: { sessionId: entry.sessionId, method: 'Target.attachedToTarget', - params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } }, + params: { + sessionId: entry.sessionId, + targetInfo: { targetId: entry.targetId, type: 'page', attached: true, openerTargetId, abGroup }, + }, }, }) } diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index 890517e..b492c0e 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.9", + "version": "0.4.10", "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 07c23fe..b8b38e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-use", - "version": "1.5.21", + "version": "1.5.22", "description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default", "type": "module", "packageManager": "pnpm@11.1.3",