From c99838a034c3e0233202699c0d0cd063ed3b65fc Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 16 Jun 2026 12:19:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(cloudflare):=20cf-status=20preflight=20?= =?UTF-8?q?=E2=80=94=20skip=20re-solving=20when=20cf=5Fclearance=20is=20st?= =?UTF-8?q?ill=20valid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli/src/commands.rs | 8 +++ cli/src/native/actions.rs | 123 ++++++++++++++++++++++++++++++++++++++ cli/src/output.rs | 38 ++++++++++++ skill-data/core/SKILL.md | 13 ++++ 4 files changed, 182 insertions(+) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index fae0468..f57eab4 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1078,6 +1078,14 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result { + Ok(json!({ "id": id, "action": "cf_status" })) + } + // === Close === "close" | "quit" | "exit" => { // `close ` closes only that tab (and the output says "Tab diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 02f770a..ae6a553 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -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 { 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, 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 { + 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 { 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$"); diff --git a/cli/src/output.rs b/cli/src/output.rs index 97c6d5c..416beb3 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -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 [selector] Check State: chrome-use is 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 [text] role, text, label, placeholder, alt, title, testid, first, last, nth diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index c881919..c789ca8 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -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 ` 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: