Compare commits

..
Author SHA1 Message Date
leeguooooo 058a286326 chore(release): 发布 0.14.0-fork.3
更新 package.json 版本并写入对应 changelog 条目。
2026-02-24 17:14:06 +09:00
leeguooooo b1f27236d8 fix(cli): 修复 type/keyboard 的 --delay 参数解析
将 --delay <ms> 从输入文本中剥离并写入 delay 字段,避免搜索词混入参数。

同时支持使用 -- 终止参数解析以输入字面量 --delay 文本,并补充回归测试与帮助文档。
2026-02-24 17:13:46 +09:00
leeguooooo a5a9327b7d docs(home): 补充首页能力亮点说明
- 新增自动区域检测能力描述

- 新增验证码自动重试能力描述
2026-02-24 17:06:34 +09:00
leeguooooo 699ccbd3cb feat(cli): 强制使用用户现有浏览器并移除 profile/channel
- 禁用 --profile/AGENT_BROWSER_PROFILE 与 --channel/AGENT_BROWSER_CHANNEL,并给出项目策略提示

- 默认模式强制连接 localhost:9333,连接失败直接报错,不再自动回退新开浏览器

- 同步更新 README、技能文档、docs 与 --help 输出

- 版本升级到 0.14.0-fork.2 并同步 cli/Cargo.toml 与 Cargo.lock
2026-02-24 17:05:11 +09:00
leeguooooo 893ddfd259 feat(cdp): 默认优先连接 9333 常驻 Chrome
- 无显式连接参数时先尝试 CDP 9333,失败后回退本地浏览器启动

- 修复 CDP 页选择稳定性:过滤 omnibox 系统页、无可用页时自动创建 fallback 页

- 调整页面关闭后的 active 索引维护,降低 No page found 问题

- 新增 agent-browser-stealth 二进制入口并保持与 agent-browser 行为一致

