Compare commits

...
17 Commits
Author SHA1 Message Date
leeguooooo 42f47c49aa chore(release): 1.5.9 — strip zero-width title unicode (#33) + screenshot --clip/element (#34)
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-16 14:26:41 +09:00
leeguooooo e29800df72 fix(tab-list): strip zero-width unicode from titles (#33); feat(screenshot): --clip pixel region + documented element capture (#34)
#33: some sites prepend runs of ZWJ/word-joiner/invisible-times/BOM to
document.title (badging/anti-scrape); left in, they polluted 'tab list', broke
text matching, and wrecked column alignment. sanitize_title() now strips
zero-width/bidi-format chars at every title ingestion point + get_title().

#34: 'screenshot <selector>' (element capture) already worked but was
undocumented; added 'screenshot --clip x,y,w,h' for an explicit pixel region
(CDP captureScreenshot clip), documented both in --help. Verified live.
2026-06-16 14:26:40 +09:00
leeguooooo e7e849ea39 chore(release): 1.5.8 — file upload over the extension relay (#13)
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-16 14:05:33 +09:00
leeguooooo ebb02c65c8 fix(relay): file upload now works over the extension relay (#13)
chrome.debugger forbids DOM.setFileInputFiles, so 'upload' used to hard-fail on
the relay and push users to a --launch/direct-CDP session. Now it falls back to
reconstructing the File entirely in the page (Playwright/Cypress-style: build a
File from the bytes, assign input.files = dataTransfer.files, fire input/change;
for drop/paste composers like X, dispatch synthetic paste+drop with the
DataTransfer). The bytes are streamed in <1 MiB base64 chunks because the relay
tunnels CDP through native messaging (1 MiB/message cap) — a whole image as one
arg closed the channel. Verified live over the relay: an 809 KB PNG lands intact
on a file input with change firing. No more direct-CDP needed for uploads.
2026-06-16 14:05:31 +09:00
leeguooooo 4317db636f chore(release): 1.5.7 — cf-status Cloudflare clearance preflight
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-16 12:19:26 +09:00
leeguooooo c99838a034 feat(cloudflare): cf-status preflight — skip re-solving when cf_clearance is still valid
Passing a Cloudflare challenge mints an HttpOnly cf_clearance cookie bound to
IP+UA. 'chrome-use cf-status' (aliases cf/cloudflare-status/clearance) reports
whether the active page is currently a CF challenge and whether a still-valid
cf_clearance exists (read via CDP — HttpOnly is invisible to document.cookie),
plus CF_VERIFIED_DEVICE trust, and a recommendation: proceed (already cleared,
don't re-solve) / solve (challenge up, no clearance) / reissue (clearance present
but page still blocks → IP/UA drifted). Lets an agent avoid re-solving what it
already cleared — the persistence optimization. Pure helpers unit-tested; live
-verified on a real cf_clearance.
2026-06-16 12:19:24 +09:00
leeguooooo dc2aa4cade chore(release): 1.5.6 — pin adopted tab against transient relay snapshots (#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 17:00:42 +09:00
leeguooooo 7085f3bf36 fix(relay): don't prune the pinned target on a transient getTargets snapshot (#31)
Driving a busy real Chrome via the relay, a single Target.getTargets call
occasionally returns a different window's tabs ('tab list hops windows'). resync
pruned every tracked page absent from that snapshot — including the agent's
explicitly-adopted (pinned) tab — after which active-target resolution fell back
to active_page_index and eval/click/snapshot drifted onto a foreign tab
(about:blank / chrome-extension:// / the user's page), breaking any 3+ step flow.

prunable_target_ids() now protects the pinned active target from snapshot-based
pruning; a genuine close still arrives as Target.targetDestroyed (event drain) and
removes it properly. Unit-tested.
2026-06-15 17:00:38 +09:00
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
13 changed files with 875 additions and 101 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.9"
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.9"
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"
+167 -28
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,
@@ -854,17 +907,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// selector: @ref or CSS selector // selector: @ref or CSS selector
// path: file path (contains / or . or ends with known extension) // path: file path (contains / or . or ends with known extension)
let mut full_page = false; let mut full_page = false;
let positional: Vec<&str> = rest let mut clip: Option<Value> = None;
.iter() let mut positional: Vec<&str> = Vec::new();
.filter(|arg| match **arg { let mut i = 0;
"--full" | "-f" => { while i < rest.len() {
full_page = true; match rest[i] {
false "--full" | "-f" => full_page = true,
// `--clip x,y,w,h` captures a pixel region (issue #34).
"--clip" => {
let raw = rest.get(i + 1).ok_or_else(|| ParseError::MissingArguments {
context: "screenshot --clip".to_string(),
usage: "screenshot --clip <x,y,w,h> [path]",
})?;
let nums: Vec<f64> = raw
.split(',')
.filter_map(|n| n.trim().parse::<f64>().ok())
.collect();
if nums.len() != 4 {
return Err(ParseError::InvalidValue {
message: format!("--clip expects 'x,y,w,h' (4 numbers), got '{raw}'"),
usage: "screenshot --clip <x,y,w,h> [path]",
});
}
clip = Some(json!({
"x": nums[0], "y": nums[1], "width": nums[2], "height": nums[3]
}));
i += 1;
} }
_ => true, other => positional.push(other),
}) }
.copied() i += 1;
.collect(); }
let (selector, path) = match (positional.first(), positional.get(1)) { let (selector, path) = match (positional.first(), positional.get(1)) {
(Some(first), Some(second)) => { (Some(first), Some(second)) => {
// Two args: first is selector, second is path // Two args: first is selector, second is path
@@ -895,6 +968,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"path": path, "selector": selector, "path": path, "selector": selector,
"fullPage": full_page, "annotate": flags.annotate "fullPage": full_page, "annotate": flags.annotate
}); });
if let Some(c) = clip {
cmd["clip"] = c;
}
if let Some(ref fmt) = flags.screenshot_format { if let Some(ref fmt) = flags.screenshot_format {
cmd["format"] = json!(fmt); cmd["format"] = json!(fmt);
} }
@@ -1025,6 +1101,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Ok(json!({ "id": id, "action": "stealth_status" })) Ok(json!({ "id": id, "action": "stealth_status" }))
} }
// `cf-status` — Cloudflare challenge/clearance preflight: is the page
// currently a CF challenge, and is there a still-valid cf_clearance (the
// HttpOnly persistence cookie)? Lets an agent SKIP re-solving when already
// cleared, and know when it must solve. Persistence optimization.
"cf-status" | "cf" | "cloudflare-status" | "clearance" => {
Ok(json!({ "id": id, "action": "cf_status" }))
}
// === Close === // === Close ===
"close" | "quit" | "exit" => { "close" | "quit" | "exit" => {
// `close <tab>` closes only that tab (and the output says "Tab // `close <tab>` closes only that tab (and the output says "Tab
@@ -2402,6 +2486,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 +2503,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 {
@@ -4257,6 +4350,21 @@ mod tests {
assert_eq!(cmd["fullPage"], true); assert_eq!(cmd["fullPage"], true);
} }
#[test]
fn test_screenshot_clip() {
// `--clip x,y,w,h` captures a pixel region (issue #34); the path still parses.
let cmd = parse_command(&args("screenshot --clip 10,20,200,40 out.png"), &default_flags())
.unwrap();
assert_eq!(cmd["action"], "screenshot");
assert_eq!(cmd["clip"]["x"], 10.0);
assert_eq!(cmd["clip"]["y"], 20.0);
assert_eq!(cmd["clip"]["width"], 200.0);
assert_eq!(cmd["clip"]["height"], 40.0);
assert_eq!(cmd["path"], "out.png");
// Bad clip is a clear error, not silent.
assert!(parse_command(&args("screenshot --clip 1,2,3"), &default_flags()).is_err());
}
#[test] #[test]
fn test_screenshot_with_ref() { fn test_screenshot_with_ref() {
let cmd = parse_command(&args("screenshot @e1"), &default_flags()).unwrap(); let cmd = parse_command(&args("screenshot @e1"), &default_flags()).unwrap();
@@ -4777,15 +4885,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 +4927,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 +4952,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()),
+157 -1
View File
@@ -1341,6 +1341,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"forward" => handle_forward(state).await, "forward" => handle_forward(state).await,
"reload" => handle_reload(state).await, "reload" => handle_reload(state).await,
"cookies_get" => handle_cookies_get(cmd, state).await, "cookies_get" => handle_cookies_get(cmd, state).await,
"cf_status" => handle_cf_status(cmd, state).await,
"cookies_set" => handle_cookies_set(cmd, state).await, "cookies_set" => handle_cookies_set(cmd, state).await,
"cookies_clear" => handle_cookies_clear(state).await, "cookies_clear" => handle_cookies_clear(state).await,
"storage_get" => handle_storage_get(cmd, state).await, "storage_get" => handle_storage_get(cmd, state).await,
@@ -2999,6 +3000,14 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
.get("screenshotDir") .get("screenshotDir")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
clip: cmd.get("clip").and_then(|c| {
Some((
c.get("x")?.as_f64()?,
c.get("y")?.as_f64()?,
c.get("width")?.as_f64()?,
c.get("height")?.as_f64()?,
))
}),
}; };
if annotate { if annotate {
@@ -3628,6 +3637,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) {
@@ -4113,6 +4131,108 @@ async fn handle_cookies_clear(state: &DaemonState) -> Result<Value, String> {
Ok(json!({ "cleared": true })) Ok(json!({ "cleared": true }))
} }
// Detect whether the active page is *currently* a Cloudflare challenge
// (the full-page "Just a moment…" / "正在进行安全验证" interstitial), so an agent
// knows whether it must solve or can proceed. Runs in the top frame main world.
const CF_CHALLENGE_JS: &str = r#"(function(){
var t = document.title || '';
var challenged =
/just a moment|attention required|checking (your|if)|verify you are human|||||/i.test(t) ||
!!document.querySelector('#challenge-form, #challenge-running, #cf-challenge-running, [id^="cf-chl"], script[src*="/cdn-cgi/challenge-platform/"]');
var turnstile = !!document.querySelector('.cf-turnstile, [data-sitekey]');
return JSON.stringify({ title: t, challenged: challenged, turnstile: turnstile, readyState: document.readyState });
})()"#;
/// Recommendation for a Cloudflare-gated page, from the current challenge state
/// and whether a still-valid `cf_clearance` exists. Pure so it's unit-testable.
/// - not challenged → "proceed" (the page is cleared/loaded)
/// - challenged, valid cookie → "reissue" (clearance present but page still
/// blocks → it's stale or the IP/UA no longer matches what it was issued for)
/// - challenged, no cookie → "solve"
fn cf_recommendation(challenged: bool, clearance_valid: bool) -> &'static str {
if !challenged {
"proceed"
} else if clearance_valid {
"reissue"
} else {
"solve"
}
}
/// `cf_clearance` validity for a cookie's expiry (epoch seconds; <=0 = session
/// cookie, treated as non-expiring). Returns (present, expired). Pure.
fn clearance_state(expires: Option<f64>, now: f64) -> (bool, bool) {
match expires {
None => (false, false),
Some(e) if e <= 0.0 => (true, false), // session cookie: no expiry
Some(e) => (true, e < now),
}
}
async fn handle_cf_status(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let url = mgr.get_url().await.unwrap_or_default();
// 1. Is the page a Cloudflare challenge right now?
let probe_raw = mgr.evaluate(CF_CHALLENGE_JS, None).await.unwrap_or(Value::Null);
let probe = parse_json_string(probe_raw, "cf challenge probe").unwrap_or(Value::Null);
let challenged = probe
.get("challenged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let turnstile = probe
.get("turnstile")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let title = probe
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// 2. Persistence artifacts: cf_clearance (HttpOnly → must read via CDP, not
// document.cookie) + CF_VERIFIED_DEVICE. Scope to the current URL.
let urls = if url.is_empty() {
None
} else {
Some(vec![url.clone()])
};
let cookies = super::cookies::get_cookies(&mgr.client, &session_id, urls)
.await
.unwrap_or_default();
let clearance = cookies.iter().find(|c| c.name == "cf_clearance");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let (present, expired) = clearance_state(clearance.map(|c| c.expires), now);
let expires_in = clearance
.filter(|_| present && !expired)
.map(|c| (c.expires - now).max(0.0) as i64);
let device_verified = cookies
.iter()
.any(|c| c.name.starts_with("CF_VERIFIED_DEVICE"));
let clearance_valid = present && !expired;
let recommendation = cf_recommendation(challenged, clearance_valid);
Ok(json!({
"url": url,
"title": title,
"challenged": challenged,
"turnstile": turnstile,
"clearance": {
"present": present,
"expired": expired,
"expiresIn": expires_in,
"httpOnly": clearance.map(|c| c.http_only).unwrap_or(false),
},
"deviceVerified": device_verified,
"recommendation": recommendation,
}))
}
async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> { async fn handle_storage_get(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string(); let session_id = mgr.active_session_id()?.to_string();
@@ -4561,7 +4681,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
@@ -7319,6 +7454,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Valu
quality: None, quality: None,
annotate: false, annotate: false,
output_dir: None, output_dir: None,
clip: None,
}; };
let result = screenshot::take_screenshot( let result = screenshot::take_screenshot(
@@ -9045,6 +9181,26 @@ mod tests {
use crate::test_utils::EnvGuard; use crate::test_utils::EnvGuard;
use std::fs; use std::fs;
#[test]
fn test_cf_recommendation() {
assert_eq!(cf_recommendation(false, false), "proceed");
assert_eq!(cf_recommendation(false, true), "proceed");
assert_eq!(cf_recommendation(true, false), "solve");
assert_eq!(cf_recommendation(true, true), "reissue");
}
#[test]
fn test_clearance_state() {
// no cookie
assert_eq!(clearance_state(None, 1000.0), (false, false));
// session cookie (expires <= 0) → present, never expired
assert_eq!(clearance_state(Some(-1.0), 1000.0), (true, false));
// valid: expiry in the future
assert_eq!(clearance_state(Some(2000.0), 1000.0), (true, false));
// expired: expiry in the past
assert_eq!(clearance_state(Some(500.0), 1000.0), (true, true));
}
#[test] #[test]
fn test_url_glob_to_regex() { fn test_url_glob_to_regex() {
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$"); assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
+285 -35
View File
@@ -121,7 +121,7 @@ fn normalize_url_for_match(url: &str) -> String {
fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool { fn update_page_target_info_in_pages(pages: &mut [PageInfo], target: &TargetInfo) -> bool {
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) { if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
page.url = target.url.clone(); page.url = target.url.clone();
page.title = target.title.clone(); page.title = sanitize_title(&target.title);
page.target_type = target.target_type.clone(); page.target_type = target.target_type.clone();
return true; return true;
} }
@@ -166,6 +166,75 @@ fn resolve_active_index(
active_page_index active_page_index
} }
/// Strip zero-width / invisible / bidi-format Unicode from a page title before
/// we store it. Some sites prepend runs of ZWJ / word-joiner / invisible-times /
/// BOM to `document.title` (badging, watermarking, anti-scrape); left in, they
/// pollute `tab list`, break text matching, and wreck column alignment (#33).
fn sanitize_title(s: &str) -> String {
s.chars()
.filter(|&c| {
!matches!(c as u32,
0x00AD // soft hyphen
| 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
| 0x2028 | 0x2029 // line / paragraph separators
| 0x202A..=0x202E // bidi embedding/override
| 0x2060..=0x2064 // word joiner, invisible operators
| 0x2066..=0x2069 // bidi isolates
| 0x180E // Mongolian vowel separator
| 0xFEFF // BOM / ZW no-break space
)
})
.collect::<String>()
.trim()
.to_string()
}
/// Best-effort MIME type from a filename extension, for the relay file-upload
/// fallback (the page-constructed `File` needs a sensible `type`). Covers the
/// common upload kinds; anything unknown falls back to a generic binary type.
fn mime_for_path(name: &str) -> &'static str {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"pdf" => "application/pdf",
"txt" => "text/plain",
"csv" => "text/csv",
"json" => "application/json",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"mp3" => "audio/mpeg",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
/// 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 /// 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`] /// 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. /// so the relay no-hijack rule is unit-testable without a live browser.
@@ -490,6 +559,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 +661,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,
@@ -713,7 +796,7 @@ impl BrowserManager {
target_id: target.target_id.clone(), target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(), session_id: attach_result.session_id.clone(),
url: target.url.clone(), url: target.url.clone(),
title: target.title.clone(), title: sanitize_title(&target.title),
target_type: target.target_type.clone(), target_type: target.target_type.clone(),
}); });
} }
@@ -939,7 +1022,7 @@ impl BrowserManager {
self.active_page_index = self.resolved_active_index(); self.active_page_index = self.resolved_active_index();
if let Some(page) = self.pages.get_mut(self.active_page_index) { if let Some(page) = self.pages.get_mut(self.active_page_index) {
page.url = page_url.clone(); page.url = page_url.clone();
page.title = title.clone(); page.title = sanitize_title(&title);
} }
self.pin_active_target(); self.pin_active_target();
@@ -1001,7 +1084,7 @@ impl BrowserManager {
pub async fn get_title(&self) -> Result<String, String> { pub async fn get_title(&self) -> Result<String, String> {
let result = self.evaluate_simple("document.title").await?; let result = self.evaluate_simple("document.title").await?;
Ok(result.as_str().unwrap_or("").to_string()) Ok(sanitize_title(result.as_str().unwrap_or("")))
} }
pub async fn get_content(&self) -> Result<String, String> { pub async fn get_content(&self) -> Result<String, String> {
@@ -1344,7 +1427,7 @@ impl BrowserManager {
target_id: target.target_id.clone(), target_id: target.target_id.clone(),
session_id: attach.session_id.clone(), session_id: attach.session_id.clone(),
url: target.url.clone(), url: target.url.clone(),
title: target.title.clone(), title: sanitize_title(&target.title),
target_type: target.target_type.clone(), target_type: target.target_type.clone(),
}; };
self.add_background_page(page.clone()); self.add_background_page(page.clone());
@@ -1405,19 +1488,16 @@ impl BrowserManager {
target_id: target.target_id.clone(), target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(), session_id: attach_result.session_id.clone(),
url: target.url.clone(), url: target.url.clone(),
title: target.title.clone(), title: sanitize_title(&target.title),
target_type: target.target_type.clone(), target_type: target.target_type.clone(),
}); });
let _ = self.enable_domains(&attach_result.session_id).await; let _ = self.enable_domains(&attach_result.session_id).await;
} }
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows. // Drop tabs that no longer exist so `tab list` doesn't show phantom rows
let gone: Vec<String> = self // but never prune the explicitly-pinned active target on a transient
.pages // getTargets snapshot (issue #31; see `prunable_target_ids`).
.iter() let gone = prunable_target_ids(&self.pages, &live_ids, self.active_target_id.as_deref());
.map(|p| p.target_id.clone())
.filter(|tid| !live_ids.contains(tid))
.collect();
for tid in gone { for tid in gone {
self.remove_page_by_target_id(&tid); self.remove_page_by_target_id(&tid);
} }
@@ -1451,7 +1531,7 @@ impl BrowserManager {
} }
} }
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) { if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
page.title = t.to_string(); page.title = sanitize_title(t);
} }
} }
} }
@@ -1639,7 +1719,7 @@ impl BrowserManager {
if let Some(page) = self.pages.get_mut(index) { if let Some(page) = self.pages.get_mut(index) {
page.url = url.clone(); page.url = url.clone();
page.title = title.clone(); page.title = sanitize_title(&title);
} }
let page = &self.pages[index]; let page = &self.pages[index];
@@ -1894,7 +1974,8 @@ impl BrowserManager {
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.ok_or("Could not get backendNodeId for file input")?; .ok_or("Could not get backendNodeId for file input")?;
self.client let set_files = self
.client
.send_command( .send_command(
"DOM.setFileInputFiles", "DOM.setFileInputFiles",
Some(json!({ Some(json!({
@@ -1903,26 +1984,153 @@ impl BrowserManager {
})), })),
Some(&effective_session_id), Some(&effective_session_id),
) )
.await .await;
.map_err(|e| {
// Chrome's chrome.debugger API (the extension-relay transport)
// forbids DOM.setFileInputFiles for security, surfacing as an
// opaque `-32000 "Not allowed"`. Translate it into an actionable
// message rather than leaking the raw CDP error (issue #13).
if e.contains("Not allowed") || e.contains("-32000") {
"file upload isn't supported over the extension relay — \
Chrome's chrome.debugger API forbids DOM.setFileInputFiles. \
Use a direct-CDP session instead: \
`chrome-use --session up --launch open <url>` (carry your \
login over with `cookies export` | `cookies set --curl`), \
then run `upload` in that session. \
See https://github.com/leeguooooo/chrome-use/issues/13"
.to_string()
} else {
e
}
})?;
if let Err(e) = set_files {
// Chrome's chrome.debugger API (the extension-relay transport) forbids
// DOM.setFileInputFiles for security, surfacing as an opaque
// `-32000 "Not allowed"`. Fall back to constructing the File entirely
// IN THE PAGE and assigning it to the input — the standard
// Playwright/Cypress trick, which needs no privileged CDP and so works
// over the relay (issue #13).
if e.contains("Not allowed") || e.contains("-32000") {
return self
.upload_files_via_page(object_id, files, &effective_session_id)
.await;
}
return Err(e);
}
Ok(())
}
/// Relay-safe file upload: read each file locally, hand its bytes to the page
/// as base64, and rebuild a `File` there — then either assign it to a file
/// `<input>` (Chrome allows `input.files = dataTransfer.files`) or, for a
/// dropzone/composer, dispatch synthetic `paste`/`drop` events carrying the
/// `DataTransfer`. No `DOM.setFileInputFiles`, so chrome.debugger permits it.
async fn upload_files_via_page(
&self,
object_id: String,
files: &[String],
session_id: &str,
) -> Result<(), String> {
use base64::Engine;
// The relay tunnels every CDP message through Chrome native messaging,
// which caps a single message at ~1 MiB. A whole image's base64 blows
// past that ("CDP response channel closed"), so we STREAM the bytes into
// a page-side buffer in sub-limit chunks, then assemble the File from it.
const CHUNK: usize = 96 * 1024; // base64 chars per message; safe under 1 MiB
// Reset the staging buffer.
self.client
.send_command(
"Runtime.evaluate",
Some(json!({ "expression": "window.__cuUpload = [];", "returnByValue": true })),
Some(session_id),
)
.await
.map_err(|e| format!("relay upload (reset) failed: {}", e))?;
for path in files {
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {}", path, e))?;
let name = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload.bin")
.to_string();
let mime = mime_for_path(&name);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
// Push the file's metadata with an empty buffer.
let init = format!(
"window.__cuUpload.push({{ name: {}, type: {}, b64: '' }});",
serde_json::to_string(&name).unwrap_or_default(),
serde_json::to_string(mime).unwrap_or_default(),
);
self.client
.send_command(
"Runtime.evaluate",
Some(json!({ "expression": init, "returnByValue": true })),
Some(session_id),
)
.await
.map_err(|e| format!("relay upload (init) failed: {}", e))?;
// Stream the base64 in chunks. base64's alphabet (AZaz09+/=) needs
// no escaping inside a single-quoted JS string, so concatenation is safe.
let idx = "window.__cuUpload[window.__cuUpload.length-1].b64";
let mut start = 0;
while start < b64.len() {
let end = (start + CHUNK).min(b64.len());
let chunk = &b64[start..end];
let expr = format!("{idx} += '{chunk}';");
self.client
.send_command(
"Runtime.evaluate",
Some(json!({ "expression": expr, "returnByValue": true })),
Some(session_id),
)
.await
.map_err(|e| format!("relay upload (chunk) failed: {}", e))?;
start = end;
}
}
// Assemble the Files from the buffer and attach to the element, then clean up.
let func = r#"function() {
const filesData = window.__cuUpload || [];
const dt = new DataTransfer();
for (const f of filesData) {
const bin = atob(f.b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
dt.items.add(new File([arr], f.name, { type: f.type }));
}
try { delete window.__cuUpload; } catch (e) { window.__cuUpload = undefined; }
const el = this;
if (el.tagName === 'INPUT' && el.type === 'file') {
el.files = dt.files;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return 'input:' + dt.files.length;
}
// Dropzone / rich composer: replay paste then drop with the files.
try { el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt })); } catch (e) {}
try {
const ev = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(ev, 'dataTransfer', { value: dt });
el.dispatchEvent(ev);
} catch (e) {}
return 'event:' + dt.files.length;
}"#;
let result: EvaluateResult = self
.client
.send_command_typed(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: func.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
.map_err(|e| format!("relay file-injection failed: {}", e))?;
if let Some(ref details) = result.exception_details {
return Err(format!(
"relay file-injection threw: {}",
details
.exception
.as_ref()
.and_then(|ex| ex.description.as_deref())
.unwrap_or(&details.text)
));
}
Ok(()) Ok(())
} }
@@ -2582,6 +2790,48 @@ mod tests {
assert!(!active_index_is_owned(&[], None, 0, &created)); assert!(!active_index_is_owned(&[], None, 0, &created));
} }
#[test]
fn test_sanitize_title() {
// The exact pollution from #33: ZWJ / word-joiner / invisible-times / BOM
// prepended to "GitHub".
let dirty = "\u{200d}\u{2061}\u{200d}\u{2063}\u{200b}\u{2062}\u{feff}GitHub";
assert_eq!(sanitize_title(dirty), "GitHub");
// Clean titles (incl. CJK + normal punctuation) pass through untouched.
assert_eq!(sanitize_title("購入手続きへ - メルカリ"), "購入手続きへ - メルカリ");
assert_eq!(sanitize_title(" Hello World "), "Hello World");
// Emoji and real content survive; only the invisibles are dropped.
assert_eq!(sanitize_title("✓ Done\u{200b}"), "✓ Done");
}
#[test]
fn test_mime_for_path() {
assert_eq!(mime_for_path("a.png"), "image/png");
assert_eq!(mime_for_path("PHOTO.JPG"), "image/jpeg");
assert_eq!(mime_for_path("clip.webp"), "image/webp");
assert_eq!(mime_for_path("doc.pdf"), "application/pdf");
assert_eq!(mime_for_path("noext"), "application/octet-stream");
assert_eq!(mime_for_path("weird.xyz"), "application/octet-stream");
}
#[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] #[test]
fn resolve_active_index_pin_survives_passive_background_tab() { fn resolve_active_index_pin_survives_passive_background_tab() {
// A foreign tab ("Z") gets appended by passive discovery after we pinned // A foreign tab ("Z") gets appended by passive discovery after we pinned
+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()));
+14 -1
View File
@@ -60,6 +60,9 @@ pub struct ScreenshotOptions {
pub quality: Option<i32>, pub quality: Option<i32>,
pub annotate: bool, pub annotate: bool,
pub output_dir: Option<String>, pub output_dir: Option<String>,
/// Explicit pixel region (x, y, width, height) — `--clip` (issue #34). Takes
/// precedence over selector/full_page.
pub clip: Option<(f64, f64, f64, f64)>,
} }
impl Default for ScreenshotOptions { impl Default for ScreenshotOptions {
@@ -72,6 +75,7 @@ impl Default for ScreenshotOptions {
quality: None, quality: None,
annotate: false, annotate: false,
output_dir: None, output_dir: None,
clip: None,
} }
} }
} }
@@ -187,7 +191,16 @@ async fn capture_screenshot_base64(
capture_beyond_viewport: if options.full_page { Some(true) } else { None }, capture_beyond_viewport: if options.full_page { Some(true) } else { None },
}; };
if options.full_page { if let Some((x, y, width, height)) = options.clip {
// Explicit pixel region wins over selector/full_page (issue #34).
params.clip = Some(Viewport {
x,
y,
width,
height,
scale: 1.0,
});
} else if options.full_page {
let metrics: Value = client let metrics: Value = client
.send_command_no_params("Page.getLayoutMetrics", Some(session_id)) .send_command_no_params("Page.getLayoutMetrics", Some(session_id))
.await?; .await?;
+62 -17
View File
@@ -224,6 +224,40 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
return; return;
} }
// Cloudflare challenge/clearance preflight (`cf-status`). Checked early
// because its response carries `url`/`title`, which later generic
// renderers would otherwise swallow.
if action == Some("cf_status") {
let challenged = data.get("challenged").and_then(|v| v.as_bool()).unwrap_or(false);
let rec = data.get("recommendation").and_then(|v| v.as_str()).unwrap_or("?");
let cl = data.get("clearance");
let present = cl.and_then(|c| c.get("present")).and_then(|v| v.as_bool()).unwrap_or(false);
let expired = cl.and_then(|c| c.get("expired")).and_then(|v| v.as_bool()).unwrap_or(false);
let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64());
let device = data.get("deviceVerified").and_then(|v| v.as_bool()).unwrap_or(false);
let (icon, headline) = match rec {
"proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"),
"solve" => (color::warning_indicator().to_string(), "Cloudflare challenge active, no valid clearance — solve it"),
"reissue" => (color::warning_indicator().to_string(), "challenge active but a clearance cookie exists — stale (IP/UA changed?), re-solve"),
_ => (color::cyan("").to_string(), "unknown"),
};
println!("{} {}", icon, headline);
println!(" challenged: {}", if challenged { "yes" } else { "no" });
let cl_desc = if !present {
"absent".to_string()
} else if expired {
"present but EXPIRED".to_string()
} else if let Some(s) = expires_in {
format!("valid, expires in {}m {}s", s / 60, s % 60)
} else {
"present (session)".to_string()
};
println!(" cf_clearance: {}", cl_desc);
println!(" device trusted: {}", if device { "yes (CF_VERIFIED_DEVICE)" } else { "no" });
return;
}
// Dialog status response // Dialog status response
if action == Some("dialog") { if action == Some("dialog") {
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) { if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
@@ -597,19 +631,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;
} }
@@ -1796,6 +1832,8 @@ Pass --hide-scrollbars false when launching to keep native scrollbars visible.
Options: Options:
--full, -f Capture full page (not just viewport) --full, -f Capture full page (not just viewport)
[selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png`
--clip <x,y,w,h> Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png`
--annotate Overlay numbered labels on interactive elements. --annotate Overlay numbered labels on interactive elements.
Each label [N] corresponds to ref @eN from snapshot. Each label [N] corresponds to ref @eN from snapshot.
Prints a legend mapping labels to element roles/names. Prints a legend mapping labels to element roles/names.
@@ -1816,6 +1854,8 @@ Examples:
chrome-use screenshot chrome-use screenshot
chrome-use screenshot ./screenshot.png chrome-use screenshot ./screenshot.png
chrome-use screenshot --full ./full-page.png chrome-use screenshot --full ./full-page.png
chrome-use screenshot ".header .indicator" corner.png # just one element
chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region
chrome-use screenshot --annotate # Labeled screenshot + legend chrome-use screenshot --annotate # Labeled screenshot + legend
chrome-use screenshot --annotate ./page.png # Save annotated screenshot chrome-use screenshot --annotate ./page.png # Save annotated screenshot
chrome-use screenshot --annotate --json # JSON output with annotations chrome-use screenshot --annotate --json # JSON output with annotations
@@ -1957,9 +1997,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 +2015,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,11 +3228,15 @@ 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
Anti-bot: chrome-use stealth | cf-status
stealth stealth self-check (webdriver/UA/plugins + overrides)
cf-status Cloudflare challenge + cf_clearance preflight (skip re-solving)
Find Elements: chrome-use find <locator> <value> <action> [text] Find Elements: chrome-use find <locator> <value> <action> [text]
role, text, label, placeholder, alt, title, testid, first, last, nth role, text, label, placeholder, alt, title, testid, first, last, nth
@@ -3287,6 +3331,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.9",
"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",
+41 -14
View File
@@ -127,6 +127,19 @@ cadence, and scroll/drag ease. Default `off`; a per-navigation detector
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
on auto; force `human` only when you already know the target scores behaviour. on auto; force `human` only when you already know the target scores behaviour.
**Cloudflare clearance — solve once, reuse.** Passing a Cloudflare challenge
mints a `cf_clearance` cookie (HttpOnly — invisible to `eval`/`document.cookie`;
read it via `chrome-use cookies`). It's bound to your **IP + User-Agent**: reuse
the same exit IP and UA and you skip the challenge until it expires. Driving the
user's real Chrome (relay) persists it natively; for isolated sessions,
`--session-name <name>` save/restores it. Before spending effort solving, run
`chrome-use cf-status` (aliases `cf`, `clearance`): it reports whether the page
is *currently* a Cloudflare challenge and whether a still-valid `cf_clearance`
exists, with a recommendation — `proceed` (already cleared, don't re-solve),
`solve` (challenge up, no clearance), or `reissue` (clearance present but page
still blocks → IP/UA drifted, re-solve). Use it as a preflight to avoid
re-solving what you already cleared.
## Two ways to drive a page — and when to drop to `eval` ## Two ways to drive a page — and when to drop to `eval`
You have a **real Chrome with the user's DOM**. Two layers, mix them freely: You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
@@ -207,9 +220,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 +233,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
@@ -254,11 +281,11 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
# (no silent no-op). Use this for custom # (no silent no-op). Use this for custom
# dropdowns where `select` returns ✓ but # dropdowns where `select` returns ✓ but
# changes nothing. # changes nothing.
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP chrome-use upload @e5 file1.pdf # upload file(s) — works over the extension relay too:
# session. Over the extension relay it CANNOT work # chrome.debugger forbids setFileInputFiles, so the
# (Chrome's chrome.debugger forbids it); chrome-use # file's bytes are streamed into the page and rebuilt as
# errors with a hint. Carry your login into a launched # a File there (chunked under native-messaging's 1 MiB cap).
# session via `cookies export` | `cookies set --curl`. # Works on file <input>s and drop/paste composers (e.g. X).
chrome-use scroll down 500 # scroll page (up/down/left/right) chrome-use scroll down 500 # scroll page (up/down/left/right)
chrome-use scrollintoview @e1 # scroll element into view chrome-use scrollintoview @e1 # scroll element into view
chrome-use drag @e1 @e2 # drag and drop chrome-use drag @e1 @e2 # drag and drop