diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index d4c1f59..b90087b 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1729,6 +1729,8 @@ fn chrome_relaunch_hint() -> &'static str { } /// Called after every successful launch / CDP connect / auto-connect. +/// Uses `CdpAttach` mode for external connections (minimal patches) and +/// `FullLaunch` mode for newly launched Chrome (all patches). async fn apply_stealth_to_browser(state: &DaemonState) { if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) { return; // Explicitly disabled @@ -1739,10 +1741,21 @@ async fn apply_stealth_to_browser(state: &DaemonState) { let Ok(session_id) = mgr.active_session_id() else { return; }; + + // Determine mode: if we attached to an external browser, use minimal patches. + // The user's real Chrome already has a genuine fingerprint — heavy patches + // would create detectable "lies" (e.g. creepjs hasIframeProxy). + let mode = if mgr.is_cdp_connection() { + stealth::StealthMode::CdpAttach + } else { + stealth::StealthMode::FullLaunch + }; + let locale = env::var("AGENT_BROWSER_LOCALE").ok(); if let Err(e) = stealth::apply_stealth( &mgr.client, session_id, + mode, locale.as_deref(), ) .await @@ -1751,7 +1764,7 @@ async fn apply_stealth_to_browser(state: &DaemonState) { } // Also inject into the current page (already loaded before our init script) if let Err(e) = - stealth::apply_stealth_to_current_page(&mgr.client, session_id, locale.as_deref()).await + stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await { eprintln!("[stealth] Failed to patch current page: {}", e); } diff --git a/cli/src/native/stealth.rs b/cli/src/native/stealth.rs index 490b6ca..504213b 100644 --- a/cli/src/native/stealth.rs +++ b/cli/src/native/stealth.rs @@ -8,11 +8,33 @@ use serde_json::json; use super::cdp::client::CdpClient; -/// Default stealth JS payload compiled at build time. -/// The first line is a config placeholder that `build_stealth_script` replaces -/// at runtime with the actual locale/language settings. +/// Full stealth JS payload compiled at build time (for --launch mode). const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js"); +/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome). +/// Only removes navigator.webdriver — the browser's own fingerprint is already real. +const MINIMAL_STEALTH_SCRIPT: &str = r#" +(function(){ + // CDP sets a getter on Navigator.prototype.webdriver that returns true. + // Simple `delete` won't remove it. We must redefine the property with + // Object.defineProperty to fully hide it from `'webdriver' in navigator`. + const targets = [Navigator.prototype]; + if (typeof WorkerNavigator !== 'undefined') targets.push(WorkerNavigator.prototype); + targets.push(Object.getPrototypeOf(navigator)); + for (const target of targets) { + if (!target) continue; + try { delete target.webdriver; } catch {} + try { + Object.defineProperty(target, 'webdriver', { + get: undefined, + configurable: true, + }); + delete target.webdriver; + } catch {} + } +})(); +"#; + /// Chrome launch arguments that reduce automation fingerprint surface. pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[ "--disable-blink-features=AutomationControlled", @@ -20,10 +42,23 @@ pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[ "--use-angle=default", ]; -/// Build the stealth JS payload with the given locale. -/// Replaces the default `__abStealth` config line with one reflecting the -/// actual browser locale so that `navigator.language` patches are consistent. -pub fn build_stealth_script(locale: Option<&str>) -> String { +/// Connection mode determines which stealth patches to apply. +#[derive(Clone, Copy, PartialEq)] +pub enum StealthMode { + /// Connected to user's real Chrome — minimal patches only (webdriver removal). + /// The browser already has a real fingerprint; heavy patches would create detectable lies. + CdpAttach, + /// Launched a new Chrome instance — apply full stealth patches. + FullLaunch, +} + +/// Build the stealth JS payload for the given mode and locale. +pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String { + if mode == StealthMode::CdpAttach { + return MINIMAL_STEALTH_SCRIPT.to_string(); + } + + // Full launch mode: inject all patches let locale = locale.unwrap_or("en-US"); let base_lang = locale.split('-').next().unwrap_or(locale); let languages: Vec<&str> = if base_lang == locale { @@ -37,29 +72,28 @@ pub fn build_stealth_script(locale: Option<&str>) -> String { serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()), ); - // Replace the placeholder first line if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix( r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#, ) { format!("{}{}", config_line, rest) } else { - // Fallback: prepend config and include everything format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW) } } -/// Apply stealth patches to a browser session: -/// 1. Inject init script (runs before any page JS on every navigation) -/// 2. Override User-Agent via CDP to remove HeadlessChrome markers -/// 3. Override navigator.userAgentData high-entropy hints +/// Apply stealth patches to a browser session. +/// +/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`. +/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override. pub async fn apply_stealth( client: &CdpClient, session_id: &str, + mode: StealthMode, locale: Option<&str>, ) -> Result<(), String> { - let script = build_stealth_script(locale); + let script = build_stealth_script(mode, locale); - // 1. Inject stealth scripts to run before page JS + // Inject stealth scripts to run before page JS client .send_command( "Page.addScriptToEvaluateOnNewDocument", @@ -68,23 +102,25 @@ pub async fn apply_stealth( ) .await?; - // 2. Detect current User-Agent and clean up HeadlessChrome marker - let ua = get_browser_user_agent(client, session_id).await; - if let Some(ua) = ua { - let cleaned = ua.replace("HeadlessChrome", "Chrome"); - if cleaned != ua { - client - .send_command( - "Emulation.setUserAgentOverride", - Some(json!({ - "userAgent": cleaned, - "acceptLanguage": locale.unwrap_or("en-US"), - "platform": platform_string(), - "userAgentMetadata": build_ua_metadata(&cleaned, locale), - })), - Some(session_id), - ) - .await?; + // In full launch mode, also override UA to remove HeadlessChrome marker + if mode == StealthMode::FullLaunch { + let ua = get_browser_user_agent(client, session_id).await; + if let Some(ua) = ua { + let cleaned = ua.replace("HeadlessChrome", "Chrome"); + if cleaned != ua { + client + .send_command( + "Emulation.setUserAgentOverride", + Some(json!({ + "userAgent": cleaned, + "acceptLanguage": locale.unwrap_or("en-US"), + "platform": platform_string(), + "userAgentMetadata": build_ua_metadata(&cleaned, locale), + })), + Some(session_id), + ) + .await?; + } } } @@ -112,9 +148,10 @@ async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option< pub async fn apply_stealth_to_current_page( client: &CdpClient, session_id: &str, + mode: StealthMode, locale: Option<&str>, ) -> Result<(), String> { - let script = build_stealth_script(locale); + let script = build_stealth_script(mode, locale); client .send_command( "Runtime.evaluate",