diff --git a/README.md b/README.md index 9e1f1f4..fbbb122 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ agent-browser find nth 2 "a" text ```bash agent-browser wait # Wait for element to be visible agent-browser wait # Wait for time (milliseconds) +agent-browser wait 2000-5000 # Random wait between 2-5 seconds agent-browser wait --text "Welcome" # Wait for text to appear agent-browser wait --url "**/dash" # Wait for URL pattern agent-browser wait --load networkidle # Wait for load state @@ -457,6 +458,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--proxy-bypass ` | Hosts to bypass proxy (or `AGENT_BROWSER_PROXY_BYPASS` env) | | `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) | | `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) | +| `--stealth` | Stealth mode (default: on): local launch uses Chromium args + init scripts; CDP/provider uses init scripts | | `-p, --provider ` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) | | `--device ` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) | | `--json` | JSON output (for agents) | @@ -717,6 +719,60 @@ The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-fi **Note:** This flag only works with Chromium. For security, it's disabled by default. +## Stealth Mode + +Stealth mode is **enabled by default**. It patches common detection vectors to make the browser appear like a regular user session, preventing websites from blocking automation. + +```bash +# Stealth is on by default -- just use normally +agent-browser open example.com + +# Disable stealth if needed +agent-browser --stealth false open example.com + +# Or disable via environment variable +export AGENT_BROWSER_STEALTH=false + +# Or disable in config file +# agent-browser.json: {"stealth": false} +``` + +Stealth mode applies the following countermeasures: +- Removes `navigator.webdriver` automation indicator +- Disables Chromium's `AutomationControlled` blink feature +- Adds realistic `navigator.plugins` (Chrome PDF Plugin, etc.) +- Patches `window.chrome.runtime` to match real Chrome +- Masks WebGL vendor/renderer when SwiftShader is detected +- Fixes `navigator.permissions.query` for notifications +- Reports realistic `navigator.hardwareConcurrency` +- Provides default media devices for `enumerateDevices()` +- Cleans up CDP-injected properties on the document + +Stealth capability matrix: + + + + + + + + + + +
Connection typeStealth capabilities
Local launchChromium launch args (--disable-blink-features=AutomationControlled) + context init scripts
CDP / auto-connectContext init scripts
Cloud providersContext init scripts (Kernel may also apply provider-managed stealth)
+ +Use --debug to print the active stealth connection type and capabilities at launch time. + +### Humanized Interactions + +In addition to stealth patches, agent-browser automatically humanizes interactions to avoid behavioral detection: + +- **Randomized typing** -- When using `type --delay`, each keystroke delay varies by +-40% so timing appears natural rather than mechanical +- **Random wait ranges** -- `wait 2000-5000` pauses for a random duration between 2 and 5 seconds +- **Bezier curve mouse movement** -- Before every `click`, the mouse moves to the target element along a randomized cubic Bezier curve with natural-looking control points + +These behaviors are always active and require no additional flags. + ## CDP Mode Connect to an existing browser via Chrome DevTools Protocol: diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 220d522..757ded3 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -220,6 +220,8 @@ pub fn ensure_daemon( provider: Option<&str>, device: Option<&str>, session_name: Option<&str>, + stealth: bool, + debug: bool, ) -> Result { // Check if daemon is running AND responsive if is_daemon_running(session) && daemon_ready(session) { @@ -364,6 +366,11 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_SESSION_NAME", sn); } + cmd.env("AGENT_BROWSER_STEALTH", if stealth { "1" } else { "0" }); + if debug { + cmd.env("AGENT_BROWSER_DEBUG", "1"); + } + // Create new process group and session to fully detach unsafe { cmd.pre_exec(|| { @@ -375,8 +382,8 @@ pub fn ensure_daemon( cmd.stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() + .stderr(Stdio::null()); + cmd.spawn() .map_err(|e| format!("Failed to start daemon: {}", e))?; } @@ -447,6 +454,11 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_SESSION_NAME", sn); } + cmd.env("AGENT_BROWSER_STEALTH", if stealth { "1" } else { "0" }); + if debug { + cmd.env("AGENT_BROWSER_DEBUG", "1"); + } + // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const DETACHED_PROCESS: u32 = 0x00000008; @@ -454,8 +466,8 @@ pub fn ensure_daemon( cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() + .stderr(Stdio::null()); + cmd.spawn() .map_err(|e| format!("Failed to start daemon: {}", e))?; } diff --git a/cli/src/main.rs b/cli/src/main.rs index 73d6c08..860b80c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -52,6 +52,14 @@ fn parse_proxy(proxy_str: &str) -> serde_json::Value { }) } +fn print_stealth_debug(resp: &connection::Response) { + if let Some(data) = &resp.data { + if let Some(stealth) = data.get("stealth") { + eprintln!("[DEBUG] stealth: {}", stealth); + } + } +} + fn run_session(args: &[String], session: &str, json_mode: bool) { let subcommand = args.get(1).map(|s| s.as_str()); @@ -226,6 +234,8 @@ fn main() { flags.provider.as_deref(), flags.device.as_deref(), flags.session_name.as_deref(), + flags.stealth, + flags.debug, ) { Ok(result) => result, Err(e) => { @@ -281,6 +291,7 @@ fn main() { }, flags.ignore_https_errors.then_some("--ignore-https-errors"), flags.cli_allow_file_access.then_some("--allow-file-access"), + flags.cli_stealth.then_some("--stealth"), ] .into_iter() .flatten() @@ -448,22 +459,31 @@ fn main() { launch_cmd["colorScheme"] = json!(cs); } - let err = match send_command(launch_cmd, &flags.session) { - Ok(resp) if resp.success => None, - Ok(resp) => Some( - resp.error - .unwrap_or_else(|| "CDP connection failed".to_string()), - ), - Err(e) => Some(e.to_string()), - }; - - if let Some(msg) = err { - if flags.json { - println!(r#"{{"success":false,"error":"{}"}}"#, msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); + match send_command(launch_cmd, &flags.session) { + Ok(resp) => { + if !resp.success { + let msg = resp + .error + .unwrap_or_else(|| "CDP connection failed".to_string()); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("{} {}", color::error_indicator(), msg); + } + exit(1); + } + if flags.debug { + print_stealth_debug(&resp); + } + } + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("{} {}", color::error_indicator(), e); + } + exit(1); } - exit(1); } } @@ -479,22 +499,31 @@ fn main() { launch_cmd["colorScheme"] = json!(cs); } - let err = match send_command(launch_cmd, &flags.session) { - Ok(resp) if resp.success => None, - Ok(resp) => Some( - resp.error - .unwrap_or_else(|| "Provider connection failed".to_string()), - ), - Err(e) => Some(e.to_string()), - }; - - if let Some(msg) = err { - if flags.json { - println!(r#"{{"success":false,"error":"{}"}}"#, msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); + match send_command(launch_cmd, &flags.session) { + Ok(resp) => { + if !resp.success { + let msg = resp + .error + .unwrap_or_else(|| "Provider connection failed".to_string()); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("{} {}", color::error_indicator(), msg); + } + exit(1); + } + if flags.debug { + print_stealth_debug(&resp); + } + } + Err(e) => { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, e); + } else { + eprintln!("{} {}", color::error_indicator(), e); + } + exit(1); } - exit(1); } } @@ -506,7 +535,10 @@ fn main() { || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some() + || flags.ignore_https_errors || flags.allow_file_access + || flags.cli_stealth + || flags.debug || flags.color_scheme.is_some()) && flags.cdp.is_none() && flags.provider.is_none() @@ -569,22 +601,29 @@ fn main() { launch_cmd["allowFileAccess"] = json!(true); } + launch_cmd["stealth"] = json!(flags.stealth); + if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } match send_command(launch_cmd, &flags.session) { - Ok(resp) if !resp.success => { - // Launch command failed (e.g., invalid state file, profile error) - let error_msg = resp - .error - .unwrap_or_else(|| "Browser launch failed".to_string()); - if flags.json { - println!(r#"{{"success":false,"error":"{}"}}"#, error_msg); - } else { - eprintln!("{} {}", color::error_indicator(), error_msg); + Ok(resp) => { + if !resp.success { + // Launch command failed (e.g., invalid state file, profile error) + let error_msg = resp + .error + .unwrap_or_else(|| "Browser launch failed".to_string()); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, error_msg); + } else { + eprintln!("{} {}", color::error_indicator(), error_msg); + } + exit(1); + } + if flags.debug { + print_stealth_debug(&resp); } - exit(1); } Err(e) => { if flags.json { @@ -598,9 +637,6 @@ fn main() { } exit(1); } - Ok(_) => { - // Launch succeeded - } } } diff --git a/cli/src/output.rs b/cli/src/output.rs index 0438338..74a2079 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -39,15 +39,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { return; } Some("diff_url") => { - if let Some(snap_data) = - obj.get("snapshot").and_then(|v| v.as_object()) - { + if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) { println!("{}", color::bold("Snapshot diff:")); print_snapshot_diff(snap_data); } - if let Some(ss_data) = - obj.get("screenshot").and_then(|v| v.as_object()) - { + if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) { println!("\n{}", color::bold("Screenshot diff:")); print_screenshot_diff(ss_data); } @@ -310,11 +306,7 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } _ => { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!( - "{} Recording started: {}", - color::success_indicator(), - path - ); + println!("{} Recording started: {}", color::success_indicator(), path); } else { println!("{} Recording started", color::success_indicator()); } @@ -497,7 +489,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or(""); let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0); let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or(""); - let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); + let encrypted = file + .get("encrypted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let size_str = if size > 1024 { format!("{:.1}KB", size as f64 / 1024.0) } else { @@ -505,7 +500,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { }; let date_str = modified.split('T').next().unwrap_or(modified); let enc_str = if encrypted { " [encrypted]" } else { "" }; - println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str))); + println!( + " {} {}", + filename, + color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)) + ); } } return; @@ -515,13 +514,22 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) { let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or(""); let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or(""); - println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name); + println!( + "{} Renamed {} -> {}", + color::success_indicator(), + old_name, + new_name + ); return; } // State clear if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) { - println!("{} Cleared {} state file(s)", color::success_indicator(), cleared); + println!( + "{} Cleared {} state file(s)", + color::success_indicator(), + cleared + ); return; } @@ -529,7 +537,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { if let Some(summary) = data.get("summary") { let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0); let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0); - let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); + let encrypted = data + .get("encrypted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let enc_str = if encrypted { " (encrypted)" } else { "" }; println!("State file summary{}:", enc_str); println!(" Cookies: {}", cookies); @@ -539,7 +550,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { // State clean if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) { - println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned); + println!( + "{} Cleaned {} old state file(s)", + color::success_indicator(), + cleaned + ); return; } @@ -1017,13 +1032,14 @@ Examples: r##" agent-browser wait - Wait for condition -Usage: agent-browser wait +Usage: agent-browser wait Waits for an element to appear, a timeout, or other conditions. Modes: Wait for element to appear Wait for specified milliseconds + - Wait for random time between min and max ms --url Wait for URL to match pattern --load Wait for load state (load, domcontentloaded, networkidle) --fn Wait for JavaScript expression to be truthy @@ -1040,6 +1056,7 @@ Global Options: Examples: agent-browser wait "#loading-spinner" agent-browser wait 2000 + agent-browser wait 2000-5000 # Random wait between 2-5 seconds agent-browser wait --url "**/dashboard" agent-browser wait --load networkidle agent-browser wait --fn "window.appReady === true" @@ -2011,7 +2028,7 @@ Core Commands: download Download file by clicking element scroll [px] Scroll (up/down/left/right) scrollintoview Scroll element into view - wait Wait for element or time + wait Wait for element, time, or random range screenshot [path] Take screenshot pdf Save as PDF snapshot Accessibility tree with refs (for AI) @@ -2097,6 +2114,7 @@ Options: e.g., --proxy-bypass "localhost,*.internal.com" --ignore-https-errors Ignore HTTPS certificate errors --allow-file-access Allow file:// URLs to access local files (Chromium only) + --stealth Stealth mode (default: on): local=launch args+init scripts, CDP/provider=init scripts -p, --provider Browser provider: ios, browserbase, kernel, browseruse --device iOS device name (e.g., "iPhone 15 Pro") --json JSON output @@ -2108,7 +2126,7 @@ Options: --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --session-name Auto-save/restore session state (cookies, localStorage) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) - --debug Debug output + --debug Debug output (includes stealth connection type + capabilities) --version, -V Show version Configuration: @@ -2147,6 +2165,7 @@ Environment: AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse) AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files + AGENT_BROWSER_STEALTH Stealth mode (default: on, set to 0/false to disable) AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name @@ -2232,10 +2251,7 @@ fn print_screenshot_diff(data: &serde_json::Map) { .get("mismatchPercentage") .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let is_match = data - .get("match") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false); let dim_mismatch = data .get("dimensionMismatch") .and_then(|v| v.as_bool()) @@ -2246,7 +2262,10 @@ fn print_screenshot_diff(data: &serde_json::Map) { color::error_indicator() ); } else if is_match { - println!("{} Images match (0% difference)", color::success_indicator()); + println!( + "{} Images match (0% difference)", + color::success_indicator() + ); } else { println!( "{} {:.2}% pixels differ", @@ -2257,7 +2276,10 @@ fn print_screenshot_diff(data: &serde_json::Map) { if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) { println!(" Diff image: {}", color::green(diff_path)); } - let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0); + let total = data + .get("totalPixels") + .and_then(|v| v.as_i64()) + .unwrap_or(0); let different = data .get("differentPixels") .and_then(|v| v.as_i64()) diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index dae316d..f0325c8 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -75,6 +75,23 @@ Or set it globally via config or environment variable: AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com ``` +## Stealth behavior + +`--stealth` is enabled by default across connection modes, but capabilities depend on how you connect: + + + + + + + + + + +
Connection typeStealth capabilities
Local launchChromium launch args + context init scripts
CDP / auto-connectContext init scripts
Cloud providersContext init scripts (Kernel may also apply provider-managed stealth)
+ +Use `--debug` to print the active connection type and applied stealth capabilities. + ## Use cases This enables control of: diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 9cc7dc8..700de47 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -95,6 +95,7 @@ agent-browser find nth 2 ".card" hover ```bash agent-browser wait # Wait for element agent-browser wait # Wait for time +agent-browser wait 2000-5000 # Random wait between 2-5 seconds agent-browser wait --text "Welcome" # Wait for text agent-browser wait --url "**/dash" # Wait for URL pattern agent-browser wait --load networkidle # Wait for load state @@ -243,6 +244,7 @@ agent-browser reload # Reload page --proxy-bypass # Hosts to bypass proxy --ignore-https-errors # Ignore HTTPS certificate errors --allow-file-access # Allow file:// URLs to access local files (Chromium only) +--stealth # Stealth mode: local uses launch args+init scripts; CDP/provider uses init scripts -p, --provider # Browser provider (ios, browserbase, kernel, browseruse) --device # iOS device name (e.g., "iPhone 15 Pro") --json # JSON output (for scripts) @@ -251,7 +253,7 @@ agent-browser reload # Reload page --headed # Show browser window (not headless) --cdp # Connect via Chrome DevTools Protocol (port or WebSocket URL) --auto-connect # Auto-discover and connect to running Chrome ---debug # Debug output +--debug # Debug output (includes stealth connection type + capabilities) ``` ## Command chaining diff --git a/scripts/check-sannysoft-webdriver.js b/scripts/check-sannysoft-webdriver.js new file mode 100755 index 0000000..e3a8298 --- /dev/null +++ b/scripts/check-sannysoft-webdriver.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +/** + * End-to-end check for bot.sannysoft.com WebDriver (New) result. + * + * Usage: + * node scripts/check-sannysoft-webdriver.js + * node scripts/check-sannysoft-webdriver.js --compare-stealth + * node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-browser + */ + +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +const args = process.argv.slice(2); +const getArgValue = (name, fallback) => { + const index = args.indexOf(name); + if (index === -1 || index + 1 >= args.length) return fallback; + return args[index + 1]; +}; + +const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser')); +const sessionPrefix = getArgValue('--session-prefix', 'botcheck-e2e'); +const compareStealth = args.includes('--compare-stealth'); +const targetUrl = getArgValue('--url', 'https://bot.sannysoft.com'); + +const extractionScript = `(() => { + const normalize = (s) => (s || '').replace(/\\s+/g, ' ').trim(); + const rows = Array.from(document.querySelectorAll('tr')); + const exact = rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase() === 'webdriver (new)'); + const fallback = exact || rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase().includes('webdriver')); + return { + found: !!fallback, + label: fallback ? normalize(fallback.cells?.[0]?.textContent) : null, + valueText: fallback ? normalize(fallback.cells?.[1]?.textContent) : null, + statusText: fallback ? normalize(fallback.textContent) : null, + navigatorWebdriver: navigator.webdriver, + webdriverInNavigator: ('webdriver' in navigator), + }; +})()`; + +function runCommand(commandArgs, options = {}) { + const result = spawnSync(binary, commandArgs, { encoding: 'utf8' }); + if (result.status !== 0 && !options.allowFailure) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + throw new Error( + `Command failed: ${binary} ${commandArgs.join(' ')}\n` + + `${stderr || stdout || `exit code ${result.status}`}` + ); + } + return result; +} + +function withSessionArgs(session, stealth) { + const base = ['--session', session]; + if (stealth === false) { + base.push('--stealth', 'false'); + } + return base; +} + +function runSingleCheck({ stealth, runId }) { + const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`; + + // Best-effort cleanup in case previous run left state behind. + runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true }); + + try { + runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]); + runCommand([...withSessionArgs(session, stealth), 'wait', '--load', 'networkidle']); + runCommand([...withSessionArgs(session, stealth), 'wait', '5000']); + + const evalResult = runCommand([ + ...withSessionArgs(session, stealth), + 'eval', + '--json', + extractionScript, + ]); + + const payload = JSON.parse(evalResult.stdout); + return { + session, + stealth, + url: targetUrl, + extracted: payload?.data?.result ?? null, + }; + } finally { + runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true }); + } +} + +function main() { + const runId = Date.now(); + const checks = compareStealth ? [true, false] : [true]; + const results = checks.map((stealth) => runSingleCheck({ stealth, runId })); + + const output = { + binary, + compareStealth, + timestamp: new Date().toISOString(), + results, + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index b62f88f..9ce0748 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -78,6 +78,7 @@ agent-browser wait @e1 # Wait for element agent-browser wait --load networkidle # Wait for network idle agent-browser wait --url "**/page" # Wait for URL pattern agent-browser wait 2000 # Wait milliseconds +agent-browser wait 2000-5000 # Random wait between 2-5 seconds # Capture agent-browser screenshot # Screenshot to temp dir @@ -216,6 +217,26 @@ agent-browser --allow-file-access open file:///path/to/page.html agent-browser screenshot output.png ``` +### Stealth Mode (Avoid Bot Detection) + +Stealth mode is enabled by default. It patches automation detection vectors (navigator.webdriver, plugins, WebGL, etc.) so websites cannot easily identify the browser as automated. + +```bash +# Stealth is on by default -- just use normally +agent-browser open https://example.com + +# Disable stealth if needed for debugging +agent-browser --stealth false open https://example.com +``` + +Stealth capabilities vary by connection type: + +- Local launch: Chromium launch args + context init scripts +- CDP / `--auto-connect`: context init scripts +- Cloud providers: context init scripts (Kernel may also apply provider-managed stealth) + +For troubleshooting, run with `--debug` to print the active stealth connection type and capabilities. + ### iOS Simulator (Mobile Safari) ```bash @@ -287,10 +308,23 @@ agent-browser wait --fn "document.readyState === 'complete'" # Wait a fixed duration (milliseconds) as a last resort agent-browser wait 5000 + +# Random wait between 2-5 seconds (useful for anti-detection) +agent-browser wait 2000-5000 ``` When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait ` or `wait @ref`. +### Humanized Interactions + +agent-browser automatically humanizes interactions to avoid behavioral detection: + +- **Randomized typing**: `type --delay` varies each keystroke delay by +-40% +- **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range +- **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve + +These behaviors are always active. For sensitive sites, combine with `--headed` and `--profile` for best results. + ## Session Management and Cleanup When running multiple agents or automations concurrently, always use named sessions to avoid conflicts: diff --git a/src/actions.ts b/src/actions.ts index aa92865..ee68070 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -517,7 +517,10 @@ async function handleLaunch( browser: BrowserManager ): Promise { await browser.launch(command); - return successResponse(command.id, { launched: true }); + return successResponse(command.id, { + launched: true, + stealth: browser.getStealthStatus(command.browser ?? 'chromium'), + }); } async function handleNavigate( @@ -541,6 +544,30 @@ async function handleNavigate( }); } +function bezierPoint(t: number, p0: number, p1: number, p2: number, p3: number): number { + const u = 1 - t; + return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; +} + +async function humanMouseMove(page: Page, toX: number, toY: number): Promise { + const viewport = page.viewportSize(); + const fromX = viewport ? Math.random() * viewport.width * 0.3 : 100; + const fromY = viewport ? Math.random() * viewport.height * 0.3 : 100; + + const cp1x = fromX + (toX - fromX) * (0.2 + Math.random() * 0.3); + const cp1y = fromY + (Math.random() - 0.5) * 200; + const cp2x = fromX + (toX - fromX) * (0.5 + Math.random() * 0.3); + const cp2y = toY + (Math.random() - 0.5) * 200; + + const steps = 15 + Math.floor(Math.random() * 15); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = bezierPoint(t, fromX, cp1x, cp2x, toX); + const y = bezierPoint(t, fromY, cp1y, cp2y, toY); + await page.mouse.move(x, y); + } +} + async function handleClick(command: ClickCommand, browser: BrowserManager): Promise { // Support both refs (@e1) and regular selectors const locator = browser.getLocator(command.selector); @@ -572,6 +599,14 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom }); } + // Human-like: move mouse along a Bezier curve before clicking + const box = await locator.boundingBox(); + if (box) { + const targetX = box.x + box.width * (0.3 + Math.random() * 0.4); + const targetY = box.y + box.height * (0.3 + Math.random() * 0.4); + await humanMouseMove(browser.getPage(), targetX, targetY); + } + await locator.click({ button: command.button, clickCount: command.clickCount, @@ -592,9 +627,18 @@ async function handleType(command: TypeCommand, browser: BrowserManager): Promis await locator.fill(''); } - await locator.pressSequentially(command.text, { - delay: command.delay, - }); + if (command.delay) { + // Humanized: type char-by-char with randomized delay (+-40%) + await locator.focus(); + const page = browser.getPage(); + for (const char of command.text) { + const jitter = command.delay * (0.6 + Math.random() * 0.8); + await page.keyboard.type(char, { delay: 0 }); + await page.waitForTimeout(jitter); + } + } else { + await locator.pressSequentially(command.text, {}); + } } catch (error) { throw toAIFriendlyError(error, command.selector); } @@ -870,7 +914,11 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis timeout: command.timeout, }); } else if (command.timeout) { - await page.waitForTimeout(command.timeout); + // Random range: wait between [timeout, timeoutMax] + const min = command.timeout; + const max = command.timeoutMax ?? min; + const delay = max > min ? min + Math.random() * (max - min) : min; + await page.waitForTimeout(Math.round(delay)); } else { // Default: wait for load state await page.waitForLoadState('load'); @@ -1897,9 +1945,19 @@ async function handleKeyboard( const sub = command.subaction ?? 'press'; switch (sub) { - case 'type': - await page.keyboard.type(command.text ?? '', { delay: command.delay }); + case 'type': { + const text = command.text ?? ''; + if (command.delay) { + for (const char of text) { + const jitter = command.delay * (0.6 + Math.random() * 0.8); + await page.keyboard.type(char, { delay: 0 }); + await page.waitForTimeout(jitter); + } + } else { + await page.keyboard.type(text); + } return successResponse(command.id, { typed: true, text: command.text }); + } case 'press': await page.keyboard.press(command.keys ?? ''); return successResponse(command.id, { pressed: command.keys }); diff --git a/src/browser.test.ts b/src/browser.test.ts index 48bf3ed..97be691 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -53,6 +53,78 @@ describe('BrowserManager', () => { expect(newBrowser.getBrowser()).toBeNull(); await newBrowser.close(); }); + + it('should report local stealth policy capabilities', async () => { + const testBrowser = new BrowserManager(); + await testBrowser.launch({ headless: true, stealth: true }); + + const status = testBrowser.getStealthStatus('chromium'); + expect(status.enabled).toBe(true); + expect(status.connectionKind).toBe('local'); + expect(status.capabilities).toContain('chromium-launch-args'); + expect(status.capabilities).toContain('context-init-scripts'); + + await testBrowser.close(); + }); + + it('should apply init-script stealth policy for CDP connections', async () => { + const addInitScript = vi.fn().mockResolvedValue(undefined); + const mockPage = { url: () => 'http://example.com', on: vi.fn() }; + const mockContext = { + pages: () => [mockPage], + on: vi.fn(), + setDefaultTimeout: vi.fn(), + addInitScript, + }; + const mockBrowser = { + contexts: () => [mockContext], + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn(() => true), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + await cdpBrowser.launch({ cdpPort: 9222, stealth: true }); + + expect(addInitScript).toHaveBeenCalledTimes(1); + const status = cdpBrowser.getStealthStatus(); + expect(status.enabled).toBe(true); + expect(status.connectionKind).toBe('cdp'); + expect(status.capabilities).toContain('context-init-scripts'); + expect(status.capabilities).not.toContain('chromium-launch-args'); + + await cdpBrowser.close(); + spy.mockRestore(); + }); + + it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => { + const addInitScript = vi.fn().mockResolvedValue(undefined); + const mockPage = { url: () => 'http://example.com', on: vi.fn() }; + const mockContext = { + pages: () => [mockPage], + on: vi.fn(), + setDefaultTimeout: vi.fn(), + addInitScript, + }; + const mockBrowser = { + contexts: () => [mockContext], + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn(() => true), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + await cdpBrowser.launch({ cdpPort: 9222, stealth: false }); + + expect(addInitScript).not.toHaveBeenCalled(); + const status = cdpBrowser.getStealthStatus(); + expect(status.enabled).toBe(false); + expect(status.connectionKind).toBe('cdp'); + expect(status.capabilities).toEqual([]); + + await cdpBrowser.close(); + spy.mockRestore(); + }); }); describe('stale session recovery (all pages closed)', () => { diff --git a/src/browser.ts b/src/browser.ts index 3b025df..debaab7 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -27,6 +27,7 @@ import { decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js'; +import { STEALTH_CHROMIUM_ARGS, applyStealthScripts } from './stealth.js'; /** * Returns the default Playwright timeout in milliseconds for standard operations. @@ -89,6 +90,30 @@ interface PageError { timestamp: number; } +type BrowserType = NonNullable; +type StealthConnectionKind = + | 'local' + | 'cdp' + | 'provider-browserbase' + | 'provider-browseruse' + | 'provider-kernel'; + +interface StealthPolicy { + enabled: boolean; + connectionKind: StealthConnectionKind; + applyChromiumArgs: boolean; + applyInitScripts: boolean; + providerManaged: boolean; + capabilities: string[]; +} + +export interface StealthStatus { + enabled: boolean; + connectionKind: StealthConnectionKind; + capabilities: string[]; + providerManaged: boolean; +} + /** * Manages the Playwright browser lifecycle with multiple tabs/windows */ @@ -116,6 +141,8 @@ export class BrowserManager { private lastSnapshot: string = ''; private scopedHeaderRoutes: Map Promise> = new Map(); private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; + private stealthEnabled: boolean = false; + private stealthConnectionKind: StealthConnectionKind = 'local'; /** * Set the persistent color scheme preference. @@ -125,6 +152,76 @@ export class BrowserManager { this.colorScheme = scheme; } + /** + * Centralized stealth policy so launch mode semantics stay consistent. + * Local Chromium gets args + init scripts; CDP/providers get init scripts only. + */ + private getStealthPolicy(browserType: BrowserType = 'chromium'): StealthPolicy { + if (!this.stealthEnabled) { + return { + enabled: false, + connectionKind: this.stealthConnectionKind, + applyChromiumArgs: false, + applyInitScripts: false, + providerManaged: false, + capabilities: [], + }; + } + + const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium'; + const applyInitScripts = true; + const providerManaged = this.stealthConnectionKind === 'provider-kernel'; + const capabilities: string[] = []; + + if (applyChromiumArgs) { + capabilities.push('chromium-launch-args'); + } + if (applyInitScripts) { + capabilities.push('context-init-scripts'); + } + if (providerManaged) { + capabilities.push('provider-managed-stealth'); + } + + return { + enabled: true, + connectionKind: this.stealthConnectionKind, + applyChromiumArgs, + applyInitScripts, + providerManaged, + capabilities, + }; + } + + private logStealthPolicy(phase: string, browserType: BrowserType = 'chromium'): void { + if (process.env.AGENT_BROWSER_DEBUG !== '1') return; + const policy = this.getStealthPolicy(browserType); + const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none'; + console.error( + `[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}` + ); + } + + getStealthStatus(browserType: BrowserType = 'chromium'): StealthStatus { + const policy = this.getStealthPolicy(browserType); + return { + enabled: policy.enabled, + connectionKind: policy.connectionKind, + capabilities: policy.capabilities, + providerManaged: policy.providerManaged, + }; + } + + /** + * Apply context init-script stealth patches when policy allows. + */ + private async applyStealthIfEnabled(context: BrowserContext): Promise { + const policy = this.getStealthPolicy(); + if (!policy.applyInitScripts) return; + await applyStealthScripts(context); + this.logStealthPolicy('init-script applied'); + } + // CDP session for screencast and input injection private cdpSession: CDPSession | null = null; private screencastActive: boolean = false; @@ -282,6 +379,7 @@ export class BrowserManager { context = await this.browser.newContext({ ...(this.colorScheme && { colorScheme: this.colorScheme }), }); + await this.applyStealthIfEnabled(context); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); @@ -852,6 +950,7 @@ export class BrowserManager { * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables. */ private async connectToBrowserbase(): Promise { + this.stealthConnectionKind = 'provider-browserbase'; const browserbaseApiKey = process.env.BROWSERBASE_API_KEY; const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID; @@ -889,6 +988,7 @@ export class BrowserManager { } const context = contexts[0]; + await this.applyStealthIfEnabled(context); const pages = context.pages(); const page = pages[0] ?? (await context.newPage()); @@ -959,6 +1059,7 @@ export class BrowserManager { * Requires KERNEL_API_KEY environment variable. */ private async connectToKernel(): Promise { + this.stealthConnectionKind = 'provider-kernel'; const kernelApiKey = process.env.KERNEL_API_KEY; if (!kernelApiKey) { throw new Error('KERNEL_API_KEY is required when using kernel as a provider'); @@ -1026,9 +1127,11 @@ export class BrowserManager { // Kernel browsers launch with a default context and page if (contexts.length === 0) { context = await browser.newContext(); + await this.applyStealthIfEnabled(context); page = await context.newPage(); } else { context = contexts[0]; + await this.applyStealthIfEnabled(context); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } @@ -1055,6 +1158,7 @@ export class BrowserManager { * Requires BROWSER_USE_API_KEY environment variable. */ private async connectToBrowserUse(): Promise { + this.stealthConnectionKind = 'provider-browseruse'; const browserUseApiKey = process.env.BROWSER_USE_API_KEY; if (!browserUseApiKey) { throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider'); @@ -1099,9 +1203,11 @@ export class BrowserManager { if (contexts.length === 0) { context = await browser.newContext(); + await this.applyStealthIfEnabled(context); page = await context.newPage(); } else { context = contexts[0]; + await this.applyStealthIfEnabled(context); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } @@ -1172,6 +1278,22 @@ export class BrowserManager { if (options.colorScheme) { this.colorScheme = options.colorScheme; } + this.stealthEnabled = options.stealth ?? false; + // -p flag takes precedence over AGENT_BROWSER_PROVIDER. + const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; + + if (cdpEndpoint || options.autoConnect) { + this.stealthConnectionKind = 'cdp'; + } else if (provider === 'browserbase') { + this.stealthConnectionKind = 'provider-browserbase'; + } else if (provider === 'browseruse') { + this.stealthConnectionKind = 'provider-browseruse'; + } else if (provider === 'kernel') { + this.stealthConnectionKind = 'provider-kernel'; + } else { + this.stealthConnectionKind = 'local'; + } + this.logStealthPolicy('launch policy', options.browser ?? 'chromium'); if (cdpEndpoint) { await this.connectViaCDP(cdpEndpoint); @@ -1184,8 +1306,6 @@ export class BrowserManager { } // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var - // -p flag takes precedence over env var - const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; if (provider === 'browserbase') { await this.connectToBrowserbase(); return; @@ -1214,16 +1334,18 @@ export class BrowserManager { const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; - // Build base args array with file access flags if enabled - // --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch - // --allow-file-access: allows the browser to access local files in general + const stealthPolicy = this.getStealthPolicy(browserType); + + // Build base args array with file access flags and stealth args when policy allows. const fileAccessArgs = options.allowFileAccess ? ['--allow-file-access-from-files', '--allow-file-access'] : []; + const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : []; + const implicitArgs = [...fileAccessArgs, ...stealthArgs]; const baseArgs = options.args - ? [...fileAccessArgs, ...options.args] - : fileAccessArgs.length > 0 - ? fileAccessArgs + ? [...implicitArgs, ...options.args] + : implicitArgs.length > 0 + ? implicitArgs : undefined; // Auto-detect args that control window size and disable viewport emulation @@ -1364,6 +1486,8 @@ export class BrowserManager { }); } + await this.applyStealthIfEnabled(context); + context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); @@ -1385,6 +1509,7 @@ export class BrowserManager { cdpEndpoint: string | undefined, options?: { timeout?: number } ): Promise { + this.stealthConnectionKind = 'cdp'; if (!cdpEndpoint) { throw new Error('CDP endpoint is required for CDP connection'); } @@ -1439,6 +1564,7 @@ export class BrowserManager { this.cdpEndpoint = cdpEndpoint; for (const context of contexts) { + await this.applyStealthIfEnabled(context); context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); @@ -1696,6 +1822,7 @@ export class BrowserManager { viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport, ...(this.colorScheme && { colorScheme: this.colorScheme }), }); + await this.applyStealthIfEnabled(context); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); @@ -2435,6 +2562,8 @@ export class BrowserManager { this.isPersistentContext = false; this.activePageIndex = 0; this.colorScheme = null; + this.stealthEnabled = false; + this.stealthConnectionKind = 'local'; this.refMap = {}; this.lastSnapshot = ''; this.frameCallback = null; diff --git a/src/protocol.test.ts b/src/protocol.test.ts index 06c2673..56639ac 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -5,6 +5,19 @@ import { parseCommand } from './protocol.js'; const cmd = (obj: object) => JSON.stringify(obj); describe('parseCommand', () => { + describe('launch', () => { + it('should parse launch command with stealth flag', () => { + const result = parseCommand( + cmd({ id: '1', action: 'launch', headless: false, stealth: true }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.action).toBe('launch'); + expect(result.command.stealth).toBe(true); + } + }); + }); + describe('navigation', () => { it('should parse navigate command', () => { const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' })); diff --git a/src/protocol.ts b/src/protocol.ts index f9d685f..815f79c 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -49,6 +49,8 @@ const launchSchema = baseCommandSchema.extend({ provider: z.string().optional(), ignoreHTTPSErrors: z.boolean().optional(), allowFileAccess: z.boolean().optional(), + // Stealth toggle is part of launch semantics for local/CDP/provider modes. + stealth: z.boolean().optional(), colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(), profile: z.string().optional(), storageState: z.string().optional(), @@ -809,6 +811,7 @@ const waitSchema = baseCommandSchema.extend({ action: z.literal('wait'), selector: z.string().min(1).optional(), timeout: z.number().positive().optional(), + timeoutMax: z.number().positive().optional(), state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(), }); diff --git a/src/stealth.test.ts b/src/stealth.test.ts new file mode 100644 index 0000000..caaf22b --- /dev/null +++ b/src/stealth.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { BrowserManager } from './browser.js'; + +async function readWebdriverSignals(browser: BrowserManager): Promise<{ + value: boolean | undefined; + inNavigator: boolean; + ownNavigator: boolean; + ownPrototype: boolean; +}> { + const page = browser.getPage(); + await page.goto('about:blank'); + return page.evaluate(() => { + const prototype = Object.getPrototypeOf(navigator); + return { + value: navigator.webdriver, + inNavigator: 'webdriver' in navigator, + ownNavigator: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'), + ownPrototype: Object.prototype.hasOwnProperty.call(prototype, 'webdriver'), + }; + }); +} + +describe('Stealth mode', () => { + let browser: BrowserManager; + + afterEach(async () => { + if (browser?.isLaunched()) { + await browser.close(); + } + }); + + it('removes navigator.webdriver when stealth is enabled', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const signals = await readWebdriverSignals(browser); + expect(signals.value).toBeUndefined(); + expect(signals.inNavigator).toBe(false); + expect(signals.ownNavigator).toBe(false); + expect(signals.ownPrototype).toBe(false); + }); + + it('applies stealth patches to contexts created by newWindow', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + await browser.newWindow(); + + const signals = await readWebdriverSignals(browser); + expect(signals.value).toBeUndefined(); + expect(signals.inNavigator).toBe(false); + expect(signals.ownNavigator).toBe(false); + expect(signals.ownPrototype).toBe(false); + }); +}); diff --git a/src/stealth.ts b/src/stealth.ts new file mode 100644 index 0000000..f975a82 --- /dev/null +++ b/src/stealth.ts @@ -0,0 +1,276 @@ +/** + * Stealth mode patches to prevent browser automation detection. + * + * These scripts run via addInitScript (before any page JS) and patch the + * fingerprinting surfaces that anti-bot systems use to identify Playwright / + * Puppeteer / headless Chrome. + */ + +import type { BrowserContext } from 'playwright-core'; + +/** + * Chromium args that reduce automation fingerprint. + * Intended to be merged into the user-supplied args array at launch time. + */ +export const STEALTH_CHROMIUM_ARGS: string[] = ['--disable-blink-features=AutomationControlled']; + +/** + * Apply all stealth patches to a BrowserContext. + * Must be called BEFORE any page is created / navigated. + */ +export async function applyStealthScripts(context: BrowserContext): Promise { + await context.addInitScript({ content: buildStealthScript() }); +} + +function buildStealthScript(): string { + // Each patch is an IIFE so variable scoping is clean + return [ + patchNavigatorWebdriver(), + patchChromeRuntime(), + patchNavigatorPlugins(), + patchNavigatorPermissions(), + patchWebGLVendor(), + patchCdcProperties(), + patchIframeContentWindow(), + patchNavigatorHardwareConcurrency(), + patchMediaDevices(), + patchUserAgent(), + patchPerformanceMemory(), + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// Individual patches +// --------------------------------------------------------------------------- + +/** + * Remove navigator.webdriver entirely. + * Modern detection checks both value and property presence (`'webdriver' in navigator`). + */ +function patchNavigatorWebdriver(): string { + return `(function(){ + const removeWebdriver = (target) => { + if (!target) return; + try { delete target.webdriver; } catch {} + }; + removeWebdriver(navigator); + removeWebdriver(Object.getPrototypeOf(navigator)); + removeWebdriver(Navigator.prototype); +})();`; +} + +/** + * Ensure window.chrome and window.chrome.runtime exist. + * Headless Chrome (and Playwright) omit chrome.runtime which is a dead giveaway. + */ +function patchChromeRuntime(): string { + return `(function(){ + if (!window.chrome) { window.chrome = {}; } + if (!window.chrome.runtime) { + window.chrome.runtime = { + connect: function(){}, + sendMessage: function(){}, + }; + } +})();`; +} + +/** + * Inject a realistic navigator.plugins array. + * Headless Chrome reports an empty PluginArray; real Chrome always has a few. + */ +function patchNavigatorPlugins(): string { + return `(function(){ + const makePlugin = (name, description, filename, mimeType) => { + const mime = { type: mimeType, suffixes: '', description, enabledPlugin: null }; + const plugin = { name, description, filename, length: 1, 0: mime }; + mime.enabledPlugin = plugin; + return plugin; + }; + const plugins = [ + makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer', 'application/x-google-chrome-pdf'), + makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', 'application/pdf'), + makePlugin('Native Client', '', 'internal-nacl-plugin', 'application/x-nacl'), + ]; + const pluginArray = Object.create(PluginArray.prototype); + plugins.forEach((p, i) => { + Object.setPrototypeOf(p, Plugin.prototype); + pluginArray[i] = p; + }); + Object.defineProperty(pluginArray, 'length', { get: () => plugins.length }); + pluginArray.item = (i) => plugins[i] || null; + pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null; + pluginArray.refresh = () => {}; + pluginArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; }; + Object.defineProperty(navigator, 'plugins', { + get: () => pluginArray, + configurable: true, + }); +})();`; +} + +/** + * navigator.permissions.query({name:'notifications'}) should resolve to + * 'denied' in a normal browser, but Playwright throws or returns 'prompt'. + */ +function patchNavigatorPermissions(): string { + return `(function(){ + if (!navigator.permissions) return; + const origQuery = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = (params) => { + if (params.name === 'notifications') { + return Promise.resolve({ state: Notification.permission, onchange: null }); + } + return origQuery(params); + }; +})();`; +} + +/** + * WebGL vendor/renderer: headless Chrome uses SwiftShader which is distinctive. + * Patch getParameter to return Intel GPU strings when SwiftShader is detected. + */ +function patchWebGLVendor(): string { + return `(function(){ + const getCtx = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { + const ctx = getCtx.call(this, type, attrs); + if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) { + const origGetParameter = ctx.getParameter.bind(ctx); + ctx.getParameter = function(param) { + const ext = ctx.getExtension('WEBGL_debug_renderer_info'); + if (ext) { + if (param === ext.UNMASKED_VENDOR_WEBGL) { + const real = origGetParameter(param); + return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real; + } + if (param === ext.UNMASKED_RENDERER_WEBGL) { + const real = origGetParameter(param); + return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real; + } + } + return origGetParameter(param); + }; + } + return ctx; + }; +})();`; +} + +/** + * Remove Playwright's injected cdc_ (Chrome DevTools) properties on document. + * Some older detection scripts look for these on the document element. + */ +function patchCdcProperties(): string { + return `(function(){ + const clean = (target) => { + for (const key of Object.keys(target)) { + if (/^cdc_|^\\$cdc_/.test(key)) { + delete target[key]; + } + } + }; + clean(document); + if (document.documentElement) clean(document.documentElement); +})();`; +} + +/** + * contentWindow on cross-origin iframes: Playwright sometimes returns null + * where real browsers return a (restricted) Window object. + */ +function patchIframeContentWindow(): string { + return `(function(){ + const orig = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow'); + if (orig && orig.get) { + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + get: function() { + const w = orig.get.call(this); + if (w === null) { + return window; + } + return w; + }, + configurable: true, + }); + } +})();`; +} + +/** + * navigator.hardwareConcurrency: headless often reports 2 (CI); + * real desktops typically have >= 4 cores. + */ +function patchNavigatorHardwareConcurrency(): string { + return `(function(){ + if (navigator.hardwareConcurrency < 4) { + Object.defineProperty(navigator, 'hardwareConcurrency', { + get: () => 4, + configurable: true, + }); + } +})();`; +} + +/** + * navigator.mediaDevices.enumerateDevices should return at least some devices + * instead of an empty array (headless default). + */ +function patchMediaDevices(): string { + return `(function(){ + if (!navigator.mediaDevices) return; + const orig = navigator.mediaDevices.enumerateDevices; + if (!orig) return; + navigator.mediaDevices.enumerateDevices = async function() { + const devices = await orig.call(navigator.mediaDevices); + if (devices.length === 0) { + return [ + { deviceId: 'default', kind: 'audioinput', label: '', groupId: 'default' }, + { deviceId: 'default', kind: 'videoinput', label: '', groupId: 'default' }, + { deviceId: 'default', kind: 'audiooutput', label: '', groupId: 'default' }, + ]; + } + return devices; + }; +})();`; +} + +/** + * Replace "HeadlessChrome" with "Chrome" in navigator.userAgent so + * UA-based detection is bypassed at the JavaScript level. + */ +function patchUserAgent(): string { + return `(function(){ + const ua = navigator.userAgent; + if (ua.includes('HeadlessChrome')) { + const patched = ua.replace(/HeadlessChrome/g, 'Chrome'); + Object.defineProperty(navigator, 'userAgent', { + get: () => patched, + configurable: true, + }); + Object.defineProperty(navigator, 'appVersion', { + get: () => patched.replace('Mozilla/', ''), + configurable: true, + }); + } +})();`; +} + +/** + * Provide a fake performance.memory (Chrome-only, non-standard). + * Headless Chrome omits this; some detectors check for its presence. + */ +function patchPerformanceMemory(): string { + return `(function(){ + if (!performance.memory) { + Object.defineProperty(performance, 'memory', { + get: () => ({ + jsHeapSizeLimit: 2172649472, + totalJSHeapSize: 35839739, + usedJSHeapSize: 22592767, + }), + configurable: true, + }); + } +})();`; +}