Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd47ec43d0 | ||
|
|
2cd361817d | ||
|
|
42f47c49aa | ||
|
|
e29800df72 | ||
|
|
e7e849ea39 | ||
|
|
ebb02c65c8 | ||
|
|
4317db636f | ||
|
|
c99838a034 | ||
|
|
dc2aa4cade | ||
|
|
7085f3bf36 | ||
|
|
29815ff5f3 | ||
|
|
2a338d4c29 |
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.4"
|
||||
version = "1.5.10"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.5.4"
|
||||
version = "1.5.10"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+56
-10
@@ -907,17 +907,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 +968,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 +1101,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 +4350,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();
|
||||
|
||||
@@ -449,6 +449,47 @@ pub fn relay_url() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a one-line record of how a CDP connection was established, to
|
||||
/// `~/.chrome-use/connect-mode.log`. This is the smoking-gun detector for the
|
||||
/// "Allow remote debugging?" consent modal: that modal ONLY appears on a raw
|
||||
/// remote-debugging attach / a browser we launched with a debug port — NEVER on
|
||||
/// the extension relay. When the modal reappears, this log says which session
|
||||
/// took which path and when, so we can tell a code regression (`raw-port` /
|
||||
/// `launched` while the relay was up) from Chrome's own extension-debugger
|
||||
/// consent UX. Low volume (one line per connection); best-effort, never fails a
|
||||
/// connection.
|
||||
pub fn log_connect_mode(ws_url: &str, launched: bool, session: &str) {
|
||||
let relay = relay_url();
|
||||
let relay_up = relay.is_some();
|
||||
let mode = if launched {
|
||||
"launched(debug-port)"
|
||||
} else if relay.as_deref() == Some(ws_url) {
|
||||
"relay"
|
||||
} else if ws_url.contains("127.0.0.1") || ws_url.contains("localhost") {
|
||||
"raw-port-attach"
|
||||
} else {
|
||||
"remote-ws"
|
||||
};
|
||||
// A raw-port attach or a self-launch while the relay was available is the
|
||||
// exact thing that pops the consent modal — flag it loudly in the line.
|
||||
let suspect = (mode == "raw-port-attach" || launched) && relay_up;
|
||||
let line = format!(
|
||||
"session={session} mode={mode} relay_up={relay_up}{} ws={ws_url}\n",
|
||||
if suspect { " CONSENT-MODAL-RISK" } else { "" }
|
||||
);
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let path = home.join(".chrome-use").join("connect-mode.log");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar recording the connected extension's version, written by the host when
|
||||
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
|
||||
/// `doctor` surface which extension build is live without a CDP round-trip.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
@@ -4122,6 +4131,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 +7454,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 +9181,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$");
|
||||
|
||||
+285
-35
@@ -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,75 @@ 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
|
||||
/// returns a different window's tabs for a single `getTargets` call ("tab list
|
||||
/// hops windows", issue #31); pruning on that transient snapshot would drop the
|
||||
/// agent's adopted tab and drift subsequent eval/click onto a foreign tab. A
|
||||
/// genuine close still arrives as `Target.targetDestroyed` (handled in the event
|
||||
/// drain), which removes the pin properly — so protecting it here only guards
|
||||
/// against flaky snapshots, not real closures.
|
||||
fn prunable_target_ids(
|
||||
pages: &[PageInfo],
|
||||
live_ids: &HashSet<String>,
|
||||
pinned: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| p.target_id.clone())
|
||||
.filter(|tid| !live_ids.contains(tid) && pinned != Some(tid.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the resolved active page is a tab the session created (its target_id
|
||||
/// is in `created_targets`). Pure core of [`BrowserManager::active_is_session_owned`]
|
||||
/// so the relay no-hijack rule is unit-testable without a live browser.
|
||||
@@ -490,6 +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" {
|
||||
initialize_lightpanda_manager(ws_url, process).await?
|
||||
} else {
|
||||
@@ -585,6 +661,13 @@ impl BrowserManager {
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let ws_url = resolve_cdp_url(url).await?;
|
||||
// Record the transport so a reappearing "Allow remote debugging?" modal
|
||||
// can be traced to a raw-port attach vs the consent-free relay (#31).
|
||||
crate::connect::log_connect_mode(
|
||||
&ws_url,
|
||||
false,
|
||||
DAEMON_SESSION.get().map(String::as_str).unwrap_or("default"),
|
||||
);
|
||||
let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?);
|
||||
let mut manager = Self {
|
||||
client,
|
||||
@@ -713,7 +796,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(),
|
||||
});
|
||||
}
|
||||
@@ -939,7 +1022,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();
|
||||
|
||||
@@ -1001,7 +1084,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> {
|
||||
@@ -1344,7 +1427,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());
|
||||
@@ -1405,19 +1488,16 @@ 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;
|
||||
}
|
||||
|
||||
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows.
|
||||
let gone: Vec<String> = self
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| p.target_id.clone())
|
||||
.filter(|tid| !live_ids.contains(tid))
|
||||
.collect();
|
||||
// Drop tabs that no longer exist so `tab list` doesn't show phantom rows —
|
||||
// but never prune the explicitly-pinned active target on a transient
|
||||
// getTargets snapshot (issue #31; see `prunable_target_ids`).
|
||||
let gone = prunable_target_ids(&self.pages, &live_ids, self.active_target_id.as_deref());
|
||||
for tid in gone {
|
||||
self.remove_page_by_target_id(&tid);
|
||||
}
|
||||
@@ -1451,7 +1531,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1639,7 +1719,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];
|
||||
@@ -1894,7 +1974,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!({
|
||||
@@ -1903,26 +1984,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 (A–Za–z0–9+/=) 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(())
|
||||
}
|
||||
|
||||
@@ -2582,6 +2790,48 @@ 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"
|
||||
// (it hopped to another window). "B" is also absent. Without protection
|
||||
// both would be pruned and the next command would drift; with the pin
|
||||
// protected, only the genuinely-unpinned "B" is dropped (issue #31).
|
||||
let pages = vec![page("A"), page("B")];
|
||||
let live: HashSet<String> = HashSet::new(); // snapshot returned neither
|
||||
let gone = prunable_target_ids(&pages, &live, Some("A"));
|
||||
assert_eq!(gone, vec!["B".to_string()]);
|
||||
// With no pin, both are prunable (unchanged behavior).
|
||||
let gone_unpinned = prunable_target_ids(&pages, &live, None);
|
||||
assert_eq!(gone_unpinned.len(), 2);
|
||||
// A pinned target that IS in the live set is simply not prunable anyway.
|
||||
let mut live2 = HashSet::new();
|
||||
live2.insert("A".to_string());
|
||||
assert_eq!(prunable_target_ids(&pages, &live2, Some("A")), vec!["B".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_pin_survives_passive_background_tab() {
|
||||
// A foreign tab ("Z") gets appended by passive discovery after we pinned
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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()) {
|
||||
@@ -1798,6 +1832,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 +1854,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 +3233,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.5.4",
|
||||
"version": "1.5.10",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -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,11 +281,11 @@ 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 scrollintoview @e1 # scroll element into view
|
||||
chrome-use drag @e1 @e2 # drag and drop
|
||||
|
||||
Reference in New Issue
Block a user