From dd2deff06cc95a9c67f31fa256cbd63a7e4d2fee Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Wed, 10 Jun 2026 09:52:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(stealth):=20forbid=20headless=20=E2=80=94?= =?UTF-8?q?=20always=20launch=20headed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless Chrome is a bot-detection tell: creepjs scores ~33% headless even with --headless=new, while a headed window with a real GPU scores 0%. Since this is a stealth fork, headless is now forbidden — build_chrome_args ignores the headless LaunchOption and never emits --headless/--enable-unsafe-swiftshader/forced --window-size. The only escape is AGENT_BROWSER_ALLOW_HEADLESS=1 for genuinely display-less servers (discouraged — forfeits stealth). Verified locally: default launch (no env) is headed (webdriver=false, platform=MacIntel, no --headless flag); creepjs headed = 0% headless vs 33% headless. chrome.rs: 48 tests pass incl. forbids-headless + escape. --- cli/src/native/cdp/chrome.rs | 64 +++++++++++++++++++++++++++--------- cli/src/output.rs | 5 +-- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 9480120..432e880 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -146,6 +146,16 @@ struct ChromeArgs { temp_user_data_dir: Option, } +/// Whether to launch Chrome headless. The stealth fork FORBIDS headless (it's a +/// bot-detection tell), so this is `false` unless an operator explicitly opts in +/// via `AGENT_BROWSER_ALLOW_HEADLESS=1` for a display-less server. The `headless` +/// LaunchOption is intentionally ignored — headed is non-negotiable for stealth. +fn launch_headless() -> bool { + std::env::var("AGENT_BROWSER_ALLOW_HEADLESS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + /// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched /// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior. fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> { @@ -202,9 +212,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result { .as_ref() .is_some_and(|exts| !exts.is_empty()); - // Extensions require headed mode in native Chrome (content scripts are not - // injected in headless mode). Skip --headless when extensions are loaded. - if options.headless && !has_extensions { + // Stealth fork: NEVER launch headless. Headless Chrome is a detectable tell + // (creepjs scores ~33% headless even with new-headless; a real GPU and a + // headed window score 0%). So we always launch headed and ignore the + // `headless` option. The only escape is an explicit AGENT_BROWSER_ALLOW_HEADLESS=1 + // for genuinely display-less servers (discouraged — it forfeits stealth). + // Extensions also require headed mode (content scripts aren't injected headless). + if launch_headless() && !has_extensions { args.push("--headless=new".to_string()); // Linux paints native scrollbars into viewport screenshots unless // Chrome is launched with this flag. `--hide-scrollbars` is @@ -278,7 +292,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result { .iter() .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size=")); - if !has_window_size && options.headless && !has_extensions { + if !has_window_size && launch_headless() && !has_extensions { let (w, h) = options.viewport_size.unwrap_or((1280, 720)); args.push(format!("--window-size={},{}", w, h)); } @@ -1520,24 +1534,44 @@ mod tests { } #[test] - fn test_build_args_headless_includes_headless_flag() { + fn test_build_args_forbids_headless_by_default() { + // Stealth fork: headless is FORBIDDEN. `headless: true` is ignored — the + // launch is always headed (no --headless / swiftshader / forced size). + let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]); + g.remove("AGENT_BROWSER_ALLOW_HEADLESS"); + let opts = LaunchOptions { + headless: true, + ..Default::default() + }; + let result = build_chrome_args(&opts).unwrap(); + assert!( + !result.args.iter().any(|a| a.contains("--headless")), + "headless must be forbidden even when the headless option is true" + ); + assert!(!result + .args + .iter() + .any(|a| a == "--enable-unsafe-swiftshader")); + if let Some(dir) = result.temp_user_data_dir { + let _ = std::fs::remove_dir_all(&dir); + } + } + + #[test] + fn test_build_args_allow_headless_escape() { + // The only way back to headless: an explicit opt-in for display-less servers. + let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]); + g.set("AGENT_BROWSER_ALLOW_HEADLESS", "1"); let opts = LaunchOptions { headless: true, ..Default::default() }; let result = build_chrome_args(&opts).unwrap(); assert!(result.args.iter().any(|a| a == "--headless=new")); - assert!(result.args.iter().any(|a| a == "--hide-scrollbars")); - assert!(result - .args - .iter() - .any(|a| a == "--enable-unsafe-swiftshader")); assert!(result.args.iter().any(|a| a == "--window-size=1280,720")); - // Temp dir created when no profile - assert!(result.temp_user_data_dir.is_some()); - let dir = result.temp_user_data_dir.unwrap(); - assert!(dir.exists()); - let _ = std::fs::remove_dir_all(&dir); + if let Some(dir) = result.temp_user_data_dir { + let _ = std::fs::remove_dir_all(&dir); + } } #[test] diff --git a/cli/src/output.rs b/cli/src/output.rs index dfc18c7..8ad63ca 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1082,7 +1082,7 @@ Global Options: --json Output as JSON --session Use specific session --headers Set HTTP headers (scoped to this origin) - --headed Show browser window + --headed Show browser window (default; headless is forbidden — it's a bot tell) --enable react-devtools Inject the React DevTools hook before any page JS --init-script Register a page init script (repeatable) @@ -3114,7 +3114,8 @@ Options: --screenshot-dir Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR) --screenshot-quality JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY) --screenshot-format Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT) - --headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env) + --headed Always on (default). Headless is forbidden (bot-detection tell); + display-less servers can opt back in with AGENT_BROWSER_ALLOW_HEADLESS=1 --cdp Connect via CDP (Chrome DevTools Protocol) --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)