Compare commits

...
11 Commits
Author SHA1 Message Date
leeguooooo ea2e93dbba feat(stealth): 优化隐身对抗并引入双版本发布体系
- 将 CreepJS like headless 指标优化到 0%(headless/stealth 维持 0%)

- 新增 ActiveText 与 prefers-color-scheme 探针修复

- 版本号采用 <upstream>-fork.<fork> 格式并在 --version 输出 upstream/fork

- 更新 README、SKILL 与 docs 中的版本体系说明
2026-02-24 15:29:11 +09:00
leeguooooo 4c6afe3e69 docs(config): 移除 --stealth 配置项说明 2026-02-24 14:55:07 +09:00
leeguooooo 9f9a90cf63 docs(cli): 清理过时 stealth 环境变量说明 2026-02-24 14:54:51 +09:00
leeguooooo 0443e4ed7a feat(stealth): 默认开启并收敛 chrome 指纹特征 2026-02-24 14:54:30 +09:00
leeguooooo c5b2292caa feat(stealth): 增强浏览器级 UA 覆盖并修复背景特征 2026-02-24 14:50:03 +09:00
leeguooooo 2ed0c6f8ec feat(stealth): 进一步降低 creepjs like-headless 指标 2026-02-24 14:36:56 +09:00
leeguooooo 3a91aef4c9 feat(stealth): 优化指纹信号并同步文档与包配置 2026-02-24 14:23:59 +09:00
leeguooooo 02ebc9f328 feat(stealth): 增强指纹一致性并新增 creepjs 检测脚本 2026-02-24 14:19:25 +09:00
leeguooooo 955543b757 chore: prepare fork sync and independent release setup 2026-02-24 14:14:50 +09:00
leeguooooo ecad112707 feat(cli): 默认开启 stealth 并支持 wait 区间超时 2026-02-24 12:27:04 +09:00
leeguooooo 8932f28926 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 能力矩阵
2026-02-24 12:18:47 +09:00
33 changed files with 2800 additions and 255 deletions
+1 -1
View File
@@ -11,6 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
id-token: write
jobs: jobs:
# Build native binaries for all platforms first # Build native binaries for all platforms first
@@ -219,7 +220,6 @@ jobs:
commit: 'chore: version packages' commit: 'chore: version packages'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
# Create GitHub release with binaries after npm publish # Create GitHub release with binaries after npm publish
github-release: github-release:
+104 -12
View File
@@ -1,6 +1,12 @@
# agent-browser # agent-browser
Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallback. Stealth-first browser automation CLI engineered for anti-bot evasion. Fast Rust CLI with Node.js fallback.
Designed for production automation on detection-heavy sites:
- Always-on stealth (no opt-in flag)
- Browser and protocol-level anti-fingerprint patches
- Humanized interaction behavior by default
- Verified against CreepJS using the built-in check script
## Installation ## Installation
@@ -9,7 +15,7 @@ Headless browser automation CLI for AI agents. Fast Rust CLI with Node.js fallba
Installs the native Rust binary for maximum performance: Installs the native Rust binary for maximum performance:
```bash ```bash
npm install -g agent-browser npm install -g agent-browser-stealth
agent-browser install # Download Chromium agent-browser install # Download Chromium
``` ```
@@ -20,8 +26,8 @@ This is the fastest option -- commands run through the native Rust CLI directly
Run directly with `npx` if you want to try it without installing globally: Run directly with `npx` if you want to try it without installing globally:
```bash ```bash
npx agent-browser install # Download Chromium (first time only) npx agent-browser-stealth install # Download Chromium (first time only)
npx agent-browser open example.com npx agent-browser-stealth open example.com
``` ```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally. > **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
@@ -31,14 +37,14 @@ npx agent-browser open example.com
For projects that want to pin the version in `package.json`: For projects that want to pin the version in `package.json`:
```bash ```bash
npm install agent-browser npm install agent-browser-stealth
npx agent-browser install npx agent-browser-stealth install
``` ```
Then use via `npx` or `package.json` scripts: Then use via `npx` or `package.json` scripts:
```bash ```bash
npx agent-browser open example.com npx agent-browser-stealth open example.com
``` ```
### Homebrew (macOS) ### Homebrew (macOS)
@@ -51,7 +57,7 @@ agent-browser install # Download Chromium
### From Source ### From Source
```bash ```bash
git clone https://github.com/vercel-labs/agent-browser git clone https://github.com/leeguooooo/agent-browser
cd agent-browser cd agent-browser
pnpm install pnpm install
pnpm build pnpm build
@@ -60,6 +66,44 @@ pnpm link --global # Makes agent-browser available globally
agent-browser install agent-browser install
``` ```
### Fork Maintenance (Independent Release + Upstream Sync)
If you maintain a fork and publish your own CLI, use this workflow:
1. Keep an upstream-tracking branch (`upstream-main`) for clean sync history.
2. Keep your release branch (`main`) for production-ready code only.
3. Merge upstream into short-lived sync branches, then open PRs into `main`.
One-time setup:
```bash
git remote add upstream https://github.com/vercel-labs/agent-browser.git
git fetch upstream
```
Regular sync:
```bash
pnpm run sync:upstream:push
```
This command:
- Fetches `upstream/main`
- Fast-forwards local `upstream-main`
- Creates `sync/YYYY-MM-DD` from local `main`
- Merges `upstream-main` into the sync branch
- Pushes the sync branch to `origin` (with `sync:upstream:push`)
If merge conflicts occur, resolve them on the sync branch and open a PR as usual.
Independent release checklist for forks:
- Use your own npm package name and CLI binary name (avoid conflicts with upstream package ownership).
- Update `repository`, `bugs`, and `homepage` in `package.json` to your fork.
- Configure npm Trusted Publishing (OIDC) for your package and repository workflow.
- Keep release tags and changelog in your own namespace/versioning policy.
- Use dual-version format: `<upstream>-fork.<fork>` (example: `0.14.0-fork.1`).
- `agent-browser --version` should show all three: full version, upstream version, and fork version.
### Linux Dependencies ### Linux Dependencies
On Linux, install system dependencies: On Linux, install system dependencies:
@@ -78,6 +122,7 @@ agent-browser click @e2 # Click by ref from snapshot
agent-browser fill @e3 "test@example.com" # Fill by ref agent-browser fill @e3 "test@example.com" # Fill by ref
agent-browser get text @e1 # Get text by ref agent-browser get text @e1 # Get text by ref
agent-browser screenshot page.png agent-browser screenshot page.png
agent-browser --version # Includes upstream/fork metadata on fork builds
agent-browser close agent-browser close
``` ```
@@ -177,6 +222,7 @@ agent-browser find nth 2 "a" text
```bash ```bash
agent-browser wait <selector> # Wait for element to be visible agent-browser wait <selector> # Wait for element to be visible
agent-browser wait <ms> # Wait for time (milliseconds) 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 --text "Welcome" # Wait for text to appear
agent-browser wait --url "**/dash" # Wait for URL pattern agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state agent-browser wait --load networkidle # Wait for load state
@@ -457,6 +503,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) | | `--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) | | `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) |
| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) | | `--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) | | `-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) | | `--device <name>` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) |
| `--json` | JSON output (for agents) | | `--json` | JSON output (for agents) |
@@ -684,7 +731,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```typescript ```typescript
import chromium from '@sparticuz/chromium'; import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser'; import { BrowserManager } from 'agent-browser-stealth';
export async function handler() { export async function handler() {
const browser = new BrowserManager(); const browser = new BrowserManager();
@@ -717,6 +764,51 @@ 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. **Note:** This flag only works with Chromium. For security, it's disabled by default.
## Stealth Mode
`agent-browser-stealth` is built around stealth as a primary design goal, not an add-on.
Stealth is **always on** with no flag needed. Every browser session automatically applies anti-detection countermeasures:
- Removes `navigator.webdriver` automation indicator
- Disables Chromium's `AutomationControlled` blink feature
- Replaces "HeadlessChrome" in User-Agent and userAgentData (including CDP-level override)
- Uses ANGLE rendering instead of SwiftShader to avoid GPU fingerprinting
- Adds realistic `navigator.plugins` and `navigator.mimeTypes` (passes `instanceof` checks)
- Patches `window.chrome.runtime` to match real Chrome
- Masks WebGL vendor/renderer
- Fixes `navigator.permissions.query` for notifications
- Reports realistic `navigator.hardwareConcurrency` and `performance.memory`
- Provides default media devices for `enumerateDevices()`
- Patches screen/window dimensions to avoid viewport-equals-screen fingerprint
- Sets opaque background color (headless default is transparent)
- Cleans up CDP-injected properties on the document
### Stealth Verification
On February 24, 2026, local validation against CreepJS using `scripts/check-creepjs-headless.js` reported:
| Metric | Result |
| --- | --- |
| like headless | 0% |
| headless | 0% |
| stealth | 0% |
Reproduce:
```bash
node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
```
### Humanized Interactions
All interactions are automatically humanized 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 ## CDP Mode
Connect to an existing browser via Chrome DevTools Protocol: Connect to an existing browser via Chrome DevTools Protocol:
@@ -839,7 +931,7 @@ Connect to `ws://localhost:9223` to receive frames and send input:
For advanced use, control streaming directly via the protocol: For advanced use, control streaming directly via the protocol:
```typescript ```typescript
import { BrowserManager } from 'agent-browser'; import { BrowserManager } from 'agent-browser-stealth';
const browser = new BrowserManager(); const browser = new BrowserManager();
await browser.launch({ headless: true }); await browser.launch({ headless: true });
@@ -915,7 +1007,7 @@ The `--help` output is comprehensive and most agents can figure it out from ther
Add the skill to your AI coding assistant for richer context: Add the skill to your AI coding assistant for richer context:
```bash ```bash
npx skills add vercel-labs/agent-browser npx skills add leeguooooo/agent-browser
``` ```
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository, so it stays up to date automatically -- do not copy `SKILL.md` from `node_modules` as it will become stale. This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository, so it stays up to date automatically -- do not copy `SKILL.md` from `node_modules` as it will become stale.
@@ -925,7 +1017,7 @@ This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, O
Install as a Claude Code skill: Install as a Claude Code skill:
```bash ```bash
npx skills add vercel-labs/agent-browser npx skills add leeguooooo/agent-browser
``` ```
This adds the skill to `.claude/skills/agent-browser/SKILL.md` in your project. The skill teaches Claude Code the full agent-browser workflow, including the snapshot-ref interaction pattern, session management, and timeout handling. This adds the skill to `.claude/skills/agent-browser/SKILL.md` in your project. The skill teaches Claude Code the full agent-browser workflow, including the snapshot-ref interaction pattern, session management, and timeout handling.
+2 -2
View File
@@ -3,8 +3,8 @@
version = 4 version = 4
[[package]] [[package]]
name = "agent-browser" name = "agent-browser-stealth"
version = "0.14.0" version = "0.14.0-fork.1"
dependencies = [ dependencies = [
"base64", "base64",
"dirs", "dirs",
+7 -3
View File
@@ -1,10 +1,14 @@
[package] [package]
name = "agent-browser" name = "agent-browser-stealth"
version = "0.14.0" version = "0.14.0-fork.1"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0" license = "Apache-2.0"
[[bin]]
name = "agent-browser"
path = "src/main.rs"
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
+56 -26
View File
@@ -111,10 +111,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url }); let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
// If --headers flag is set, include headers (scoped to this origin) // If --headers flag is set, include headers (scoped to this origin)
if let Some(ref headers_json) = flags.headers { if let Some(ref headers_json) = flags.headers {
let headers = serde_json::from_str::<serde_json::Value>(headers_json) let headers =
.map_err(|_| ParseError::InvalidValue { serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
message: format!("Invalid JSON for --headers: {}", headers_json), ParseError::InvalidValue {
usage: "open <url> --headers '{\"Key\": \"Value\"}'", message: format!("Invalid JSON for --headers: {}", headers_json),
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
}
})?; })?;
nav_cmd["headers"] = headers; nav_cmd["headers"] = headers;
} }
@@ -287,7 +289,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
usage: "keyboard inserttext <text>", usage: "keyboard inserttext <text>",
}); });
} }
Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text })) Ok(
json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }),
)
} }
_ => Err(ParseError::UnknownSubcommand { _ => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -386,8 +390,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
return Ok(cmd); return Ok(cmd);
} }
// Default: selector or timeout // Default: selector, timeout, or range (e.g. 2000-5000)
if let Some(arg) = rest.first() { if let Some(arg) = rest.first() {
// Check for range syntax: "2000-5000"
if let Some((min_str, max_str)) = arg.split_once('-') {
if let (Ok(min), Ok(max)) = (min_str.parse::<u64>(), max_str.parse::<u64>()) {
return Ok(
json!({ "id": id, "action": "wait", "timeout": min, "timeoutMax": max }),
);
}
}
if let Ok(timeout) = arg.parse::<u64>() { if let Ok(timeout) = arg.parse::<u64>() {
Ok(json!({ "id": id, "action": "wait", "timeout": timeout })) Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
} else { } else {
@@ -396,7 +408,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} else { } else {
Err(ParseError::MissingArguments { Err(ParseError::MissingArguments {
context: "wait".to_string(), context: "wait".to_string(),
usage: "wait <selector|ms|--url|--load|--fn|--text>", usage: "wait <selector|ms|min-max|--url|--load|--fn|--text>",
}) })
} }
} }
@@ -929,9 +941,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?; })?;
Ok(json!({ "id": id, "action": "state_load", "path": path })) Ok(json!({ "id": id, "action": "state_load", "path": path }))
} }
Some("list") => { Some("list") => Ok(json!({ "id": id, "action": "state_list" })),
Ok(json!({ "id": id, "action": "state_list" }))
}
Some("clear") => { Some("clear") => {
let mut session_name: Option<&str> = None; let mut session_name: Option<&str> = None;
let mut all = false; let mut all = false;
@@ -952,7 +962,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
if let Some(name) = session_name { if let Some(name) = session_name {
if !is_valid_session_name(name) { if !is_valid_session_name(name) {
return Err(ParseError::InvalidSessionName { name: name.to_string() }); return Err(ParseError::InvalidSessionName {
name: name.to_string(),
});
} }
} }
@@ -1006,13 +1018,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
let new_name = new_name.trim_end_matches(".json"); let new_name = new_name.trim_end_matches(".json");
if !is_valid_session_name(old_name) { if !is_valid_session_name(old_name) {
return Err(ParseError::InvalidSessionName { name: old_name.to_string() }); return Err(ParseError::InvalidSessionName {
name: old_name.to_string(),
});
} }
if !is_valid_session_name(new_name) { if !is_valid_session_name(new_name) {
return Err(ParseError::InvalidSessionName { name: new_name.to_string() }); return Err(ParseError::InvalidSessionName {
name: new_name.to_string(),
});
} }
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name })) Ok(
json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }),
)
} }
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
@@ -1121,7 +1139,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
} }
Err(_) => { Err(_) => {
return Err(ParseError::InvalidValue { return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d), message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff snapshot --depth <n>", usage: "diff snapshot --depth <n>",
}); });
} }
@@ -1187,7 +1208,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
} }
Ok(n) => { Ok(n) => {
return Err(ParseError::InvalidValue { return Err(ParseError::InvalidValue {
message: format!("Threshold must be between 0 and 1, got {}", n), message: format!(
"Threshold must be between 0 and 1, got {}",
n
),
usage: "diff screenshot --threshold <0-1>", usage: "diff screenshot --threshold <0-1>",
}); });
} }
@@ -1304,7 +1328,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
} }
Err(_) => { Err(_) => {
return Err(ParseError::InvalidValue { return Err(ParseError::InvalidValue {
message: format!("Depth must be a non-negative integer, got: {}", d), message: format!(
"Depth must be a non-negative integer, got: {}",
d
),
usage: "diff url <url1> <url2> --depth <n>", usage: "diff url <url1> <url2> --depth <n>",
}); });
} }
@@ -3029,8 +3056,11 @@ mod tests {
#[test] #[test]
fn test_diff_snapshot_baseline() { fn test_diff_snapshot_baseline() {
let cmd = let cmd = parse_command(
parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap(); &args("diff snapshot --baseline before.txt"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot"); assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "before.txt"); assert_eq!(cmd["baseline"], "before.txt");
} }
@@ -3050,9 +3080,11 @@ mod tests {
#[test] #[test]
fn test_diff_snapshot_short_flags() { fn test_diff_snapshot_short_flags() {
let cmd = let cmd = parse_command(
parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags()) &args("diff snapshot -b snap.txt -s .content -c -d 2"),
.unwrap(); &default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "diff_snapshot"); assert_eq!(cmd["action"], "diff_snapshot");
assert_eq!(cmd["baseline"], "snap.txt"); assert_eq!(cmd["baseline"], "snap.txt");
assert_eq!(cmd["selector"], ".content"); assert_eq!(cmd["selector"], ".content");
@@ -3100,8 +3132,7 @@ mod tests {
fn test_diff_screenshot_global_full_flag() { fn test_diff_screenshot_global_full_flag() {
let mut flags = default_flags(); let mut flags = default_flags();
flags.full = true; flags.full = true;
let cmd = let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
assert_eq!(cmd["action"], "diff_screenshot"); assert_eq!(cmd["action"], "diff_screenshot");
assert_eq!(cmd["fullPage"], true); assert_eq!(cmd["fullPage"], true);
} }
@@ -3145,8 +3176,7 @@ mod tests {
fn test_diff_url_global_full_flag() { fn test_diff_url_global_full_flag() {
let mut flags = default_flags(); let mut flags = default_flags();
flags.full = true; flags.full = true;
let cmd = let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
assert_eq!(cmd["fullPage"], true); assert_eq!(cmd["fullPage"], true);
} }
+15 -4
View File
@@ -220,6 +220,7 @@ pub fn ensure_daemon(
provider: Option<&str>, provider: Option<&str>,
device: Option<&str>, device: Option<&str>,
session_name: Option<&str>, session_name: Option<&str>,
debug: bool,
) -> Result<DaemonResult, String> { ) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive // Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) { if is_daemon_running(session) && daemon_ready(session) {
@@ -364,6 +365,11 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_SESSION_NAME", sn); cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
} }
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// Create new process group and session to fully detach // Create new process group and session to fully detach
unsafe { unsafe {
cmd.pre_exec(|| { cmd.pre_exec(|| {
@@ -375,8 +381,8 @@ pub fn ensure_daemon(
cmd.stdin(Stdio::null()) cmd.stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null());
.spawn() cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?; .map_err(|e| format!("Failed to start daemon: {}", e))?;
} }
@@ -447,6 +453,11 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_SESSION_NAME", sn); cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
} }
cmd.env("AGENT_BROWSER_STEALTH", "1");
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008; const DETACHED_PROCESS: u32 = 0x00000008;
@@ -454,8 +465,8 @@ pub fn ensure_daemon(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null());
.spawn() cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?; .map_err(|e| format!("Failed to start daemon: {}", e))?;
} }
+60 -40
View File
@@ -156,8 +156,7 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
}); });
if let Some((source, maybe_path)) = explicit { if let Some((source, maybe_path)) = explicit {
let path_str = let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
let path = PathBuf::from(&path_str); let path = PathBuf::from(&path_str);
if !path.exists() { if !path.exists() {
return Err(format!("config file not found: {}", path_str)); return Err(format!("config file not found: {}", path_str));
@@ -241,49 +240,47 @@ pub fn parse_flags(args: &[String]) -> Flags {
}; };
let mut flags = Flags { let mut flags = Flags {
json: env_var_is_truthy("AGENT_BROWSER_JSON") json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|| config.json.unwrap_or(false), full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
full: env_var_is_truthy("AGENT_BROWSER_FULL") headed: match env::var("AGENT_BROWSER_HEADED") {
|| config.full.unwrap_or(false), Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") Err(_) => config.headed.unwrap_or(true),
|| config.headed.unwrap_or(false), },
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|| config.debug.unwrap_or(false), session: env::var("AGENT_BROWSER_SESSION")
session: env::var("AGENT_BROWSER_SESSION").ok() .ok()
.or(config.session) .or(config.session)
.unwrap_or_else(|| "default".to_string()), .unwrap_or_else(|| "default".to_string()),
headers: config.headers, headers: config.headers,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok() executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
.ok()
.or(config.executable_path), .or(config.executable_path),
cdp: config.cdp, cdp: config.cdp,
extensions, extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok() profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
.or(config.profile), state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
state: env::var("AGENT_BROWSER_STATE").ok() proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
.or(config.state), proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
proxy: env::var("AGENT_BROWSER_PROXY").ok() .ok()
.or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
.or(config.proxy_bypass), .or(config.proxy_bypass),
args: env::var("AGENT_BROWSER_ARGS").ok() args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
.or(config.args), user_agent: env::var("AGENT_BROWSER_USER_AGENT")
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok() .ok()
.or(config.user_agent), .or(config.user_agent),
provider: env::var("AGENT_BROWSER_PROVIDER").ok() provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
.or(config.provider),
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS") ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|| config.ignore_https_errors.unwrap_or(false), || config.ignore_https_errors.unwrap_or(false),
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS") allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|| config.allow_file_access.unwrap_or(false), || config.allow_file_access.unwrap_or(false),
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok() device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
.or(config.device),
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT") auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|| config.auto_connect.unwrap_or(false), || config.auto_connect.unwrap_or(false),
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok() session_name: env::var("AGENT_BROWSER_SESSION_NAME")
.ok()
.or(config.session_name), .or(config.session_name),
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
|| config.annotate.unwrap_or(false), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok() .ok()
.or(config.color_scheme), .or(config.color_scheme),
cli_executable_path: false, cli_executable_path: false,
cli_extensions: false, cli_extensions: false,
@@ -303,22 +300,30 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--json" => { "--json" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.json = val; flags.json = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--full" | "-f" => { "--full" | "-f" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.full = val; flags.full = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--headed" => { "--headed" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.headed = val; flags.headed = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--debug" => { "--debug" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.debug = val; flags.debug = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--session" => { "--session" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
@@ -403,13 +408,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--ignore-https-errors" => { "--ignore-https-errors" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.ignore_https_errors = val; flags.ignore_https_errors = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--allow-file-access" => { "--allow-file-access" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.allow_file_access = val; flags.allow_file_access = val;
flags.cli_allow_file_access = true; flags.cli_allow_file_access = true;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--device" => { "--device" => {
if let Some(d) = args.get(i + 1) { if let Some(d) = args.get(i + 1) {
@@ -420,7 +429,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
"--auto-connect" => { "--auto-connect" => {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.auto_connect = val; flags.auto_connect = val;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--session-name" => { "--session-name" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
@@ -432,7 +443,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
let (val, consumed) = parse_bool_arg(args, i); let (val, consumed) = parse_bool_arg(args, i);
flags.annotate = val; flags.annotate = val;
flags.cli_annotate = true; flags.cli_annotate = true;
if consumed { i += 1; } if consumed {
i += 1;
}
} }
"--color-scheme" => { "--color-scheme" => {
if let Some(s) = args.get(i + 1) { if let Some(s) = args.get(i + 1) {
@@ -721,7 +734,10 @@ mod tests {
assert_eq!(config.session.as_deref(), Some("test-session")); assert_eq!(config.session.as_deref(), Some("test-session"));
assert_eq!(config.session_name.as_deref(), Some("my-app")); assert_eq!(config.session_name.as_deref(), Some("my-app"));
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium")); assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()])); assert_eq!(
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile")); assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json")); assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080")); assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
@@ -1030,7 +1046,11 @@ mod tests {
let merged = user.merge(project); let merged = user.merge(project);
assert_eq!( assert_eq!(
merged.extensions, merged.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()]) Some(vec![
"/ext1".to_string(),
"/ext2".to_string(),
"/ext3".to_string()
])
); );
} }
+60 -43
View File
@@ -226,6 +226,7 @@ fn main() {
flags.provider.as_deref(), flags.provider.as_deref(),
flags.device.as_deref(), flags.device.as_deref(),
flags.session_name.as_deref(), flags.session_name.as_deref(),
flags.debug,
) { ) {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
@@ -448,22 +449,29 @@ fn main() {
launch_cmd["colorScheme"] = json!(cs); launch_cmd["colorScheme"] = json!(cs);
} }
let err = match send_command(launch_cmd, &flags.session) { match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None, Ok(resp) => {
Ok(resp) => Some( if !resp.success {
resp.error let msg = resp
.unwrap_or_else(|| "CDP connection failed".to_string()), .error
), .unwrap_or_else(|| "CDP connection failed".to_string());
Err(e) => Some(e.to_string()), if flags.json {
}; println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
if let Some(msg) = err { eprintln!("{} {}", color::error_indicator(), msg);
if flags.json { }
println!(r#"{{"success":false,"error":"{}"}}"#, msg); exit(1);
} else { }
eprintln!("{} {}", color::error_indicator(), msg);
}
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
} }
exit(1);
} }
} }
@@ -479,22 +487,29 @@ fn main() {
launch_cmd["colorScheme"] = json!(cs); launch_cmd["colorScheme"] = json!(cs);
} }
let err = match send_command(launch_cmd, &flags.session) { match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None, Ok(resp) => {
Ok(resp) => Some( if !resp.success {
resp.error let msg = resp
.unwrap_or_else(|| "Provider connection failed".to_string()), .error
), .unwrap_or_else(|| "Provider connection failed".to_string());
Err(e) => Some(e.to_string()), if flags.json {
}; println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
if let Some(msg) = err { eprintln!("{} {}", color::error_indicator(), msg);
if flags.json { }
println!(r#"{{"success":false,"error":"{}"}}"#, msg); exit(1);
} else { }
eprintln!("{} {}", color::error_indicator(), msg);
}
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
} }
exit(1);
} }
} }
@@ -506,7 +521,9 @@ fn main() {
|| flags.proxy.is_some() || flags.proxy.is_some()
|| flags.args.is_some() || flags.args.is_some()
|| flags.user_agent.is_some() || flags.user_agent.is_some()
|| flags.ignore_https_errors
|| flags.allow_file_access || flags.allow_file_access
|| flags.debug
|| flags.color_scheme.is_some()) || flags.color_scheme.is_some())
&& flags.cdp.is_none() && flags.cdp.is_none()
&& flags.provider.is_none() && flags.provider.is_none()
@@ -574,17 +591,20 @@ fn main() {
} }
match send_command(launch_cmd, &flags.session) { match send_command(launch_cmd, &flags.session) {
Ok(resp) if !resp.success => { Ok(resp) => {
// Launch command failed (e.g., invalid state file, profile error) if !resp.success {
let error_msg = resp // Launch command failed (e.g., invalid state file, profile error)
.error let error_msg = resp
.unwrap_or_else(|| "Browser launch failed".to_string()); .error
if flags.json { .unwrap_or_else(|| "Browser launch failed".to_string());
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg); if flags.json {
} else { println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
eprintln!("{} {}", color::error_indicator(), error_msg); } else {
eprintln!("{} {}", color::error_indicator(), error_msg);
}
exit(1);
} }
exit(1);
} }
Err(e) => { Err(e) => {
if flags.json { if flags.json {
@@ -598,9 +618,6 @@ fn main() {
} }
exit(1); exit(1);
} }
Ok(_) => {
// Launch succeeded
}
} }
} }
+80 -30
View File
@@ -39,15 +39,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
return; return;
} }
Some("diff_url") => { Some("diff_url") => {
if let Some(snap_data) = if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) {
obj.get("snapshot").and_then(|v| v.as_object())
{
println!("{}", color::bold("Snapshot diff:")); println!("{}", color::bold("Snapshot diff:"));
print_snapshot_diff(snap_data); print_snapshot_diff(snap_data);
} }
if let Some(ss_data) = if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) {
obj.get("screenshot").and_then(|v| v.as_object())
{
println!("\n{}", color::bold("Screenshot diff:")); println!("\n{}", color::bold("Screenshot diff:"));
print_screenshot_diff(ss_data); 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()) { if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!( println!("{} Recording started: {}", color::success_indicator(), path);
"{} Recording started: {}",
color::success_indicator(),
path
);
} else { } else {
println!("{} Recording started", color::success_indicator()); 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 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 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 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 { let size_str = if size > 1024 {
format!("{:.1}KB", size as f64 / 1024.0) format!("{:.1}KB", size as f64 / 1024.0)
} else { } 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 date_str = modified.split('T').next().unwrap_or(modified);
let enc_str = if encrypted { " [encrypted]" } else { "" }; 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; 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()) { 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 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(""); 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; return;
} }
// State clear // State clear
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) { 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; return;
} }
@@ -529,7 +537,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
if let Some(summary) = data.get("summary") { if let Some(summary) = data.get("summary") {
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0); 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 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 { "" }; let enc_str = if encrypted { " (encrypted)" } else { "" };
println!("State file summary{}:", enc_str); println!("State file summary{}:", enc_str);
println!(" Cookies: {}", cookies); println!(" Cookies: {}", cookies);
@@ -539,7 +550,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
// State clean // State clean
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) { 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; return;
} }
@@ -1017,13 +1032,14 @@ Examples:
r##" r##"
agent-browser wait - Wait for condition 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. Waits for an element to appear, a timeout, or other conditions.
Modes: Modes:
<selector> Wait for element to appear <selector> Wait for element to appear
<ms> Wait for specified milliseconds <ms> Wait for specified milliseconds
<min>-<max> Wait for random time between min and max ms
--url <pattern> Wait for URL to match pattern --url <pattern> Wait for URL to match pattern
--load <state> Wait for load state (load, domcontentloaded, networkidle) --load <state> Wait for load state (load, domcontentloaded, networkidle)
--fn <expression> Wait for JavaScript expression to be truthy --fn <expression> Wait for JavaScript expression to be truthy
@@ -1040,6 +1056,7 @@ Global Options:
Examples: Examples:
agent-browser wait "#loading-spinner" agent-browser wait "#loading-spinner"
agent-browser wait 2000 agent-browser wait 2000
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
agent-browser wait --url "**/dashboard" agent-browser wait --url "**/dashboard"
agent-browser wait --load networkidle agent-browser wait --load networkidle
agent-browser wait --fn "window.appReady === true" agent-browser wait --fn "window.appReady === true"
@@ -2011,7 +2028,7 @@ Core Commands:
download <sel> <path> Download file by clicking element download <sel> <path> Download file by clicking element
scroll <dir> [px] Scroll (up/down/left/right) scroll <dir> [px] Scroll (up/down/left/right)
scrollintoview <sel> Scroll element into view 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 screenshot [path] Take screenshot
pdf <path> Save as PDF pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI) snapshot Accessibility tree with refs (for AI)
@@ -2109,7 +2126,7 @@ Options:
--session-name <name> Auto-save/restore session state (cookies, localStorage) --session-name <name> Auto-save/restore session state (cookies, localStorage)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env) --config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output --debug Debug output
--version, -V Show version --version, -V Show version (fork builds include upstream/fork info)
Configuration: Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority): agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
@@ -2147,6 +2164,7 @@ Environment:
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse) AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome 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_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference) AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000) AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
@@ -2157,11 +2175,11 @@ Environment:
AGENT_BROWSER_IOS_UDID Default iOS device UDID AGENT_BROWSER_IOS_UDID Default iOS device UDID
Install (recommended, fastest - native Rust CLI): Install (recommended, fastest - native Rust CLI):
npm install -g agent-browser npm install -g agent-browser-stealth
agent-browser install # Download Chromium (first time) agent-browser install # Download Chromium (first time)
Try without installing (slower, routes through Node.js): Try without installing (slower, routes through Node.js):
npx agent-browser open example.com npx agent-browser-stealth open example.com
Examples: Examples:
agent-browser open example.com agent-browser open example.com
@@ -2232,10 +2250,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
.get("mismatchPercentage") .get("mismatchPercentage")
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.0); .unwrap_or(0.0);
let is_match = data let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false);
.get("match")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let dim_mismatch = data let dim_mismatch = data
.get("dimensionMismatch") .get("dimensionMismatch")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -2246,7 +2261,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
color::error_indicator() color::error_indicator()
); );
} else if is_match { } else if is_match {
println!("{} Images match (0% difference)", color::success_indicator()); println!(
"{} Images match (0% difference)",
color::success_indicator()
);
} else { } else {
println!( println!(
"{} {:.2}% pixels differ", "{} {:.2}% pixels differ",
@@ -2257,7 +2275,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()) { if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
println!(" Diff image: {}", color::green(diff_path)); 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 let different = data
.get("differentPixels") .get("differentPixels")
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
@@ -2269,6 +2290,35 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
); );
} }
pub fn print_version() { /// Parse fork version metadata from semver-like strings:
println!("agent-browser {}", env!("CARGO_PKG_VERSION")); /// <upstream>-fork.<fork>
/// Example:
/// 0.14.0-fork.1 -> (0.14.0, 1)
fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
let (upstream, fork) = version.split_once("-fork.")?;
if upstream.is_empty() || fork.is_empty() {
return None;
}
if !upstream
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
{
return None;
}
if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
return None;
}
Some((upstream, fork))
}
pub fn print_version() {
let version = env!("CARGO_PKG_VERSION");
if let Some((upstream, fork)) = parse_fork_version(version) {
println!(
"agent-browser {} (upstream {}, fork {})",
version, upstream, fork
);
} else {
println!("agent-browser {}", version);
}
} }
+4 -1
View File
@@ -1,6 +1,9 @@
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only) /// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
pub fn is_valid_session_name(name: &str) -> bool { pub fn is_valid_session_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') !name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
} }
/// Generate error message for invalid session name /// Generate error message for invalid session name
+2 -2
View File
@@ -14,9 +14,9 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents. const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
GitHub repository: https://github.com/vercel-labs/agent-browser GitHub repository: https://github.com/leeguooooo/agent-browser
Documentation: https://agent-browser.dev Documentation: https://agent-browser.dev
npm package: agent-browser npm package: agent-browser-stealth
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory. You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
+17
View File
@@ -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 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 ## Use cases
This enables control of: This enables control of:
+10 -1
View File
@@ -32,9 +32,16 @@ agent-browser pdf <path> # Save page as PDF
agent-browser snapshot # Accessibility tree with refs agent-browser snapshot # Accessibility tree with refs
agent-browser eval <js> # Run JavaScript agent-browser eval <js> # Run JavaScript
agent-browser connect <port|url> # Connect to browser via CDP agent-browser connect <port|url> # Connect to browser via CDP
agent-browser --version # Show CLI version
agent-browser close # Close browser (aliases: quit, exit) agent-browser close # Close browser (aliases: quit, exit)
``` ```
Fork builds print dual-version metadata with `--version`:
```bash
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
```
## Get info ## Get info
```bash ```bash
@@ -95,6 +102,7 @@ agent-browser find nth 2 ".card" hover
```bash ```bash
agent-browser wait <selector> # Wait for element agent-browser wait <selector> # Wait for element
agent-browser wait <ms> # Wait for time 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 --text "Welcome" # Wait for text
agent-browser wait --url "**/dash" # Wait for URL pattern agent-browser wait --url "**/dash" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for load state agent-browser wait --load networkidle # Wait for load state
@@ -243,6 +251,7 @@ agent-browser reload # Reload page
--proxy-bypass <hosts> # Hosts to bypass proxy --proxy-bypass <hosts> # Hosts to bypass proxy
--ignore-https-errors # Ignore HTTPS certificate errors --ignore-https-errors # Ignore HTTPS certificate errors
--allow-file-access # Allow file:// URLs to access local files (Chromium only) --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) -p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
--device <name> # iOS device name (e.g., "iPhone 15 Pro") --device <name> # iOS device name (e.g., "iPhone 15 Pro")
--json # JSON output (for scripts) --json # JSON output (for scripts)
@@ -251,7 +260,7 @@ agent-browser reload # Reload page
--headed # Show browser window (not headless) --headed # Show browser window (not headless)
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL) --cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
--auto-connect # Auto-discover and connect to running Chrome --auto-connect # Auto-discover and connect to running Chrome
--debug # Debug output --debug # Debug output (includes stealth connection type + capabilities)
``` ```
## Command chaining ## Command chaining
+18 -9
View File
@@ -9,7 +9,7 @@ export const metadata = pageMetadata("installation")
Installs the native Rust binary for maximum performance: Installs the native Rust binary for maximum performance:
```bash ```bash
npm install -g agent-browser npm install -g agent-browser-stealth
agent-browser install # Download Chromium agent-browser install # Download Chromium
``` ```
@@ -20,8 +20,8 @@ This is the fastest option -- commands run through the native Rust CLI directly
Run directly with `npx` if you want to try it without installing globally: Run directly with `npx` if you want to try it without installing globally:
```bash ```bash
npx agent-browser install # Download Chromium (first time only) npx agent-browser-stealth install # Download Chromium (first time only)
npx agent-browser open example.com npx agent-browser-stealth open example.com
``` ```
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally. > **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
@@ -31,14 +31,14 @@ npx agent-browser open example.com
For projects that want to pin the version in `package.json`: For projects that want to pin the version in `package.json`:
```bash ```bash
npm install agent-browser npm install agent-browser-stealth
npx agent-browser install npx agent-browser-stealth install
``` ```
Then use via `npx` or `package.json` scripts: Then use via `npx` or `package.json` scripts:
```bash ```bash
npx agent-browser open example.com npx agent-browser-stealth open example.com
``` ```
## Homebrew (macOS) ## Homebrew (macOS)
@@ -51,7 +51,7 @@ agent-browser install # Download Chromium
## From source ## From source
```bash ```bash
git clone https://github.com/vercel-labs/agent-browser git clone https://github.com/leeguooooo/agent-browser
cd agent-browser cd agent-browser
pnpm install pnpm install
pnpm build pnpm build
@@ -60,6 +60,15 @@ pnpm build:native
pnpm link --global pnpm link --global
``` ```
## Fork versioning
Fork releases use a dual-version format:
- `<upstream>-fork.<fork>`
- Example: `0.14.0-fork.1`
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
## Linux dependencies ## Linux dependencies
On Linux, install system dependencies: On Linux, install system dependencies:
@@ -89,7 +98,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
```typescript ```typescript
import chromium from '@sparticuz/chromium'; import chromium from '@sparticuz/chromium';
import { BrowserManager } from 'agent-browser'; import { BrowserManager } from 'agent-browser-stealth';
export async function handler() { export async function handler() {
const browser = new BrowserManager(); const browser = new BrowserManager();
@@ -110,7 +119,7 @@ agent-browser works with any AI agent out of the box. For richer context:
Install the skill for your AI coding assistant: Install the skill for your AI coding assistant:
```bash ```bash
npx skills add vercel-labs/agent-browser npx skills add leeguooooo/agent-browser
``` ```
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically. This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
+2 -2
View File
@@ -7,11 +7,11 @@ export const metadata = pageMetadata("")
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback. Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
```bash ```bash
npm install -g agent-browser # all platforms (fastest, native Rust CLI) npm install -g agent-browser-stealth # all platforms (fastest, native Rust CLI)
brew install agent-browser # macOS brew install agent-browser # macOS
# or try without installing # or try without installing
npx agent-browser open example.com npx agent-browser-stealth open example.com
``` ```
## Features ## Features
+1 -1
View File
@@ -176,7 +176,7 @@ Send input events to control the browser remotely.
For advanced use, control streaming directly via the TypeScript API: For advanced use, control streaming directly via the TypeScript API:
```typescript ```typescript
import { BrowserManager } from 'agent-browser'; import { BrowserManager } from 'agent-browser-stealth';
const browser = new BrowserManager(); const browser = new BrowserManager();
await browser.launch({ headless: true }); await browser.launch({ headless: true });
+2 -2
View File
@@ -53,7 +53,7 @@ export function Header() {
</div> </div>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
<a <a
href="https://github.com/vercel-labs/agent-browser" href="https://github.com/leeguooooo/agent-browser"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -69,7 +69,7 @@ export function Header() {
<span>14k</span> <span>14k</span>
</a> </a>
<a <a
href="https://www.npmjs.com/package/agent-browser" href="https://www.npmjs.com/package/agent-browser-stealth"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors" className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors"
+12 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "agent-browser", "name": "agent-browser-stealth",
"version": "0.14.0", "version": "0.14.0-fork.1",
"description": "Headless browser automation CLI for AI agents", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module", "type": "module",
"main": "dist/daemon.js", "main": "dist/daemon.js",
"files": [ "files": [
@@ -11,6 +11,7 @@
"skills" "skills"
], ],
"bin": { "bin": {
"agent-browser-stealth": "./bin/agent-browser.js",
"agent-browser": "./bin/agent-browser.js" "agent-browser": "./bin/agent-browser.js"
}, },
"scripts": { "scripts": {
@@ -33,6 +34,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"postinstall": "node scripts/postinstall.js", "postinstall": "node scripts/postinstall.js",
"sync:upstream": "bash scripts/sync-upstream.sh",
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
"changeset": "changeset", "changeset": "changeset",
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile", "ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish" "ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
@@ -41,6 +44,9 @@
"browser", "browser",
"automation", "automation",
"headless", "headless",
"stealth",
"anti-bot",
"anti-detection",
"playwright", "playwright",
"cli", "cli",
"agent" "agent"
@@ -48,12 +54,12 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/vercel-labs/agent-browser.git" "url": "git+https://github.com/leeguooooo/agent-browser.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/vercel-labs/agent-browser/issues" "url": "https://github.com/leeguooooo/agent-browser/issues"
}, },
"homepage": "https://github.com/vercel-labs/agent-browser#readme", "homepage": "https://github.com/leeguooooo/agent-browser#readme",
"dependencies": { "dependencies": {
"node-simctl": "^7.4.0", "node-simctl": "^7.4.0",
"playwright-core": "^1.57.0", "playwright-core": "^1.57.0",
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* End-to-end check for CreepJS headless/stealth indicators.
*
* Usage:
* node scripts/check-creepjs-headless.js
* node scripts/check-creepjs-headless.js --compare-stealth
* node scripts/check-creepjs-headless.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', 'creepjs-e2e');
const compareStealth = args.includes('--compare-stealth');
const targetUrl = getArgValue('--url', 'https://abrahamjuliot.github.io/creepjs/');
const extractionScript = `(() => {
const headless = globalThis.Fingerprint?.headless ?? null;
const toNumber = (value) => (typeof value === 'number' ? value : null);
return {
found: !!headless,
metrics: headless ? {
chromium: !!headless.chromium,
likeHeadless: toNumber(headless.likeHeadlessRating),
headless: toNumber(headless.headlessRating),
stealth: toNumber(headless.stealthRating),
raw: headless,
} : null,
navigator: {
userAgent: navigator.userAgent,
userAgentData: navigator.userAgentData ? navigator.userAgentData.toJSON?.() ?? null : null,
language: navigator.language,
languages: navigator.languages,
platform: navigator.platform,
webdriver: navigator.webdriver,
webdriverInNavigator: ('webdriver' in navigator),
},
window: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
screenX: window.screenX,
screenY: window.screenY,
},
intl: {
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
};
})()`;
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'}`;
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
try {
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
runCommand([
...withSessionArgs(session, stealth),
'wait',
'--fn',
'!!(window.Fingerprint && window.Fingerprint.headless)',
]);
runCommand([...withSessionArgs(session, stealth), 'wait', '2000']);
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();
+112
View File
@@ -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();
+83 -43
View File
@@ -9,7 +9,7 @@
* - Mac/Linux: Replaces symlink to point to native binary * - Mac/Linux: Replaces symlink to point to native binary
*/ */
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync } from 'fs'; import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync, readFileSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { platform, arch } from 'os'; import { platform, arch } from 'os';
@@ -27,15 +27,41 @@ const binaryName = `agent-browser-${platformKey}${ext}`;
const binaryPath = join(binDir, binaryName); const binaryPath = join(binDir, binaryName);
// Package info // Package info
const packageJson = JSON.parse( const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
(await import('fs')).readFileSync(join(projectRoot, 'package.json'), 'utf8')
);
const version = packageJson.version; const version = packageJson.version;
const packageName = packageJson.name;
const binCommands = getBinCommands(packageJson);
// GitHub release URL // GitHub release URL
const GITHUB_REPO = 'vercel-labs/agent-browser'; const GITHUB_REPO = getGitHubRepoFromPackage(packageJson);
const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`; const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
function getGitHubRepoFromPackage(pkg) {
const repo = pkg?.repository;
const repoUrl = typeof repo === 'string' ? repo : repo?.url;
if (typeof repoUrl === 'string') {
const match = repoUrl.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/i);
if (match?.[1]) {
return match[1];
}
}
// Fallback for legacy package metadata
return 'vercel-labs/agent-browser';
}
function getBinCommands(pkg) {
const bin = pkg?.bin;
if (typeof bin === 'string') {
return [pkg.name.replace(/^@[^/]+\//, '')];
}
if (bin && typeof bin === 'object') {
return Object.keys(bin);
}
return ['agent-browser'];
}
async function downloadFile(url, dest) { async function downloadFile(url, dest) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const file = createWriteStream(dest); const file = createWriteStream(dest);
@@ -107,7 +133,7 @@ async function main() {
console.log(''); console.log('');
console.log('To build the native binary locally:'); console.log('To build the native binary locally:');
console.log(' 1. Install Rust: https://rustup.rs'); console.log(' 1. Install Rust: https://rustup.rs');
console.log(' 2. Run: npm run build:native'); console.log(' 2. Run: pnpm run build:native');
} }
// On global installs, fix npm's bin entry to use native binary directly // On global installs, fix npm's bin entry to use native binary directly
@@ -157,27 +183,34 @@ async function fixUnixSymlink() {
return; // npm not available return; // npm not available
} }
const symlinkPath = join(npmBinDir, 'agent-browser'); let optimized = false;
for (const commandName of binCommands) {
const symlinkPath = join(npmBinDir, commandName);
// Check if symlink exists (indicates global install) // Check if symlink exists (indicates global install)
try { try {
const stat = lstatSync(symlinkPath); const stat = lstatSync(symlinkPath);
if (!stat.isSymbolicLink()) { if (!stat.isSymbolicLink()) {
return; // Not a symlink, don't touch it continue; // Not a symlink, don't touch it
}
} catch {
continue; // Symlink doesn't exist, not a global install
}
// Replace symlink to point directly to native binary
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
optimized = true;
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize symlink (${commandName}): ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
} }
} catch {
return; // Symlink doesn't exist, not a global install
} }
// Replace symlink to point directly to native binary if (optimized) {
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
console.log('✓ Optimized: symlink points to native binary (zero overhead)'); console.log('✓ Optimized: symlink points to native binary (zero overhead)');
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize symlink: ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
} }
} }
@@ -195,25 +228,28 @@ async function fixWindowsShims() {
return; // Not a global install or npm not available return; // Not a global install or npm not available
} }
// The shims are in the npm prefix directory (not prefix/bin on Windows)
const cmdShim = join(npmBinDir, 'agent-browser.cmd');
const ps1Shim = join(npmBinDir, 'agent-browser.ps1');
// Only fix if shims exist (indicates global install)
if (!existsSync(cmdShim)) {
return;
}
// Path to native binary relative to npm prefix // Path to native binary relative to npm prefix
const relativeBinaryPath = 'node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe'; const packagePath = packageName.replace(/\//g, '\\');
const relativeBinaryPath = `node_modules\\${packagePath}\\bin\\${binaryName}`;
let optimized = false;
try { for (const commandName of binCommands) {
// Overwrite .cmd shim // The shims are in the npm prefix directory (not prefix/bin on Windows)
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`; const cmdShim = join(npmBinDir, `${commandName}.cmd`);
writeFileSync(cmdShim, cmdContent); const ps1Shim = join(npmBinDir, `${commandName}.ps1`);
// Overwrite .ps1 shim // Only fix if shims exist (indicates global install)
const ps1Content = `#!/usr/bin/env pwsh if (!existsSync(cmdShim)) {
continue;
}
try {
// Overwrite .cmd shim
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
writeFileSync(cmdShim, cmdContent);
// Overwrite .ps1 shim
const ps1Content = `#!/usr/bin/env pwsh
$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent $basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe = "" $exe = ""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
@@ -222,13 +258,17 @@ if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
& "$basedir/${relativeBinaryPath.replace(/\\/g, '/')}" $args & "$basedir/${relativeBinaryPath.replace(/\\/g, '/')}" $args
exit $LASTEXITCODE exit $LASTEXITCODE
`; `;
writeFileSync(ps1Shim, ps1Content); writeFileSync(ps1Shim, ps1Content);
optimized = true;
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize shims (${commandName}): ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
}
}
if (optimized) {
console.log('✓ Optimized: shims point to native binary (zero overhead)'); console.log('✓ Optimized: shims point to native binary (zero overhead)');
} catch (err) {
// Permission error or other issue - not critical, JS wrapper still works
console.log(`⚠ Could not optimize shims: ${err.message}`);
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
} }
} }
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
UPSTREAM_REMOTE="upstream"
UPSTREAM_BRANCH="main"
BASE_BRANCH="main"
TRACK_BRANCH="upstream-main"
SYNC_BRANCH=""
PUSH_BRANCH=false
usage() {
cat <<'EOF'
Synchronize upstream changes into a dedicated sync branch.
Usage:
./scripts/sync-upstream.sh [options]
Options:
--push Push the created sync branch to origin
--upstream-remote <name> Upstream remote name (default: upstream)
--upstream-branch <name> Upstream branch to sync from (default: main)
--base-branch <name> Local base branch for sync branch (default: main)
--track-branch <name> Local branch tracking upstream (default: upstream-main)
--sync-branch <name> Explicit sync branch name (default: sync/YYYY-MM-DD)
-h, --help Show this help message
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--push)
PUSH_BRANCH=true
shift
;;
--upstream-remote)
UPSTREAM_REMOTE="${2:-}"
shift 2
;;
--upstream-branch)
UPSTREAM_BRANCH="${2:-}"
shift 2
;;
--base-branch)
BASE_BRANCH="${2:-}"
shift 2
;;
--track-branch)
TRACK_BRANCH="${2:-}"
shift 2
;;
--sync-branch)
SYNC_BRANCH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
for var_name in UPSTREAM_REMOTE UPSTREAM_BRANCH BASE_BRANCH TRACK_BRANCH; do
if [[ -z "${!var_name}" ]]; then
echo "Error: ${var_name} cannot be empty." >&2
exit 1
fi
done
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash changes first." >&2
exit 1
fi
if ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
echo "Error: remote '$UPSTREAM_REMOTE' does not exist." >&2
exit 1
fi
echo "Fetching upstream branch: ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
if git show-ref --verify --quiet "refs/heads/$TRACK_BRANCH"; then
echo "Updating local track branch: $TRACK_BRANCH"
git switch "$TRACK_BRANCH" >/dev/null
git merge --ff-only "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
else
echo "Creating local track branch: $TRACK_BRANCH"
git branch "$TRACK_BRANCH" "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
fi
echo "Switching to base branch: $BASE_BRANCH"
git switch "$BASE_BRANCH" >/dev/null
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
echo "Fast-forwarding ${BASE_BRANCH} from origin/${BASE_BRANCH}"
git fetch origin "$BASE_BRANCH"
git merge --ff-only "origin/${BASE_BRANCH}"
fi
if [[ -z "$SYNC_BRANCH" ]]; then
SYNC_BRANCH="sync/$(date +%F)"
fi
if git show-ref --verify --quiet "refs/heads/$SYNC_BRANCH"; then
suffix=1
while git show-ref --verify --quiet "refs/heads/${SYNC_BRANCH}-${suffix}"; do
suffix=$((suffix + 1))
done
SYNC_BRANCH="${SYNC_BRANCH}-${suffix}"
fi
echo "Creating sync branch: $SYNC_BRANCH"
git switch -c "$SYNC_BRANCH" "$BASE_BRANCH" >/dev/null
merge_message="chore(sync): merge ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH} into ${BASE_BRANCH}"
echo "Merging $TRACK_BRANCH into $SYNC_BRANCH"
if ! git merge --no-ff "$TRACK_BRANCH" -m "$merge_message"; then
echo ""
echo "Merge conflict detected. Resolve conflicts, then run:"
echo " git add <resolved-files>"
echo " git commit"
if [[ "$PUSH_BRANCH" == true ]]; then
echo " git push -u origin $SYNC_BRANCH"
fi
exit 1
fi
echo "Upstream merge completed on branch: $SYNC_BRANCH"
if [[ "$PUSH_BRANCH" == true ]]; then
echo "Pushing branch to origin: $SYNC_BRANCH"
git push -u origin "$SYNC_BRANCH"
echo "Done. Open a PR: ${SYNC_BRANCH} -> ${BASE_BRANCH}"
else
echo "Branch is local only. Push when ready:"
echo " git push -u origin $SYNC_BRANCH"
fi
+21 -3
View File
@@ -20,13 +20,31 @@ const packageJson = JSON.parse(
); );
const version = packageJson.version; const version = packageJson.version;
console.log(`Syncing version ${version} to all config files...`); function parseForkVersion(raw) {
const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/);
if (!match) return null;
return {
upstream: match[1],
fork: match[2],
};
}
const forkVersion = parseForkVersion(version);
if (forkVersion) {
console.log(
`Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...`
);
} else {
console.log(`Syncing version ${version} to all config files...`);
}
// Update Cargo.toml // Update Cargo.toml
const cargoTomlPath = join(cliDir, "Cargo.toml"); const cargoTomlPath = join(cliDir, "Cargo.toml");
let cargoToml = readFileSync(cargoTomlPath, "utf-8"); let cargoToml = readFileSync(cargoTomlPath, "utf-8");
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m; const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
const newCargoVersion = `version = "${version}"`; const newCargoVersion = `version = "${version}"`;
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
let cargoTomlUpdated = false; let cargoTomlUpdated = false;
if (cargoVersionRegex.test(cargoToml)) { if (cargoVersionRegex.test(cargoToml)) {
@@ -47,7 +65,7 @@ if (cargoVersionRegex.test(cargoToml)) {
// Update Cargo.lock to match Cargo.toml // Update Cargo.lock to match Cargo.toml
if (cargoTomlUpdated) { if (cargoTomlUpdated) {
try { try {
execSync("cargo update -p agent-browser --offline", { execSync(`cargo update -p ${cargoPackageName} --offline`, {
cwd: cliDir, cwd: cliDir,
stdio: "pipe", stdio: "pipe",
}); });
@@ -55,7 +73,7 @@ if (cargoTomlUpdated) {
} catch { } catch {
// --offline may fail if package not in cache, try without it // --offline may fail if package not in cache, try without it
try { try {
execSync("cargo update -p agent-browser", { execSync(`cargo update -p ${cargoPackageName}`, {
cwd: cliDir, cwd: cliDir,
stdio: "pipe", stdio: "pipe",
}); });
+24 -1
View File
@@ -1,11 +1,13 @@
--- ---
name: agent-browser name: agent-browser
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*) allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*)
--- ---
# Browser Automation with agent-browser # Browser Automation with agent-browser
Install package: `npm install -g agent-browser-stealth` (CLI command remains `agent-browser` for compatibility).
## Core Workflow ## Core Workflow
Every browser automation follows this pattern: Every browser automation follows this pattern:
@@ -50,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
# Navigation # Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate) agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser agent-browser close # Close browser
agent-browser --version # Show CLI version (fork builds include upstream/fork)
# Snapshot # Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended) agent-browser snapshot -i # Interactive elements with refs (recommended)
@@ -78,6 +81,7 @@ agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds agent-browser wait 2000 # Wait milliseconds
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
# Capture # Capture
agent-browser screenshot # Screenshot to temp dir agent-browser screenshot # Screenshot to temp dir
@@ -216,6 +220,12 @@ agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png agent-browser screenshot output.png
``` ```
### Stealth Mode (Always On)
Stealth is always active -- no flags needed. All sessions automatically apply anti-detection patches (navigator.webdriver removal, UA override, plugin injection, WebGL masking, humanized interactions, etc.).
For best results against strong bot detection, use `--headed` and `--profile`.
### iOS Simulator (Mobile Safari) ### iOS Simulator (Mobile Safari)
```bash ```bash
@@ -287,10 +297,23 @@ agent-browser wait --fn "document.readyState === 'complete'"
# Wait a fixed duration (milliseconds) as a last resort # Wait a fixed duration (milliseconds) as a last resort
agent-browser wait 5000 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`. 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 ## Session Management and Cleanup
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts: When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
+65 -7
View File
@@ -517,7 +517,10 @@ async function handleLaunch(
browser: BrowserManager browser: BrowserManager
): Promise<Response> { ): Promise<Response> {
await browser.launch(command); 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( 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> { async function handleClick(command: ClickCommand, browser: BrowserManager): Promise<Response> {
// Support both refs (@e1) and regular selectors // Support both refs (@e1) and regular selectors
const locator = browser.getLocator(command.selector); 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({ await locator.click({
button: command.button, button: command.button,
clickCount: command.clickCount, clickCount: command.clickCount,
@@ -592,9 +627,18 @@ async function handleType(command: TypeCommand, browser: BrowserManager): Promis
await locator.fill(''); await locator.fill('');
} }
await locator.pressSequentially(command.text, { if (command.delay) {
delay: 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) { } catch (error) {
throw toAIFriendlyError(error, command.selector); throw toAIFriendlyError(error, command.selector);
} }
@@ -870,7 +914,11 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
timeout: command.timeout, timeout: command.timeout,
}); });
} else if (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 { } else {
// Default: wait for load state // Default: wait for load state
await page.waitForLoadState('load'); await page.waitForLoadState('load');
@@ -1897,9 +1945,19 @@ async function handleKeyboard(
const sub = command.subaction ?? 'press'; const sub = command.subaction ?? 'press';
switch (sub) { switch (sub) {
case 'type': case 'type': {
await page.keyboard.type(command.text ?? '', { delay: command.delay }); 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 }); return successResponse(command.id, { typed: true, text: command.text });
}
case 'press': case 'press':
await page.keyboard.press(command.keys ?? ''); await page.keyboard.press(command.keys ?? '');
return successResponse(command.id, { pressed: command.keys }); return successResponse(command.id, { pressed: command.keys });
+72
View File
@@ -53,6 +53,78 @@ describe('BrowserManager', () => {
expect(newBrowser.getBrowser()).toBeNull(); expect(newBrowser.getBrowser()).toBeNull();
await newBrowser.close(); 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)', () => { describe('stale session recovery (all pages closed)', () => {
+323 -16
View File
@@ -27,6 +27,12 @@ import {
decryptData, decryptData,
ENCRYPTION_KEY_ENV, ENCRYPTION_KEY_ENV,
} from './state-utils.js'; } from './state-utils.js';
import {
STEALTH_CHROMIUM_ARGS,
applyStealthScripts,
applyBrowserLevelStealth,
type StealthScriptOptions,
} from './stealth.js';
/** /**
* Returns the default Playwright timeout in milliseconds for standard operations. * Returns the default Playwright timeout in milliseconds for standard operations.
@@ -89,6 +95,36 @@ interface PageError {
timestamp: number; 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;
}
interface StealthContextDefaults {
locale?: string;
timezoneId?: string;
extraHTTPHeaders?: Record<string, string>;
}
/** /**
* Manages the Playwright browser lifecycle with multiple tabs/windows * Manages the Playwright browser lifecycle with multiple tabs/windows
*/ */
@@ -116,6 +152,12 @@ export class BrowserManager {
private lastSnapshot: string = ''; private lastSnapshot: string = '';
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map(); private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
private stealthEnabled: boolean = true;
private stealthConnectionKind: StealthConnectionKind = 'local';
private contextLocale: string | undefined = undefined;
private contextTimezoneId: string | undefined = undefined;
private contextHeaders: Record<string, string> | undefined = undefined;
private contextUserAgent: string | undefined = undefined;
/** /**
* Set the persistent color scheme preference. * Set the persistent color scheme preference.
@@ -125,6 +167,182 @@ export class BrowserManager {
this.colorScheme = scheme; 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 {
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,
};
}
private normalizeLocaleTag(locale?: string): string | undefined {
if (!locale) return undefined;
const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
if (!cleaned) return undefined;
try {
return new Intl.Locale(cleaned).toString();
} catch {
return undefined;
}
}
private buildAcceptLanguageHeader(locale: string): string {
const baseLanguage = locale.split('-')[0];
if (!baseLanguage || baseLanguage === locale) {
return `${locale};q=0.9`;
}
return `${locale},${baseLanguage};q=0.9`;
}
private getHeaderValue(
headers: Record<string, string> | undefined,
name: string
): string | undefined {
if (!headers) return undefined;
const target = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === target) return value;
}
return undefined;
}
private resolveStealthLocale(headers?: Record<string, string>): string {
const headerLocale = this.getHeaderValue(headers, 'accept-language');
const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale);
if (normalizedHeaderLocale) return normalizedHeaderLocale;
const candidates = [
process.env.AGENT_BROWSER_LOCALE,
process.env.LC_ALL,
process.env.LC_MESSAGES,
process.env.LANG,
Intl.DateTimeFormat().resolvedOptions().locale,
];
for (const candidate of candidates) {
const normalized = this.normalizeLocaleTag(candidate);
if (normalized) return normalized;
}
return 'en-US';
}
private resolveStealthTimezoneId(): string | undefined {
const candidates = [
process.env.AGENT_BROWSER_TIMEZONE,
process.env.TZ,
Intl.DateTimeFormat().resolvedOptions().timeZone,
];
for (const value of candidates) {
const timezone = value?.trim();
if (!timezone) continue;
if (timezone === 'UTC' || timezone.includes('/')) return timezone;
}
return undefined;
}
private buildStealthContextDefaults(
policy: StealthPolicy,
headers?: Record<string, string>
): StealthContextDefaults {
if (!policy.enabled) {
return { extraHTTPHeaders: headers };
}
const locale = this.resolveStealthLocale(headers);
const timezoneId = this.resolveStealthTimezoneId();
const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined;
const extraHTTPHeaders = hasAcceptLanguage
? headers
: {
...(headers ?? {}),
'Accept-Language': this.buildAcceptLanguageHeader(locale),
};
return {
locale,
timezoneId,
extraHTTPHeaders,
};
}
private extractChromiumVersion(versionText: string): string | undefined {
const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/);
return match?.[1];
}
private buildStealthChromiumUserAgent(chromeVersion: string): string {
const platform = os.platform();
let osToken = 'X11; Linux x86_64';
if (platform === 'darwin') {
osToken = 'Macintosh; Intel Mac OS X 10_15_7';
} else if (platform === 'win32') {
osToken = 'Windows NT 10.0; Win64; x64';
}
return (
`Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` +
`(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
);
}
private getStealthUserAgentVersionHint(): string | undefined {
const deviceUA = devices['Desktop Chrome']?.userAgent;
if (!deviceUA) return undefined;
return this.extractChromiumVersion(deviceUA);
}
/**
* Apply context init-script stealth patches when policy allows.
*/
private async applyStealthIfEnabled(
context: BrowserContext,
options: StealthScriptOptions = {}
): Promise<void> {
const policy = this.getStealthPolicy();
if (!policy.applyInitScripts) return;
await applyStealthScripts(context, options);
this.logStealthPolicy('init-script applied');
}
// CDP session for screencast and input injection // CDP session for screencast and input injection
private cdpSession: CDPSession | null = null; private cdpSession: CDPSession | null = null;
private screencastActive: boolean = false; private screencastActive: boolean = false;
@@ -280,8 +498,13 @@ export class BrowserManager {
context = this.contexts[this.contexts.length - 1]; context = this.contexts[this.contexts.length - 1];
} else if (this.browser) { } else if (this.browser) {
context = await this.browser.newContext({ context = await this.browser.newContext({
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(this.colorScheme && { colorScheme: this.colorScheme }), ...(this.colorScheme && { colorScheme: this.colorScheme }),
}); });
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout()); context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context); this.contexts.push(context);
this.setupContextTracking(context); this.setupContextTracking(context);
@@ -852,6 +1075,7 @@ export class BrowserManager {
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables. * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
*/ */
private async connectToBrowserbase(): Promise<void> { private async connectToBrowserbase(): Promise<void> {
this.stealthConnectionKind = 'provider-browserbase';
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY; const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID; const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
@@ -889,6 +1113,7 @@ export class BrowserManager {
} }
const context = contexts[0]; const context = contexts[0];
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages(); const pages = context.pages();
const page = pages[0] ?? (await context.newPage()); const page = pages[0] ?? (await context.newPage());
@@ -959,6 +1184,7 @@ export class BrowserManager {
* Requires KERNEL_API_KEY environment variable. * Requires KERNEL_API_KEY environment variable.
*/ */
private async connectToKernel(): Promise<void> { private async connectToKernel(): Promise<void> {
this.stealthConnectionKind = 'provider-kernel';
const kernelApiKey = process.env.KERNEL_API_KEY; const kernelApiKey = process.env.KERNEL_API_KEY;
if (!kernelApiKey) { if (!kernelApiKey) {
throw new Error('KERNEL_API_KEY is required when using kernel as a provider'); throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
@@ -1026,9 +1252,11 @@ export class BrowserManager {
// Kernel browsers launch with a default context and page // Kernel browsers launch with a default context and page
if (contexts.length === 0) { if (contexts.length === 0) {
context = await browser.newContext(); context = await browser.newContext();
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
page = await context.newPage(); page = await context.newPage();
} else { } else {
context = contexts[0]; context = contexts[0];
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages(); const pages = context.pages();
page = pages[0] ?? (await context.newPage()); page = pages[0] ?? (await context.newPage());
} }
@@ -1055,6 +1283,7 @@ export class BrowserManager {
* Requires BROWSER_USE_API_KEY environment variable. * Requires BROWSER_USE_API_KEY environment variable.
*/ */
private async connectToBrowserUse(): Promise<void> { private async connectToBrowserUse(): Promise<void> {
this.stealthConnectionKind = 'provider-browseruse';
const browserUseApiKey = process.env.BROWSER_USE_API_KEY; const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
if (!browserUseApiKey) { if (!browserUseApiKey) {
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider'); throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
@@ -1099,9 +1328,11 @@ export class BrowserManager {
if (contexts.length === 0) { if (contexts.length === 0) {
context = await browser.newContext(); context = await browser.newContext();
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
page = await context.newPage(); page = await context.newPage();
} else { } else {
context = contexts[0]; context = contexts[0];
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
const pages = context.pages(); const pages = context.pages();
page = pages[0] ?? (await context.newPage()); page = pages[0] ?? (await context.newPage());
} }
@@ -1172,6 +1403,26 @@ export class BrowserManager {
if (options.colorScheme) { if (options.colorScheme) {
this.colorScheme = options.colorScheme; this.colorScheme = options.colorScheme;
} }
this.stealthEnabled = true;
this.contextLocale = this.resolveStealthLocale(options.headers);
this.contextTimezoneId = this.resolveStealthTimezoneId();
this.contextHeaders = undefined;
this.contextUserAgent = options.userAgent;
// -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) { if (cdpEndpoint) {
await this.connectViaCDP(cdpEndpoint); await this.connectViaCDP(cdpEndpoint);
@@ -1184,8 +1435,6 @@ export class BrowserManager {
} }
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var // 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') { if (provider === 'browserbase') {
await this.connectToBrowserbase(); await this.connectToBrowserbase();
return; return;
@@ -1214,16 +1463,41 @@ export class BrowserManager {
const launcher = const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
// Build base args array with file access flags if enabled const stealthPolicy = this.getStealthPolicy(browserType);
// --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
// --allow-file-access: allows the browser to access local files in general const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
this.contextLocale = contextDefaults.locale;
this.contextTimezoneId = contextDefaults.timezoneId;
this.contextHeaders = contextDefaults.extraHTTPHeaders;
let contextUserAgent = options.userAgent;
if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') {
const versionHint = this.getStealthUserAgentVersionHint();
if (versionHint) {
contextUserAgent = this.buildStealthChromiumUserAgent(versionHint);
}
}
this.contextUserAgent = contextUserAgent;
// Build base args array with file access flags and stealth args when policy allows.
const fileAccessArgs = options.allowFileAccess const fileAccessArgs = options.allowFileAccess
? ['--allow-file-access-from-files', '--allow-file-access'] ? ['--allow-file-access-from-files', '--allow-file-access']
: []; : [];
const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent='));
const launchUserAgentArgs =
!hasUserAgentArg &&
!options.userAgent &&
stealthPolicy.enabled &&
browserType === 'chromium' &&
contextUserAgent
? [`--user-agent=${contextUserAgent}`]
: [];
const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs];
const baseArgs = options.args const baseArgs = options.args
? [...fileAccessArgs, ...options.args] ? [...implicitArgs, ...options.args]
: fileAccessArgs.length > 0 : implicitArgs.length > 0
? fileAccessArgs ? implicitArgs
: undefined; : undefined;
// Auto-detect args that control window size and disable viewport emulation // Auto-detect args that control window size and disable viewport emulation
@@ -1253,8 +1527,10 @@ export class BrowserManager {
executablePath: options.executablePath, executablePath: options.executablePath,
args: allArgs, args: allArgs,
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders,
userAgent: options.userAgent, userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }), ...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }), ...(this.colorScheme && { colorScheme: this.colorScheme }),
@@ -1266,12 +1542,14 @@ export class BrowserManager {
// Expand ~ to home directory since it won't be shell-expanded // Expand ~ to home directory since it won't be shell-expanded
const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/'); const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
context = await launcher.launchPersistentContext(profilePath, { context = await launcher.launchPersistentContext(profilePath, {
headless: options.headless ?? true, headless: options.headless ?? false,
executablePath: options.executablePath, executablePath: options.executablePath,
args: baseArgs, args: baseArgs,
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders,
userAgent: options.userAgent, userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }), ...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }), ...(this.colorScheme && { colorScheme: this.colorScheme }),
@@ -1280,12 +1558,24 @@ export class BrowserManager {
} else { } else {
// Regular ephemeral browser // Regular ephemeral browser
this.browser = await launcher.launch({ this.browser = await launcher.launch({
headless: options.headless ?? true, headless: options.headless ?? false,
executablePath: options.executablePath, executablePath: options.executablePath,
args: baseArgs, args: baseArgs,
}); });
this.cdpEndpoint = null; this.cdpEndpoint = null;
if (stealthPolicy.enabled && browserType === 'chromium') {
await applyBrowserLevelStealth(this.browser);
}
if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
const runtimeVersion = this.extractChromiumVersion(this.browser.version());
if (runtimeVersion) {
contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion);
this.contextUserAgent = contextUserAgent;
}
}
// Check for auto-load state file (supports encrypted files) // Check for auto-load state file (supports encrypted files)
let storageState: let storageState:
| string | string
@@ -1355,15 +1645,19 @@ export class BrowserManager {
context = await this.browser.newContext({ context = await this.browser.newContext({
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders,
userAgent: options.userAgent, userAgent: contextUserAgent,
storageState, storageState,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }), ...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }), ...(this.colorScheme && { colorScheme: this.colorScheme }),
}); });
} }
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout()); context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context); this.contexts.push(context);
this.setupContextTracking(context); this.setupContextTracking(context);
@@ -1385,6 +1679,7 @@ export class BrowserManager {
cdpEndpoint: string | undefined, cdpEndpoint: string | undefined,
options?: { timeout?: number } options?: { timeout?: number }
): Promise<void> { ): Promise<void> {
this.stealthConnectionKind = 'cdp';
if (!cdpEndpoint) { if (!cdpEndpoint) {
throw new Error('CDP endpoint is required for CDP connection'); throw new Error('CDP endpoint is required for CDP connection');
} }
@@ -1439,6 +1734,7 @@ export class BrowserManager {
this.cdpEndpoint = cdpEndpoint; this.cdpEndpoint = cdpEndpoint;
for (const context of contexts) { for (const context of contexts) {
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(10000); context.setDefaultTimeout(10000);
this.contexts.push(context); this.contexts.push(context);
this.setupContextTracking(context); this.setupContextTracking(context);
@@ -1694,8 +1990,13 @@ export class BrowserManager {
const context = await this.browser.newContext({ const context = await this.browser.newContext({
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport, viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(this.colorScheme && { colorScheme: this.colorScheme }), ...(this.colorScheme && { colorScheme: this.colorScheme }),
}); });
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
context.setDefaultTimeout(getDefaultTimeout()); context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context); this.contexts.push(context);
this.setupContextTracking(context); this.setupContextTracking(context);
@@ -2435,6 +2736,12 @@ export class BrowserManager {
this.isPersistentContext = false; this.isPersistentContext = false;
this.activePageIndex = 0; this.activePageIndex = 0;
this.colorScheme = null; this.colorScheme = null;
this.stealthEnabled = true;
this.stealthConnectionKind = 'local';
this.contextLocale = undefined;
this.contextTimezoneId = undefined;
this.contextHeaders = undefined;
this.contextUserAgent = undefined;
this.refMap = {}; this.refMap = {};
this.lastSnapshot = ''; this.lastSnapshot = '';
this.frameCallback = null; this.frameCallback = null;
+2
View File
@@ -450,6 +450,7 @@ export async function startDaemon(options?: {
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1'; const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
// Stealth is always enabled in agent-browser-stealth
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME; const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme = const colorScheme =
colorSchemeEnv === 'dark' || colorSchemeEnv === 'dark' ||
@@ -470,6 +471,7 @@ export async function startDaemon(options?: {
proxy, proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors, ignoreHTTPSErrors: ignoreHTTPSErrors,
allowFileAccess: allowFileAccess, allowFileAccess: allowFileAccess,
colorScheme, colorScheme,
autoStateFilePath: getSessionAutoStatePath(), autoStateFilePath: getSessionAutoStatePath(),
}); });
+13
View File
@@ -5,6 +5,19 @@ import { parseCommand } from './protocol.js';
const cmd = (obj: object) => JSON.stringify(obj); const cmd = (obj: object) => JSON.stringify(obj);
describe('parseCommand', () => { 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', () => { describe('navigation', () => {
it('should parse navigate command', () => { it('should parse navigate command', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' })); const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' }));
+1
View File
@@ -809,6 +809,7 @@ const waitSchema = baseCommandSchema.extend({
action: z.literal('wait'), action: z.literal('wait'),
selector: z.string().min(1).optional(), selector: z.string().min(1).optional(),
timeout: z.number().positive().optional(), timeout: z.number().positive().optional(),
timeoutMax: z.number().positive().optional(),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(), state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
}); });
+213
View File
@@ -0,0 +1,213 @@
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);
});
it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => {
const previousLocale = process.env.AGENT_BROWSER_LOCALE;
process.env.AGENT_BROWSER_LOCALE = 'fr-FR';
try {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const languageSignals = await browser.getPage().evaluate(() => ({
language: navigator.language,
languages: navigator.languages,
}));
expect(languageSignals.language).toBe('fr-FR');
expect(languageSignals.languages).toEqual(['fr-FR', 'fr']);
} finally {
if (previousLocale === undefined) {
delete process.env.AGENT_BROWSER_LOCALE;
} else {
process.env.AGENT_BROWSER_LOCALE = previousLocale;
}
}
});
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const userAgentSignals = await browser.getPage().evaluate(async () => {
const pageUA = navigator.userAgent;
const workerUA = await new Promise<string>((resolve) => {
const source = 'postMessage(navigator.userAgent);';
const blob = new Blob([source], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = (event) => resolve(String(event.data));
});
return { pageUA, workerUA };
});
expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome');
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
});
it('neutralizes the css webdriver heuristic probe', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
probe: CSS.supports('border-end-end-radius: initial'),
baseline: CSS.supports('display: block'),
webdriver: navigator.webdriver,
inNavigator: 'webdriver' in navigator,
}));
expect(signals.probe).toBe(false);
expect(signals.baseline).toBe(true);
expect(signals.webdriver).toBeUndefined();
expect(signals.inNavigator).toBe(false);
});
it('neutralizes creepjs prefers-color-scheme light probe', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => {
const node = document.createElement('div');
node.setAttribute('style', 'background-color: ActiveText');
document.body.appendChild(node);
const activeTextColor = getComputedStyle(node).backgroundColor;
node.remove();
return {
activeTextColor,
prefersLight: matchMedia('(prefers-color-scheme: light)').matches,
prefersDark: matchMedia('(prefers-color-scheme: dark)').matches,
};
});
expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)');
expect(signals.prefersLight).toBe(false);
expect(typeof signals.prefersDark).toBe('boolean');
});
it('exposes realistic mimeTypes/pdf/share signals', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
mimeTypesLength: navigator.mimeTypes ? navigator.mimeTypes.length : 0,
pdfViewerEnabled: navigator.pdfViewerEnabled,
hasShare: typeof navigator.share === 'function',
hasCanShare: typeof navigator.canShare === 'function',
hasConnectionDownlinkMax:
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
hasConnectionDownlinkMaxOnProto:
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
}));
expect(signals.mimeTypesLength).toBeGreaterThan(0);
expect(signals.pdfViewerEnabled).toBe(true);
expect(signals.hasShare).toBe(true);
expect(signals.hasCanShare).toBe(true);
expect(signals.hasConnectionDownlinkMax).toBe(true);
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
});
it('exposes contacts manager and content index APIs', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await browser.getPage().evaluate(() => ({
hasContacts: 'contacts' in navigator,
contactsManagerCtor: typeof (window as any).ContactsManager === 'function',
hasContentIndexCtor: typeof (window as any).ContentIndex === 'function',
hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined',
hasContentIndexOnSWR:
typeof ServiceWorkerRegistration !== 'undefined' &&
('contentIndex' in ServiceWorkerRegistration.prototype ||
'index' in ServiceWorkerRegistration.prototype),
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null,
screenMatchesViewport:
screen.width === window.innerWidth && screen.height === window.innerHeight,
}));
expect(signals.hasContacts).toBe(true);
expect(signals.contactsManagerCtor).toBe(true);
expect(signals.hasContentIndexCtor).toBe(true);
if (signals.hasServiceWorkerRegistration) {
expect(signals.hasContentIndexOnSWR).toBe(true);
}
expect(signals.notificationPermission).toBe('default');
expect(signals.screenMatchesViewport).toBe(false);
});
it('exposes downlinkMax inside dedicated workers', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const workerSignals = await browser.getPage().evaluate(async () => {
return new Promise<{
hasConnection: boolean;
hasDownlinkMax: boolean;
hasDownlinkMaxOnProto: boolean;
downlinkMax: unknown;
}>((resolve) => {
const source =
"postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});";
const blob = new Blob([source], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = (event) => resolve(event.data);
});
});
expect(workerSignals.hasConnection).toBe(true);
expect(workerSignals.hasDownlinkMax).toBe(true);
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
expect(typeof workerSignals.downlinkMax).toBe('number');
});
});
+1138
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -828,6 +828,7 @@ export interface WaitCommand extends BaseCommand {
action: 'wait'; action: 'wait';
selector?: string; selector?: string;
timeout?: number; timeout?: number;
timeoutMax?: number; // When set with timeout, waits a random duration in [timeout, timeoutMax]
state?: 'attached' | 'detached' | 'visible' | 'hidden'; state?: 'attached' | 'detached' | 'visible' | 'hidden';
} }