Compare commits

..
10 Commits
Author SHA1 Message Date
leeguooooo 70ab38d35f chore(release): 1.5.11 — cross-origin iframe scroll/click + open auto-reattach (#35, #36)
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 18:05:15 +09:00
leeguooooo 6830df50ea fix(relay): reach cross-origin iframes; auto-reattach open (#35, #36)
#35: `open` auto-reattaches when the bound relay tab is gone — drops the dead
page, opens a fresh tab in the session's group, and navigates it, instead of
only `tab new` recovering.

#36: scroll and click now reach content inside cross-origin OOPIFs:
- scroll dispatches a real wheel at a viewport point (default center, --at x,y,
  or --frame n) so it scrolls the iframe under the pointer, which
  window.scrollBy on the top document silently no-ops on.
- over the extension relay, clicks always use DOM-dispatch instead of
  coordinate Input events — a coordinate event isn't confined to the target tab
  on a busy real Chrome (it drifted onto the foreground tab) and an OOPIF
  element's box can't be mapped to a top-viewport point.
2026-06-16 18:05:06 +09:00
leeguooooo cd47ec43d0 chore(release): 1.5.10 — warn on debug-port launch while relay is up (#32)
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:32:29 +09:00
leeguooooo 2cd361817d fix(launch): warn when launching a debug-port Chrome while the relay is up (#32)
The connect-mode diagnostic (1.5.5) proved the 'Allow remote debugging?' modal
is NOT Chrome 149 UX (my earlier hypothesis) — it's chrome-use launching a fresh
debug-port Chrome on session=default while the ab-connect relay is up (32 logged
CONSENT-MODAL-RISK launches), almost always from a stray --launch/--no-auto-connect.
A launch now warns loudly when the relay is available, naming the modal and how
to avoid it (drop --launch/--new, don't pass --no-auto-connect), so the modal is
self-explained and the offending caller is fixable.
2026-06-16 14:32:28 +09:00
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
11 changed files with 876 additions and 63 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrome-use"
version = "1.5.6"
version = "1.5.11"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chrome-use"
version = "1.5.6"
version = "1.5.11"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+132 -11
View File
@@ -729,10 +729,56 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
} else {
return Err(ParseError::MissingArguments {
context: "scroll --selector".to_string(),
usage: "scroll [direction] [amount] [--selector <sel>]",
usage: "scroll [direction] [amount] [--selector <sel>] [--at <x,y>] [--frame <n>]",
});
}
}
"--at" => {
// `--at x,y`: dispatch the wheel at this viewport pixel, so it
// scrolls whatever element/iframe is under the pointer — including
// cross-origin iframes that `window.scrollBy` can't reach (#36).
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
context: "scroll --at".to_string(),
usage: "scroll [direction] [amount] --at <x,y>",
})?;
let mut parts = val.split(',');
match (
parts.next().and_then(|s| s.trim().parse::<f64>().ok()),
parts.next().and_then(|s| s.trim().parse::<f64>().ok()),
) {
(Some(x), Some(y)) => {
obj.insert("at".to_string(), json!([x, y]));
}
_ => {
return Err(ParseError::InvalidValue {
message: format!("scroll --at: invalid coordinate `{}`", val),
usage: "scroll [direction] [amount] --at <x,y> (e.g. --at 640,400)",
})
}
}
i += 1;
}
"--frame" => {
// `--frame n`: scroll the n-th frame from `chrome-use frames` by
// dispatching the wheel at that frame's center — reaches content in
// a cross-origin iframe without needing a selector into it (#36).
let val = rest.get(i + 1).ok_or(ParseError::MissingArguments {
context: "scroll --frame".to_string(),
usage: "scroll [direction] [amount] --frame <n>",
})?;
match val.trim().parse::<usize>() {
Ok(n) => {
obj.insert("frame".to_string(), json!(n));
}
Err(_) => {
return Err(ParseError::InvalidValue {
message: format!("scroll --frame: invalid index `{}`", val),
usage: "scroll [direction] [amount] --frame <n> (index from `chrome-use frames`)",
})
}
}
i += 1;
}
arg if arg.starts_with('-') => {}
_ => {
match positional_index {
@@ -907,17 +953,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// selector: @ref or CSS selector
// path: file path (contains / or . or ends with known extension)
let mut full_page = false;
let positional: Vec<&str> = rest
.iter()
.filter(|arg| match **arg {
"--full" | "-f" => {
full_page = true;
false
let mut clip: Option<Value> = None;
let mut positional: Vec<&str> = Vec::new();
let mut i = 0;
while i < rest.len() {
match rest[i] {
"--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,
})
.copied()
.collect();
other => positional.push(other),
}
i += 1;
}
let (selector, path) = match (positional.first(), positional.get(1)) {
(Some(first), Some(second)) => {
// Two args: first is selector, second is path
@@ -948,6 +1014,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
"path": path, "selector": selector,
"fullPage": full_page, "annotate": flags.annotate
});
if let Some(c) = clip {
cmd["clip"] = c;
}
if let Some(ref fmt) = flags.screenshot_format {
cmd["format"] = json!(fmt);
}
@@ -1078,6 +1147,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
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" | "quit" | "exit" => {
// `close <tab>` closes only that tab (and the output says "Tab
@@ -4319,6 +4396,21 @@ mod tests {
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]
fn test_screenshot_with_ref() {
let cmd = parse_command(&args("screenshot @e1"), &default_flags()).unwrap();
@@ -5822,6 +5914,35 @@ mod tests {
assert_eq!(cmd["selector"], ".sidebar");
}
#[test]
fn test_scroll_at_coordinate() {
// `--at x,y` carries a [x, y] array for a wheel dispatched at that pixel
// (issue #36: cross-origin iframe scroll).
let cmd = parse_command(&args("scroll down 700 --at 640,400"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "scroll");
assert_eq!(cmd["direction"], "down");
assert_eq!(cmd["amount"], 700);
assert_eq!(cmd["at"], json!([640.0, 400.0]));
}
#[test]
fn test_scroll_at_rejects_garbage() {
assert!(parse_command(&args("scroll --at nope"), &default_flags()).is_err());
assert!(parse_command(&args("scroll --at 1"), &default_flags()).is_err());
}
#[test]
fn test_scroll_frame_index() {
let cmd = parse_command(&args("scroll down 700 --frame 2"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "scroll");
assert_eq!(cmd["frame"], 2);
}
#[test]
fn test_scroll_frame_rejects_non_integer() {
assert!(parse_command(&args("scroll --frame two"), &default_flags()).is_err());
}
#[test]
fn test_scroll_selector_before_positional() {
let cmd =
+14
View File
@@ -1369,6 +1369,20 @@ fn main() {
&& flags.provider.is_none()
&& (flags.force_launch || !flags.auto_connect)
{
// Launching a debug-port Chrome pops Chrome's "Allow remote debugging?"
// consent modal (Chrome 136+). When the ab-connect relay is already up,
// this is almost always unintended — the relay drives the user's real
// Chrome with NO modal. Warn so the modal is self-explained and the
// caller (often a stray --launch / --no-auto-connect) is fixable (#32).
if !flags.json && connect::relay_url().is_some() {
eprintln!(
"{} launching a new Chrome with a debug port — this pops Chrome's \
\"Allow remote debugging?\" modal.\n The ab-connect relay is up; \
drop --launch/--new (and don't pass --no-auto-connect) to drive your \
real Chrome with no modal.",
color::warning_indicator()
);
}
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
+295 -11
View File
@@ -1341,6 +1341,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"forward" => handle_forward(state).await,
"reload" => handle_reload(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_clear" => handle_cookies_clear(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")
.and_then(|v| v.as_str())
.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 {
@@ -3458,17 +3467,169 @@ async fn handle_scroll(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
}
}
interaction::scroll(
&mgr.client,
&session_id,
&state.ref_map,
selector,
dx,
dy,
&state.iframe_sessions,
)
.await?;
Ok(json!({ "scrolled": true }))
// An explicit `--selector` keeps the precise element-scroll path (scrollBy on
// the resolved node, same-origin only).
if let Some(sel) = selector {
interaction::scroll(
&mgr.client,
&session_id,
&state.ref_map,
Some(sel),
dx,
dy,
&state.iframe_sessions,
)
.await?;
return Ok(json!({ "scrolled": true, "via": "selector" }));
}
// No selector: dispatch a real (isTrusted) wheel at a viewport coordinate.
// This hits the compositor and scrolls whatever scroll container is under the
// pointer — including cross-origin iframes that `window.scrollBy` on the top
// document silently no-ops on (issue #36). The coordinate is, in priority:
// --at x,y → that exact pixel
// --frame n → the center of frame n from `chrome-use frames`
// default → the viewport center
let (x, y, via) = if let Some(at) = cmd.get("at").and_then(|v| v.as_array()) {
let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
(x, y, "at")
} else if let Some(n) = cmd.get("frame").and_then(|v| v.as_u64()) {
let (x, y) = frame_center(mgr, &session_id, &state.iframe_sessions, n as usize).await?;
(x, y, "frame")
} else {
let (x, y) = viewport_center(mgr, &session_id).await?;
(x, y, "center")
};
dispatch_wheel(&mgr.client, &session_id, x, y, dx, dy).await?;
Ok(json!({ "scrolled": true, "via": via, "at": [x, y] }))
}
/// Viewport center in CSS pixels, used as the default wheel landing point for
/// `scroll` (issue #36). Falls back to a sane 640×400 center if the page can't
/// be evaluated (e.g. a restricted document).
async fn viewport_center(mgr: &BrowserManager, session_id: &str) -> Result<(f64, f64), String> {
let dims = mgr
.client
.send_command_typed::<_, Value>(
"Runtime.evaluate",
&super::cdp::types::EvaluateParams {
expression: "[window.innerWidth, window.innerHeight]".to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
.ok();
let arr = dims
.as_ref()
.and_then(|v| v.get("result"))
.and_then(|v| v.get("value"))
.and_then(|v| v.as_array());
let w = arr
.and_then(|a| a.first())
.and_then(|v| v.as_f64())
.filter(|w| *w > 0.0)
.unwrap_or(1280.0);
let h = arr
.and_then(|a| a.get(1))
.and_then(|v| v.as_f64())
.filter(|h| *h > 0.0)
.unwrap_or(800.0);
Ok((w / 2.0, h / 2.0))
}
/// Center of the `n`-th frame (as listed by `chrome-use frames`) in top-viewport
/// CSS pixels, so `scroll --frame n` lands its wheel inside a cross-origin iframe
/// without needing a selector into it (issue #36). Resolves the frame's owning
/// `<iframe>` element box via `DOM.getFrameOwner` + `DOM.getBoxModel` — exact for
/// a frame nested directly under the top document; for a deeper nesting the box is
/// relative to the intermediate frame, so prefer `--at x,y` from a screenshot.
async fn frame_center(
mgr: &BrowserManager,
session_id: &str,
iframe_sessions: &HashMap<String, String>,
n: usize,
) -> Result<(f64, f64), String> {
let frames =
super::element::collect_all_frames_text(&mgr.client, session_id, iframe_sessions).await?;
let frame = frames.get(n).ok_or_else(|| {
format!(
"frame index {} out of range (run `chrome-use frames`: {} frame(s))",
n,
frames.len()
)
})?;
if n == 0 {
// Frame 0 is the top document — there's no owner element; scroll its center.
return viewport_center(mgr, session_id).await;
}
let owner = mgr
.client
.send_command_typed::<_, Value>(
"DOM.getFrameOwner",
&json!({ "frameId": frame.frame_id }),
Some(session_id),
)
.await
.map_err(|e| format!("can't locate frame {}'s owner element: {}", n, e))?;
let backend_node_id = owner
.get("backendNodeId")
.and_then(|v| v.as_i64())
.ok_or_else(|| format!("frame {} has no owner <iframe> element", n))?;
let box_model = mgr
.client
.send_command_typed::<_, Value>(
"DOM.getBoxModel",
&json!({ "backendNodeId": backend_node_id }),
Some(session_id),
)
.await
.map_err(|e| format!("can't measure frame {}'s box: {}", n, e))?;
let content = box_model
.get("model")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.ok_or_else(|| format!("frame {} box model has no content quad", n))?;
let coord = |i: usize| content.get(i).and_then(|v| v.as_f64()).unwrap_or(0.0);
// content quad is [x1,y1, x2,y2, x3,y3, x4,y4]; opposite corners are 0 and 2.
let cx = (coord(0) + coord(4)) / 2.0;
let cy = (coord(1) + coord(5)) / 2.0;
Ok((cx, cy))
}
/// Dispatch a trusted mouse wheel at `(x, y)`, humanized like `handle_wheel`.
async fn dispatch_wheel(
client: &super::cdp::client::CdpClient,
session_id: &str,
x: f64,
y: f64,
delta_x: f64,
delta_y: f64,
) -> Result<(), String> {
let level = humanize::active_level();
let seed = humanize::next_seed();
for (dx, dy, delay) in humanize::scroll_segments(delta_x, delta_y, level, seed) {
client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({
"type": "mouseWheel",
"x": x,
"y": y,
"deltaX": dx,
"deltaY": dy,
})),
Some(session_id),
)
.await?;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
Ok(())
}
async fn handle_select(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
@@ -4122,6 +4283,108 @@ async fn handle_cookies_clear(state: &DaemonState) -> Result<Value, String> {
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> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
@@ -7343,6 +7606,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Valu
quality: None,
annotate: false,
output_dir: None,
clip: None,
};
let result = screenshot::take_screenshot(
@@ -9069,6 +9333,26 @@ mod tests {
use crate::test_utils::EnvGuard;
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]
fn test_url_glob_to_regex() {
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
+315 -31
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 {
if let Some(page) = pages.iter_mut().find(|p| p.target_id == target.target_id) {
page.url = target.url.clone();
page.title = target.title.clone();
page.title = sanitize_title(&target.title);
page.target_type = target.target_type.clone();
return true;
}
@@ -166,6 +166,54 @@ fn resolve_active_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
@@ -206,6 +254,21 @@ fn active_index_is_owned(
.unwrap_or(false)
}
/// Whether a CDP error means the bound relay target is gone — the tab was
/// closed, navigated across processes (renderer swap), or lost after an
/// extension/service-worker restart, and the relay could not re-attach. The
/// ab-connect relay surfaces these as `stale sessionId … its tab is gone`,
/// `unknown sessionId …`, or `no attached tab …`. `navigate` keys its
/// auto-reattach recovery off this (issue #35) so a dead session rebinds to a
/// fresh tab instead of erroring on every command until the user runs `tab new`.
fn is_stale_target_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("its tab is gone")
|| lower.contains("stale sessionid")
|| lower.contains("unknown sessionid")
|| lower.contains("no attached tab")
}
/// Converts common error messages into AI-friendly, actionable descriptions.
pub fn to_ai_friendly_error(error: &str) -> String {
let lower = error.to_lowercase();
@@ -748,7 +811,7 @@ impl BrowserManager {
target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
title: sanitize_title(&target.title),
target_type: target.target_type.clone(),
});
}
@@ -867,6 +930,24 @@ impl BrowserManager {
)
}
/// Drop the page bound to `session_id` from the tracked list — used when the
/// relay reports its tab is gone (issue #35) so the stale entry can't keep
/// resolving as active. Forgets ownership, unpins it if it was pinned, and
/// keeps `active_page_index` in range.
fn drop_page_by_session(&mut self, session_id: &str) {
let Some(pos) = self.pages.iter().position(|p| p.session_id == session_id) else {
return;
};
let target_id = self.pages[pos].target_id.clone();
self.pages.remove(pos);
self.created_targets.remove(&target_id);
if self.active_target_id.as_deref() == Some(target_id.as_str()) {
self.active_target_id = None;
}
self.active_page_index =
active_page_index_after_removal(self.active_page_index, pos, self.pages.len());
}
/// Pin the current active page by target_id so later commands stick to it.
/// Call after any explicit open / tab new / tab switch.
fn pin_active_target(&mut self) {
@@ -896,10 +977,10 @@ impl BrowserManager {
if self.agent_group().is_some() && !self.active_is_session_owned() {
self.tab_new(None, None).await?;
}
let session_id = self.active_session_id()?.to_string();
let mut session_id = self.active_session_id()?.to_string();
let mut lifecycle_rx = self.client.subscribe();
let nav_result: PageNavigateResult = self
let nav_result: PageNavigateResult = match self
.client
.send_command_typed(
"Page.navigate",
@@ -909,7 +990,38 @@ impl BrowserManager {
},
Some(&session_id),
)
.await?;
.await
{
Ok(r) => r,
// Auto-reattach when the bound tab is gone (issue #35). On the shared
// real browser the human can close/swap the agent's tab, and a
// cross-process nav can destroy the target without a re-attachable
// tabId — both leave the cached `cb-tab-<id>` session stale, so every
// command (including `open`) failed on it and only `tab new`
// recovered. The relay error literally says "re-open your target URL
// to re-attach"; fulfil that here: drop the dead page, open a fresh
// owned tab in this session's group, and navigate THAT. Gated on the
// relay (`agent_group`) and on the explicit navigation intent — read
// commands deliberately still fail loudly rather than silently
// recover onto a blank tab and return wrong data (issue #8.1).
Err(e) if self.agent_group().is_some() && is_stale_target_error(&e) => {
self.drop_page_by_session(&session_id);
self.tab_new(None, None).await?;
session_id = self.active_session_id()?.to_string();
lifecycle_rx = self.client.subscribe();
self.client
.send_command_typed(
"Page.navigate",
&PageNavigateParams {
url: url.to_string(),
referrer: None,
},
Some(&session_id),
)
.await?
}
Err(e) => return Err(e),
};
if let Some(ref error_text) = nav_result.error_text {
return Err(format!("Navigation failed: {}", error_text));
@@ -974,7 +1086,7 @@ impl BrowserManager {
self.active_page_index = self.resolved_active_index();
if let Some(page) = self.pages.get_mut(self.active_page_index) {
page.url = page_url.clone();
page.title = title.clone();
page.title = sanitize_title(&title);
}
self.pin_active_target();
@@ -1036,7 +1148,7 @@ impl BrowserManager {
pub async fn get_title(&self) -> Result<String, String> {
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> {
@@ -1379,7 +1491,7 @@ impl BrowserManager {
target_id: target.target_id.clone(),
session_id: attach.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
title: sanitize_title(&target.title),
target_type: target.target_type.clone(),
};
self.add_background_page(page.clone());
@@ -1440,7 +1552,7 @@ impl BrowserManager {
target_id: target.target_id.clone(),
session_id: attach_result.session_id.clone(),
url: target.url.clone(),
title: target.title.clone(),
title: sanitize_title(&target.title),
target_type: target.target_type.clone(),
});
let _ = self.enable_domains(&attach_result.session_id).await;
@@ -1483,7 +1595,7 @@ impl BrowserManager {
}
}
if let Some(t) = ti.get("title").and_then(|v| v.as_str()) {
page.title = t.to_string();
page.title = sanitize_title(t);
}
}
}
@@ -1671,7 +1783,7 @@ impl BrowserManager {
if let Some(page) = self.pages.get_mut(index) {
page.url = url.clone();
page.title = title.clone();
page.title = sanitize_title(&title);
}
let page = &self.pages[index];
@@ -1926,7 +2038,8 @@ impl BrowserManager {
.and_then(|v| v.as_i64())
.ok_or("Could not get backendNodeId for file input")?;
self.client
let set_files = self
.client
.send_command(
"DOM.setFileInputFiles",
Some(json!({
@@ -1935,26 +2048,153 @@ impl BrowserManager {
})),
Some(&effective_session_id),
)
.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
}
})?;
.await;
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(())
}
@@ -2517,6 +2757,27 @@ mod tests {
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
}
#[test]
fn stale_target_error_matches_relay_signatures() {
// The exact relay error `open` must recover from (issue #35), as wrapped
// by send_command's `CDP error (Page.navigate): …` prefix.
assert!(is_stale_target_error(
"CDP error (Page.navigate): stale sessionId cb-tab-1655244623 for Page.navigate: \
its tab is gone (closed, navigated across processes, or lost after an extension \
restart). Re-attach by re-opening your target URL before retrying."
));
assert!(is_stale_target_error("unknown sessionId cb-tab-7 for Page.navigate"));
assert!(is_stale_target_error("no attached tab for Page.navigate"));
}
#[test]
fn stale_target_error_ignores_unrelated_failures() {
// A genuine navigation failure (bad URL, DNS, blocked) must NOT trigger
// the open-a-fresh-tab recovery — that would mask the real error.
assert!(!is_stale_target_error("Navigation failed: net::ERR_NAME_NOT_RESOLVED"));
assert!(!is_stale_target_error("CDP command timed out: Page.navigate"));
}
fn page(target_id: &str) -> PageInfo {
PageInfo {
tab_id: 1,
@@ -2614,6 +2875,29 @@ mod tests {
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"
+25
View File
@@ -45,6 +45,31 @@ pub async fn click(
.await;
}
// Over the extension relay we drive the user's real, in-use Chrome, where a
// coordinate `Input.dispatchMouseEvent` is NOT reliably confined to our target
// tab — it can be delivered to whatever tab is in the foreground, and an OOPIF
// element's box can't be mapped to a top-viewport point at all. This twice
// opened an unrelated tab on the user's busy Chrome (issues #31/#36). So on the
// relay, never use coordinates for a normal left click: DOM-dispatch invokes
// the element's click in its own (frame) session, always hitting the right
// element in the right tab. Double/right clicks still need true pointer
// semantics, and `coord` mode is an explicit opt-out.
if mode != "coord" && button == "left" && click_count == 1 {
let in_iframe = parse_ref(selector_or_ref)
.and_then(|r| ref_map.get(&r).map(|e| e.frame_id.is_some()))
.unwrap_or(false);
if in_iframe || crate::connect::relay_url().is_some() {
return dom_click(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
}
}
let resolved = resolve_element_center(
client,
session_id,
+14 -1
View File
@@ -60,6 +60,9 @@ pub struct ScreenshotOptions {
pub quality: Option<i32>,
pub annotate: bool,
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 {
@@ -72,6 +75,7 @@ impl Default for ScreenshotOptions {
quality: None,
annotate: false,
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 },
};
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
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
.await?;
+56 -1
View File
@@ -224,6 +224,40 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
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
if action == Some("dialog") {
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
@@ -1701,12 +1735,23 @@ Usage: chrome-use scroll [direction] [amount] [options]
Scrolls the page or a specific element in the specified direction.
Without --selector, scroll dispatches a real (isTrusted) mouse wheel at a
viewport coordinate, so it scrolls whatever container is under the pointer
including cross-origin iframes (Google Payments, Stripe, embedded checkout/KYC)
that plain page scroll can't reach.
Arguments:
direction up, down, left, right (default: down)
amount Pixels to scroll (default: 300)
Options:
-s, --selector <sel> CSS selector for a scrollable container
-s, --selector <sel> CSS selector for a scrollable container (same-origin)
--at <x,y> Dispatch the wheel at this viewport pixel (read it from a
screenshot) precise way into a cross-origin iframe
--frame <n> Scroll the n-th frame from `chrome-use frames` (wheel at
that frame's center)
Without --selector/--at/--frame the wheel lands at the viewport center.
Global Options:
--json Output as JSON
@@ -1718,6 +1763,8 @@ Examples:
chrome-use scroll up 200
chrome-use scroll left 100
chrome-use scroll down 500 --selector "div.scroll-container"
chrome-use scroll down 700 --at 640,400 # wheel at a pixel over an iframe
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `frames`
"##
}
"scrollintoview" | "scrollinto" => {
@@ -1798,6 +1845,8 @@ Pass --hide-scrollbars false when launching to keep native scrollbars visible.
Options:
--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.
Each label [N] corresponds to ref @eN from snapshot.
Prints a legend mapping labels to element roles/names.
@@ -1818,6 +1867,8 @@ Examples:
chrome-use screenshot
chrome-use screenshot ./screenshot.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 ./page.png # Save annotated screenshot
chrome-use screenshot --annotate --json # JSON output with annotations
@@ -3195,6 +3246,10 @@ Get Info: chrome-use get <what> [selector]
Check State: chrome-use is <what> <selector>
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]
role, text, label, placeholder, alt, title, testid, first, last, nth
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chrome-use",
"version": "1.5.6",
"version": "1.5.11",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module",
"packageManager": "pnpm@11.1.3",
+22 -5
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
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`
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
@@ -268,12 +281,16 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
# (no silent no-op). Use this for custom
# dropdowns where `select` returns ✓ but
# changes nothing.
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP
# session. Over the extension relay it CANNOT work
# (Chrome's chrome.debugger forbids it); chrome-use
# errors with a hint. Carry your login into a launched
# session via `cookies export` | `cookies set --curl`.
chrome-use upload @e5 file1.pdf # upload file(s) — works over the extension relay too:
# chrome.debugger forbids setFileInputFiles, so the
# file's bytes are streamed into the page and rebuilt as
# a File there (chunked under native-messaging's 1 MiB cap).
# 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 700 --at 640,400 # wheel at a pixel — scrolls a cross-origin
# iframe (Payments/Stripe/checkout/KYC) that
# plain page scroll can't reach
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `chrome-use frames`
chrome-use scrollintoview @e1 # scroll element into view
chrome-use drag @e1 @e2 # drag and drop
```