Compare commits

...
9 Commits
Author SHA1 Message Date
leeguooooo 29815ff5f3 chore(release): 1.5.5 — connect-mode diagnostic log for the remote-debugging consent modal (#31)
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
2026-06-15 16:47:14 +09:00
leeguooooo 2a338d4c29 diag(connect): log CDP transport mode to detect 'Allow remote debugging?' modal source (#31)
The consent modal only appears on a raw remote-debugging attach or a browser we
launched with a debug port — never on the ab-connect extension relay. Append one
line per connection to ~/.chrome-use/connect-mode.log (relay | raw-port-attach |
launched | remote-ws), flagging 'CONSENT-MODAL-RISK' when a raw-port/launch path
runs while the relay was available. Lets us tell a code regression from Chrome's
own extension-debugger consent UX when the modal reappears. Best-effort, never
fails a connection. Verified: normal 'open' logs mode=relay (consent-free).
2026-06-15 16:47:12 +09:00
leeguooooo 6fcf52db60 chore(release): 1.5.4 — get text --pierce (closed shadow DOM, #30)
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
2026-06-15 15:46:31 +09:00
leeguooooo af8823b27b feat(text): 'get text --pierce' reads through CLOSED shadow DOM (#30)
Some injected UI (browser-extension debug panels, web components) renders into a
CLOSED shadow root that eval/innerText cannot read. --pierce walks the CDP DOM
tree (DOM.getDocument depth:-1 pierce:true), which includes closed shadow roots
and child documents, and collects text nodes (skipping script/style/etc).

Review-safe: rides the per-tab debugger session already attached, no new Chrome
permission and no ab-connect/extension change — so it works in extension-relay
mode without touching the published extension. Verified live: a closed-shadow
panel that main-world eval reports HIDDEN is read in full via --pierce.

First slice of #30 (read extension/injected-panel content). Deeper extension
introspection (background SW / chrome.storage) stays a launch-mode / raw-CDP
concern, deliberately NOT done by expanding ab-connect's debugger powers.
2026-06-15 15:46:23 +09:00
leeguooooo c85e3faa82 chore(release): 1.5.3 — get text defaults to cross-frame; #29 (sessions/did-you-mean/tab liveness); CI changelog fix
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
2026-06-15 13:42:58 +09:00
leeguooooo b25958946c feat(text): 'get text' (no selector) defaults to cross-frame whole-page read
So an agent never silently misses iframed content (listing descriptions etc.)
without having to know the --all-frames flag. Single-frame pages are unchanged
(identical to the old body read); multi-frame pages now include child frames —
a strict superset. Skill + help updated to make the default and 'frames'/--main
discoverable.
2026-06-15 13:42:48 +09:00
leeguooooo d4ff49caa8 ci(release): don't let an empty changelog section abort the release (bash -e)
The changelog step runs under 'bash -e'. section() returned non-zero when a
commit category was empty (grep no-match / empty [ -n ] test), aborting the
script before the closing heredoc delimiter — so any release whose range lacked
a whole category (e.g. 1.5.2: only feat, no fix) failed to attach binaries.
Add '|| true' + 'return 0' so section() always succeeds.
2026-06-15 13:42:48 +09:00
leeguooooo 4e949fffbf chore(release): 1.5.2 — sessions command + did-you-mean + honest tab-switch liveness (#29) 2026-06-15 13:34:26 +09:00
leeguooooo af46490812 feat(cli): sessions command + 'did you mean' suggestions + honest tab-switch liveness (#29)
- chrome-use sessions: top-level alias for the daemon inventory (the skill
  advertises sessions, so it's a natural guess that used to error).
- Unknown commands now suggest the nearest valid one (Levenshtein + prefix
  match), staying silent when nothing is close (e.g. 'clik' -> click,
  'sesions' -> sessions, 'xyzzy' -> no suggestion).
- tab <id>: probe the switched session and show a warning indicator instead of
  a green check when it isn't responding yet, so a switch onto a re-attaching
  (churned-tabId) session no longer reports false success. The #24 targetId
  recovery self-heals within ~6s, hence a warning rather than a hard error.
2026-06-15 13:25:16 +09:00
12 changed files with 342 additions and 50 deletions
+9 -2
View File
@@ -152,9 +152,16 @@ jobs:
# Group commit subjects by conventional-commit type so the notes are # Group commit subjects by conventional-commit type so the notes are
# scannable ("what's new / what's fixed") instead of a flat dev log. # scannable ("what's new / what's fixed") instead of a flat dev log.
LOG="$(git log "$RANGE" --no-merges --pretty='%s' | grep -v '^chore(release)' || true)" LOG="$(git log "$RANGE" --no-merges --pretty='%s' | grep -v '^chore(release)' || true)"
# NOTE: the job runs under `bash -e`. grep returning 1 (no match) and
# the `[ -n "$body" ]` test returning 1 (empty section) must NOT abort
# the script — otherwise a release whose commit range lacks a whole
# category (e.g. only `feat`, no `fix`) dies before writing the closing
# heredoc delimiter and the whole release step fails. `|| true` +
# `return 0` keep section() always-succeeding.
section() { # $1=header $2=grep-pattern section() { # $1=header $2=grep-pattern
local body; body="$(printf '%s\n' "$LOG" | grep -E "$2" | sed 's/^/- /')" local body; body="$(printf '%s\n' "$LOG" | grep -E "$2" | sed 's/^/- /' || true)"
[ -n "$body" ] && { printf '\n### %s\n%s\n' "$1" "$body"; } [ -n "$body" ] && printf '\n### %s\n%s\n' "$1" "$body"
return 0
} }
{ {
echo "notes<<__NOTES_EOF__" echo "notes<<__NOTES_EOF__"
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrome-use" name = "chrome-use"
version = "1.5.1" version = "1.5.5"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chrome-use" name = "chrome-use"
version = "1.5.1" version = "1.5.5"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+111 -18
View File
@@ -30,12 +30,65 @@ pub enum ParseError {
InvalidSessionName { name: String }, InvalidSessionName { name: String },
} }
/// Top-level commands an agent is likely to mistype, used for "did you mean"
/// suggestions on an unknown command (issue #29). Not exhaustive — just the
/// common verbs plus a few known wrong-guesses mapped to the real command.
const KNOWN_COMMANDS: &[&str] = &[
"open", "navigate", "click", "fill", "type", "press", "snapshot", "screenshot", "eval", "get",
"text", "html", "frames", "find", "wait", "scroll", "hover", "select", "check", "uncheck",
"tab", "tabs", "close", "back", "forward", "reload", "sessions", "status", "daemon", "doctor",
"upgrade", "connect", "cookies", "mouse", "keyboard", "stream", "frame", "profiles", "title",
"url", "is", "drag", "dialog", "upload",
];
/// Levenshtein distance, capped — small inputs only (command names).
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
/// Closest known command within a small edit distance, or a prefix/substring
/// match — `None` if nothing is close enough to suggest confidently.
fn nearest_command(input: &str) -> Option<String> {
let lower = input.to_lowercase();
// Exact prefix/substring hits first (e.g. "session" -> "sessions").
if let Some(c) = KNOWN_COMMANDS
.iter()
.find(|c| c.starts_with(&lower) || lower.starts_with(**c))
{
return Some(c.to_string());
}
// Tolerance scales with length: short words get distance 1, longer get 2.
let max_dist = if lower.len() <= 4 { 1 } else { 2 };
KNOWN_COMMANDS
.iter()
.map(|c| (*c, edit_distance(&lower, c)))
.filter(|(_, d)| *d <= max_dist)
.min_by_key(|(_, d)| *d)
.map(|(c, _)| c.to_string())
}
impl ParseError { impl ParseError {
pub fn format(&self) -> String { pub fn format(&self) -> String {
match self { match self {
ParseError::UnknownCommand { command } => { ParseError::UnknownCommand { command } => match nearest_command(command) {
format!("Unknown command: {}", command) Some(suggestion) => format!(
} "Unknown command: {}\nDid you mean: chrome-use {}?",
command, suggestion
),
None => format!("Unknown command: {}", command),
},
ParseError::UnknownSubcommand { ParseError::UnknownSubcommand {
subcommand, subcommand,
valid_options, valid_options,
@@ -2402,6 +2455,15 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
if all_frames { if all_frames {
return Ok(json!({ "id": id, "action": "gettext", "allFrames": true })); 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), // `get text --main` returns the main-content region (readability),
// skipping header/nav/footer/sidebar boilerplate (issue #27). // skipping header/nav/footer/sidebar boilerplate (issue #27).
let main = rest[1..] let main = rest[1..]
@@ -2410,16 +2472,16 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
if main { if main {
return Ok(json!({ "id": id, "action": "gettext", "main": true })); return Ok(json!({ "id": id, "action": "gettext", "main": true }));
} }
// `get text` with no selector returns the whole page's text (body) — // `get text` with no selector reads the WHOLE PAGE and now defaults
// a common convenience; previously it errored without a selector // to cross-frame aggregation, so an agent gets a page's iframed
// (issue #24-D). // content (listing descriptions etc.) without having to know about
let sel = rest // `--all-frames` (#27). On a single-frame page this is identical to
.iter() // the old body read; multi-frame pages get the child frames too —
.skip(1) // a strict superset. An explicit selector stays element-scoped.
.find(|a| !a.starts_with("--")) match rest.iter().skip(1).find(|a| !a.starts_with("--")).copied() {
.copied() Some(sel) => Ok(json!({ "id": id, "action": "gettext", "selector": sel })),
.unwrap_or("body"); None => Ok(json!({ "id": id, "action": "gettext", "allFrames": true })),
Ok(json!({ "id": id, "action": "gettext", "selector": sel })) }
} }
Some("html") => { Some("html") => {
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
@@ -4777,15 +4839,21 @@ mod tests {
} }
#[test] #[test]
fn test_get_text_defaults_to_body() { fn test_get_text_defaults_to_all_frames() {
// `get text` with no selector now returns the whole page (body) instead // `get text` with no selector now reads the whole page across ALL frames
// of erroring (issue #24-D). // by default (#27), so iframed content isn't silently missed. (Was: a
// top-frame `body` read, #24-D.)
let cmd = parse_command(&args("get text"), &default_flags()).unwrap(); let cmd = parse_command(&args("get text"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "gettext"); assert_eq!(cmd["action"], "gettext");
assert_eq!(cmd["selector"], "body"); assert_eq!(cmd["allFrames"], true);
// An explicit selector still wins. assert!(cmd.get("selector").is_none());
// An explicit selector still wins and stays element-scoped.
let cmd2 = parse_command(&args("get text h1"), &default_flags()).unwrap(); let cmd2 = parse_command(&args("get text h1"), &default_flags()).unwrap();
assert_eq!(cmd2["selector"], "h1"); assert_eq!(cmd2["selector"], "h1");
assert!(cmd2.get("allFrames").is_none());
// `text` top-level shortcut behaves the same.
let cmd3 = parse_command(&args("text"), &default_flags()).unwrap();
assert_eq!(cmd3["allFrames"], true);
} }
#[test] #[test]
@@ -4813,6 +4881,21 @@ mod tests {
assert_eq!(cmd["action"], "frames"); assert_eq!(cmd["action"], "frames");
} }
#[test]
fn test_nearest_command_suggestions() {
assert_eq!(nearest_command("sesions").as_deref(), Some("sessions"));
assert_eq!(nearest_command("session").as_deref(), Some("sessions"));
assert_eq!(nearest_command("clik").as_deref(), Some("click"));
assert_eq!(nearest_command("screenshits").as_deref(), Some("screenshot"));
// Nonsense with no close match stays silent.
assert_eq!(nearest_command("xyzzy"), None);
// The unknown-command error embeds the suggestion.
let err = ParseError::UnknownCommand {
command: "sesions".to_string(),
};
assert!(err.format().contains("Did you mean: chrome-use sessions?"));
}
#[test] #[test]
fn test_get_text_main() { fn test_get_text_main() {
for variant in ["get text --main", "get text --readable", "text -m"] { for variant in ["get text --main", "get text --readable", "text -m"] {
@@ -4823,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] #[test]
fn test_tab_activate_flag() { fn test_tab_activate_flag() {
let plain = parse_command(&args("tab t3"), &default_flags()).unwrap(); let plain = parse_command(&args("tab t3"), &default_flags()).unwrap();
+41
View File
@@ -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 /// Sidecar recording the connected extension's version, written by the host when
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets /// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
/// `doctor` surface which extension build is live without a CDP round-trip. /// `doctor` surface which extension build is live without a CDP round-trip.
+8
View File
@@ -893,6 +893,14 @@ fn main() {
return; return;
} }
// `sessions` is a natural top-level guess for "list my sessions" (the skill
// advertises sessions as a feature) — route it to the daemon inventory the
// same way `daemon status` does (issue #29).
if clean.first().map(|s| s.as_str()) == Some("sessions") {
run_daemon(&["sessions".to_string(), "status".to_string()], flags.json);
return;
}
// Handle close --all: close all active sessions // Handle close --all: close all active sessions
if matches!( if matches!(
clean.first().map(|s| s.as_str()), clean.first().map(|s| s.as_str()),
+25 -1
View File
@@ -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), // `get text --main` returns the page's main-content region (readability-lite),
// skipping global header/nav/footer/sidebar boilerplate (#27). // skipping global header/nav/footer/sidebar boilerplate (#27).
if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) { if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) {
@@ -4561,7 +4570,22 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
state.ref_map.clear(); state.ref_map.clear();
state.iframe_sessions.clear(); state.iframe_sessions.clear();
state.active_frame_id = None; state.active_frame_id = None;
let result = mgr.tab_switch_by_id(tab_id).await?; let mut result = mgr.tab_switch_by_id(tab_id).await?;
// Liveness probe: confirm the new session actually answers before we report
// success, so `tab <id>` doesn't print a misleading ✓ for a session that's
// stale and will fail on the very next command (issue #29.3). On the churned
// -tabId case the ext-0.4.9 targetId recovery (#24) self-heals within ~6s, so
// we surface a warning rather than a hard error to avoid a false failure
// during that window.
if mgr.evaluate("1", None).await.is_err() {
if let Some(obj) = result.as_object_mut() {
obj.insert(
"warning".to_string(),
json!("switched tab is not responding yet (session re-attaching); retry the next command"),
);
}
}
// `--activate`: raise this tab to the foreground (the switch made it active; // `--activate`: raise this tab to the foreground (the switch made it active;
// bring_to_front acts on the active tab) — for handing a specific tab to the // bring_to_front acts on the active tab) — for handing a specific tab to the
+14
View File
@@ -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" { let manager = if engine == "lightpanda" {
initialize_lightpanda_manager(ws_url, process).await? initialize_lightpanda_manager(ws_url, process).await?
} else { } else {
@@ -585,6 +592,13 @@ impl BrowserManager {
headers: Option<Vec<(String, String)>>, headers: Option<Vec<(String, String)>>,
) -> Result<Self, String> { ) -> Result<Self, String> {
let ws_url = resolve_cdp_url(url).await?; 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 client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?);
let mut manager = Self { let mut manager = Self {
client, client,
+88
View File
@@ -1169,6 +1169,69 @@ pub async fn get_main_content_text(client: &CdpClient, session_id: &str) -> Resu
.to_string()) .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( pub async fn get_element_attribute(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -1642,6 +1705,31 @@ mod tests {
assert_eq!(parse_ref("@e123"), Some("e123".to_string())); 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] #[test]
fn test_parse_ref_equals_prefix() { fn test_parse_ref_equals_prefix() {
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string())); assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
+20 -17
View File
@@ -597,19 +597,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
// Tab switch // Tab switch
if action == Some("tab_switch") { if action == Some("tab_switch") {
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) { if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
if let Some(url) = data.get("url").and_then(|v| v.as_str()) { let warning = data.get("warning").and_then(|v| v.as_str());
println!( // A non-responding session isn't a real success — show a warning
"{} Switched to tab [{}] ({})", // indicator instead of the green ✓ (issue #29.3).
color::success_indicator(), let indicator = if warning.is_some() {
tab_id, color::warning_indicator()
url
);
} else { } else {
println!( color::success_indicator()
"{} Switched to tab [{}]", };
color::success_indicator(), if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
tab_id println!("{} Switched to tab [{}] ({})", indicator, tab_id, url);
); } else {
println!("{} Switched to tab [{}]", indicator, tab_id);
}
if let Some(w) = warning {
eprintln!("{}", color::dim(w));
} }
return; return;
} }
@@ -1957,9 +1959,9 @@ Usage: chrome-use get <subcommand> [args]
Retrieves various types of information from elements or the page. Retrieves various types of information from elements or the page.
Subcommands: Subcommands:
text <selector> Get text content of element text [selector] Element text; no selector = WHOLE PAGE, all frames
text --all-frames Aggregate text across ALL frames (incl. iframes)
text --main Main-content text only (skip nav/header/sidebar) 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 html <selector> Get inner HTML of element
value <selector> Get value of input element value <selector> Get value of input element
attr <selector> <name> Get attribute value attr <selector> <name> Get attribute value
@@ -1975,8 +1977,8 @@ Global Options:
--session <name> Use specific session --session <name> Use specific session
Examples: Examples:
chrome-use get text @e1 chrome-use get text # whole page across ALL frames (default)
chrome-use get text --all-frames # read iframed content (listing pages) chrome-use get text @e1 # one element
chrome-use get text --main # main content, no nav/sidebar boilerplate chrome-use get text --main # main content, no nav/sidebar boilerplate
chrome-use frames # list frames + where the text lives chrome-use frames # list frames + where the text lives
chrome-use get html "#content" chrome-use get html "#content"
@@ -3188,7 +3190,7 @@ Navigation:
Get Info: chrome-use get <what> [selector] Get Info: chrome-use get <what> [selector]
text, html, value, attr <name>, title, url, count, box, styles, cdp-url text, html, value, attr <name>, title, url, count, box, styles, cdp-url
text --all-frames (cross-frame), text --main (no boilerplate), frames (list) text (no selector = whole page, all frames), text --main, frames (list)
Check State: chrome-use is <what> <selector> Check State: chrome-use is <what> <selector>
visible, enabled, checked visible, enabled, checked
@@ -3287,6 +3289,7 @@ Confirmation:
Sessions: Sessions:
session Show current session name session Show current session name
session list List active sessions session list List active sessions
sessions List running session daemons (alias of daemon status)
daemon status List running session daemons (+ relay state) daemon status List running session daemons (+ relay state)
daemon restart Kill all session daemons; keeps the extension relay daemon restart Kill all session daemons; keeps the extension relay
up. Clears stale/cross-leaked state after an upgrade. up. Clears stale/cross-leaked state after an upgrade.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "chrome-use", "name": "chrome-use",
"version": "1.5.1", "version": "1.5.5",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default", "description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module", "type": "module",
"packageManager": "pnpm@11.1.3", "packageManager": "pnpm@11.1.3",
+23 -9
View File
@@ -207,9 +207,10 @@ assigned fresh on every snapshot.
For unstructured reading (no refs needed): For unstructured reading (no refs needed):
```bash ```bash
chrome-use get text @e1 # visible text of an element chrome-use get text # WHOLE PAGE — all frames by default (see below)
chrome-use get text --all-frames # whole page, aggregated across ALL frames 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 --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 frames # list every frame + where the text lives
chrome-use get html @e1 # innerHTML chrome-use get html @e1 # innerHTML
chrome-use get attr @e1 href # any attribute chrome-use get attr @e1 href # any attribute
@@ -219,13 +220,26 @@ chrome-use get url # current URL
chrome-use get count ".item" # count matching elements chrome-use get count ".item" # count matching elements
``` ```
On listing/marketplace pages (Yahoo Auctions, Rakuten, Mercari shops) the seller's **Whole-page text is cross-frame by default.** `chrome-use get text` with no
description often lives in a **child frame** or is buried under a "related items" selector aggregates visible text across **every** frame — top document plus
sidebar, so a plain `get text body` returns only header/nav boilerplate. When the same-process child frames plus cross-origin iframes — so you never silently miss
text you expect is missing: run `chrome-use frames` to see where it is, then content that lives in an iframe (Yahoo Auctions / Rakuten / Mercari shop
`get text --all-frames` (reads every reachable frame incl. cross-origin iframes) descriptions, embedded checkout/spec frames). Each child frame is delimited with
or `get text --main` (drops the global chrome). If the content is lazy-loaded, a `----- frame [kind] url -----` marker. You do **not** need to remember a flag —
`scroll` it into view first. the default already reads all frames. (`--all-frames` is still accepted as an
explicit alias.)
So: when text looks missing or wrong, you don't have to guess — just
`chrome-use get text` reads everything. To **see** the structure (which frame
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 ## Interacting