Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc2aa4cade | ||
|
|
7085f3bf36 | ||
|
|
29815ff5f3 | ||
|
|
2a338d4c29 | ||
|
|
6fcf52db60 | ||
|
|
af8823b27b |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.3"
|
||||
version = "1.5.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.5.3"
|
||||
version = "1.5.6"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -2455,6 +2455,15 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
if all_frames {
|
||||
return Ok(json!({ "id": id, "action": "gettext", "allFrames": true }));
|
||||
}
|
||||
// `get text --pierce` reads through CLOSED shadow DOM / child docs
|
||||
// via the CDP DOM tree — content eval/innerText can't reach, e.g. an
|
||||
// extension's injected panel in a closed shadow root (issue #30).
|
||||
let pierce = rest[1..]
|
||||
.iter()
|
||||
.any(|a| matches!(*a, "--pierce" | "--shadow" | "--deep"));
|
||||
if pierce {
|
||||
return Ok(json!({ "id": id, "action": "gettext", "pierce": true }));
|
||||
}
|
||||
// `get text --main` returns the main-content region (readability),
|
||||
// skipping header/nav/footer/sidebar boilerplate (issue #27).
|
||||
let main = rest[1..]
|
||||
@@ -4897,6 +4906,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_text_pierce() {
|
||||
for variant in ["get text --pierce", "get text --shadow", "text --deep"] {
|
||||
let cmd = parse_command(&args(variant), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "gettext", "{variant}");
|
||||
assert_eq!(cmd["pierce"], true, "{variant}");
|
||||
assert!(cmd.get("selector").is_none(), "{variant}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_activate_flag() {
|
||||
let plain = parse_command(&args("tab t3"), &default_flags()).unwrap();
|
||||
|
||||
@@ -449,6 +449,47 @@ pub fn relay_url() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a one-line record of how a CDP connection was established, to
|
||||
/// `~/.chrome-use/connect-mode.log`. This is the smoking-gun detector for the
|
||||
/// "Allow remote debugging?" consent modal: that modal ONLY appears on a raw
|
||||
/// remote-debugging attach / a browser we launched with a debug port — NEVER on
|
||||
/// the extension relay. When the modal reappears, this log says which session
|
||||
/// took which path and when, so we can tell a code regression (`raw-port` /
|
||||
/// `launched` while the relay was up) from Chrome's own extension-debugger
|
||||
/// consent UX. Low volume (one line per connection); best-effort, never fails a
|
||||
/// connection.
|
||||
pub fn log_connect_mode(ws_url: &str, launched: bool, session: &str) {
|
||||
let relay = relay_url();
|
||||
let relay_up = relay.is_some();
|
||||
let mode = if launched {
|
||||
"launched(debug-port)"
|
||||
} else if relay.as_deref() == Some(ws_url) {
|
||||
"relay"
|
||||
} else if ws_url.contains("127.0.0.1") || ws_url.contains("localhost") {
|
||||
"raw-port-attach"
|
||||
} else {
|
||||
"remote-ws"
|
||||
};
|
||||
// A raw-port attach or a self-launch while the relay was available is the
|
||||
// exact thing that pops the consent modal — flag it loudly in the line.
|
||||
let suspect = (mode == "raw-port-attach" || launched) && relay_up;
|
||||
let line = format!(
|
||||
"session={session} mode={mode} relay_up={relay_up}{} ws={ws_url}\n",
|
||||
if suspect { " CONSENT-MODAL-RISK" } else { "" }
|
||||
);
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let path = home.join(".chrome-use").join("connect-mode.log");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar recording the connected extension's version, written by the host when
|
||||
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
|
||||
/// `doctor` surface which extension build is live without a CDP round-trip.
|
||||
|
||||
@@ -3628,6 +3628,15 @@ async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
||||
}));
|
||||
}
|
||||
|
||||
// `get text --pierce` reads text through CLOSED shadow roots and child
|
||||
// documents via the CDP DOM tree — content `innerText`/`eval` can't see,
|
||||
// e.g. an extension's injected panel in a closed shadow DOM (#30).
|
||||
if cmd.get("pierce").and_then(|v| v.as_bool()) == Some(true) {
|
||||
let text = super::element::get_pierced_text(&mgr.client, &session_id).await?;
|
||||
let url = mgr.get_url().await.unwrap_or_default();
|
||||
return Ok(json!({ "text": text, "origin": url, "pierce": true }));
|
||||
}
|
||||
|
||||
// `get text --main` returns the page's main-content region (readability-lite),
|
||||
// skipping global header/nav/footer/sidebar boilerplate (#27).
|
||||
if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) {
|
||||
|
||||
@@ -166,6 +166,27 @@ fn resolve_active_index(
|
||||
active_page_index
|
||||
}
|
||||
|
||||
/// Target ids to prune after a `Target.getTargets` resync: tracked pages whose
|
||||
/// target is no longer in the live set — EXCEPT the explicitly-pinned active
|
||||
/// target, which is protected. The relay against a busy real Chrome occasionally
|
||||
/// returns a different window's tabs for a single `getTargets` call ("tab list
|
||||
/// hops windows", issue #31); pruning on that transient snapshot would drop the
|
||||
/// agent's adopted tab and drift subsequent eval/click onto a foreign tab. A
|
||||
/// genuine close still arrives as `Target.targetDestroyed` (handled in the event
|
||||
/// drain), which removes the pin properly — so protecting it here only guards
|
||||
/// against flaky snapshots, not real closures.
|
||||
fn prunable_target_ids(
|
||||
pages: &[PageInfo],
|
||||
live_ids: &HashSet<String>,
|
||||
pinned: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| p.target_id.clone())
|
||||
.filter(|tid| !live_ids.contains(tid) && pinned != Some(tid.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the resolved active page is a tab the session created (its target_id
|
||||
/// is in `created_targets`). Pure core of [`BrowserManager::active_is_session_owned`]
|
||||
/// so the relay no-hijack rule is unit-testable without a live browser.
|
||||
@@ -490,6 +511,13 @@ impl BrowserManager {
|
||||
}
|
||||
};
|
||||
|
||||
// A launched browser carries a debug port → it's the other path that can
|
||||
// pop Chrome's consent modal; record it for #31 diagnosis.
|
||||
crate::connect::log_connect_mode(
|
||||
&ws_url,
|
||||
true,
|
||||
DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"),
|
||||
);
|
||||
let manager = if engine == "lightpanda" {
|
||||
initialize_lightpanda_manager(ws_url, process).await?
|
||||
} else {
|
||||
@@ -585,6 +613,13 @@ impl BrowserManager {
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let ws_url = resolve_cdp_url(url).await?;
|
||||
// Record the transport so a reappearing "Allow remote debugging?" modal
|
||||
// can be traced to a raw-port attach vs the consent-free relay (#31).
|
||||
crate::connect::log_connect_mode(
|
||||
&ws_url,
|
||||
false,
|
||||
DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"),
|
||||
);
|
||||
let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?);
|
||||
let mut manager = Self {
|
||||
client,
|
||||
@@ -1411,13 +1446,10 @@ impl BrowserManager {
|
||||
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();
|
||||
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows —
|
||||
// but never prune the explicitly-pinned active target on a transient
|
||||
// getTargets snapshot (issue #31; see `prunable_target_ids`).
|
||||
let gone = prunable_target_ids(&self.pages, &live_ids, self.active_target_id.as_deref());
|
||||
for tid in gone {
|
||||
self.remove_page_by_target_id(&tid);
|
||||
}
|
||||
@@ -2582,6 +2614,25 @@ mod tests {
|
||||
assert!(!active_index_is_owned(&[], None, 0, &created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_protects_pinned_target_on_transient_snapshot() {
|
||||
// The relay returned a getTargets snapshot missing the pinned tab "A"
|
||||
// (it hopped to another window). "B" is also absent. Without protection
|
||||
// both would be pruned and the next command would drift; with the pin
|
||||
// protected, only the genuinely-unpinned "B" is dropped (issue #31).
|
||||
let pages = vec![page("A"), page("B")];
|
||||
let live: HashSet<String> = HashSet::new(); // snapshot returned neither
|
||||
let gone = prunable_target_ids(&pages, &live, Some("A"));
|
||||
assert_eq!(gone, vec!["B".to_string()]);
|
||||
// With no pin, both are prunable (unchanged behavior).
|
||||
let gone_unpinned = prunable_target_ids(&pages, &live, None);
|
||||
assert_eq!(gone_unpinned.len(), 2);
|
||||
// A pinned target that IS in the live set is simply not prunable anyway.
|
||||
let mut live2 = HashSet::new();
|
||||
live2.insert("A".to_string());
|
||||
assert_eq!(prunable_target_ids(&pages, &live2, Some("A")), vec!["B".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_pin_survives_passive_background_tab() {
|
||||
// A foreign tab ("Z") gets appended by passive discovery after we pinned
|
||||
|
||||
@@ -1169,6 +1169,69 @@ pub async fn get_main_content_text(client: &CdpClient, session_id: &str) -> Resu
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// Text nodes whose parent is one of these carry no visible content.
|
||||
fn is_noise_tag(name: &str) -> bool {
|
||||
matches!(name, "SCRIPT" | "STYLE" | "NOSCRIPT" | "TEMPLATE" | "HEAD")
|
||||
}
|
||||
|
||||
// Walk a CDP DOM.Node tree, collecting text-node values. Unlike `innerText`
|
||||
// (JS, blocked by CLOSED shadow roots), the CDP DOM tree from
|
||||
// `DOM.getDocument(pierce:true)` includes closed shadow roots and child
|
||||
// documents — so this reaches text JS can't. `parent_noise` carries whether an
|
||||
// ancestor was <script>/<style>/etc so their text is skipped.
|
||||
fn collect_dom_text(node: &Value, parent_noise: bool, out: &mut String) {
|
||||
let node_type = node.get("nodeType").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let node_name = node.get("nodeName").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if node_type == 3 {
|
||||
if !parent_noise {
|
||||
if let Some(t) = node.get("nodeValue").and_then(|v| v.as_str()) {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let noise = parent_noise || is_noise_tag(node_name);
|
||||
if let Some(children) = node.get("children").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_dom_text(child, noise, out);
|
||||
}
|
||||
}
|
||||
if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) {
|
||||
for sr in shadow {
|
||||
collect_dom_text(sr, noise, out);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = node.get("contentDocument") {
|
||||
collect_dom_text(doc, noise, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract text from the page via the CDP DOM tree with `pierce:true`, which
|
||||
/// reaches into CLOSED shadow roots and child documents that `innerText`/`eval`
|
||||
/// cannot. Lets an agent read content rendered into a closed shadow DOM (e.g. an
|
||||
/// extension's injected debug panel) without any extra Chrome permission — it
|
||||
/// rides the per-tab debugger session that's already attached (#30).
|
||||
pub async fn get_pierced_text(client: &CdpClient, session_id: &str) -> Result<String, String> {
|
||||
let doc = client
|
||||
.send_command(
|
||||
"DOM.getDocument",
|
||||
Some(serde_json::json!({ "depth": -1, "pierce": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
let mut out = String::new();
|
||||
if let Some(root) = doc.get("root") {
|
||||
collect_dom_text(root, false, &mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get_element_attribute(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -1642,6 +1705,31 @@ mod tests {
|
||||
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_dom_text_pierces_closed_shadow_and_skips_noise() {
|
||||
// A CDP DOM.Node tree: a host element whose CLOSED shadow root holds the
|
||||
// text, plus a <script> whose text must be skipped.
|
||||
let tree = serde_json::json!({
|
||||
"nodeType": 1, "nodeName": "BODY",
|
||||
"children": [
|
||||
{ "nodeType": 1, "nodeName": "SCRIPT",
|
||||
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "var secret=1;" } ] },
|
||||
{ "nodeType": 1, "nodeName": "DIV",
|
||||
"shadowRoots": [
|
||||
{ "nodeType": 11, "nodeName": "#document-fragment",
|
||||
"children": [
|
||||
{ "nodeType": 1, "nodeName": "SPAN",
|
||||
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "DECRYPTED 42" } ] }
|
||||
] }
|
||||
] }
|
||||
]
|
||||
});
|
||||
let mut out = String::new();
|
||||
collect_dom_text(&tree, false, &mut out);
|
||||
assert_eq!(out, "DECRYPTED 42");
|
||||
assert!(!out.contains("secret"), "script text must be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_equals_prefix() {
|
||||
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
||||
|
||||
@@ -1961,6 +1961,7 @@ Retrieves various types of information from elements or the page.
|
||||
Subcommands:
|
||||
text [selector] Element text; no selector = WHOLE PAGE, all frames
|
||||
text --main Main-content text only (skip nav/header/sidebar)
|
||||
text --pierce Read through CLOSED shadow DOM (injected panels)
|
||||
html <selector> Get inner HTML of element
|
||||
value <selector> Get value of input element
|
||||
attr <selector> <name> Get attribute value
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.6",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -210,6 +210,7 @@ For unstructured reading (no refs needed):
|
||||
chrome-use get text # WHOLE PAGE — all frames by default (see below)
|
||||
chrome-use get text @e1 # visible text of one element (or a CSS selector)
|
||||
chrome-use get text --main # main content only — skip nav/header/sidebar
|
||||
chrome-use get text --pierce # read through CLOSED shadow DOM (injected panels)
|
||||
chrome-use frames # list every frame + where the text lives
|
||||
chrome-use get html @e1 # innerHTML
|
||||
chrome-use get attr @e1 href # any attribute
|
||||
@@ -234,6 +235,12 @@ holds what), run `chrome-use frames`. To **cut boilerplate** (global nav/header/
|
||||
footer, "related items" sidebars), use `chrome-use get text --main`. If content
|
||||
is lazy-loaded, `scroll` it into view first, then read.
|
||||
|
||||
**Closed shadow DOM.** Some injected UI (browser-extension debug panels, web
|
||||
components) renders into a *closed* shadow root that `eval`/`innerText` cannot
|
||||
read. `chrome-use get text --pierce` reads through closed shadow roots and child
|
||||
documents via the CDP DOM tree — use it when content is clearly on screen (you
|
||||
see it in a screenshot) but `get text`/`eval` come back empty.
|
||||
|
||||
## Interacting
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user