From 699ccbd3cb002166e7fda41f5db3bb90ceb93218 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 24 Feb 2026 17:05:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E5=BC=BA=E5=88=B6=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=94=A8=E6=88=B7=E7=8E=B0=E6=9C=89=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=B9=B6=E7=A7=BB=E9=99=A4=20profile/channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 禁用 --profile/AGENT_BROWSER_PROFILE 与 --channel/AGENT_BROWSER_CHANNEL,并给出项目策略提示 - 默认模式强制连接 localhost:9333,连接失败直接报错,不再自动回退新开浏览器 - 同步更新 README、技能文档、docs 与 --help 输出 - 版本升级到 0.14.0-fork.2 并同步 cli/Cargo.toml 与 Cargo.lock --- README.md | 51 ++++----- cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/commands.rs | 2 - cli/src/connection.rs | 9 -- cli/src/flags.rs | 28 +---- cli/src/main.rs | 71 +++++++++--- cli/src/output.rs | 16 ++- docs/src/app/cdp-mode/page.mdx | 8 +- docs/src/app/commands/page.mdx | 3 +- docs/src/app/configuration/page.mdx | 6 +- docs/src/app/sessions/page.mdx | 23 ---- package.json | 2 +- skills/agent-browser/SKILL.md | 27 ++++- src/actions.ts | 72 ++++++++++++- src/browser.ts | 161 +++++++++++++++++++++------- src/daemon.ts | 3 +- src/protocol.ts | 1 - src/types.ts | 2 +- 19 files changed, 317 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 8db70aa..14044e5 100644 --- a/README.md +++ b/README.md @@ -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 ` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | | `--session-name ` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) | -| `--profile ` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) | | `--state ` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) | | `--headers ` | Set HTTP headers scoped to the URL's origin | | `--executable-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 to bypass proxy (or `AGENT_BROWSER_PROXY_BYPASS` env) | | `--ignore-https-errors` | Ignore HTTPS certificate errors (useful for self-signed certs) | | `--allow-file-access` | Allow file:// URLs to access local files (Chromium only) | -| `--stealth` | Stealth mode (default: on): local launch uses Chromium args + init scripts; CDP/provider uses init scripts | | `-p, --provider ` | Cloud browser provider (or `AGENT_BROWSER_PROVIDER` env) | | `--device ` | iOS device name, e.g. "iPhone 15 Pro" (or `AGENT_BROWSER_IOS_DEVICE` env) | | `--json` | JSON output (for agents) | @@ -516,6 +490,11 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--config ` | 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,14 +786,27 @@ 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 first tries `localhost:9333` (resident Chrome) and falls back to launching a local Playwright browser if CDP is unavailable. +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 diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 1a549a7..9913943 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agent-browser-stealth" -version = "0.14.0-fork.1" +version = "0.14.0-fork.2" dependencies = [ "base64", "dirs", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d67c008..22dde91 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-browser-stealth" -version = "0.14.0-fork.1" +version = "0.14.0-fork.2" edition = "2021" description = "Stealth browser automation CLI for AI agents with anti-bot evasions" license = "Apache-2.0" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 107c6b1..53afaac 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1901,7 +1901,6 @@ mod tests { executable_path: None, extensions: Vec::new(), cdp: None, - profile: None, state: None, proxy: None, proxy_bypass: None, @@ -1915,7 +1914,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, diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 1c4a1be..5b9f447 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -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); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index ba60250..dae788f 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -19,7 +19,6 @@ pub struct Config { pub session_name: Option, pub executable_path: Option, pub extensions: Option>, - pub profile: Option, pub state: Option, pub proxy: Option, pub proxy_bypass: Option, @@ -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> { "--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, pub cdp: Option, pub extensions: Vec, - pub profile: Option, pub state: Option, pub proxy: Option, pub proxy_bypass: Option, @@ -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 { "--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 } diff --git a/cli/src/main.rs b/cli/src/main.rs index da7c7b4..c8ef882 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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,14 +548,13 @@ fn main() { } } - // Default fork behavior: when no explicit connection mode is provided, - // try attaching to resident Chrome on CDP :9333 first. If unavailable, - // silently fall back to local launch behavior below. + // 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.profile.is_none() && flags.state.is_none() && flags.proxy.is_none() && flags.args.is_none() @@ -545,11 +579,19 @@ fn main() { 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 ."; + 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() @@ -577,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)); @@ -627,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()); diff --git a/cli/src/output.rs b/cli/src/output.rs index 96a91ab..a1810b4 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -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); @@ -2100,7 +2103,6 @@ Snapshot Options: Options: --session Isolated session (or AGENT_BROWSER_SESSION env) - --profile Persistent browser profile (or AGENT_BROWSER_PROFILE env) --state Load storage state from JSON file (or AGENT_BROWSER_STATE env) --headers HTTP headers scoped to URL's origin (for auth) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) @@ -2122,13 +2124,18 @@ Options: --headed Show browser window (not headless) --cdp Connect via CDP (Chrome DevTools Protocol) --auto-connect Auto-discover and connect to running Chrome - Default launch tries CDP at localhost:9333 first, then falls back to local browser launch + Project default: require existing browser at localhost:9333 (no auto local fallback) --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --session-name Auto-save/restore session state (cookies, localStorage) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output --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 @@ -2146,7 +2153,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) @@ -2166,6 +2173,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 @@ -2195,7 +2204,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: diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index 0e65dac..45606da 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -6,7 +6,12 @@ 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 first tries `localhost:9333` and falls back to local browser launch if CDP is unavailable. +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 @@ -112,7 +117,6 @@ This enables control of: --session <name>Use isolated session - --profile <path>Persistent browser profile directory -p <provider>Cloud browser provider (browserbase, browseruse, kernel) --headers <json>HTTP headers scoped to origin --executable-pathCustom browser executable diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index b850a5a..b860b3f 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -240,7 +240,6 @@ agent-browser reload # Reload page ```bash --session # Isolated browser session --session-name # Auto-save/restore session state (cookies, localStorage) ---profile # Persistent browser profile directory --state # Load storage state from JSON file --headers # HTTP headers scoped to URL's origin --executable-path # Custom browser executable @@ -251,7 +250,7 @@ agent-browser reload # Reload page --proxy-bypass # Hosts to bypass proxy --ignore-https-errors # Ignore HTTPS certificate errors --allow-file-access # Allow file:// URLs to access local files (Chromium only) ---stealth # Stealth mode: local uses launch args+init scripts; CDP/provider uses init scripts +--stealth # Stealth mode (always on by default) -p, --provider # Browser provider (ios, browserbase, kernel, browseruse) --device # iOS device name (e.g., "iPhone 15 Pro") --json # JSON output (for scripts) diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 7de8852..d6edb3e 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -6,7 +6,7 @@ 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 already prefers a resident Chrome at `localhost:9333` (CDP) and falls back to local Playwright launch when unavailable. +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 @@ -39,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 } @@ -62,7 +61,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent: sessionName--session-namestring executablePath--executable-pathstring extensions--extensionstring[] - profile--profilestring state--statestring proxy--proxystring proxyBypass--proxy-bypassstring @@ -86,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" } ``` diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index bf234c2..6ba8495 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -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: diff --git a/package.json b/package.json index 67c0044..4bb4263 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-browser-stealth", - "version": "0.14.0-fork.1", + "version": "0.14.0-fork.2", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions", "type": "module", "main": "dist/daemon.js", diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 95d7b0e..d98d7f6 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -179,7 +179,7 @@ agent-browser session list ### Connect to Existing Chrome -By default in this fork, commands without `--cdp` try `localhost:9333` first and automatically fall back to a local browser launch if CDP is unavailable. +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 @@ -222,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) @@ -314,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 @@ -419,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" } ``` diff --git a/src/actions.ts b/src/actions.ts index ee68070..b07cc94 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -529,21 +529,89 @@ async function handleNavigate( ): Promise> { 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; diff --git a/src/browser.ts b/src/browser.ts index 326cdc9..3ab180a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -249,13 +249,117 @@ export class BrowserManager { return undefined; } + // TLD -> {locale, timezone} mapping for automatic region consistency + private static readonly TLD_REGION_MAP: Record = { + 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 { + 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 { 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, @@ -269,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; } @@ -1409,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)' @@ -1510,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; @@ -1572,6 +1669,7 @@ export class BrowserManager { { headless: false, executablePath: options.executablePath, + ...(chromeChannel && { channel: chromeChannel }), args: allArgs, viewport, extraHTTPHeaders, @@ -1584,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; diff --git a/src/daemon.ts b/src/daemon.ts index 18c1fd3..81243b0 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -466,7 +466,6 @@ export async function startDaemon(options?: { 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, @@ -480,7 +479,7 @@ export async function startDaemon(options?: { let launchedViaDefaultCdp = false; try { - // Keep default CDP attempt minimal. Launch-only options like profile/extensions + // 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, diff --git a/src/protocol.ts b/src/protocol.ts index 575ce08..980f7b8 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -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(), }); diff --git a/src/types.ts b/src/types.ts index 3497282..2683038 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = SuccessResponse | ErrorResponse; export interface NavigateData { url: string; title: string; + warning?: string; } export interface Annotation {