Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e005c7251b | ||
|
|
11eab471f1 | ||
|
|
aa256e30c7 | ||
|
|
c0e2b80f8c | ||
|
|
f319195974 | ||
|
|
77f2caa1bc | ||
|
|
6f1dd39121 | ||
|
|
85d18799a4 | ||
|
|
43e781a8d3 | ||
|
|
25e8719e51 | ||
|
|
ec011f46ff | ||
|
|
aef8fcc038 | ||
|
|
96582b79fd | ||
|
|
058a286326 | ||
|
|
b1f27236d8 | ||
|
|
a5a9327b7d | ||
|
|
699ccbd3cb | ||
|
|
893ddfd259 |
@@ -142,8 +142,8 @@ jobs:
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published: ${{ steps.publish_metadata.outputs.published }}
|
||||
publishedPackages: ${{ steps.publish_metadata.outputs.publishedPackages }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -160,7 +160,6 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -215,12 +214,51 @@ jobs:
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
title: 'chore: version packages'
|
||||
commit: 'chore: version packages'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if publish is needed
|
||||
id: publish_check
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
REMOTE_VERSION=$(npm view agent-browser-stealth version 2>/dev/null || echo "")
|
||||
echo "local_version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "remote_version=$REMOTE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "$LOCAL_VERSION" != "$REMOTE_VERSION" ]; then
|
||||
echo "needs_publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "needs_publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Local: $LOCAL_VERSION"
|
||||
echo "Remote: ${REMOTE_VERSION:-<none>}"
|
||||
|
||||
- name: Publish to npm (trusted publishing)
|
||||
id: publish_npm
|
||||
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ""
|
||||
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: |
|
||||
npm install -g npm@^11
|
||||
npm --version
|
||||
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
|
||||
pnpm ci:publish
|
||||
|
||||
- name: Set release outputs
|
||||
id: publish_metadata
|
||||
run: |
|
||||
if [ "${{ steps.publish_npm.outcome }}" = "success" ]; then
|
||||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
echo "publishedPackages=[{\"name\":\"agent-browser-stealth\",\"version\":\"${{ steps.publish_check.outputs.local_version }}\"}]" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
echo "publishedPackages=[]" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Create GitHub release with binaries after npm publish
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
|
||||
@@ -27,10 +27,15 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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.5"
|
||||
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.5"
|
||||
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"
|
||||
|
||||
+259
-17
@@ -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 {
|
||||
@@ -126,6 +182,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
nav_cmd["iosDevice"] = json!(device);
|
||||
}
|
||||
}
|
||||
if let Some(ref risk_mode) = flags.risk_mode {
|
||||
if matches!(risk_mode.as_str(), "off" | "warn" | "block") {
|
||||
nav_cmd["riskMode"] = json!(risk_mode);
|
||||
} else {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"Invalid --risk-mode value: {} (expected off, warn, or block)",
|
||||
risk_mode
|
||||
),
|
||||
usage: "open <url>",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(nav_cmd)
|
||||
}
|
||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||
@@ -165,9 +234,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 +350,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(" ");
|
||||
@@ -302,12 +382,48 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
||||
|
||||
// === Scroll ===
|
||||
"scroll" => {
|
||||
let dir = rest.first().unwrap_or(&"down");
|
||||
let amount = rest
|
||||
.get(1)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(300);
|
||||
Ok(json!({ "id": id, "action": "scroll", "direction": dir, "amount": amount }))
|
||||
let mut cmd = json!({ "id": id, "action": "scroll" });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
let mut positional_index = 0;
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"-s" | "--selector" => {
|
||||
if let Some(s) = rest.get(i + 1) {
|
||||
obj.insert("selector".to_string(), json!(s));
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "scroll --selector".to_string(),
|
||||
usage: "scroll [direction] [amount] [--selector <sel>]",
|
||||
});
|
||||
}
|
||||
}
|
||||
arg if arg.starts_with('-') => {}
|
||||
_ => {
|
||||
match positional_index {
|
||||
0 => {
|
||||
obj.insert("direction".to_string(), json!(rest[i]));
|
||||
}
|
||||
1 => {
|
||||
if let Ok(n) = rest[i].parse::<i32>() {
|
||||
obj.insert("amount".to_string(), json!(n));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
positional_index += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !obj.contains_key("direction") {
|
||||
obj.insert("direction".to_string(), json!("down"));
|
||||
}
|
||||
if !obj.contains_key("amount") {
|
||||
obj.insert("amount".to_string(), json!(300));
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
"scrollintoview" | "scrollinto" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
@@ -1901,7 +2017,6 @@ mod tests {
|
||||
executable_path: None,
|
||||
extensions: Vec::new(),
|
||||
cdp: None,
|
||||
profile: None,
|
||||
state: None,
|
||||
proxy: None,
|
||||
proxy_bypass: None,
|
||||
@@ -1915,7 +2030,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,
|
||||
@@ -1923,8 +2037,11 @@ mod tests {
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
annotate: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
risk_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2190,6 +2307,14 @@ mod tests {
|
||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_risk_mode() {
|
||||
let mut flags = default_flags();
|
||||
flags.risk_mode = Some("block".to_string());
|
||||
let cmd = parse_command(&args("open https://example.com"), &flags).unwrap();
|
||||
assert_eq!(cmd["riskMode"], "block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_with_multiple_headers() {
|
||||
let mut flags = default_flags();
|
||||
@@ -2302,6 +2427,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 +2629,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();
|
||||
@@ -3464,4 +3625,85 @@ mod tests {
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// === Scroll Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_scroll_defaults() {
|
||||
let cmd = parse_command(&args("scroll"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
assert!(cmd.get("selector").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_direction_and_amount() {
|
||||
let cmd = parse_command(&args("scroll up 200"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "up");
|
||||
assert_eq!(cmd["amount"], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll down 500 --selector div.scroll-container"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 500);
|
||||
assert_eq!(cmd["selector"], "div.scroll-container");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_with_selector_short_flag() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll left 100 -s .sidebar"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "left");
|
||||
assert_eq!(cmd["amount"], 100);
|
||||
assert_eq!(cmd["selector"], ".sidebar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_before_positional() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .panel down 400"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 400);
|
||||
assert_eq!(cmd["selector"], ".panel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_only() {
|
||||
let cmd = parse_command(
|
||||
&args("scroll --selector .content"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["action"], "scroll");
|
||||
assert_eq!(cmd["direction"], "down");
|
||||
assert_eq!(cmd["amount"], 300);
|
||||
assert_eq!(cmd["selector"], ".content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_selector_missing_value() {
|
||||
let result = parse_command(&args("scroll down 500 --selector"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ParseError::MissingArguments { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,12 +215,12 @@ 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>,
|
||||
session_name: Option<&str>,
|
||||
debug: bool,
|
||||
download_path: Option<&str>,
|
||||
) -> Result<DaemonResult, String> {
|
||||
// Check if daemon is running AND responsive
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
@@ -345,10 +345,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);
|
||||
}
|
||||
@@ -369,6 +365,9 @@ pub fn ensure_daemon(
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
@@ -433,10 +432,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);
|
||||
}
|
||||
@@ -457,6 +452,9 @@ pub fn ensure_daemon(
|
||||
if debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(dp) = download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
|
||||
+63
-27
@@ -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>,
|
||||
@@ -34,6 +33,8 @@ pub struct Config {
|
||||
pub headers: Option<String>,
|
||||
pub annotate: Option<bool>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
pub risk_mode: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -53,7 +54,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),
|
||||
@@ -68,6 +68,8 @@ impl Config {
|
||||
headers: other.headers.or(self.headers),
|
||||
annotate: other.annotate.or(self.annotate),
|
||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||
download_path: other.download_path.or(self.download_path),
|
||||
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +134,9 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--channel",
|
||||
"--download-path",
|
||||
"--risk-mode",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -188,7 +193,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>,
|
||||
@@ -202,12 +206,15 @@ pub struct Flags {
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
/// How verification/captcha detections are handled on navigation:
|
||||
/// `off` (disable), `warn` (retry and warn), `block` (fail fast).
|
||||
pub risk_mode: Option<String>,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (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,
|
||||
@@ -215,6 +222,7 @@ pub struct Flags {
|
||||
pub cli_proxy_bypass: bool,
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
}
|
||||
|
||||
pub fn parse_flags(args: &[String]) -> Flags {
|
||||
@@ -257,7 +265,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")
|
||||
@@ -282,9 +289,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||
.ok()
|
||||
.or(config.color_scheme),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
||||
.or(config.download_path),
|
||||
risk_mode: env::var("AGENT_BROWSER_RISK_MODE")
|
||||
.ok()
|
||||
.or(config.risk_mode)
|
||||
.map(|s| s.to_ascii_lowercase()),
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_profile: false,
|
||||
cli_state: false,
|
||||
cli_args: false,
|
||||
cli_user_agent: false,
|
||||
@@ -292,6 +304,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
};
|
||||
|
||||
let mut i = 0;
|
||||
@@ -357,13 +370,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());
|
||||
@@ -453,6 +459,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--download-path" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.download_path = Some(s.clone());
|
||||
flags.cli_download_path = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--risk-mode" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.risk_mode = Some(s.to_ascii_lowercase());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -486,7 +505,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--executable-path",
|
||||
"--cdp",
|
||||
"--extension",
|
||||
"--profile",
|
||||
"--state",
|
||||
"--proxy",
|
||||
"--proxy-bypass",
|
||||
@@ -497,6 +515,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--device",
|
||||
"--session-name",
|
||||
"--color-scheme",
|
||||
"--download-path",
|
||||
"--risk-mode",
|
||||
"--config",
|
||||
];
|
||||
|
||||
@@ -668,12 +688,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"));
|
||||
@@ -687,13 +701,37 @@ mod tests {
|
||||
assert!(!flags.cli_annotate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_tracking() {
|
||||
let flags = parse_flags(&args("--download-path /tmp/dl snapshot"));
|
||||
assert!(flags.cli_download_path);
|
||||
assert_eq!(flags.download_path, Some("/tmp/dl".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_download_path_not_set_without_flag() {
|
||||
let flags = parse_flags(&args("snapshot"));
|
||||
assert!(!flags.cli_download_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_risk_mode_flag() {
|
||||
let flags = parse_flags(&args("--risk-mode block open example.com"));
|
||||
assert_eq!(flags.risk_mode.as_deref(), Some("block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_risk_mode() {
|
||||
let cleaned = clean_args(&args("--risk-mode warn open example.com"));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
#[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 +750,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",
|
||||
@@ -724,7 +761,8 @@ mod tests {
|
||||
"allowFileAccess": true,
|
||||
"cdp": "9222",
|
||||
"autoConnect": true,
|
||||
"headers": "{\"Auth\":\"token\"}"
|
||||
"headers": "{\"Auth\":\"token\"}",
|
||||
"riskMode": "block"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.headed, Some(true));
|
||||
@@ -738,7 +776,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"));
|
||||
@@ -751,6 +788,7 @@ mod tests {
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
assert_eq!(config.auto_connect, Some(true));
|
||||
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
||||
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -784,7 +822,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 +832,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
|
||||
}
|
||||
|
||||
|
||||
+130
-36
@@ -153,6 +153,62 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref risk_mode) = flags.risk_mode {
|
||||
if !matches!(risk_mode.as_str(), "off" | "warn" | "block") {
|
||||
let msg = format!(
|
||||
"Invalid --risk-mode value: {} (expected off, warn, or block)",
|
||||
risk_mode
|
||||
);
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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,12 +277,12 @@ 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(),
|
||||
flags.session_name.as_deref(),
|
||||
flags.debug,
|
||||
flags.download_path.as_deref(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -254,11 +310,6 @@ fn main() {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if flags.cli_profile {
|
||||
Some("--profile")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if flags.cli_state {
|
||||
Some("--state")
|
||||
} else {
|
||||
@@ -282,6 +333,7 @@ fn main() {
|
||||
},
|
||||
flags.ignore_https_errors.then_some("--ignore-https-errors"),
|
||||
flags.cli_allow_file_access.then_some("--allow-file-access"),
|
||||
flags.cli_download_path.then_some("--download-path"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -363,6 +415,10 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
Ok(resp) => Some(
|
||||
@@ -449,29 +505,26 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) => {
|
||||
if !resp.success {
|
||||
let msg = resp
|
||||
.error
|
||||
.unwrap_or_else(|| "CDP connection failed".to_string());
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
}
|
||||
exit(1);
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
let err = match send_command(launch_cmd, &flags.session) {
|
||||
Ok(resp) if resp.success => None,
|
||||
Ok(resp) => Some(
|
||||
resp.error
|
||||
.unwrap_or_else(|| "CDP connection failed".to_string()),
|
||||
),
|
||||
Err(e) => Some(e.to_string()),
|
||||
};
|
||||
|
||||
if let Some(msg) = err {
|
||||
if flags.json {
|
||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,10 +566,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()
|
||||
@@ -524,9 +617,11 @@ fn main() {
|
||||
|| flags.ignore_https_errors
|
||||
|| flags.allow_file_access
|
||||
|| flags.debug
|
||||
|| flags.color_scheme.is_some())
|
||||
|| flags.color_scheme.is_some()
|
||||
|| flags.download_path.is_some())
|
||||
&& flags.cdp.is_none()
|
||||
&& flags.provider.is_none()
|
||||
&& !launched_via_default_cdp
|
||||
{
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
@@ -543,11 +638,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));
|
||||
@@ -590,10 +680,14 @@ fn main() {
|
||||
launch_cmd["colorScheme"] = json!(cs);
|
||||
}
|
||||
|
||||
if let Some(ref dp) = flags.download_path {
|
||||
launch_cmd["downloadPath"] = json!(dp);
|
||||
}
|
||||
|
||||
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");
|
||||
+60
-9
@@ -22,6 +22,36 @@ 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);
|
||||
}
|
||||
if let Some(risk_signals) = data.get("riskSignals").and_then(|v| v.as_array()) {
|
||||
for signal in risk_signals {
|
||||
let code = signal
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown_risk");
|
||||
let source = signal.get("source").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let evidence = signal
|
||||
.get("evidence")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
let confidence = signal.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
println!(
|
||||
"{} risk-signal code={} source={} evidence={} confidence={:.2}",
|
||||
color::warning_indicator(),
|
||||
code,
|
||||
source,
|
||||
evidence,
|
||||
confidence
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(warnings) = data.get("warnings").and_then(|v| v.as_array()) {
|
||||
for warning in warnings.iter().filter_map(|v| v.as_str()) {
|
||||
println!("{} {}", color::warning_indicator(), warning);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
println!("{}", url);
|
||||
@@ -587,10 +617,12 @@ Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
--headers <json> Set HTTP headers (scoped to this origin)
|
||||
--risk-mode <mode> Risk handling for verify/captcha pages: off, warn, block
|
||||
--headed Show browser window
|
||||
|
||||
Examples:
|
||||
agent-browser open example.com
|
||||
agent-browser --risk-mode block open example.com
|
||||
agent-browser open https://github.com
|
||||
agent-browser open localhost:3000
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||
@@ -716,10 +748,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 +760,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 +993,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 +1006,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"
|
||||
|
||||
@@ -988,14 +1024,17 @@ Use Cases:
|
||||
r##"
|
||||
agent-browser scroll - Scroll the page
|
||||
|
||||
Usage: agent-browser scroll [direction] [amount]
|
||||
Usage: agent-browser scroll [direction] [amount] [options]
|
||||
|
||||
Scrolls the page in the specified direction.
|
||||
Scrolls the page or a specific element in the specified direction.
|
||||
|
||||
Arguments:
|
||||
direction up, down, left, right (default: down)
|
||||
amount Pixels to scroll (default: 300)
|
||||
|
||||
Options:
|
||||
-s, --selector <sel> CSS selector for a scrollable container
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1005,6 +1044,7 @@ Examples:
|
||||
agent-browser scroll down 500
|
||||
agent-browser scroll up 200
|
||||
agent-browser scroll left 100
|
||||
agent-browser scroll down 500 --selector "div.scroll-container"
|
||||
"##
|
||||
}
|
||||
"scrollintoview" | "scrollinto" => {
|
||||
@@ -2013,10 +2053,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 +2140,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 +2161,20 @@ 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)
|
||||
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
|
||||
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
|
||||
--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 +2192,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,7 +2212,11 @@ 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_DOWNLOAD_PATH Default download directory for browser downloads
|
||||
AGENT_BROWSER_RISK_MODE Verify/captcha handling mode (off, warn, block)
|
||||
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||
@@ -2194,7 +2245,7 @@ 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 --risk-mode block open example.com # Block on verification/captcha pages
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
|
||||
export const metadata = pageMetadata("commands")
|
||||
export const metadata = pageMetadata('commands');
|
||||
|
||||
# Commands
|
||||
|
||||
@@ -8,12 +8,13 @@ export const metadata = pageMetadata("commands")
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block when verification/captcha interstitial is detected
|
||||
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
|
||||
@@ -22,7 +23,7 @@ agent-browser focus <sel> # Focus element
|
||||
agent-browser select <sel> <val> # Select dropdown option
|
||||
agent-browser check <sel> # Check checkbox
|
||||
agent-browser uncheck <sel> # Uncheck checkbox
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right)
|
||||
agent-browser scroll <dir> [px] # Scroll (up/down/left/right, --selector <sel>)
|
||||
agent-browser scrollintoview <sel> # Scroll element into view
|
||||
agent-browser drag <src> <dst> # Drag and drop
|
||||
agent-browser upload <sel> <files> # Upload files
|
||||
@@ -110,6 +111,16 @@ agent-browser wait --fn "condition" # Wait for JS condition
|
||||
agent-browser wait --download [path] # Wait for download
|
||||
```
|
||||
|
||||
## Risk Mode
|
||||
|
||||
Control how `open`/`navigate` handles verification or captcha interstitials:
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com # default: retry and warn with riskSignals
|
||||
agent-browser --risk-mode block open https://example.com # fail fast on detection
|
||||
agent-browser --risk-mode off open https://example.com # disable detection/retry
|
||||
```
|
||||
|
||||
## Downloads
|
||||
|
||||
```bash
|
||||
@@ -117,6 +128,8 @@ agent-browser download <sel> <path> # Click element to trigger download
|
||||
agent-browser wait --download [path] # Wait for any download to complete
|
||||
```
|
||||
|
||||
Use `--download-path <dir>` (or `AGENT_BROWSER_DOWNLOAD_PATH` env) to set a default download directory. Without it, downloads go to a temporary directory that is deleted when the browser closes.
|
||||
|
||||
## Mouse
|
||||
|
||||
```bash
|
||||
@@ -240,7 +253,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 +263,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)
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
|
||||
export const metadata = pageMetadata("configuration")
|
||||
export const metadata = pageMetadata('configuration');
|
||||
|
||||
# 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:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
|
||||
<tr>
|
||||
<th>Priority</th>
|
||||
<th>Location</th>
|
||||
<th>Scope</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
|
||||
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
|
||||
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
|
||||
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
|
||||
<tr>
|
||||
<td>1 (lowest)</td>
|
||||
<td>
|
||||
<code>~/.agent-browser/config.json</code>
|
||||
</td>
|
||||
<td>User-level defaults</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>
|
||||
<code>./agent-browser.json</code>
|
||||
</td>
|
||||
<td>Project-level overrides</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_*</code> env vars
|
||||
</td>
|
||||
<td>Override config values</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4 (highest)</td>
|
||||
<td>CLI flags</td>
|
||||
<td>Override everything</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -37,7 +65,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
|
||||
}
|
||||
@@ -49,34 +76,229 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
|
||||
<tr>
|
||||
<th>Config Key</th>
|
||||
<th>CLI Flag</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
||||
<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>
|
||||
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
|
||||
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
|
||||
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
|
||||
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
|
||||
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
|
||||
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
|
||||
<tr><td><code>colorScheme</code></td><td><code>--color-scheme</code></td><td>string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
|
||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>headed</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--headed</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>json</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--json</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>full</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--full, -f</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>debug</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--debug</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>session</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--session</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<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>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>
|
||||
<tr>
|
||||
<td>
|
||||
<code>args</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--args</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>userAgent</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--user-agent</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>provider</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>-p, --provider</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>device</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--device</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>ignoreHttpsErrors</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--ignore-https-errors</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>allowFileAccess</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--allow-file-access</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>cdp</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--cdp</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>autoConnect</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--auto-connect</code>
|
||||
</td>
|
||||
<td>boolean</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>colorScheme</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--color-scheme</code>
|
||||
</td>
|
||||
<td>
|
||||
string (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>downloadPath</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--download-path</code>
|
||||
</td>
|
||||
<td>string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>riskMode</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--risk-mode</code>
|
||||
</td>
|
||||
<td>
|
||||
string (<code>off</code>, <code>warn</code>, <code>block</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>headers</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>--headers</code>
|
||||
</td>
|
||||
<td>string (JSON)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
`riskMode` defaults to `warn` when unset.
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Local Development
|
||||
@@ -84,7 +306,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"profile": "./browser-data"
|
||||
"sessionName": "local-dev"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -145,20 +367,125 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Variable</th><th>Description</th><th>Default</th></tr>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Description</th>
|
||||
<th>Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>AGENT_BROWSER_AUTO_CONNECT</code></td><td>Auto-discover and connect to a running Chrome instance.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code></td><td>Allow <code>file://</code> URLs to access local files.</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_COLOR_SCHEME</code></td><td>Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEFAULT_TIMEOUT</code></td><td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td><td><code>25000</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete saved session states older than N days.</td><td><code>30</code></td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM session encryption.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_STREAM_PORT</code></td><td>Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).</td><td>(disabled)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Default iOS device name for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Default iOS device UDID for the <code>ios</code> provider.</td><td>(none)</td></tr>
|
||||
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_AUTO_CONNECT</code>
|
||||
</td>
|
||||
<td>Auto-discover and connect to a running Chrome instance.</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code>
|
||||
</td>
|
||||
<td>
|
||||
Allow <code>file://</code> URLs to access local files.
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_COLOR_SCHEME</code>
|
||||
</td>
|
||||
<td>
|
||||
Color scheme preference (<code>dark</code>, <code>light</code>, <code>no-preference</code>).
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DOWNLOAD_PATH</code>
|
||||
</td>
|
||||
<td>Default directory for browser downloads.</td>
|
||||
<td>(temp directory)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_RISK_MODE</code>
|
||||
</td>
|
||||
<td>
|
||||
Verification/captcha handling mode (<code>off</code>, <code>warn</code>, <code>block</code>
|
||||
).
|
||||
</td>
|
||||
<td>
|
||||
<code>warn</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DEFAULT_TIMEOUT</code>
|
||||
</td>
|
||||
<td>Default Playwright timeout in ms. Keep below 30000 to avoid IPC timeouts.</td>
|
||||
<td>
|
||||
<code>25000</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_SESSION_NAME</code>
|
||||
</td>
|
||||
<td>Auto-save/load state persistence name.</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code>
|
||||
</td>
|
||||
<td>Auto-delete saved session states older than N days.</td>
|
||||
<td>
|
||||
<code>30</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_ENCRYPTION_KEY</code>
|
||||
</td>
|
||||
<td>64-char hex key for AES-256-GCM session encryption.</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_STREAM_PORT</code>
|
||||
</td>
|
||||
<td>
|
||||
Enable WebSocket streaming on the specified port (e.g., <code>9223</code>).
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_IOS_DEVICE</code>
|
||||
</td>
|
||||
<td>
|
||||
Default iOS device name for the <code>ios</code> provider.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_IOS_UDID</code>
|
||||
</td>
|
||||
<td>
|
||||
Default iOS device UDID for the <code>ios</code> provider.
|
||||
</td>
|
||||
<td>(none)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>AGENT_BROWSER_DEBUG</code>
|
||||
</td>
|
||||
<td>
|
||||
Enable debug output (<code>1</code> to enable).
|
||||
</td>
|
||||
<td>(disabled)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.14.0-fork.1",
|
||||
"version": "0.14.0-fork.5",
|
||||
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
@@ -33,7 +33,9 @@
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||
"sync:upstream": "bash scripts/sync-upstream.sh",
|
||||
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
||||
"changeset": "changeset",
|
||||
@@ -68,6 +70,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.52",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
Generated
+174
@@ -24,6 +24,9 @@ importers:
|
||||
specifier: ^3.22.4
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@anthropic-ai/claude-agent-sdk':
|
||||
specifier: ^0.2.52
|
||||
version: 0.2.52(zod@3.25.76)
|
||||
'@changesets/cli':
|
||||
specifier: ^2.29.8
|
||||
version: 2.29.8(@types/node@20.19.28)
|
||||
@@ -57,6 +60,12 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.2.52':
|
||||
resolution: {integrity: sha512-rdTQUu/HjKlDNNxJuhtXY6LJDOLvzVBU7sXFuFIG6CEC/nFfcvYq035EyjVw4nzu7lLZim/m+g2yZ8uNIcbaFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
|
||||
'@appium/logger@1.7.1':
|
||||
resolution: {integrity: sha512-9C2o9X/lBEDBUnKfAi3mRo9oG7Z03nmISLwsGkWxIWjMAvBdJD0RRSJMekWVKzfXN3byrI1WlCXTITzN4LAoLw==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=8'}
|
||||
@@ -276,6 +285,95 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1950,6 +2048,20 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.2.52(zod@3.25.76)':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
|
||||
'@appium/logger@1.7.1':
|
||||
dependencies:
|
||||
console-control-strings: 1.1.0
|
||||
@@ -2181,6 +2293,68 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@1.0.3(@types/node@20.19.28)':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
SKILL_NAME="agent-browser-stealth"
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm is required for ClawHub sync"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "skills/${SKILL_NAME}/SKILL.md" ]; then
|
||||
echo "Missing skill file: skills/${SKILL_NAME}/SKILL.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sync only this fork-owned skill to avoid permission errors on other skills.
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
mkdir -p "$TMP_DIR/skills"
|
||||
cp -R "skills/${SKILL_NAME}" "$TMP_DIR/skills/${SKILL_NAME}"
|
||||
|
||||
echo "Syncing local skill '${SKILL_NAME}' to ClawHub..."
|
||||
cd "$TMP_DIR"
|
||||
pnpm dlx clawhub@latest sync --all --root ./skills
|
||||
echo "ClawHub sync completed."
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: agent-browser-stealth
|
||||
description: Stealth-first browser automation for OpenClaw using agent-browser-stealth. Use when tasks involve bot-protected websites, anti-fingerprint evasion, captcha-prone flows, login persistence, region-sensitive targets (e.g., Shopee/TikTok/e-commerce), or any request to automate web actions with lower detection risk.
|
||||
homepage: https://github.com/leeguooooo/agent-browser
|
||||
---
|
||||
|
||||
# agent-browser-stealth for OpenClaw
|
||||
|
||||
Use this skill when the task needs web automation and anti-bot stability.
|
||||
|
||||
## What this skill prioritizes
|
||||
|
||||
- Use `agent-browser` CLI from `agent-browser-stealth` package
|
||||
- Prefer stealth-safe interaction patterns over brittle one-shot scripts
|
||||
- Keep command flow deterministic: `open -> snapshot -> act -> re-snapshot`
|
||||
- Minimize bot signals with humanized pacing and stable session reuse
|
||||
|
||||
## Install and baseline
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser-stealth
|
||||
agent-browser install
|
||||
agent-browser --version
|
||||
```
|
||||
|
||||
If default CDP mode is used in your environment, ensure a browser is available at `localhost:9333`, or pass `--cdp` / `--auto-connect` explicitly.
|
||||
|
||||
## Standard execution workflow
|
||||
|
||||
```bash
|
||||
agent-browser open <url>
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i
|
||||
# choose refs (@e1, @e2, ...)
|
||||
agent-browser click @eN
|
||||
agent-browser fill @eM "..."
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Use refs (`@e1`) from snapshot output whenever possible.
|
||||
|
||||
## Anti-bot operating rules
|
||||
|
||||
1. Prefer headed mode for sensitive targets:
|
||||
|
||||
```bash
|
||||
agent-browser --headed --session-name shop open https://example.com
|
||||
```
|
||||
|
||||
2. Reuse session state to avoid repeated cold-start fingerprints:
|
||||
|
||||
```bash
|
||||
agent-browser --session-name shop open https://example.com
|
||||
```
|
||||
|
||||
3. Keep interactions human-like:
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 "query" --delay 120
|
||||
agent-browser wait 1200-2600
|
||||
```
|
||||
|
||||
4. For contenteditable editors, use keyboard mode:
|
||||
|
||||
```bash
|
||||
agent-browser click "[contenteditable='true']"
|
||||
agent-browser keyboard type "Hello world" --delay 90
|
||||
```
|
||||
|
||||
5. If text must literally include `--delay`, stop arg parsing with `--`:
|
||||
|
||||
```bash
|
||||
agent-browser type @e2 -- "--delay 120"
|
||||
agent-browser keyboard type -- "--delay 120"
|
||||
```
|
||||
|
||||
## Region-sensitive websites
|
||||
|
||||
For region-bound sites, open target domain directly and let locale/timezone alignment apply.
|
||||
|
||||
```bash
|
||||
agent-browser open https://shopee.tw
|
||||
```
|
||||
|
||||
Only override locale/timezone when explicitly required by the task.
|
||||
|
||||
## Recovery patterns
|
||||
|
||||
If blocked or unstable:
|
||||
|
||||
1. Retry with `--headed`.
|
||||
2. Reuse `--session-name`.
|
||||
3. Slow down action cadence (`wait`, `type --delay`).
|
||||
4. Re-open page and regenerate refs with `snapshot -i`.
|
||||
|
||||
## Minimal recipes
|
||||
|
||||
Login flow:
|
||||
|
||||
```bash
|
||||
agent-browser --session-name account open https://example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
```
|
||||
|
||||
Search and capture:
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser type @e2 "iphone" --delay 120
|
||||
agent-browser press Enter
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser screenshot result.png
|
||||
```
|
||||
|
||||
## Output expectations for OpenClaw
|
||||
|
||||
When using this skill, return:
|
||||
|
||||
- Exact commands executed
|
||||
- Key page state changes (URL/title/important element text)
|
||||
- Any anti-bot signal encountered and mitigation used
|
||||
- Next safe action
|
||||
@@ -51,6 +51,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser --risk-mode block open <url> # Block if verification/captcha interstitial is detected
|
||||
agent-browser close # Close browser
|
||||
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||
|
||||
@@ -63,13 +64,14 @@ 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
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
@@ -83,6 +85,11 @@ agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
@@ -179,6 +186,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 +229,41 @@ 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, behavior is controlled by `--risk-mode` (or `AGENT_BROWSER_RISK_MODE`):
|
||||
|
||||
- `warn` (default): retry up to 2 times with randomized backoff (3-7s), then return warning plus structured `riskSignals`
|
||||
- `block`: fail fast once a risk interstitial is detected
|
||||
- `off`: disable this detection/retry path
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
agent-browser --risk-mode warn open https://example.com
|
||||
agent-browser --risk-mode block open https://example.com
|
||||
AGENT_BROWSER_RISK_MODE=off agent-browser open https://example.com
|
||||
```
|
||||
|
||||
### iOS Simulator (Mobile Safari)
|
||||
|
||||
@@ -312,7 +351,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
|
||||
|
||||
@@ -364,6 +403,7 @@ agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
Use annotated screenshots when:
|
||||
|
||||
- The page has unlabeled icon buttons or visual-only elements
|
||||
- You need to verify visual layout or styling
|
||||
- Canvas or chart elements are present (invisible to text snapshots)
|
||||
@@ -406,6 +446,7 @@ agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map
|
||||
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
|
||||
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||
@@ -417,8 +458,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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -426,23 +466,23 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
|
||||
|
||||
## Deep-Dive Documentation
|
||||
|
||||
| Reference | When to Use |
|
||||
|-----------|-------------|
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| Reference | When to Use |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
| Template | Description |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
|
||||
```bash
|
||||
./templates/form-automation.sh https://example.com/form
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
name: dogfood
|
||||
description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams.
|
||||
allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
|
||||
---
|
||||
|
||||
# Dogfood
|
||||
|
||||
Systematically explore a web application, find issues, and produce a report with full reproduction evidence for every finding.
|
||||
|
||||
## Setup
|
||||
|
||||
Only the **Target URL** is required. Everything else has sensible defaults -- use them unless the user explicitly provides an override.
|
||||
|
||||
| Parameter | Default | Example override |
|
||||
|-----------|---------|-----------------|
|
||||
| **Target URL** | _(required)_ | `vercel.com`, `http://localhost:3000` |
|
||||
| **Session name** | Slugified domain (e.g., `vercel.com` -> `vercel-com`) | `--session my-session` |
|
||||
| **Output directory** | `./dogfood-output/` | `Output directory: /tmp/qa` |
|
||||
| **Scope** | Full app | `Focus on the billing page` |
|
||||
| **Authentication** | None | `Sign in to user@example.com` |
|
||||
|
||||
If the user says something like "dogfood vercel.com", start immediately with defaults. Do not ask clarifying questions unless authentication is mentioned but credentials are missing.
|
||||
|
||||
Always use `agent-browser` directly -- never `npx agent-browser`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. Initialize Set up session, output dirs, report file
|
||||
2. Authenticate Sign in if needed, save state
|
||||
3. Orient Navigate to starting point, take initial snapshot
|
||||
4. Explore Systematically visit pages and test features
|
||||
5. Document Screenshot + record each issue as found
|
||||
6. Wrap up Update summary counts, close session
|
||||
```
|
||||
|
||||
### 1. Initialize
|
||||
|
||||
```bash
|
||||
mkdir -p {OUTPUT_DIR}/screenshots {OUTPUT_DIR}/videos
|
||||
```
|
||||
|
||||
Copy the report template into the output directory and fill in the header fields:
|
||||
|
||||
```bash
|
||||
cp {SKILL_DIR}/templates/dogfood-report-template.md {OUTPUT_DIR}/report.md
|
||||
```
|
||||
|
||||
Start a named session:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} open {TARGET_URL}
|
||||
agent-browser --session {SESSION} wait --load networkidle
|
||||
```
|
||||
|
||||
### 2. Authenticate
|
||||
|
||||
If the app requires login:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
# Identify login form refs, fill credentials
|
||||
agent-browser --session {SESSION} fill @e1 "{EMAIL}"
|
||||
agent-browser --session {SESSION} fill @e2 "{PASSWORD}"
|
||||
agent-browser --session {SESSION} click @e3
|
||||
agent-browser --session {SESSION} wait --load networkidle
|
||||
```
|
||||
|
||||
For OTP/email codes: ask the user, wait for their response, then enter the code.
|
||||
|
||||
After successful login, save state for potential reuse:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json
|
||||
```
|
||||
|
||||
### 3. Orient
|
||||
|
||||
Take an initial annotated screenshot and snapshot to understand the app structure:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
```
|
||||
|
||||
Identify the main navigation elements and map out the sections to visit.
|
||||
|
||||
### 4. Explore
|
||||
|
||||
Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full list of what to look for and the exploration checklist.
|
||||
|
||||
**Strategy -- work through the app systematically:**
|
||||
|
||||
- Start from the main navigation. Visit each top-level section.
|
||||
- Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals.
|
||||
- Check edge cases: empty states, error handling, boundary inputs.
|
||||
- Try realistic end-to-end workflows (create, edit, delete flows).
|
||||
- Check the browser console for errors periodically.
|
||||
|
||||
**At each page:**
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} snapshot -i
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png
|
||||
agent-browser --session {SESSION} errors
|
||||
agent-browser --session {SESSION} console
|
||||
```
|
||||
|
||||
Use your judgment on how deep to go. Spend more time on core features and less on peripheral pages. If you find a cluster of issues in one area, investigate deeper.
|
||||
|
||||
### 5. Document Issues (Repro-First)
|
||||
|
||||
Steps 4 and 5 happen together -- explore and document in a single pass. When you find an issue, stop exploring and document it immediately before moving on. Do not explore the whole app first and document later.
|
||||
|
||||
Every issue must be reproducible. When you find something wrong, do not just note it -- prove it with evidence. The goal is that someone reading the report can see exactly what happened and replay it.
|
||||
|
||||
**Choose the right level of evidence for the issue:**
|
||||
|
||||
#### Interactive / behavioral issues (functional, ux, console errors on action)
|
||||
|
||||
These require user interaction to reproduce -- use full repro with video and step-by-step screenshots:
|
||||
|
||||
1. **Start a repro video** _before_ reproducing:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm
|
||||
```
|
||||
|
||||
2. **Walk through the steps at human pace.** Pause 1-2 seconds between actions so the video is watchable. Take a screenshot at each step:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png
|
||||
sleep 1
|
||||
# Perform action (click, fill, etc.)
|
||||
sleep 1
|
||||
agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png
|
||||
sleep 1
|
||||
# ...continue until the issue manifests
|
||||
```
|
||||
|
||||
3. **Capture the broken state.** Pause so the viewer can see it, then take an annotated screenshot:
|
||||
|
||||
```bash
|
||||
sleep 2
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png
|
||||
```
|
||||
|
||||
4. **Stop the video:**
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} record stop
|
||||
```
|
||||
|
||||
5. Write numbered repro steps in the report, each referencing its screenshot.
|
||||
|
||||
#### Static / visible-on-load issues (typos, placeholder text, clipped text, misalignment, console errors on load)
|
||||
|
||||
These are visible without interaction -- a single annotated screenshot is sufficient. No video, no multi-step repro:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png
|
||||
```
|
||||
|
||||
Write a brief description and reference the screenshot in the report. Set **Repro Video** to `N/A`.
|
||||
|
||||
---
|
||||
|
||||
**For all issues:**
|
||||
|
||||
1. **Append to the report immediately.** Do not batch issues for later. Write each one as you find it so nothing is lost if the session is interrupted.
|
||||
|
||||
2. **Increment the issue counter** (ISSUE-001, ISSUE-002, ...).
|
||||
|
||||
### 6. Wrap Up
|
||||
|
||||
Aim to find **5-10 well-documented issues**, then wrap up. Depth of evidence matters more than total count -- 5 issues with full repro beats 20 with vague descriptions.
|
||||
|
||||
After exploring:
|
||||
|
||||
1. Re-read the report and update the summary severity counts so they match the actual issues. Every `### ISSUE-` block must be reflected in the totals.
|
||||
2. Close the session:
|
||||
|
||||
```bash
|
||||
agent-browser --session {SESSION} close
|
||||
```
|
||||
|
||||
3. Tell the user the report is ready and summarize findings: total issues, breakdown by severity, and the most critical items.
|
||||
|
||||
## Guidance
|
||||
|
||||
- **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot.
|
||||
- **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes.
|
||||
- **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence.
|
||||
- **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser.
|
||||
- **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate.
|
||||
- **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end.
|
||||
- **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward.
|
||||
- **Never read the target app's source code.** You are testing as a user, not auditing code. Do not read HTML, JS, or config files of the app under test. All findings must come from what you observe in the browser.
|
||||
- **Check the console.** Many issues are invisible in the UI but show up as JS errors or failed requests.
|
||||
- **Test like a user, not a robot.** Try common workflows end-to-end. Click things a real user would click. Enter realistic data.
|
||||
- **Type like a human.** When filling form fields during video recording, use `type` instead of `fill` -- it types character-by-character. Use `fill` only outside of video recording when speed matters.
|
||||
- **Pace repro videos for humans.** Add `sleep 1` between actions and `sleep 2` before the final result screenshot. Videos should be watchable at 1x speed -- a human reviewing the report needs to see what happened, not a blur of instant state changes.
|
||||
- **Be efficient with commands.** Batch multiple `agent-browser` commands in a single shell call when they are independent (e.g., `agent-browser ... screenshot ... && agent-browser ... console`). Use `agent-browser --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll.
|
||||
|
||||
## References
|
||||
|
||||
| Reference | When to Read |
|
||||
|-----------|--------------|
|
||||
| [references/issue-taxonomy.md](references/issue-taxonomy.md) | Start of session -- calibrate what to look for, severity levels, exploration checklist |
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | Purpose |
|
||||
|----------|---------|
|
||||
| [templates/dogfood-report-template.md](templates/dogfood-report-template.md) | Copy into output directory as the report file |
|
||||
@@ -0,0 +1,109 @@
|
||||
# Issue Taxonomy
|
||||
|
||||
Reference for categorizing issues found during dogfooding. Read this at the start of a dogfood session to calibrate what to look for.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Severity Levels](#severity-levels)
|
||||
- [Categories](#categories)
|
||||
- [Exploration Checklist](#exploration-checklist)
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Severity | Definition |
|
||||
|----------|------------|
|
||||
| **critical** | Blocks a core workflow, causes data loss, or crashes the app |
|
||||
| **high** | Major feature broken or unusable, no workaround |
|
||||
| **medium** | Feature works but with noticeable problems, workaround exists |
|
||||
| **low** | Minor cosmetic or polish issue |
|
||||
|
||||
## Categories
|
||||
|
||||
### Visual / UI
|
||||
|
||||
- Layout broken or misaligned elements
|
||||
- Overlapping or clipped text
|
||||
- Inconsistent spacing, padding, or margins
|
||||
- Missing or broken icons/images
|
||||
- Dark mode / light mode rendering issues
|
||||
- Responsive layout problems (viewport sizes)
|
||||
- Z-index stacking issues (elements hidden behind others)
|
||||
- Font rendering issues (wrong font, size, weight)
|
||||
- Color contrast problems
|
||||
- Animation glitches or jank
|
||||
|
||||
### Functional
|
||||
|
||||
- Broken links (404, wrong destination)
|
||||
- Buttons or controls that do nothing on click
|
||||
- Form validation that rejects valid input or accepts invalid input
|
||||
- Incorrect redirects
|
||||
- Features that fail silently
|
||||
- State not persisted when expected (lost on refresh, navigation)
|
||||
- Race conditions (double-submit, stale data)
|
||||
- Broken search or filtering
|
||||
- Pagination issues
|
||||
- File upload/download failures
|
||||
|
||||
### UX
|
||||
|
||||
- Confusing or unclear navigation
|
||||
- Missing loading indicators or feedback after actions
|
||||
- Slow or unresponsive interactions (>300ms perceived delay)
|
||||
- Unclear error messages
|
||||
- Missing confirmation for destructive actions
|
||||
- Dead ends (no way to go back or proceed)
|
||||
- Inconsistent patterns across similar features
|
||||
- Missing keyboard shortcuts or focus management
|
||||
- Unintuitive defaults
|
||||
- Missing empty states or unhelpful empty states
|
||||
|
||||
### Content
|
||||
|
||||
- Typos or grammatical errors
|
||||
- Outdated or incorrect text
|
||||
- Placeholder or lorem ipsum content left in
|
||||
- Truncated text without tooltip or expansion
|
||||
- Missing or wrong labels
|
||||
- Inconsistent terminology
|
||||
|
||||
### Performance
|
||||
|
||||
- Slow page loads (>3s)
|
||||
- Janky scrolling or animations
|
||||
- Large layout shifts (content jumping)
|
||||
- Excessive network requests (check via console/network)
|
||||
- Memory leaks (page slows over time)
|
||||
- Unoptimized images (large file sizes)
|
||||
|
||||
### Console / Errors
|
||||
|
||||
- JavaScript exceptions in console
|
||||
- Failed network requests (4xx, 5xx)
|
||||
- Deprecation warnings
|
||||
- CORS errors
|
||||
- Mixed content warnings
|
||||
- Unhandled promise rejections
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Missing alt text on images
|
||||
- Unlabeled form inputs
|
||||
- Poor keyboard navigation (can't tab to elements)
|
||||
- Focus traps
|
||||
- Insufficient color contrast
|
||||
- Missing ARIA attributes on dynamic content
|
||||
- Screen reader incompatible patterns
|
||||
|
||||
## Exploration Checklist
|
||||
|
||||
Use this as a guide for what to test on each page/feature:
|
||||
|
||||
1. **Visual scan** -- Take an annotated screenshot. Look for layout, alignment, and rendering issues.
|
||||
2. **Interactive elements** -- Click every button, link, and control. Do they work? Is there feedback?
|
||||
3. **Forms** -- Fill and submit. Test empty submission, invalid input, and edge cases.
|
||||
4. **Navigation** -- Follow all navigation paths. Check breadcrumbs, back button, deep links.
|
||||
5. **States** -- Check empty states, loading states, error states, and full/overflow states.
|
||||
6. **Console** -- Check for JS errors, failed requests, and warnings.
|
||||
7. **Responsiveness** -- If relevant, test at different viewport sizes.
|
||||
8. **Auth boundaries** -- Test what happens when not logged in, with different roles if applicable.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Dogfood Report: {APP_NAME}
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Date** | {DATE} |
|
||||
| **App URL** | {URL} |
|
||||
| **Session** | {SESSION_NAME} |
|
||||
| **Scope** | {SCOPE} |
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 0 |
|
||||
| Low | 0 |
|
||||
| **Total** | **0** |
|
||||
|
||||
## Issues
|
||||
|
||||
<!-- Copy this block for each issue found. Interactive issues need video + step-by-step screenshots. Static issues (typos, visual glitches) only need a single screenshot -- set Repro Video to N/A. -->
|
||||
|
||||
### ISSUE-001: {Short title}
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Severity** | critical / high / medium / low |
|
||||
| **Category** | visual / functional / ux / content / performance / console / accessibility |
|
||||
| **URL** | {page URL where issue was found} |
|
||||
| **Repro Video** | {path to video, or N/A for static issues} |
|
||||
|
||||
**Description**
|
||||
|
||||
{What is wrong, what was expected, and what actually happened.}
|
||||
|
||||
**Repro Steps**
|
||||
|
||||
<!-- Each step has a screenshot. A reader should be able to follow along visually. -->
|
||||
|
||||
1. Navigate to {URL}
|
||||

|
||||
|
||||
2. {Action -- e.g., click "Settings" in the sidebar}
|
||||

|
||||
|
||||
3. {Action -- e.g., type "test" in the search field and press Enter}
|
||||

|
||||
|
||||
4. **Observe:** {what goes wrong -- e.g., the page shows a blank white screen instead of search results}
|
||||

|
||||
|
||||
---
|
||||
+20
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toAIFriendlyError } from './actions.js';
|
||||
import { detectRiskSignals, toAIFriendlyError } from './actions.js';
|
||||
|
||||
describe('toAIFriendlyError', () => {
|
||||
describe('element blocked by overlay', () => {
|
||||
@@ -37,3 +37,22 @@ describe('toAIFriendlyError', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRiskSignals', () => {
|
||||
it('should detect verification patterns from URL and title', () => {
|
||||
const signals = detectRiskSignals(
|
||||
'https://example.com/verify/captcha?scene=anti_bot',
|
||||
'Just a moment...'
|
||||
);
|
||||
expect(signals.length).toBeGreaterThan(0);
|
||||
expect(signals.some((s) => s.source === 'url' && s.code === 'captcha_interstitial')).toBe(true);
|
||||
expect(
|
||||
signals.some((s) => s.source === 'title' && s.code === 'verification_interstitial')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for normal pages', () => {
|
||||
const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard');
|
||||
expect(signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+150
-27
@@ -127,7 +127,6 @@ import type {
|
||||
DiffScreenshotCommand,
|
||||
DiffUrlCommand,
|
||||
Annotation,
|
||||
NavigateData,
|
||||
ScreenshotData,
|
||||
EvaluateData,
|
||||
DiffSnapshotData,
|
||||
@@ -145,6 +144,8 @@ import type {
|
||||
RecordingRestartData,
|
||||
InputEventData,
|
||||
StylesData,
|
||||
RiskMode,
|
||||
RiskSignal,
|
||||
} from './types.js';
|
||||
import { successResponse, errorResponse } from './protocol.js';
|
||||
import { diffSnapshots, diffScreenshots } from './diff.js';
|
||||
@@ -526,24 +527,146 @@ async function handleLaunch(
|
||||
async function handleNavigate(
|
||||
command: NavigateCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<NavigateData>> {
|
||||
): Promise<Response> {
|
||||
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',
|
||||
});
|
||||
|
||||
const riskMode: RiskMode = command.riskMode ?? 'warn';
|
||||
if (riskMode === 'off') {
|
||||
return successResponse(command.id, {
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
});
|
||||
}
|
||||
|
||||
// Detect risk interstitials (captcha/verification) and handle by risk mode.
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title();
|
||||
let encounteredSignals = detectRiskSignals(finalUrl, title);
|
||||
if (encounteredSignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: finalUrl,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
if (riskMode === 'block') {
|
||||
const first = encounteredSignals[0];
|
||||
return errorResponse(
|
||||
command.id,
|
||||
`Navigation blocked by risk-mode=block: ${first.code} (${first.source}="${first.evidence}")`
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const retrySignals = detectRiskSignals(retryUrl, retryTitle);
|
||||
if (retrySignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: retryUrl,
|
||||
title: retryTitle,
|
||||
warning:
|
||||
'Risk interstitial detected and recovered after retry. Review riskSignals for evidence.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
encounteredSignals = mergeRiskSignals(encounteredSignals, retrySignals);
|
||||
}
|
||||
|
||||
// All retries exhausted -- return the page as-is with a warning and evidence.
|
||||
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.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeRiskSignals(current: RiskSignal[], next: RiskSignal[]): RiskSignal[] {
|
||||
const merged = new Map<string, RiskSignal>();
|
||||
for (const signal of [...current, ...next]) {
|
||||
const key = `${signal.code}|${signal.source}|${signal.evidence}`;
|
||||
if (!merged.has(key) || (merged.get(key)?.confidence ?? 0) < signal.confidence) {
|
||||
merged.set(key, signal);
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect verification/captcha interstitials and return structured risk evidence.
|
||||
*/
|
||||
export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{ pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 },
|
||||
{ pattern: '/captcha', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
{ pattern: '/challenge', code: 'verification_interstitial', confidence: 0.93 },
|
||||
{ pattern: 'scene=crawler', code: 'bot_challenge', confidence: 0.99 },
|
||||
{ pattern: 'scene=anti_bot', code: 'bot_challenge', confidence: 0.99 },
|
||||
{ pattern: 'recaptcha', code: 'captcha_interstitial', confidence: 0.97 },
|
||||
{ pattern: 'hcaptcha', code: 'captcha_interstitial', confidence: 0.97 },
|
||||
];
|
||||
const titlePatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{ pattern: 'verify', code: 'verification_interstitial', confidence: 0.78 },
|
||||
{ pattern: 'captcha', code: 'captcha_interstitial', confidence: 0.9 },
|
||||
{ pattern: 'challenge', code: 'verification_interstitial', confidence: 0.8 },
|
||||
{ pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 },
|
||||
{ pattern: 'just a moment', code: 'verification_interstitial', confidence: 0.95 },
|
||||
{ pattern: 'checking your browser', code: 'verification_interstitial', confidence: 0.97 },
|
||||
{ pattern: 'access denied', code: 'access_gate', confidence: 0.86 },
|
||||
{ pattern: '驗證', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '验证', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '人机验证', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
];
|
||||
const signals: RiskSignal[] = [];
|
||||
for (const item of urlPatterns) {
|
||||
if (lowerUrl.includes(item.pattern)) {
|
||||
signals.push({
|
||||
code: item.code,
|
||||
source: 'url',
|
||||
evidence: item.pattern,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of titlePatterns) {
|
||||
if (lowerTitle.includes(item.pattern)) {
|
||||
signals.push({
|
||||
code: item.code,
|
||||
source: 'title',
|
||||
evidence: item.pattern,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
return mergeRiskSignals([], signals);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -930,41 +1053,41 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
|
||||
async function handleScroll(command: ScrollCommand, browser: BrowserManager): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
let deltaX = command.x ?? 0;
|
||||
let deltaY = command.y ?? 0;
|
||||
const hasExplicitDelta = command.x !== undefined || command.y !== undefined;
|
||||
|
||||
if (command.direction) {
|
||||
const amount = command.amount ?? 100;
|
||||
switch (command.direction) {
|
||||
case 'up':
|
||||
deltaY = -amount;
|
||||
break;
|
||||
case 'down':
|
||||
deltaY = amount;
|
||||
break;
|
||||
case 'left':
|
||||
deltaX = -amount;
|
||||
break;
|
||||
case 'right':
|
||||
deltaX = amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.selector) {
|
||||
const element = browser.getLocator(command.selector);
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
|
||||
if (command.x !== undefined || command.y !== undefined) {
|
||||
if (hasExplicitDelta || deltaX !== 0 || deltaY !== 0) {
|
||||
await element.evaluate(
|
||||
(el, { x, y }) => {
|
||||
el.scrollBy(x ?? 0, y ?? 0);
|
||||
el.scrollBy(x, y);
|
||||
},
|
||||
{ x: command.x, y: command.y }
|
||||
{ x: deltaX, y: deltaY }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Scroll the page
|
||||
let deltaX = command.x ?? 0;
|
||||
let deltaY = command.y ?? 0;
|
||||
|
||||
if (command.direction) {
|
||||
const amount = command.amount ?? 100;
|
||||
switch (command.direction) {
|
||||
case 'up':
|
||||
deltaY = -amount;
|
||||
break;
|
||||
case 'down':
|
||||
deltaY = amount;
|
||||
break;
|
||||
case 'left':
|
||||
deltaX = -amount;
|
||||
break;
|
||||
case 'right':
|
||||
deltaX = amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
|
||||
}
|
||||
|
||||
|
||||
+75
-14
@@ -9,11 +9,11 @@ describe('BrowserManager', () => {
|
||||
beforeAll(async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({ headless: true });
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
describe('launch and close', () => {
|
||||
it('should report as launched', () => {
|
||||
@@ -56,7 +56,7 @@ describe('BrowserManager', () => {
|
||||
|
||||
it('should report local stealth policy capabilities', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
await testBrowser.launch({ headless: true, stealth: true });
|
||||
await testBrowser.launch({ headless: true });
|
||||
|
||||
const status = testBrowser.getStealthStatus('chromium');
|
||||
expect(status.enabled).toBe(true);
|
||||
@@ -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(),
|
||||
@@ -84,7 +84,7 @@ describe('BrowserManager', () => {
|
||||
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222, stealth: true });
|
||||
await cdpBrowser.launch({ cdpPort: 9222 });
|
||||
|
||||
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||
const status = cdpBrowser.getStealthStatus();
|
||||
@@ -97,9 +97,9 @@ describe('BrowserManager', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => {
|
||||
it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', 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(),
|
||||
@@ -116,11 +116,12 @@ describe('BrowserManager', () => {
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222, stealth: false });
|
||||
|
||||
expect(addInitScript).not.toHaveBeenCalled();
|
||||
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||
const status = cdpBrowser.getStealthStatus();
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.connectionKind).toBe('cdp');
|
||||
expect(status.capabilities).toEqual([]);
|
||||
expect(status.capabilities).toContain('context-init-scripts');
|
||||
expect(status.capabilities).not.toContain('chromium-launch-args');
|
||||
|
||||
await cdpBrowser.close();
|
||||
spy.mockRestore();
|
||||
@@ -926,15 +927,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 +952,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', () => {
|
||||
|
||||
+249
-49
@@ -16,7 +16,7 @@ import {
|
||||
} from 'playwright-core';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
@@ -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
|
||||
*/
|
||||
@@ -158,6 +160,7 @@ export class BrowserManager {
|
||||
private contextTimezoneId: string | undefined = undefined;
|
||||
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||
private contextUserAgent: string | undefined = undefined;
|
||||
private downloadPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -247,13 +250,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 +374,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;
|
||||
}
|
||||
|
||||
@@ -339,7 +446,10 @@ export class BrowserManager {
|
||||
): Promise<void> {
|
||||
const policy = this.getStealthPolicy();
|
||||
if (!policy.applyInitScripts) return;
|
||||
await applyStealthScripts(context, options);
|
||||
await applyStealthScripts(context, {
|
||||
...options,
|
||||
userAgent: this.contextUserAgent,
|
||||
});
|
||||
this.logStealthPolicy('init-script applied');
|
||||
}
|
||||
|
||||
@@ -483,6 +593,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 +664,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 +1163,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 +1517,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)'
|
||||
@@ -1424,6 +1568,17 @@ export class BrowserManager {
|
||||
}
|
||||
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
||||
|
||||
if (options.downloadPath) {
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
|
||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||
const warning =
|
||||
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
|
||||
if (cdpEndpoint) {
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
return;
|
||||
@@ -1435,6 +1590,13 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
||||
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||
if (this.downloadPath && provider) {
|
||||
const warning =
|
||||
"--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
if (provider === 'browserbase') {
|
||||
await this.connectToBrowserbase();
|
||||
return;
|
||||
@@ -1450,6 +1612,23 @@ export class BrowserManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.downloadPath) {
|
||||
const resolved = path.resolve(this.downloadPath);
|
||||
const stat = statSync(resolved, { throwIfNoEntry: false });
|
||||
if (stat && !stat.isDirectory()) {
|
||||
throw new Error(`Download path is not a directory: ${resolved}`);
|
||||
}
|
||||
if (!stat) {
|
||||
try {
|
||||
mkdirSync(resolved, { recursive: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`Cannot create download directory '${resolved}': ${msg}`);
|
||||
}
|
||||
}
|
||||
this.downloadPath = resolved;
|
||||
}
|
||||
|
||||
const browserType = options.browser ?? 'chromium';
|
||||
if (hasExtensions && browserType !== 'chromium') {
|
||||
throw new Error('Extensions are only supported in Chromium');
|
||||
@@ -1463,6 +1642,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 +1708,7 @@ export class BrowserManager {
|
||||
{
|
||||
headless: false,
|
||||
executablePath: options.executablePath,
|
||||
...(chromeChannel && { channel: chromeChannel }),
|
||||
args: allArgs,
|
||||
viewport,
|
||||
extraHTTPHeaders,
|
||||
@@ -1534,38 +1718,25 @@ export class BrowserManager {
|
||||
...(options.proxy && { proxy: options.proxy }),
|
||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
}
|
||||
);
|
||||
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.downloadPath && { downloadsPath: this.downloadPath }),
|
||||
});
|
||||
this.cdpEndpoint = null;
|
||||
|
||||
if (stealthPolicy.enabled && browserType === 'chromium') {
|
||||
await applyBrowserLevelStealth(this.browser);
|
||||
await applyBrowserLevelStealth(this.browser, {
|
||||
userAgent: contextUserAgent,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||
@@ -1722,11 +1893,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 +2023,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 +2058,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 +2114,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 +2130,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-2
@@ -6,14 +6,14 @@ const cmd = (obj: object) => JSON.stringify(obj);
|
||||
|
||||
describe('parseCommand', () => {
|
||||
describe('launch', () => {
|
||||
it('should parse launch command with stealth flag', () => {
|
||||
it('should parse launch command and ignore legacy stealth flag', () => {
|
||||
const result = parseCommand(
|
||||
cmd({ id: '1', action: 'launch', headless: false, stealth: true })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.action).toBe('launch');
|
||||
expect(result.command.stealth).toBe(true);
|
||||
expect((result.command as any).stealth).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,11 +44,38 @@ describe('parseCommand', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse navigate with riskMode', () => {
|
||||
const result = parseCommand(
|
||||
cmd({
|
||||
id: '1',
|
||||
action: 'navigate',
|
||||
url: 'https://example.com',
|
||||
riskMode: 'block',
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.riskMode).toBe('block');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject navigate without url', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject navigate with invalid riskMode', () => {
|
||||
const result = parseCommand(
|
||||
cmd({
|
||||
id: '1',
|
||||
action: 'navigate',
|
||||
url: 'https://example.com',
|
||||
riskMode: 'invalid',
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse back command', () => {
|
||||
const result = parseCommand(cmd({ id: '1', action: 'back' }));
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
+2
-1
@@ -50,7 +50,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
ignoreHTTPSErrors: z.boolean().optional(),
|
||||
allowFileAccess: z.boolean().optional(),
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||
profile: z.string().optional(),
|
||||
downloadPath: z.string().optional(),
|
||||
storageState: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ const navigateSchema = baseCommandSchema.extend({
|
||||
url: z.string().min(1),
|
||||
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
riskMode: z.enum(['off', 'warn', 'block']).optional(),
|
||||
});
|
||||
|
||||
const clickSchema = baseCommandSchema.extend({
|
||||
|
||||
+21
-9
@@ -10,6 +10,8 @@ import type { Browser, BrowserContext, Page } from 'playwright-core';
|
||||
|
||||
export interface StealthScriptOptions {
|
||||
locale?: string;
|
||||
userAgent?: string;
|
||||
acceptLanguage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,25 +37,30 @@ export async function applyStealthScripts(
|
||||
// Apply CDP-level User-Agent override so Workers also get the patched UA.
|
||||
// This must be done per-page since CDP sessions are page-scoped.
|
||||
for (const page of context.pages()) {
|
||||
await applyCDPStealthToPage(page);
|
||||
await applyCDPStealthToPage(page, options);
|
||||
}
|
||||
context.on('page', (page: Page) => applyCDPStealthToPage(page));
|
||||
context.on('page', (page: Page) => applyCDPStealthToPage(page, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply browser-level CDP overrides that affect all targets (including Workers).
|
||||
* Call this right after browser.launch() and before creating pages.
|
||||
*/
|
||||
export async function applyBrowserLevelStealth(browser: Browser): Promise<void> {
|
||||
export async function applyBrowserLevelStealth(
|
||||
browser: Browser,
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await (browser as any).newBrowserCDPSession();
|
||||
const version = await cdp.send('Browser.getVersion');
|
||||
const rawUA = version?.userAgent ?? '';
|
||||
if (!rawUA.includes('HeadlessChrome')) {
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
if (!explicitUA && !rawUA.includes('HeadlessChrome')) {
|
||||
await cdp.detach();
|
||||
return;
|
||||
}
|
||||
const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
|
||||
const metadata = buildUserAgentMetadata(patchedUA);
|
||||
|
||||
// Override on all existing targets
|
||||
@@ -66,7 +73,7 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise<void>
|
||||
});
|
||||
await cdp.send('Emulation.setUserAgentOverride', {
|
||||
userAgent: patchedUA,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
acceptLanguage,
|
||||
platform: getPlatformString(),
|
||||
userAgentMetadata: metadata,
|
||||
});
|
||||
@@ -81,17 +88,22 @@ export async function applyBrowserLevelStealth(browser: Browser): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCDPStealthToPage(page: Page): Promise<void> {
|
||||
async function applyCDPStealthToPage(
|
||||
page: Page,
|
||||
options: StealthScriptOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const ua = await cdp.send('Browser.getVersion').catch(() => null);
|
||||
const rawUA = ua?.userAgent ?? '';
|
||||
const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const explicitUA = options.userAgent?.trim();
|
||||
const patchedUA = explicitUA || rawUA.replace(/HeadlessChrome/g, 'Chrome');
|
||||
const acceptLanguage = options.acceptLanguage ?? 'en-US,en;q=0.9';
|
||||
const metadata = buildUserAgentMetadata(patchedUA);
|
||||
|
||||
await cdp.send('Emulation.setUserAgentOverride', {
|
||||
userAgent: patchedUA,
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
acceptLanguage,
|
||||
platform: getPlatformString(),
|
||||
userAgentMetadata: metadata,
|
||||
});
|
||||
|
||||
+15
-1
@@ -6,6 +6,15 @@ export interface BaseCommand {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export type RiskMode = 'off' | 'warn' | 'block';
|
||||
|
||||
export interface RiskSignal {
|
||||
code: string;
|
||||
source: 'url' | 'title';
|
||||
evidence: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
// Action-specific command types
|
||||
export interface LaunchCommand extends BaseCommand {
|
||||
action: 'launch';
|
||||
@@ -18,7 +27,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;
|
||||
@@ -32,6 +40,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
ignoreHTTPSErrors?: boolean;
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
@@ -41,6 +50,8 @@ export interface NavigateCommand extends BaseCommand {
|
||||
url: string;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||
headers?: Record<string, string>;
|
||||
// off: skip detection/retry, warn: retry then return warning+riskSignals, block: fail fast
|
||||
riskMode?: RiskMode;
|
||||
}
|
||||
|
||||
export interface ClickCommand extends BaseCommand {
|
||||
@@ -1073,6 +1084,9 @@ export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
||||
export interface NavigateData {
|
||||
url: string;
|
||||
title: string;
|
||||
warning?: string;
|
||||
// Structured evidence emitted when verification/captcha patterns are detected.
|
||||
riskSignals?: RiskSignal[];
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const AI_GATEWAY_URL =
|
||||
process.env.ANTHROPIC_BASE_URL || 'https://ai-gateway.vercel.sh';
|
||||
const API_KEY = process.env.AI_GATEWAY_API_KEY;
|
||||
const MODEL = process.env.DOGFOOD_MODEL || 'anthropic/claude-haiku-4.5';
|
||||
const CUSTOM_URL = process.env.DOGFOOD_URL;
|
||||
|
||||
const FIXTURE_PATH = path.resolve('test/e2e/fixtures/buggy-app.html');
|
||||
const SKILL_PATH = path.resolve('skills/dogfood/SKILL.md');
|
||||
const TARGET_URL = CUSTOM_URL || `file://${FIXTURE_PATH}`;
|
||||
const IS_FIXTURE = !CUSTOM_URL;
|
||||
|
||||
const OUTPUT_DIR = path.resolve('test/e2e/.dogfood-output');
|
||||
const EVAL_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
async function runDogfood(outputDir: string): Promise<{
|
||||
result: SDKResultMessage | null;
|
||||
messages: SDKMessage[];
|
||||
toolsUsed: Set<string>;
|
||||
}> {
|
||||
const instruction = [
|
||||
`Read the dogfood skill at ${SKILL_PATH} and follow its workflow.`,
|
||||
`Dogfood ${TARGET_URL}`,
|
||||
`Output directory: ${outputDir}`,
|
||||
].join(' ');
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const toolsUsed = new Set<string>();
|
||||
let result: SDKResultMessage | null = null;
|
||||
|
||||
const conversation = query({
|
||||
prompt: instruction,
|
||||
options: {
|
||||
model: MODEL,
|
||||
cwd: process.cwd(),
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
maxTurns: 80,
|
||||
maxBudgetUsd: 2,
|
||||
settingSources: ['project'],
|
||||
persistSession: false,
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_BASE_URL: AI_GATEWAY_URL,
|
||||
ANTHROPIC_API_KEY: API_KEY,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const verbose = process.env.DOGFOOD_VERBOSE !== '0';
|
||||
const log = verbose ? (msg: string) => process.stderr.write(` [dogfood] ${msg}\n`) : () => {};
|
||||
|
||||
const chatLogPath = path.join(outputDir, 'chat-log.jsonl');
|
||||
writeFileSync(chatLogPath, '');
|
||||
|
||||
function appendToLog(entry: Record<string, unknown>) {
|
||||
appendFileSync(chatLogPath, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
for await (const message of conversation) {
|
||||
messages.push(message);
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
log(`session started (model: ${message.model})`);
|
||||
appendToLog({ type: 'system', subtype: 'init', model: message.model });
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' && message.message?.content) {
|
||||
const logParts: Record<string, unknown>[] = [];
|
||||
for (const block of message.message.content) {
|
||||
if ('type' in block && block.type === 'tool_use') {
|
||||
toolsUsed.add(block.name);
|
||||
const input = block.input as Record<string, unknown>;
|
||||
let preview: string;
|
||||
if (block.name === 'Bash') {
|
||||
const cmd = String(input.command ?? '');
|
||||
const firstLine = cmd.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) ?? cmd.split('\n')[0];
|
||||
preview = firstLine.trim().slice(0, 200);
|
||||
} else if (block.name === 'Write') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else if (block.name === 'Read') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else if (block.name === 'Edit') {
|
||||
preview = String(input.file_path ?? input.path ?? '');
|
||||
} else {
|
||||
preview = JSON.stringify(input).slice(0, 120);
|
||||
}
|
||||
log(`${block.name}: ${preview}`);
|
||||
logParts.push({ tool: block.name, input: block.input });
|
||||
}
|
||||
if ('type' in block && block.type === 'text' && block.text) {
|
||||
const line = block.text.split('\n')[0].slice(0, 120);
|
||||
if (line.trim()) log(line);
|
||||
logParts.push({ text: block.text });
|
||||
}
|
||||
}
|
||||
appendToLog({ type: 'assistant', content: logParts });
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
result = message;
|
||||
const cost = `$${message.total_cost_usd.toFixed(4)}`;
|
||||
const usage = message.usage;
|
||||
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
||||
const cacheCreate = usage.cache_creation_input_tokens ?? 0;
|
||||
const inputTokens = usage.input_tokens ?? 0;
|
||||
const cacheInfo = cacheRead > 0
|
||||
? ` | cache: ${cacheRead} read, ${cacheCreate} created, ${inputTokens} uncached`
|
||||
: '';
|
||||
if (message.subtype === 'success') {
|
||||
log(`done (${message.num_turns} turns, ${cost}${cacheInfo})`);
|
||||
} else {
|
||||
log(`stopped: ${message.subtype} (${message.num_turns} turns, ${cost}${cacheInfo})`);
|
||||
}
|
||||
appendToLog({ type: 'result', subtype: message.subtype, num_turns: message.num_turns, cost: message.total_cost_usd });
|
||||
}
|
||||
}
|
||||
|
||||
log(`chat log: ${chatLogPath}`);
|
||||
|
||||
return { result, messages, toolsUsed };
|
||||
}
|
||||
|
||||
function findFiles(dir: string, ext: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((f) => f.endsWith(ext));
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)('Dogfood e2e eval (Agent SDK)', () => {
|
||||
const outputDir = OUTPUT_DIR;
|
||||
let evalResult: Awaited<ReturnType<typeof runDogfood>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (existsSync(outputDir)) {
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
evalResult = await runDogfood(outputDir);
|
||||
}, EVAL_TIMEOUT);
|
||||
|
||||
it('completes without hard failure', () => {
|
||||
expect(evalResult.result, 'No result message received').toBeTruthy();
|
||||
const acceptable = ['success', 'error_max_turns', 'error_max_budget_usd'];
|
||||
expect(
|
||||
acceptable,
|
||||
`Agent failed unexpectedly: ${evalResult.result!.subtype}`
|
||||
).toContain(evalResult.result!.subtype);
|
||||
});
|
||||
|
||||
it('used agent-browser via Bash tool', () => {
|
||||
expect(
|
||||
evalResult.toolsUsed.has('Bash'),
|
||||
'Agent never used Bash (needed for agent-browser commands)'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('produced a report file', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
expect(existsSync(reportPath), 'report.md not found in output dir').toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('found a minimum number of issues', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
const issueBlocks = report.match(/###\s+ISSUE-\d+/g) || [];
|
||||
if (IS_FIXTURE) {
|
||||
expect(
|
||||
issueBlocks.length,
|
||||
`Expected >=2 issues from fixture, found ${issueBlocks.length}`
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
} else {
|
||||
expect(issueBlocks.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('each issue has required fields and repro evidence', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
const issueSections = report.split(/(?=###\s+ISSUE-\d+)/).slice(1);
|
||||
for (const section of issueSections) {
|
||||
const issueId = section.match(/ISSUE-\d+/)?.[0] ?? 'unknown';
|
||||
|
||||
expect(section, `${issueId}: missing Severity`).toMatch(
|
||||
/\*\*Severity\*\*/i
|
||||
);
|
||||
|
||||
const sevMatch = section.match(
|
||||
/\*\*Severity\*\*\s*\|?\s*(critical|high|medium|low)/i
|
||||
);
|
||||
expect(sevMatch, `${issueId}: invalid severity value`).toBeTruthy();
|
||||
|
||||
expect(section, `${issueId}: missing Category`).toMatch(
|
||||
/\*\*Category\*\*/i
|
||||
);
|
||||
|
||||
expect(section, `${issueId}: missing URL`).toMatch(/\*\*URL\*\*/i);
|
||||
|
||||
expect(section, `${issueId}: missing Repro Video field`).toMatch(
|
||||
/\*\*Repro Video\*\*/i
|
||||
);
|
||||
|
||||
const hasScreenshot = /!\[.*?\]\(.*?\)/.test(section);
|
||||
const hasReproSteps = /\*\*Repro Steps\*\*/i.test(section);
|
||||
expect(
|
||||
hasScreenshot || hasReproSteps,
|
||||
`${issueId}: needs either screenshot refs or repro steps`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('has a summary table with non-zero total', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
|
||||
expect(report, 'Missing Summary section').toContain('## Summary');
|
||||
const totalMatch = report.match(/\*\*Total\*\*\s*\|?\s*\*\*(\d+)\*\*/);
|
||||
expect(totalMatch, 'Summary Total not found').toBeTruthy();
|
||||
if (totalMatch) {
|
||||
const total = parseInt(totalMatch[1], 10);
|
||||
expect(total, 'Summary Total should be > 0').toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('produced screenshot files', () => {
|
||||
const screenshotsDir = path.join(outputDir, 'screenshots');
|
||||
const screenshots = findFiles(screenshotsDir, '.png');
|
||||
expect(
|
||||
screenshots.length,
|
||||
'No screenshot files found in output'
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produced video files for interactive issues', () => {
|
||||
const reportPath = path.join(outputDir, 'report.md');
|
||||
if (!existsSync(reportPath)) return;
|
||||
const report = readFileSync(reportPath, 'utf-8');
|
||||
const hasVideoRefs = /videos\/issue-\d+/.test(report);
|
||||
if (!hasVideoRefs) return;
|
||||
const videosDir = path.join(outputDir, 'videos');
|
||||
const videos = findFiles(videosDir, '.webm');
|
||||
expect(
|
||||
videos.length,
|
||||
'Report references videos but none were found'
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKILL_DIR = path.resolve('skills/dogfood');
|
||||
const SKILL_MD = path.join(SKILL_DIR, 'SKILL.md');
|
||||
const TAXONOMY_MD = path.join(SKILL_DIR, 'references', 'issue-taxonomy.md');
|
||||
const TEMPLATE_MD = path.join(SKILL_DIR, 'templates', 'dogfood-report-template.md');
|
||||
|
||||
function readSkillFile(filePath: string): string {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
fields[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
describe('Dogfood skill: file structure', () => {
|
||||
it('SKILL.md exists', () => {
|
||||
expect(existsSync(SKILL_MD)).toBe(true);
|
||||
});
|
||||
|
||||
it('references/issue-taxonomy.md exists', () => {
|
||||
expect(existsSync(TAXONOMY_MD)).toBe(true);
|
||||
});
|
||||
|
||||
it('templates/dogfood-report-template.md exists', () => {
|
||||
expect(existsSync(TEMPLATE_MD)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: SKILL.md frontmatter', () => {
|
||||
const content = readSkillFile(SKILL_MD);
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
it('has name field', () => {
|
||||
expect(frontmatter.name).toBe('dogfood');
|
||||
});
|
||||
|
||||
it('has description field', () => {
|
||||
expect(frontmatter.description).toBeTruthy();
|
||||
expect(frontmatter.description!.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('has allowed-tools field', () => {
|
||||
expect(frontmatter['allowed-tools']).toBeTruthy();
|
||||
expect(frontmatter['allowed-tools']).toContain('agent-browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: SKILL.md body references', () => {
|
||||
const content = readSkillFile(SKILL_MD);
|
||||
|
||||
it('references issue-taxonomy.md', () => {
|
||||
expect(content).toContain('references/issue-taxonomy.md');
|
||||
});
|
||||
|
||||
it('references dogfood-report-template.md', () => {
|
||||
expect(content).toContain('templates/dogfood-report-template.md');
|
||||
});
|
||||
|
||||
it('referenced files exist on disk', () => {
|
||||
const refPattern = /\[.*?\]\((references\/.*?\.md|templates\/.*?\.md)\)/g;
|
||||
const refs = [...content.matchAll(refPattern)].map((m) => m[1]);
|
||||
expect(refs.length).toBeGreaterThan(0);
|
||||
for (const ref of refs) {
|
||||
const fullPath = path.join(SKILL_DIR, ref);
|
||||
expect(existsSync(fullPath), `Missing: ${ref}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: report template', () => {
|
||||
const template = readSkillFile(TEMPLATE_MD);
|
||||
|
||||
it('has ISSUE- prefix in issue blocks', () => {
|
||||
expect(template).toContain('ISSUE-');
|
||||
});
|
||||
|
||||
it('has Severity field', () => {
|
||||
expect(template).toContain('**Severity**');
|
||||
});
|
||||
|
||||
it('has Category field', () => {
|
||||
expect(template).toContain('**Category**');
|
||||
});
|
||||
|
||||
it('has URL field', () => {
|
||||
expect(template).toContain('**URL**');
|
||||
});
|
||||
|
||||
it('has Repro Video field', () => {
|
||||
expect(template).toContain('**Repro Video**');
|
||||
});
|
||||
|
||||
it('has Repro Steps section', () => {
|
||||
expect(template).toContain('**Repro Steps**');
|
||||
});
|
||||
|
||||
it('has screenshot image references in repro steps', () => {
|
||||
expect(template).toMatch(/!\[.*?\]\(screenshots\//);
|
||||
});
|
||||
|
||||
it('lists all valid severity values', () => {
|
||||
expect(template).toMatch(/critical\s*\/\s*high\s*\/\s*medium\s*\/\s*low/);
|
||||
});
|
||||
|
||||
it('lists all valid category values', () => {
|
||||
const categoryLine = template
|
||||
.split('\n')
|
||||
.find((l) => l.includes('**Category**'));
|
||||
expect(categoryLine).toBeTruthy();
|
||||
for (const cat of [
|
||||
'visual',
|
||||
'functional',
|
||||
'ux',
|
||||
'content',
|
||||
'performance',
|
||||
'console',
|
||||
'accessibility',
|
||||
]) {
|
||||
expect(categoryLine!.toLowerCase()).toContain(cat);
|
||||
}
|
||||
});
|
||||
|
||||
it('has Summary table with severity counts', () => {
|
||||
expect(template).toContain('## Summary');
|
||||
for (const sev of ['Critical', 'High', 'Medium', 'Low', 'Total']) {
|
||||
expect(template).toContain(sev);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: issue taxonomy', () => {
|
||||
const taxonomy = readSkillFile(TAXONOMY_MD);
|
||||
|
||||
it('has severity level definitions', () => {
|
||||
expect(taxonomy).toContain('## Severity Levels');
|
||||
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
||||
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
|
||||
}
|
||||
});
|
||||
|
||||
it('has all 7 category sections', () => {
|
||||
const expectedCategories = [
|
||||
'Visual',
|
||||
'Functional',
|
||||
'UX',
|
||||
'Content',
|
||||
'Performance',
|
||||
'Console',
|
||||
'Accessibility',
|
||||
];
|
||||
for (const cat of expectedCategories) {
|
||||
expect(taxonomy).toMatch(new RegExp(`###\\s+.*${cat}`, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
it('has exploration checklist', () => {
|
||||
expect(taxonomy).toContain('## Exploration Checklist');
|
||||
});
|
||||
|
||||
it('checklist has numbered items', () => {
|
||||
const checklistSection = taxonomy.split('## Exploration Checklist')[1];
|
||||
expect(checklistSection).toBeTruthy();
|
||||
const numberedItems = checklistSection!.match(/^\d+\./gm);
|
||||
expect(numberedItems!.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dogfood skill: cross-consistency', () => {
|
||||
const template = readSkillFile(TEMPLATE_MD);
|
||||
const taxonomy = readSkillFile(TAXONOMY_MD);
|
||||
|
||||
it('every category in template exists in taxonomy', () => {
|
||||
const categoryLine = template
|
||||
.split('\n')
|
||||
.find((l) => l.includes('**Category**'));
|
||||
expect(categoryLine).toBeTruthy();
|
||||
|
||||
const categories = categoryLine!
|
||||
.split('|')
|
||||
.pop()!
|
||||
.split('/')
|
||||
.map((c) => c.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const cat of categories) {
|
||||
expect(
|
||||
taxonomy.toLowerCase(),
|
||||
`Category "${cat}" from template not found in taxonomy`
|
||||
).toMatch(new RegExp(`###\\s+.*${cat}`));
|
||||
}
|
||||
});
|
||||
|
||||
it('every severity in template exists in taxonomy', () => {
|
||||
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
||||
expect(taxonomy.toLowerCase()).toContain(`**${sev}**`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Buggy App - Dogfood Test Fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; color: #333; background: #f9f9f9; }
|
||||
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 20px; }
|
||||
nav { display: flex; gap: 16px; }
|
||||
nav a { color: #ccc; text-decoration: none; }
|
||||
nav a:hover { color: #fff; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
|
||||
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
|
||||
.card h2 { margin-bottom: 12px; }
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-danger { background: #ef4444; color: #fff; }
|
||||
input, textarea { padding: 8px 12px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; width: 100%; margin-bottom: 12px; }
|
||||
label { display: block; margin-bottom: 4px; font-weight: 500; }
|
||||
footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
|
||||
|
||||
/* BUG: Visual - clipped text via overflow: hidden on a short container */
|
||||
.clipped-container {
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 4px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* BUG: Visual - misaligned element */
|
||||
.misaligned {
|
||||
display: flex;
|
||||
align-items: flex-start; /* should be center */
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f0f4ff;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.misaligned .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 14px; /* intentionally off */
|
||||
}
|
||||
.misaligned .label {
|
||||
font-size: 16px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Buggy App</h1>
|
||||
<nav>
|
||||
<a href="#dashboard">Dashboard</a>
|
||||
<a href="#settings">Settings</a>
|
||||
<!-- BUG: Functional - broken link to nonexistent page -->
|
||||
<a href="#/this-page-does-not-exist">Reports</a>
|
||||
<a href="#help">Help</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- BUG: Content - typo "Welocme" -->
|
||||
<h2 style="margin-bottom: 24px;">Welocme to the Dashboard</h2>
|
||||
|
||||
<!-- Card 1: Functional bug - button throws JS error -->
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<p>Perform common tasks from here.</p>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px;">
|
||||
<!-- BUG: Functional - button throws JS error on click -->
|
||||
<button class="btn btn-primary" onclick="processAction()">Run Analysis</button>
|
||||
<button class="btn btn-danger" onclick="deleteAllData()">Delete All Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Visual bugs - clipped text and misaligned element -->
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<!-- BUG: Visual - text is clipped because container is too short -->
|
||||
<div class="clipped-container">
|
||||
The system is currently operating normally. All services are online and responding within expected latency thresholds. Last health check completed at 14:32 UTC.
|
||||
</div>
|
||||
<!-- BUG: Visual - icon and label are misaligned -->
|
||||
<div class="misaligned">
|
||||
<div class="icon"></div>
|
||||
<span class="label">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Content bug - placeholder text -->
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
<!-- BUG: Content - lorem ipsum placeholder left in -->
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: UX bug - form with no feedback on submit -->
|
||||
<div class="card">
|
||||
<h2>Contact Support</h2>
|
||||
<form id="support-form">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" placeholder="Enter subject">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" rows="3" placeholder="Describe your issue"></textarea>
|
||||
<!-- BUG: UX - submit does nothing, no feedback -->
|
||||
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 5: UX bug - empty state with no message -->
|
||||
<div class="card">
|
||||
<h2>Notifications</h2>
|
||||
<!-- BUG: UX - empty container with no empty state message -->
|
||||
<div id="notifications-list" style="min-height: 60px;">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© 2025 Buggy App Inc. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// BUG: Console - error on page load
|
||||
console.error("Failed to initialize analytics: endpoint not configured");
|
||||
|
||||
// BUG: Console - failed fetch on page load
|
||||
fetch("https://api.nonexistent-endpoint.invalid/v1/health")
|
||||
.catch(function() {});
|
||||
|
||||
// BUG: Functional - function referenced by button is broken
|
||||
function processAction() {
|
||||
// Throws because undefinedService is not defined
|
||||
undefinedService.runAnalysis();
|
||||
}
|
||||
|
||||
// No confirmation for destructive action
|
||||
function deleteAllData() {
|
||||
alert("All data deleted!");
|
||||
}
|
||||
|
||||
// Form submit does nothing
|
||||
document.getElementById("support-form").addEventListener("submit", function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -146,9 +146,9 @@ describe('File Access (Issue #345)', () => {
|
||||
const content = await page.locator('h1').textContent();
|
||||
expect(content).toBe('Test File Access');
|
||||
|
||||
// Verify webdriver is hidden (from custom arg)
|
||||
// Verify webdriver is hidden under stealth defaults
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('Launch Options', () => {
|
||||
});
|
||||
|
||||
describe('browser args', () => {
|
||||
it('should launch with custom args to disable webdriver detection', async () => {
|
||||
it('should keep webdriver undefined with custom args under stealth defaults', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
headless: true,
|
||||
@@ -21,9 +21,9 @@ describe('Launch Options', () => {
|
||||
const page = browser.getPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Check that navigator.webdriver is false
|
||||
// Under stealth defaults, webdriver is hidden (undefined)
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should launch with multiple args', async () => {
|
||||
@@ -39,7 +39,7 @@ describe('Launch Options', () => {
|
||||
expect(browser.isLaunched()).toBe(true);
|
||||
});
|
||||
|
||||
it('should launch without args (default behavior)', async () => {
|
||||
it('should launch without args and keep webdriver hidden by default', async () => {
|
||||
browser = new BrowserManager();
|
||||
await browser.launch({
|
||||
headless: true,
|
||||
@@ -48,9 +48,9 @@ describe('Launch Options', () => {
|
||||
const page = browser.getPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Default Playwright behavior - webdriver is true
|
||||
// Stealth default behavior - webdriver is hidden
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(true);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Launch Options', () => {
|
||||
|
||||
// Verify webdriver is hidden
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBe(false);
|
||||
expect(webdriver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.eval.ts'],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user