fix(stealth): 修复 headed 模式下 stealth 失效并统一策略
- 修复 launch 协议未透传 stealth 导致 --headed 下补丁失效的问题\n- 在 BrowserManager 引入 StealthPolicy,统一 local/CDP/provider 能力决策\n- 增加 launch 返回 stealth 状态并在 --debug 输出连接类型与能力\n- 补充 local/CDP 回归测试与 bot.sannysoft.com 自动检查脚本\n- 同步 README、CLI help、技能文档与 CDP 文档中的 stealth 能力矩阵
This commit is contained in:
@@ -177,6 +177,7 @@ agent-browser find nth 2 "a" text
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element to be visible
|
||||
agent-browser wait <ms> # 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>` | 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 <name>` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) |
|
||||
| `--device <name>` | 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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Local launch</td><td>Chromium launch args (<code>--disable-blink-features=AutomationControlled</code>) + context init scripts</td></tr>
|
||||
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
|
||||
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Use <code>--debug</code> 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:
|
||||
|
||||
+16
-4
@@ -220,6 +220,8 @@ pub fn ensure_daemon(
|
||||
provider: Option<&str>,
|
||||
device: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
stealth: bool,
|
||||
debug: bool,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// 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))?;
|
||||
}
|
||||
|
||||
|
||||
+60
-24
@@ -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,16 +459,12 @@ 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 {
|
||||
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 {
|
||||
@@ -465,6 +472,19 @@ fn main() {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch with cloud provider if -p flag is set
|
||||
@@ -479,16 +499,12 @@ 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 {
|
||||
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 {
|
||||
@@ -496,6 +512,19 @@ fn main() {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch headed browser or configure browser options (without CDP or provider)
|
||||
@@ -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,12 +601,15 @@ 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 => {
|
||||
Ok(resp) => {
|
||||
if !resp.success {
|
||||
// Launch command failed (e.g., invalid state file, profile error)
|
||||
let error_msg = resp
|
||||
.error
|
||||
@@ -586,6 +621,10 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
if flags.debug {
|
||||
print_stealth_debug(&resp);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
@@ -598,9 +637,6 @@ fn main() {
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
Ok(_) => {
|
||||
// Launch succeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-26
@@ -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 <selector|ms|option>
|
||||
Usage: agent-browser wait <selector|ms|min-max|option>
|
||||
|
||||
Waits for an element to appear, a timeout, or other conditions.
|
||||
|
||||
Modes:
|
||||
<selector> Wait for element to appear
|
||||
<ms> Wait for specified milliseconds
|
||||
<min>-<max> Wait for random time between min and max ms
|
||||
--url <pattern> Wait for URL to match pattern
|
||||
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
||||
--fn <expression> 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 <sel> <path> Download file by clicking element
|
||||
scroll <dir> [px] Scroll (up/down/left/right)
|
||||
scrollintoview <sel> Scroll element into view
|
||||
wait <sel|ms> Wait for element or time
|
||||
wait <sel|ms|min-max> Wait for element, time, or random range
|
||||
screenshot [path] Take screenshot
|
||||
pdf <path> 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 <name> Browser provider: ios, browserbase, kernel, browseruse
|
||||
--device <name> iOS device name (e.g., "iPhone 15 Pro")
|
||||
--json JSON output
|
||||
@@ -2108,7 +2126,7 @@ Options:
|
||||
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
|
||||
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||
--config <path> 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<String, serde_json::Value>) {
|
||||
.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<String, serde_json::Value>) {
|
||||
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<String, serde_json::Value>) {
|
||||
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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Local launch</td><td>Chromium launch args + context init scripts</td></tr>
|
||||
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
|
||||
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Use `--debug` to print the active connection type and applied stealth capabilities.
|
||||
|
||||
## Use cases
|
||||
|
||||
This enables control of:
|
||||
|
||||
@@ -95,6 +95,7 @@ agent-browser find nth 2 ".card" hover
|
||||
```bash
|
||||
agent-browser wait <selector> # Wait for element
|
||||
agent-browser wait <ms> # 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> # 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 <name> # Browser provider (ios, browserbase, kernel, browseruse)
|
||||
--device <name> # 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 <port|url> # 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
|
||||
|
||||
Executable
+112
@@ -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();
|
||||
@@ -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 <selector>` 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:
|
||||
|
||||
+65
-7
@@ -517,7 +517,10 @@ async function handleLaunch(
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
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<void> {
|
||||
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<Response> {
|
||||
// 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 });
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
+137
-8
@@ -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<LaunchCommand['browser']>;
|
||||
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<string, (route: Route) => Promise<void>> = 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+276
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
Reference in New Issue
Block a user