Compare commits

...
6 Commits
Author SHA1 Message Date
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
9 changed files with 284 additions and 10 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrome-use"
version = "1.5.4"
version = "1.5.7"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chrome-use"
version = "1.5.4"
version = "1.5.7"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+8
View File
@@ -1078,6 +1078,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
+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
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
/// `doctor` surface which extension build is live without a CDP round-trip.
+123
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,
@@ -4122,6 +4123,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();
@@ -9069,6 +9172,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$");
+58 -7
View File
@@ -166,6 +166,27 @@ fn resolve_active_index(
active_page_index
}
/// 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 +511,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 +613,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,
@@ -1411,13 +1446,10 @@ impl BrowserManager {
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);
}
@@ -2582,6 +2614,25 @@ mod tests {
assert!(!active_index_is_owned(&[], None, 0, &created));
}
#[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
+38
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()) {
@@ -3195,6 +3229,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.4",
"version": "1.5.7",
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
"type": "module",
"packageManager": "pnpm@11.1.3",
+13
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: