Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
058a286326 | ||
|
|
b1f27236d8 | ||
|
|
a5a9327b7d | ||
|
|
699ccbd3cb | ||
|
|
893ddfd259 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!("main.rs");
|
||||
+20
-7
@@ -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:
|
||||
|
||||
@@ -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 <name></code></td><td>Use isolated session</td></tr>
|
||||
<tr><td><code>--profile <path></code></td><td>Persistent browser profile directory</td></tr>
|
||||
<tr><td><code>-p <provider></code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
|
||||
<tr><td><code>--headers <json></code></td><td>HTTP headers scoped to origin</td></tr>
|
||||
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user