- 同步更新 CLI 帮助、README、技能文档与 docs 说明
2026-02-24 16:22:49 +09:00
23 changed files with 681 additions and 199 deletions
+10
View File
@@ -1,5 +1,15 @@
# agent-browser
## 0.14.0-fork.3
### Patch Changes
- Fix CLI typing delay parsing so `--delay` is treated as an option instead of typed text.
- Add `--delay <ms>` parsing for `type` and `keyboard type`
- Support `--` to type literal `--delay` text
- Add regression tests for parsing and delay behavior
- Update CLI help, README, skills, and docs command references
## 0.14.0
### Minor Changes
+26 -31
View File
@@ -143,10 +143,10 @@ agent-browser open <url> # Navigate to URL (aliases: goto, navigate
agent-browser click <sel> # Click element (--new-tab to open in new tab)
agent-browser dblclick <sel> # Double-click element
agent-browser focus <sel> # Focus element
agent-browser type <sel> <text> # Type into element
agent-browser type <sel> <text> [--delay <ms>] # Type into element
agent-browser fill <sel> <text> # Clear and fill
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
agent-browser keyboard type <text> # Type with real keystrokes (no selector, current focus)
agent-browser keyboard type <text> [--delay <ms>] # Type with real keystrokes (no selector, current focus)
agent-browser keyboard inserttext <text> # Insert text without key events (no selector)
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
@@ -383,33 +383,9 @@ Each session has its own:
- Navigation history
- Authentication state
## Persistent Profiles
By default, browser state (cookies, localStorage, login sessions) is ephemeral and lost when the browser closes. Use `--profile` to persist state across browser restarts:
```bash
# Use a persistent profile directory
agent-browser --profile ~/.myapp-profile open myapp.com
# Login once, then reuse the authenticated session
agent-browser --profile ~/.myapp-profile open myapp.com/dashboard
# Or via environment variable
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
```
The profile directory stores:
- Cookies and localStorage
- IndexedDB data
- Service workers
- Browser cache
- Login sessions
**Tip**: Use different profile paths for different projects to keep their browser state isolated.
## Session Persistence
Alternatively, use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
```bash
# Auto-save/load state for "twitter" session
@@ -492,7 +468,6 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) |
| `--profile <path>` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) |
| `--state <path>` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
@@ -503,7 +478,6 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--proxy-bypass <hosts>` | Hosts to bypass proxy (or `AGENT_BROWSER_PROXY_BYPASS` env) |
| `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) |
| `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) |
| `--stealth` | Stealth mode (default: on): local launch uses Chromium args + init scripts; CDP/provider uses init scripts |
| `-p, --provider <name>` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) |
| `--device <name>` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) |
| `--json` | JSON output (for agents) |
@@ -516,6 +490,11 @@ This is useful for multimodal AI models that can reason about visual layout, unl
| `--config <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
Project policy:
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
- Default mode must connect to an existing browser at `localhost:9333` (no automatic local-launch fallback)
## Configuration
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
@@ -533,7 +512,6 @@ Create an `agent-browser.json` file to set persistent defaults instead of repeat
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data",
"userAgent": "my-agent/1.0",
"ignoreHttpsErrors": true
}
@@ -769,6 +747,8 @@ The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-fi
`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:
- **Uses Chrome channel for Chromium launches** -- local Chromium sessions are launched through Playwright's `chrome` channel for a genuine Chrome fingerprint
- Removes `navigator.webdriver` automation indicator
- Disables Chromium's `AutomationControlled` blink feature
- Replaces "HeadlessChrome" in User-Agent and userAgentData (including CDP-level override)
@@ -806,13 +786,28 @@ 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
- **Navigation pacing** -- Each page navigation includes a short random delay (300-1000ms) to avoid burst patterns
These behaviors are always active and require no additional flags.
### Auto Region Detection
When navigating to a site, the URL's TLD is used to automatically match locale, timezone, and Accept-Language headers to the target region. For example, opening `shopee.tw` automatically sets locale to `zh-TW` and timezone to `Asia/Taipei`, eliminating region-signal mismatches that server-side risk systems commonly flag.
Supported TLDs include: `.tw`, `.cn`, `.hk`, `.jp`, `.kr`, `.th`, `.vn`, `.sg`, `.my`, `.id`, `.ph`, `.br`, `.mx`, `.de`, `.fr`, `.uk`, `.ru`, `.in`, `.au`, and more.
Override with environment variables: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE`.
### Captcha / Verification Detection
If a navigation lands on a known captcha or verification page (detected by URL patterns like `/verify/captcha` or titles like "Checking your browser"), the browser automatically retries up to 2 times with randomized backoff (3-7 seconds). If all retries are exhausted, a warning suggests `--headed` mode or `--session-name` persistence.
## CDP Mode
Connect to an existing browser via Chrome DevTools Protocol:
By default in this fork, when you run commands without `--cdp`, agent-browser requires an existing browser at `localhost:9333` (resident browser via CDP). If CDP is unavailable, the command fails fast instead of launching a new managed browser.
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
@@ -854,7 +849,7 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
Auto-connect discovers Chrome by:
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
2. Falling back to probing common debugging ports (9222, 9229)
2. Falling back to probing common debugging ports (9222, 9229, 9333)
This is useful when:
- Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port)
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-browser-stealth"
version = "0.14.0-fork.1"
version = "0.14.0-fork.3"
dependencies = [
"base64",
"dirs",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.14.0-fork.1"
version = "0.14.0-fork.3"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
@@ -9,6 +9,10 @@ license = "Apache-2.0"
name = "agent-browser"
path = "src/main.rs"
[[bin]]
name = "agent-browser-stealth"
path = "src/main_stealth.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+112 -11
View File
@@ -71,6 +71,62 @@ pub fn gen_id() -> String {
)
}
/// Parse free-form text arguments with optional `--delay <ms>`.
///
/// `--` can be used to stop flag parsing if text must include `--delay` literally.
fn parse_text_with_optional_delay(
args: &[&str],
context: &str,
usage: &'static str,
) -> Result<(String, Option<u64>), ParseError> {
let mut text_parts: Vec<&str> = Vec::new();
let mut delay_ms: Option<u64> = None;
let mut parse_flags = true;
let mut i = 0;
while i < args.len() {
let arg = args[i];
if parse_flags && arg == "--" {
parse_flags = false;
i += 1;
continue;
}
if parse_flags && arg == "--delay" {
let raw = args
.get(i + 1)
.ok_or_else(|| ParseError::MissingArguments {
context: format!("{} --delay", context),
usage,
})?;
let parsed = raw.parse::<u64>().map_err(|_| ParseError::InvalidValue {
message: format!(
"Invalid --delay value: {} (must be a non-negative integer in milliseconds)",
raw
),
usage,
})?;
delay_ms = Some(parsed);
i += 2;
continue;
}
text_parts.push(arg);
i += 1;
}
let text = text_parts.join(" ");
if text.is_empty() {
return Err(ParseError::MissingArguments {
context: context.to_string(),
usage,
});
}
Ok((text, delay_ms))
}
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
if args.is_empty() {
return Err(ParseError::MissingArguments {
@@ -165,9 +221,18 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
"type" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "type".to_string(),
usage: "type <selector> <text>",
usage: "type <selector> <text> [--delay <ms>]",
})?;
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
let (text, delay) = parse_text_with_optional_delay(
&rest[1..],
"type",
"type <selector> <text> [--delay <ms>]",
)?;
let mut cmd = json!({ "id": id, "action": "type", "selector": sel, "text": text });
if let Some(ms) = delay {
cmd["delay"] = json!(ms);
}
Ok(cmd)
}
"hover" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
@@ -272,14 +337,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
})?;
match *sub {
"type" => {
let text: String = rest[1..].join(" ");
if text.is_empty() {
return Err(ParseError::MissingArguments {
context: "keyboard type".to_string(),
usage: "keyboard type <text>",
});
let (text, delay) = parse_text_with_optional_delay(
&rest[1..],
"keyboard type",
"keyboard type <text> [--delay <ms>]",
)?;
let mut cmd = json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text });
if let Some(ms) = delay {
cmd["delay"] = json!(ms);
}
Ok(json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text }))
Ok(cmd)
}
"inserttext" | "insertText" => {
let text: String = rest[1..].join(" ");
@@ -1901,7 +1968,6 @@ mod tests {
executable_path: None,
extensions: Vec::new(),
cdp: None,
profile: None,
state: None,
proxy: None,
proxy_bypass: None,
@@ -1915,7 +1981,6 @@ mod tests {
session_name: None,
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
cli_state: false,
cli_args: false,
cli_user_agent: false,
@@ -2302,6 +2367,29 @@ mod tests {
assert_eq!(cmd["text"], "some text");
}
#[test]
fn test_type_command_with_delay() {
let cmd =
parse_command(&args("type #input some text --delay 120"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#input");
assert_eq!(cmd["text"], "some text");
assert_eq!(cmd["delay"], 120);
}
#[test]
fn test_type_command_with_literal_delay_text() {
let cmd = parse_command(
&args("type #input -- --delay 120 should be typed"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#input");
assert_eq!(cmd["text"], "--delay 120 should be typed");
assert!(cmd.get("delay").is_none());
}
#[test]
fn test_select() {
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
@@ -2481,6 +2569,19 @@ mod tests {
assert_eq!(cmd["selector"], "#element");
}
#[test]
fn test_keyboard_type_with_delay() {
let cmd = parse_command(
&args("keyboard type natural typing --delay 90"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "keyboard");
assert_eq!(cmd["subaction"], "type");
assert_eq!(cmd["text"], "natural typing");
assert_eq!(cmd["delay"], 90);
}
#[test]
fn test_wait_timeout() {
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
-9
View File
@@ -215,7 +215,6 @@ pub fn ensure_daemon(
proxy_bypass: Option<&str>,
ignore_https_errors: bool,
allow_file_access: bool,
profile: Option<&str>,
state: Option<&str>,
provider: Option<&str>,
device: Option<&str>,
@@ -345,10 +344,6 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
@@ -433,10 +428,6 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
+2 -26
View File
@@ -19,7 +19,6 @@ pub struct Config {
pub session_name: Option<String>,
pub executable_path: Option<String>,
pub extensions: Option<Vec<String>>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -53,7 +52,6 @@ impl Config {
}
(a, b) => b.or(a),
},
profile: other.profile.or(self.profile),
state: other.state.or(self.state),
proxy: other.proxy.or(self.proxy),
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
@@ -132,6 +130,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--device",
"--session-name",
"--color-scheme",
"--channel",
];
let mut i = 0;
while i < args.len() {
@@ -188,7 +187,6 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub profile: Option<String>,
pub state: Option<String>,
pub proxy: Option<String>,
pub proxy_bypass: Option<String>,
@@ -207,7 +205,6 @@ pub struct Flags {
// (as opposed to being set only via environment variables)
pub cli_executable_path: bool,
pub cli_extensions: bool,
pub cli_profile: bool,
pub cli_state: bool,
pub cli_args: bool,
pub cli_user_agent: bool,
@@ -257,7 +254,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.executable_path),
cdp: config.cdp,
extensions,
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
@@ -284,7 +280,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.color_scheme),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
cli_state: false,
cli_args: false,
cli_user_agent: false,
@@ -357,13 +352,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--profile" => {
if let Some(s) = args.get(i + 1) {
flags.profile = Some(s.clone());
flags.cli_profile = true;
i += 1;
}
}
"--state" => {
if let Some(s) = args.get(i + 1) {
flags.state = Some(s.clone());
@@ -486,7 +474,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--executable-path",
"--cdp",
"--extension",
"--profile",
"--state",
"--proxy",
"--proxy-bypass",
@@ -668,12 +655,6 @@ mod tests {
assert!(flags.cli_extensions);
}
#[test]
fn test_cli_profile_tracking() {
let flags = parse_flags(&args("--profile /path/to/profile snapshot"));
assert!(flags.cli_profile);
}
#[test]
fn test_cli_annotate_tracking() {
let flags = parse_flags(&args("--annotate screenshot"));
@@ -690,10 +671,9 @@ mod tests {
#[test]
fn test_cli_multiple_flags_tracking() {
let flags = parse_flags(&args(
"--executable-path /chrome --profile /profile --proxy http://proxy snapshot",
"--executable-path /chrome --proxy http://proxy snapshot",
));
assert!(flags.cli_executable_path);
assert!(flags.cli_profile);
assert!(flags.cli_proxy);
assert!(!flags.cli_extensions);
assert!(!flags.cli_state);
@@ -712,7 +692,6 @@ mod tests {
"sessionName": "my-app",
"executablePath": "/usr/bin/chromium",
"extensions": ["/ext1", "/ext2"],
"profile": "/tmp/profile",
"state": "/tmp/state.json",
"proxy": "http://proxy:8080",
"proxyBypass": "localhost",
@@ -738,7 +717,6 @@ mod tests {
config.extensions,
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
);
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
@@ -784,7 +762,6 @@ mod tests {
let user = Config {
headed: Some(true),
proxy: Some("http://user-proxy:8080".to_string()),
profile: Some("/user/profile".to_string()),
..Config::default()
};
let project = Config {
@@ -795,7 +772,6 @@ mod tests {
let merged = user.merge(project);
assert_eq!(merged.headed, Some(true)); // kept from user
assert_eq!(merged.proxy.as_deref(), Some("http://project-proxy:9090")); // overridden by project
assert_eq!(merged.profile.as_deref(), Some("/user/profile")); // kept from user
assert_eq!(merged.debug, Some(true)); // added by project
}
+84 -13
View File
@@ -153,6 +153,47 @@ fn main() {
return;
}
if args.iter().any(|a| a == "--profile") {
let msg =
"Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if env::var("AGENT_BROWSER_PROFILE").is_ok() {
let msg =
"Project policy: AGENT_BROWSER_PROFILE is forbidden. Remove it and use --session-name.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if args.iter().any(|a| a == "--channel") {
let msg = "Project policy: --channel is forbidden. Browser selection follows your existing browser session.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if env::var("AGENT_BROWSER_CHANNEL").is_ok() {
let msg =
"Project policy: AGENT_BROWSER_CHANNEL is forbidden. Remove it and use your existing browser session.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if clean.is_empty() {
print_help();
return;
@@ -221,7 +262,6 @@ fn main() {
flags.proxy_bypass.as_deref(),
flags.ignore_https_errors,
flags.allow_file_access,
flags.profile.as_deref(),
flags.state.as_deref(),
flags.provider.as_deref(),
flags.device.as_deref(),
@@ -254,11 +294,6 @@ fn main() {
} else {
None
},
if flags.cli_profile {
Some("--profile")
} else {
None
},
if flags.cli_state {
Some("--state")
} else {
@@ -513,10 +548,50 @@ fn main() {
}
}
// Project policy: when no explicit connection mode is provided,
// commands must attach to an existing browser on CDP :9333.
// If unavailable, fail fast instead of launching a managed browser.
let can_try_default_cdp = flags.cdp.is_none()
&& !flags.auto_connect
&& flags.provider.is_none()
&& flags.executable_path.is_none()
&& flags.state.is_none()
&& flags.proxy.is_none()
&& flags.args.is_none()
&& flags.user_agent.is_none()
&& !flags.ignore_https_errors
&& !flags.allow_file_access
&& flags.extensions.is_empty();
let mut launched_via_default_cdp = false;
if can_try_default_cdp {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"cdpPort": 9333
});
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
launched_via_default_cdp = resp.success;
}
}
if can_try_default_cdp && !launched_via_default_cdp {
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333. Start your browser with remote debugging on port 9333, or pass --cdp <port|url>.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
// Launch headed browser or configure browser options (without CDP or provider)
if (flags.headed
|| flags.executable_path.is_some()
|| flags.profile.is_some()
|| flags.state.is_some()
|| flags.proxy.is_some()
|| flags.args.is_some()
@@ -527,6 +602,7 @@ fn main() {
|| flags.color_scheme.is_some())
&& flags.cdp.is_none()
&& flags.provider.is_none()
&& !launched_via_default_cdp
{
let mut launch_cmd = json!({
"id": gen_id(),
@@ -543,11 +619,6 @@ fn main() {
cmd_obj.insert("executablePath".to_string(), json!(exec_path));
}
// Add profile path if specified
if let Some(ref profile_path) = flags.profile {
cmd_obj.insert("profile".to_string(), json!(profile_path));
}
// Add state path if specified
if let Some(ref state_path) = flags.state {
cmd_obj.insert("storageState".to_string(), json!(state_path));
@@ -593,7 +664,7 @@ fn main() {
match send_command(launch_cmd, &flags.session) {
Ok(resp) => {
if !resp.success {
// Launch command failed (e.g., invalid state file, profile error)
// Launch command failed (e.g., invalid state file)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
+1
View File
@@ -0,0 +1 @@
include!("main.rs");
+20 -7
View File
@@ -22,6 +22,9 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} {}", color::success_indicator(), color::bold(title));
println!(" {}", color::dim(url));
if let Some(warning) = data.get("warning").and_then(|v| v.as_str()) {
println!("{} {}", color::warning_indicator(), warning);
}
return;
}
println!("{}", url);
@@ -716,10 +719,11 @@ Examples:
r##"
agent-browser type - Type text into an element
Usage: agent-browser type <selector> <text>
Usage: agent-browser type <selector> <text> [--delay <ms>]
Types text into the specified element character by character.
Unlike fill, this does not clear existing content first.
Use --delay to add per-character delay (milliseconds).
Global Options:
--json Output as JSON
@@ -727,7 +731,9 @@ Global Options:
Examples:
agent-browser type "#search" "hello"
agent-browser type "#search" "iphone" --delay 120
agent-browser type @e2 "additional text"
agent-browser type @e2 -- "--delay 120 (literal text)"
See Also:
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
@@ -958,7 +964,7 @@ the current focus — essential for contenteditable editors like
Lexical, ProseMirror, CodeMirror, and Monaco.
Subcommands:
type <text> Type text character-by-character with real
type <text> [--delay <ms>] Type text character-by-character with real
key events (keydown, keypress, keyup per char)
inserttext <text> Insert text without key events (like paste)
@@ -971,6 +977,7 @@ Global Options:
Examples:
agent-browser keyboard type "Hello, World!"
agent-browser keyboard type "human pacing" --delay 90
agent-browser keyboard type "# My Heading"
agent-browser keyboard inserttext "pasted content"
@@ -2013,10 +2020,10 @@ Core Commands:
open <url> Navigate to URL
click <sel> Click element (or @ref)
dblclick <sel> Double-click element
type <sel> <text> Type into element
type <sel> <text> [--delay <ms>] Type into element
fill <sel> <text> Clear and fill
press <key> Press key (Enter, Tab, Control+a)
keyboard type <text> Type text with real keystrokes (no selector)
keyboard type <text> [--delay <ms>] Type text with real keystrokes (no selector)
keyboard inserttext <text> Insert text without key events
hover <sel> Hover element
focus <sel> Focus element
@@ -2100,7 +2107,6 @@ Snapshot Options:
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
@@ -2122,12 +2128,18 @@ Options:
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
Project default: require existing browser at localhost:9333 (no auto local fallback)
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version (fork builds include upstream/fork info)
Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden
Use existing browser session (CDP localhost:9333) or pass --cdp explicitly
Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
1. ~/.agent-browser/config.json User-level defaults
@@ -2145,7 +2157,7 @@ Configuration:
Extensions from user and project configs are merged (not replaced).
Example agent-browser.json:
{{"headed": true, "proxy": "http://localhost:8080", "profile": "./browser-data"}}
{{"headed": true, "proxy": "http://localhost:8080", "userAgent": "my-agent/1.0"}}
Environment:
AGENT_BROWSER_CONFIG Path to config file (or use --config)
@@ -2165,6 +2177,8 @@ Environment:
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_LOCALE Override auto-detected locale (e.g., zh-TW, ja-JP)
AGENT_BROWSER_TIMEZONE Override auto-detected timezone (e.g., Asia/Taipei)
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
@@ -2194,7 +2208,6 @@ Examples:
agent-browser --cdp 9222 snapshot # Connect via CDP port
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser --color-scheme dark open example.com # Dark mode
agent-browser --profile ~/.myapp open example.com # Persistent profile
agent-browser --session-name myapp open example.com # Auto-save/restore state
Command Chaining:
+8 -2
View File
@@ -6,6 +6,13 @@ export const metadata = pageMetadata("cdp-mode")
Connect to an existing browser via Chrome DevTools Protocol:
Default behavior in this fork: when `--cdp` is omitted, agent-browser requires an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no local-launch fallback).
Project policy:
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
```bash
# Start Chrome with: google-chrome --remote-debugging-port=9222
@@ -52,7 +59,7 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot
Auto-connect discovers Chrome by:
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
2. Falling back to probing common debugging ports (9222, 9229)
2. Falling back to probing common debugging ports (9222, 9229, 9333)
This is useful when:
@@ -110,7 +117,6 @@ This enables control of:
</thead>
<tbody>
<tr><td><code>--session &lt;name&gt;</code></td><td>Use isolated session</td></tr>
<tr><td><code>--profile &lt;path&gt;</code></td><td>Persistent browser profile directory</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
<tr><td><code>--headers &lt;json&gt;</code></td><td>HTTP headers scoped to origin</td></tr>
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
+3 -4
View File
@@ -11,9 +11,9 @@ agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser click <sel> # Click element (--new-tab to open in new tab)
agent-browser dblclick <sel> # Double-click
agent-browser fill <sel> <text> # Clear and fill
agent-browser type <sel> <text> # Type into element
agent-browser type <sel> <text> [--delay <ms>] # Type into element
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
agent-browser keyboard type <text> # Type at current focus (no selector needed)
agent-browser keyboard type <text> [--delay <ms>] # Type at current focus (no selector needed)
agent-browser keyboard inserttext <text> # Insert text without key events
agent-browser keydown <key> # Hold key down
agent-browser keyup <key> # Release key
@@ -240,7 +240,6 @@ agent-browser reload # Reload page
```bash
--session <name> # Isolated browser session
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
--profile <path> # Persistent browser profile directory
--state <path> # Load storage state from JSON file
--headers <json> # HTTP headers scoped to URL's origin
--executable-path <path> # Custom browser executable
@@ -251,7 +250,7 @@ agent-browser reload # Reload page
--proxy-bypass <hosts> # Hosts to bypass proxy
--ignore-https-errors # Ignore HTTPS certificate errors
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
--stealth # Stealth mode: local uses launch args+init scripts; CDP/provider uses init scripts
--stealth # Stealth mode (always on by default)
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
--json # JSON output (for scripts)
+3 -3
View File
@@ -6,6 +6,8 @@ export const metadata = pageMetadata("configuration")
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
In this fork, default launch behavior requires a resident browser at `localhost:9333` (CDP). If unavailable, commands fail fast instead of launching a managed browser.
## Config File Locations
agent-browser checks two locations, merged in priority order:
@@ -37,7 +39,6 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data",
"userAgent": "my-agent/1.0",
"ignoreHttpsErrors": true
}
@@ -60,7 +61,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
@@ -84,7 +84,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
```json
{
"headed": true,
"profile": "./browser-data"
"sessionName": "local-dev"
}
```
+2
View File
@@ -22,6 +22,8 @@ npx agent-browser-stealth open example.com
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
- **Sessions** - Multiple isolated browser instances with separate auth
- **Cross-platform** - macOS, Linux, Windows with native binaries
- **Auto region detection** - Locale, timezone, and Accept-Language automatically match the target site's TLD
- **Captcha auto-retry** - Detects captcha/verification pages and retries with randomized backoff
## Works with
-23
View File
@@ -34,29 +34,6 @@ Each session has its own:
- Navigation history
- Authentication state
## Persistent profiles
By default, browser state is lost when the browser closes. Use `--profile` to persist state across restarts:
```bash
# Use a persistent profile directory
agent-browser --profile ~/.myapp-profile open myapp.com
# Login once, then reuse the authenticated session
agent-browser --profile ~/.myapp-profile open myapp.com/dashboard
# Or via environment variable
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
```
The profile directory stores:
- Cookies and localStorage
- IndexedDB data
- Service workers
- Browser cache
- Login sessions
## Session persistence
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.14.0-fork.1",
"version": "0.14.0-fork.3",
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module",
"main": "dist/daemon.js",
+25 -6
View File
@@ -63,11 +63,11 @@ agent-browser snapshot -s "#selector" # Scope to CSS selector
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser type @e2 "text" --delay 120 # Type without clearing (human-like pacing)
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard type "text" --delay 90 # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
@@ -179,6 +179,8 @@ agent-browser session list
### Connect to Existing Chrome
By default in this fork, commands without `--cdp` require an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no automatic local browser launch).
```bash
# Auto-discover running Chrome with remote debugging enabled
agent-browser --auto-connect open https://example.com
@@ -220,11 +222,29 @@ agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png
```
### Project Policy
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
- Use existing browser sessions (default CDP `localhost:9333`) or pass `--cdp` explicitly
### 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`.
Chromium launches in managed mode use Chrome channel by default for a genuine browser binary fingerprint.
For best results against strong bot detection, use `--headed` and `--session-name`.
### Auto Region Detection
The browser automatically detects the target site's region from the URL TLD and sets matching locale, timezone, and Accept-Language headers. For example, navigating to `shopee.tw` sets locale `zh-TW` and timezone `Asia/Taipei`. This reduces server-side risk scoring from region-signal mismatches.
Override: `AGENT_BROWSER_LOCALE`, `AGENT_BROWSER_TIMEZONE` env vars.
### Captcha Detection & Auto-Retry
When a navigation lands on a captcha/verification page, the browser automatically retries up to 2 times with randomized backoff (3-7s). If detection persists, a warning is shown suggesting `--headed` mode or `--session-name` persistence.
### iOS Simulator (Mobile Safari)
@@ -312,7 +332,7 @@ agent-browser automatically humanizes interactions to avoid behavioral detection
- **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.
These behaviors are always active. For sensitive sites, combine with `--headed` and `--session-name` for best results.
## Session Management and Cleanup
@@ -417,8 +437,7 @@ Create `agent-browser.json` in the project root for persistent settings:
```json
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data"
"proxy": "http://localhost:8080"
}
```
+70 -2
View File
@@ -529,21 +529,89 @@ async function handleNavigate(
): Promise<Response<NavigateData>> {
const page = browser.getPage();
// Set target URL for region auto-detection (locale/timezone)
await browser.setTargetUrl(command.url);
// If headers are provided, set up scoped headers for this origin
if (command.headers && Object.keys(command.headers).length > 0) {
await browser.setScopedHeaders(command.url, command.headers);
}
// Humanized navigation pacing: random short delay before navigating
const pace = 300 + Math.random() * 700;
await page.waitForTimeout(Math.round(pace));
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
// Detect captcha/verification pages and retry with backoff
const finalUrl = page.url();
const title = await page.title();
const captchaDetected = isCaptchaPage(finalUrl, title);
if (captchaDetected) {
const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const backoff = 3000 + Math.random() * 4000;
await page.waitForTimeout(Math.round(backoff));
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
const retryUrl = page.url();
const retryTitle = await page.title();
if (!isCaptchaPage(retryUrl, retryTitle)) {
return successResponse(command.id, {
url: retryUrl,
title: retryTitle,
});
}
}
// All retries exhausted -- return the page as-is with a warning
return successResponse(command.id, {
url: page.url(),
title: await page.title(),
warning:
'Captcha/verification page detected. Try --headed mode or use --session-name for state persistence.',
} as NavigateData);
}
return successResponse(command.id, {
url: page.url(),
title: await page.title(),
url: finalUrl,
title,
});
}
function isCaptchaPage(url: string, title: string): boolean {
const lowerUrl = url.toLowerCase();
const lowerTitle = title.toLowerCase();
const captchaPatterns = [
'/verify/captcha',
'/captcha',
'/challenge',
'scene=crawler',
'scene=anti_bot',
'recaptcha',
'hcaptcha',
];
const titlePatterns = [
'verify',
'captcha',
'challenge',
'attention required',
'just a moment',
'checking your browser',
'access denied',
'驗證',
'验证',
'人机验证',
];
return (
captchaPatterns.some((p) => lowerUrl.includes(p)) ||
titlePatterns.some((p) => lowerTitle.includes(p))
);
}
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;
+66 -6
View File
@@ -69,7 +69,7 @@ describe('BrowserManager', () => {
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 mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
@@ -99,7 +99,7 @@ describe('BrowserManager', () => {
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 mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
@@ -926,15 +926,16 @@ describe('BrowserManager', () => {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
{ url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
@@ -950,6 +951,65 @@ describe('BrowserManager', () => {
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
it('should ignore omnibox popup pages during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/',
on: vi.fn(),
isClosed: () => false,
},
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('http://example.com');
spy.mockRestore();
});
it('should create a fallback page when CDP has only internal pages', async () => {
const newPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const context = {
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/omnibox_popup_aim.html',
on: vi.fn(),
isClosed: () => false,
},
],
newPage: vi.fn().mockResolvedValue(newPage),
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [context],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(context.newPage).toHaveBeenCalledTimes(1);
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('about:blank');
spy.mockRestore();
});
});
describe('screencast', () => {
+203 -46
View File
@@ -125,6 +125,8 @@ interface StealthContextDefaults {
extraHTTPHeaders?: Record<string, string>;
}
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
/**
* Manages the Playwright browser lifecycle with multiple tabs/windows
*/
@@ -247,13 +249,117 @@ export class BrowserManager {
return undefined;
}
// TLD -> {locale, timezone} mapping for automatic region consistency
private static readonly TLD_REGION_MAP: Record<string, { locale: string; timezone: string }> = {
tw: { locale: 'zh-TW', timezone: 'Asia/Taipei' },
cn: { locale: 'zh-CN', timezone: 'Asia/Shanghai' },
hk: { locale: 'zh-HK', timezone: 'Asia/Hong_Kong' },
jp: { locale: 'ja-JP', timezone: 'Asia/Tokyo' },
kr: { locale: 'ko-KR', timezone: 'Asia/Seoul' },
th: { locale: 'th-TH', timezone: 'Asia/Bangkok' },
vn: { locale: 'vi-VN', timezone: 'Asia/Ho_Chi_Minh' },
sg: { locale: 'en-SG', timezone: 'Asia/Singapore' },
my: { locale: 'ms-MY', timezone: 'Asia/Kuala_Lumpur' },
id: { locale: 'id-ID', timezone: 'Asia/Jakarta' },
ph: { locale: 'en-PH', timezone: 'Asia/Manila' },
br: { locale: 'pt-BR', timezone: 'America/Sao_Paulo' },
mx: { locale: 'es-MX', timezone: 'America/Mexico_City' },
ar: { locale: 'es-AR', timezone: 'America/Argentina/Buenos_Aires' },
de: { locale: 'de-DE', timezone: 'Europe/Berlin' },
fr: { locale: 'fr-FR', timezone: 'Europe/Paris' },
uk: { locale: 'en-GB', timezone: 'Europe/London' },
ru: { locale: 'ru-RU', timezone: 'Europe/Moscow' },
in: { locale: 'hi-IN', timezone: 'Asia/Kolkata' },
au: { locale: 'en-AU', timezone: 'Australia/Sydney' },
};
// Target URL set during navigation, used for region auto-detection
private targetUrl: string | undefined = undefined;
/**
* Set the target URL for region auto-detection.
* Called from navigate/open commands so locale/timezone can adapt.
* Applies CDP overrides to align locale/timezone with the target site's region.
*/
async setTargetUrl(url: string): Promise<void> {
this.targetUrl = url;
const region = this.getRegionFromUrl(url);
if (!region) return;
// Skip if user has explicitly set locale/timezone via env
const envLocale = process.env.AGENT_BROWSER_LOCALE;
const envTimezone = process.env.AGENT_BROWSER_TIMEZONE || process.env.TZ;
try {
const page = this.getPage();
const cdp = await page.context().newCDPSession(page);
if (!envTimezone) {
await cdp
.send('Emulation.setTimezoneOverride', { timezoneId: region.timezone })
.catch(() => {});
}
if (!envLocale) {
await cdp.send('Emulation.setLocaleOverride', { locale: region.locale }).catch(() => {});
// Update Accept-Language header to match
const langHeader = this.buildAcceptLanguageHeader(region.locale);
const context = page.context();
const currentHeaders = this.contextHeaders ?? {};
await context.setExtraHTTPHeaders({ ...currentHeaders, 'Accept-Language': langHeader });
}
await cdp.detach().catch(() => {});
} catch {
// CDP not available (non-Chromium), skip dynamic override
}
}
private getRegionFromUrl(url?: string): { locale: string; timezone: string } | undefined {
if (!url) return undefined;
try {
const hostname = new URL(url).hostname;
const parts = hostname.split('.');
const tld = parts[parts.length - 1];
// Check compound TLDs like co.th, com.tw, co.id
const secondLevel = parts.length >= 2 ? parts[parts.length - 2] : '';
const compoundTld = `${secondLevel}.${tld}`;
// Try compound first (e.g., "co.th" -> "th", "com.tw" -> "tw")
const compoundMatch = BrowserManager.TLD_REGION_MAP[tld];
if (
compoundMatch &&
(secondLevel === 'co' ||
secondLevel === 'com' ||
secondLevel === 'or' ||
secondLevel === 'org')
) {
return compoundMatch;
}
// Then direct TLD
if (BrowserManager.TLD_REGION_MAP[tld]) {
return BrowserManager.TLD_REGION_MAP[tld];
}
return undefined;
} catch {
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;
// Explicit env var takes priority
const envLocale = this.normalizeLocaleTag(process.env.AGENT_BROWSER_LOCALE);
if (envLocale) return envLocale;
// Auto-detect from target URL TLD
const urlRegion = this.getRegionFromUrl(this.targetUrl);
if (urlRegion) return urlRegion.locale;
const candidates = [
process.env.AGENT_BROWSER_LOCALE,
process.env.LC_ALL,
process.env.LC_MESSAGES,
process.env.LANG,
@@ -267,16 +373,16 @@ export class BrowserManager {
}
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;
}
// Explicit env var takes priority
const envTz = process.env.AGENT_BROWSER_TIMEZONE?.trim() || process.env.TZ?.trim();
if (envTz && (envTz === 'UTC' || envTz.includes('/'))) return envTz;
// Auto-detect from target URL TLD
const urlRegion = this.getRegionFromUrl(this.targetUrl);
if (urlRegion) return urlRegion.timezone;
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (systemTz) return systemTz;
return undefined;
}
@@ -483,6 +589,33 @@ export class BrowserManager {
return this.pages.length > 0;
}
private getSafePageUrl(page: Page): string {
try {
return page.url();
} catch {
return '';
}
}
private isIgnoredCDPPageUrl(url: string): boolean {
if (!url) return false;
const normalizedUrl = url.toLowerCase();
return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix));
}
private isUsableCDPPage(page: Page): boolean {
if (page.isClosed()) return false;
const url = this.getSafePageUrl(page);
if (!url) return false;
return !this.isIgnoredCDPPageUrl(url);
}
private collectUsableCDPPages(contexts: BrowserContext[]): Page[] {
return contexts
.flatMap((context) => context.pages())
.filter((page) => this.isUsableCDPPage(page));
}
/**
* Ensure at least one page exists. If the browser is launched but all pages
* were closed (stale session), creates a new page on the existing context.
@@ -527,6 +660,24 @@ export class BrowserManager {
if (this.pages.length === 0) {
throw new Error('Browser not launched. Call launch first.');
}
const current = this.pages[this.activePageIndex];
if (current && this.isUsableCDPPage(current)) {
return current;
}
const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page));
if (usableIndex !== -1) {
this.activePageIndex = usableIndex;
return this.pages[this.activePageIndex];
}
const openIndex = this.pages.findIndex((page) => !page.isClosed());
if (openIndex !== -1) {
this.activePageIndex = openIndex;
return this.pages[this.activePageIndex];
}
return this.pages[this.activePageIndex];
}
@@ -1008,7 +1159,7 @@ export class BrowserManager {
try {
const contexts = this.browser.contexts();
if (contexts.length === 0) return false;
return contexts.some((context) => context.pages().length > 0);
return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page)));
} catch {
return false;
}
@@ -1362,23 +1513,12 @@ export class BrowserManager {
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
const hasExtensions = !!options.extensions?.length;
const hasProfile = !!options.profile;
const hasStorageState = !!options.storageState;
if (hasExtensions && cdpEndpoint) {
throw new Error('Extensions cannot be used with CDP connection');
}
if (hasProfile && cdpEndpoint) {
throw new Error('Profile cannot be used with CDP connection');
}
if (hasStorageState && hasProfile) {
throw new Error(
'Storage state cannot be used with profile (profile is already persistent storage)'
);
}
if (hasStorageState && hasExtensions) {
throw new Error(
'Storage state cannot be used with extensions (extensions require persistent context)'
@@ -1463,6 +1603,10 @@ export class BrowserManager {
const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
// Chromium launches always use the Chrome channel unless a custom executable is provided.
const chromeChannel =
browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined;
const stealthPolicy = this.getStealthPolicy(browserType);
const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
@@ -1525,6 +1669,7 @@ export class BrowserManager {
{
headless: false,
executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }),
args: allArgs,
viewport,
extraHTTPHeaders,
@@ -1537,29 +1682,12 @@ export class BrowserManager {
}
);
this.isPersistentContext = true;
} else if (hasProfile) {
// Profile uses persistent context for durable cookies/storage
// Expand ~ to home directory since it won't be shell-expanded
const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/');
context = await launcher.launchPersistentContext(profilePath, {
headless: options.headless ?? false,
executablePath: options.executablePath,
args: baseArgs,
viewport,
extraHTTPHeaders,
userAgent: contextUserAgent,
...(this.contextLocale && { locale: this.contextLocale }),
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
...(options.proxy && { proxy: options.proxy }),
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
...(this.colorScheme && { colorScheme: this.colorScheme }),
});
this.isPersistentContext = true;
} else {
// Regular ephemeral browser
this.browser = await launcher.launch({
headless: options.headless ?? false,
executablePath: options.executablePath,
...(chromeChannel && { channel: chromeChannel }),
args: baseArgs,
});
this.cdpEndpoint = null;
@@ -1722,11 +1850,32 @@ export class BrowserManager {
throw new Error('No browser context found. Make sure the app has an open window.');
}
// Filter out pages with empty URLs, which can cause Playwright to hang
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
let allPages = this.collectUsableCDPPages(contexts);
if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.');
// Some Chrome instances (especially with custom UI pages) expose only internal/transient
// pages over CDP. Create a fresh page so commands always have a stable target.
let fallbackPage: Page | null = null;
for (const context of contexts) {
try {
const page = await context.newPage();
if (!fallbackPage) {
fallbackPage = page;
}
if (this.isUsableCDPPage(page)) {
fallbackPage = page;
break;
}
} catch {
// Try next context
}
}
if (!fallbackPage) {
throw new Error('No page found. Make sure the app has loaded content.');
}
allPages = [fallbackPage];
}
// All validation passed - commit state
@@ -1831,7 +1980,7 @@ export class BrowserManager {
* Discovery strategy:
* 1. Read DevToolsActivePort from Chrome's default user data directories
* 2. If found, connect using the port and WebSocket path from that file
* 3. If not found, probe common debugging ports (9222, 9229)
* 3. If not found, probe common debugging ports (9222, 9229, 9333)
* 4. If a port responds, connect via CDP
*/
private async autoConnectViaCDP(): Promise<void> {
@@ -1866,7 +2015,7 @@ export class BrowserManager {
}
// Strategy 2: Probe common debugging ports
const commonPorts = [9222, 9229];
const commonPorts = [9222, 9229, 9333];
for (const port of commonPorts) {
const wsUrl = await this.probeDebugPort(port);
if (wsUrl) {
@@ -1922,6 +2071,9 @@ export class BrowserManager {
const index = this.pages.indexOf(page);
if (index !== -1) {
this.pages.splice(index, 1);
if (index < this.activePageIndex) {
this.activePageIndex--;
}
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = Math.max(0, this.pages.length - 1);
}
@@ -1935,6 +2087,11 @@ export class BrowserManager {
*/
private setupContextTracking(context: BrowserContext): void {
context.on('page', (page) => {
const pageUrl = this.getSafePageUrl(page);
if (this.isIgnoredCDPPageUrl(pageUrl)) {
return;
}
// Only add if not already tracked (avoids duplicates when newTab() creates pages)
if (!this.pages.includes(page)) {
this.pages.push(page);
+38 -5
View File
@@ -405,7 +405,9 @@ export async function startDaemon(options?: {
continue;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: first try attaching to a resident Chrome on CDP :9333,
// then fall back to launching a local Playwright browser if CDP is unavailable.
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
@@ -452,19 +454,18 @@ export async function startDaemon(options?: {
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 colorScheme =
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
await manager.launch({
const launchOptions = {
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
@@ -474,7 +475,39 @@ export async function startDaemon(options?: {
colorScheme,
autoStateFilePath: getSessionAutoStatePath(),
});
};
let launchedViaDefaultCdp = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause a false-negative fallback.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
cdpPort: 9333,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
};
await manager.launch({
...cdpLaunchOptions,
});
launchedViaDefaultCdp = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, falling back to local launch: ${message}`
);
}
}
if (!launchedViaDefaultCdp) {
await manager.launch(launchOptions);
}
}
}
-1
View File
@@ -50,7 +50,6 @@ const launchSchema = baseCommandSchema.extend({
ignoreHTTPSErrors: z.boolean().optional(),
allowFileAccess: z.boolean().optional(),
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
profile: z.string().optional(),
storageState: z.string().optional(),
});
+1 -1
View File
@@ -18,7 +18,6 @@ export interface LaunchCommand extends BaseCommand {
cdpUrl?: string;
autoConnect?: boolean; // Auto-discover and connect to running Chrome via DevToolsActivePort
extensions?: string[];
profile?: string; // Path to persistent browser profile directory
storageState?: string; // Path to storage state JSON file
proxy?: {
server: string;
@@ -1073,6 +1072,7 @@ export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
export interface NavigateData {
url: string;
title: string;
warning?: string;
}
export interface Annotation {