diff --git a/cli/src/commands.rs b/cli/src/commands.rs index c725a75..548ccea 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -931,6 +931,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result { + // `stealth [status]` — local stealth self-check: mode, live probes + // (navigator.webdriver, window.chrome, plugins, UA), and the list of + // active overrides. --json for a stable machine-readable shape. + // (Distinct from `doctor`, which checks install/env/Chrome health.) + Ok(json!({ "id": id, "action": "stealth_status" })) + } + // === Close === "close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })), diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index ec4e020..b71bdfc 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "content" => handle_content(state).await, "evaluate" => handle_evaluate(cmd, state).await, "close" => handle_close(state).await, + "stealth_status" => handle_stealth_status(state).await, "snapshot" => handle_snapshot(cmd, state).await, "screenshot" => handle_screenshot(cmd, state).await, "click" => handle_click(cmd, state).await, @@ -2680,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let connect = mgr.is_cdp_connection(); + + let probe_js = r#"(() => { + const ua = navigator.userAgent || ''; + return { + webdriver: navigator.webdriver === true, + hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null, + plugins: navigator.plugins ? navigator.plugins.length : 0, + languages: navigator.languages || [], + platform: navigator.platform || '', + headlessUA: /Headless/i.test(ua), + }; + })()"#; + let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null); + let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true); + let has_chrome = p + .get("hasWindowChrome") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0); + let headless_ua = p + .get("headlessUA") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let checks = json!([ + { "name": "navigator.webdriver is false", "pass": !webdriver }, + { "name": "window.chrome present", "pass": has_chrome }, + { "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins }, + { "name": "userAgent has no 'Headless'", "pass": !headless_ua }, + ]); + let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua; + + let overrides = if connect { + json!([ + "navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)", + "Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)", + "zero JS patches injected — the browser's real fingerprint is used as-is", + ]) + } else { + let iframe_proxy = + std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1"); + json!([ + "navigator.webdriver removed; navigator.languages/locale normalized", + "window.chrome / chrome.runtime shimmed; navigator.platform fixed", + "WebGL vendor/renderer, plugins, permissions normalized", + format!( + "srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)", + if iframe_proxy { + "ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%" + } else { + "off" + } + ), + format!( + "canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)", + if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") { + "on" + } else { + "off (opt-in)" + } + ), + "Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL", + ]) + }; + + Ok(json!({ + "stealthStatus": { + "mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" }, + "ok": ok, + "checks": checks, + "overrides": overrides, + "probe": p, + } + })) +} + async fn handle_close(state: &mut DaemonState) -> Result { if let Some(ref mgr) = state.browser { if let Some(ref session_name) = state.session_name { diff --git a/cli/src/output.rs b/cli/src/output.rs index 219c9e5..a20cb54 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -352,6 +352,39 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou println!("{}", enabled); return; } + // Stealth self-check (`stealth status` / `doctor`) + if let Some(s) = data.get("stealthStatus") { + let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?"); + println!( + "{} stealth: {} · mode: {}", + if ok { + color::success_indicator().to_string() + } else { + color::cyan("•") + }, + if ok { + "all checks pass" + } else { + "some checks need attention" + }, + mode + ); + if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) { + for c in checks { + let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false); + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {}", if pass { "✓" } else { "✗" }, name); + } + } + if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) { + println!(" applied overrides:"); + for o in ovs.iter().filter_map(|v| v.as_str()) { + println!(" {}", color::dim(&format!("· {o}"))); + } + } + return; + } if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) { println!("{}", checked); return; diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 6542ba8..4d37471 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -520,6 +520,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons, agent-browser doctor --offline --quick # fast, local-only agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...) agent-browser doctor --json # structured output for programmatic consumption +agent-browser stealth status # stealth self-check: mode + live probes +agent-browser stealth status --json # (webdriver/chrome/plugins/UA) + applied + # overrides. Gate a sensitive flow on this + # instead of driving an external detector. ``` `doctor` auto-cleans stale socket/pid/version sidecar files on every run.