Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29815ff5f3 | ||
|
|
2a338d4c29 | ||
|
|
6fcf52db60 | ||
|
|
af8823b27b |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.3"
|
||||
version = "1.5.5"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.5.3"
|
||||
version = "1.5.5"
|
||||
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) {
|
||||
|
||||
@@ -490,6 +490,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 +592,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,
|
||||
|
||||
@@ -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.5",
|
||||
"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