Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e005c7251b | ||
|
|
11eab471f1 | ||
|
|
aa256e30c7 | ||
|
|
6f1dd39121 | ||
|
|
85d18799a4 | ||
|
|
43e781a8d3 | ||
|
|
25e8719e51 | ||
|
|
ec011f46ff | ||
|
|
aef8fcc038 | ||
|
|
96582b79fd | ||
|
|
058a286326 | ||
|
|
b1f27236d8 | ||
|
|
a5a9327b7d | ||
|
|
699ccbd3cb | ||
|
|
893ddfd259 | ||
|
|
ea2e93dbba | ||
|
|
4c6afe3e69 | ||
|
|
9f9a90cf63 | ||
|
|
0443e4ed7a | ||
|
|
c5b2292caa | ||
|
|
2ed0c6f8ec | ||
|
|
3a91aef4c9 | ||
|
|
02ebc9f328 | ||
|
|
955543b757 | ||
|
|
ecad112707 | ||
|
|
8932f28926 |
@@ -11,6 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build native binaries for all platforms first
|
# Build native binaries for all platforms first
|
||||||
@@ -141,8 +142,8 @@ jobs:
|
|||||||
needs: build-binaries
|
needs: build-binaries
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
published: ${{ steps.changesets.outputs.published }}
|
published: ${{ steps.publish_metadata.outputs.published }}
|
||||||
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
|
publishedPackages: ${{ steps.publish_metadata.outputs.publishedPackages }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -159,7 +160,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -214,12 +214,50 @@ jobs:
|
|||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
version: pnpm ci:version
|
version: pnpm ci:version
|
||||||
publish: pnpm ci:publish
|
|
||||||
title: 'chore: version packages'
|
title: 'chore: version packages'
|
||||||
commit: 'chore: version packages'
|
commit: 'chore: version packages'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
|
||||||
|
- 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
|
# Create GitHub release with binaries after npm publish
|
||||||
github-release:
|
github-release:
|
||||||
|
|||||||
@@ -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
|
# 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
|
## 0.14.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
Generated
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser"
|
name = "agent-browser-stealth"
|
||||||
version = "0.14.0"
|
version = "0.14.0-fork.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
+11
-3
@@ -1,10 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser"
|
name = "agent-browser-stealth"
|
||||||
version = "0.14.0"
|
version = "0.14.0-fork.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "agent-browser"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "agent-browser-stealth"
|
||||||
|
path = "src/main_stealth.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
+190
-37
@@ -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> {
|
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Err(ParseError::MissingArguments {
|
return Err(ParseError::MissingArguments {
|
||||||
@@ -111,10 +167,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
|
let mut nav_cmd = json!({ "id": id, "action": "navigate", "url": url });
|
||||||
// If --headers flag is set, include headers (scoped to this origin)
|
// If --headers flag is set, include headers (scoped to this origin)
|
||||||
if let Some(ref headers_json) = flags.headers {
|
if let Some(ref headers_json) = flags.headers {
|
||||||
let headers = serde_json::from_str::<serde_json::Value>(headers_json)
|
let headers =
|
||||||
.map_err(|_| ParseError::InvalidValue {
|
serde_json::from_str::<serde_json::Value>(headers_json).map_err(|_| {
|
||||||
message: format!("Invalid JSON for --headers: {}", headers_json),
|
ParseError::InvalidValue {
|
||||||
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
|
message: format!("Invalid JSON for --headers: {}", headers_json),
|
||||||
|
usage: "open <url> --headers '{\"Key\": \"Value\"}'",
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
nav_cmd["headers"] = headers;
|
nav_cmd["headers"] = headers;
|
||||||
}
|
}
|
||||||
@@ -124,6 +182,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
nav_cmd["iosDevice"] = json!(device);
|
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)
|
Ok(nav_cmd)
|
||||||
}
|
}
|
||||||
"back" => Ok(json!({ "id": id, "action": "back" })),
|
"back" => Ok(json!({ "id": id, "action": "back" })),
|
||||||
@@ -163,9 +234,18 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
"type" => {
|
"type" => {
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
context: "type".to_string(),
|
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" => {
|
"hover" => {
|
||||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
@@ -270,14 +350,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
})?;
|
})?;
|
||||||
match *sub {
|
match *sub {
|
||||||
"type" => {
|
"type" => {
|
||||||
let text: String = rest[1..].join(" ");
|
let (text, delay) = parse_text_with_optional_delay(
|
||||||
if text.is_empty() {
|
&rest[1..],
|
||||||
return Err(ParseError::MissingArguments {
|
"keyboard type",
|
||||||
context: "keyboard type".to_string(),
|
"keyboard type <text> [--delay <ms>]",
|
||||||
usage: "keyboard type <text>",
|
)?;
|
||||||
});
|
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" => {
|
"inserttext" | "insertText" => {
|
||||||
let text: String = rest[1..].join(" ");
|
let text: String = rest[1..].join(" ");
|
||||||
@@ -287,7 +369,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
usage: "keyboard inserttext <text>",
|
usage: "keyboard inserttext <text>",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }))
|
Ok(
|
||||||
|
json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => Err(ParseError::UnknownSubcommand {
|
_ => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
@@ -422,8 +506,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
return Ok(cmd);
|
return Ok(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: selector or timeout
|
// Default: selector, timeout, or range (e.g. 2000-5000)
|
||||||
if let Some(arg) = rest.first() {
|
if let Some(arg) = rest.first() {
|
||||||
|
// Check for range syntax: "2000-5000"
|
||||||
|
if let Some((min_str, max_str)) = arg.split_once('-') {
|
||||||
|
if let (Ok(min), Ok(max)) = (min_str.parse::<u64>(), max_str.parse::<u64>()) {
|
||||||
|
return Ok(
|
||||||
|
json!({ "id": id, "action": "wait", "timeout": min, "timeoutMax": max }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Ok(timeout) = arg.parse::<u64>() {
|
if let Ok(timeout) = arg.parse::<u64>() {
|
||||||
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +524,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
} else {
|
} else {
|
||||||
Err(ParseError::MissingArguments {
|
Err(ParseError::MissingArguments {
|
||||||
context: "wait".to_string(),
|
context: "wait".to_string(),
|
||||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
usage: "wait <selector|ms|min-max|--url|--load|--fn|--text>",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -965,9 +1057,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
})?;
|
})?;
|
||||||
Ok(json!({ "id": id, "action": "state_load", "path": path }))
|
Ok(json!({ "id": id, "action": "state_load", "path": path }))
|
||||||
}
|
}
|
||||||
Some("list") => {
|
Some("list") => Ok(json!({ "id": id, "action": "state_list" })),
|
||||||
Ok(json!({ "id": id, "action": "state_list" }))
|
|
||||||
}
|
|
||||||
Some("clear") => {
|
Some("clear") => {
|
||||||
let mut session_name: Option<&str> = None;
|
let mut session_name: Option<&str> = None;
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
@@ -988,7 +1078,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
|
|
||||||
if let Some(name) = session_name {
|
if let Some(name) = session_name {
|
||||||
if !is_valid_session_name(name) {
|
if !is_valid_session_name(name) {
|
||||||
return Err(ParseError::InvalidSessionName { name: name.to_string() });
|
return Err(ParseError::InvalidSessionName {
|
||||||
|
name: name.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,13 +1134,19 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
let new_name = new_name.trim_end_matches(".json");
|
let new_name = new_name.trim_end_matches(".json");
|
||||||
|
|
||||||
if !is_valid_session_name(old_name) {
|
if !is_valid_session_name(old_name) {
|
||||||
return Err(ParseError::InvalidSessionName { name: old_name.to_string() });
|
return Err(ParseError::InvalidSessionName {
|
||||||
|
name: old_name.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if !is_valid_session_name(new_name) {
|
if !is_valid_session_name(new_name) {
|
||||||
return Err(ParseError::InvalidSessionName { name: new_name.to_string() });
|
return Err(ParseError::InvalidSessionName {
|
||||||
|
name: new_name.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }))
|
Ok(
|
||||||
|
json!({ "id": id, "action": "state_rename", "oldName": old_name, "newName": new_name }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
@@ -1157,7 +1255,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(ParseError::InvalidValue {
|
return Err(ParseError::InvalidValue {
|
||||||
message: format!("Depth must be a non-negative integer, got: {}", d),
|
message: format!(
|
||||||
|
"Depth must be a non-negative integer, got: {}",
|
||||||
|
d
|
||||||
|
),
|
||||||
usage: "diff snapshot --depth <n>",
|
usage: "diff snapshot --depth <n>",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1324,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
|||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
return Err(ParseError::InvalidValue {
|
return Err(ParseError::InvalidValue {
|
||||||
message: format!("Threshold must be between 0 and 1, got {}", n),
|
message: format!(
|
||||||
|
"Threshold must be between 0 and 1, got {}",
|
||||||
|
n
|
||||||
|
),
|
||||||
usage: "diff screenshot --threshold <0-1>",
|
usage: "diff screenshot --threshold <0-1>",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1340,7 +1444,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseErro
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(ParseError::InvalidValue {
|
return Err(ParseError::InvalidValue {
|
||||||
message: format!("Depth must be a non-negative integer, got: {}", d),
|
message: format!(
|
||||||
|
"Depth must be a non-negative integer, got: {}",
|
||||||
|
d
|
||||||
|
),
|
||||||
usage: "diff url <url1> <url2> --depth <n>",
|
usage: "diff url <url1> <url2> --depth <n>",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1910,7 +2017,6 @@ mod tests {
|
|||||||
executable_path: None,
|
executable_path: None,
|
||||||
extensions: Vec::new(),
|
extensions: Vec::new(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
profile: None,
|
|
||||||
state: None,
|
state: None,
|
||||||
proxy: None,
|
proxy: None,
|
||||||
proxy_bypass: None,
|
proxy_bypass: None,
|
||||||
@@ -1924,7 +2030,6 @@ mod tests {
|
|||||||
session_name: None,
|
session_name: None,
|
||||||
cli_executable_path: false,
|
cli_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
cli_profile: false,
|
|
||||||
cli_state: false,
|
cli_state: false,
|
||||||
cli_args: false,
|
cli_args: false,
|
||||||
cli_user_agent: false,
|
cli_user_agent: false,
|
||||||
@@ -1936,6 +2041,7 @@ mod tests {
|
|||||||
annotate: false,
|
annotate: false,
|
||||||
color_scheme: None,
|
color_scheme: None,
|
||||||
download_path: None,
|
download_path: None,
|
||||||
|
risk_mode: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2201,6 +2307,14 @@ mod tests {
|
|||||||
assert_eq!(cmd["headers"]["Authorization"], "Bearer token");
|
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]
|
#[test]
|
||||||
fn test_navigate_with_multiple_headers() {
|
fn test_navigate_with_multiple_headers() {
|
||||||
let mut flags = default_flags();
|
let mut flags = default_flags();
|
||||||
@@ -2313,6 +2427,29 @@ mod tests {
|
|||||||
assert_eq!(cmd["text"], "some text");
|
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]
|
#[test]
|
||||||
fn test_select() {
|
fn test_select() {
|
||||||
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("select #menu option1"), &default_flags()).unwrap();
|
||||||
@@ -2492,6 +2629,19 @@ mod tests {
|
|||||||
assert_eq!(cmd["selector"], "#element");
|
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]
|
#[test]
|
||||||
fn test_wait_timeout() {
|
fn test_wait_timeout() {
|
||||||
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("wait 5000"), &default_flags()).unwrap();
|
||||||
@@ -3067,8 +3217,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_snapshot_baseline() {
|
fn test_diff_snapshot_baseline() {
|
||||||
let cmd =
|
let cmd = parse_command(
|
||||||
parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap();
|
&args("diff snapshot --baseline before.txt"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(cmd["action"], "diff_snapshot");
|
assert_eq!(cmd["action"], "diff_snapshot");
|
||||||
assert_eq!(cmd["baseline"], "before.txt");
|
assert_eq!(cmd["baseline"], "before.txt");
|
||||||
}
|
}
|
||||||
@@ -3088,9 +3241,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_diff_snapshot_short_flags() {
|
fn test_diff_snapshot_short_flags() {
|
||||||
let cmd =
|
let cmd = parse_command(
|
||||||
parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags())
|
&args("diff snapshot -b snap.txt -s .content -c -d 2"),
|
||||||
.unwrap();
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(cmd["action"], "diff_snapshot");
|
assert_eq!(cmd["action"], "diff_snapshot");
|
||||||
assert_eq!(cmd["baseline"], "snap.txt");
|
assert_eq!(cmd["baseline"], "snap.txt");
|
||||||
assert_eq!(cmd["selector"], ".content");
|
assert_eq!(cmd["selector"], ".content");
|
||||||
@@ -3138,8 +3293,7 @@ mod tests {
|
|||||||
fn test_diff_screenshot_global_full_flag() {
|
fn test_diff_screenshot_global_full_flag() {
|
||||||
let mut flags = default_flags();
|
let mut flags = default_flags();
|
||||||
flags.full = true;
|
flags.full = true;
|
||||||
let cmd =
|
let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
|
||||||
parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap();
|
|
||||||
assert_eq!(cmd["action"], "diff_screenshot");
|
assert_eq!(cmd["action"], "diff_screenshot");
|
||||||
assert_eq!(cmd["fullPage"], true);
|
assert_eq!(cmd["fullPage"], true);
|
||||||
}
|
}
|
||||||
@@ -3183,8 +3337,7 @@ mod tests {
|
|||||||
fn test_diff_url_global_full_flag() {
|
fn test_diff_url_global_full_flag() {
|
||||||
let mut flags = default_flags();
|
let mut flags = default_flags();
|
||||||
flags.full = true;
|
flags.full = true;
|
||||||
let cmd =
|
let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
|
||||||
parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap();
|
|
||||||
assert_eq!(cmd["fullPage"], true);
|
assert_eq!(cmd["fullPage"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -215,11 +215,11 @@ pub fn ensure_daemon(
|
|||||||
proxy_bypass: Option<&str>,
|
proxy_bypass: Option<&str>,
|
||||||
ignore_https_errors: bool,
|
ignore_https_errors: bool,
|
||||||
allow_file_access: bool,
|
allow_file_access: bool,
|
||||||
profile: Option<&str>,
|
|
||||||
state: Option<&str>,
|
state: Option<&str>,
|
||||||
provider: Option<&str>,
|
provider: Option<&str>,
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
session_name: Option<&str>,
|
session_name: Option<&str>,
|
||||||
|
debug: bool,
|
||||||
download_path: Option<&str>,
|
download_path: Option<&str>,
|
||||||
) -> Result<DaemonResult, String> {
|
) -> Result<DaemonResult, String> {
|
||||||
// Check if daemon is running AND responsive
|
// Check if daemon is running AND responsive
|
||||||
@@ -345,10 +345,6 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(prof) = profile {
|
|
||||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(st) = state {
|
if let Some(st) = state {
|
||||||
cmd.env("AGENT_BROWSER_STATE", st);
|
cmd.env("AGENT_BROWSER_STATE", st);
|
||||||
}
|
}
|
||||||
@@ -365,6 +361,10 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||||
|
if debug {
|
||||||
|
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||||
|
}
|
||||||
if let Some(dp) = download_path {
|
if let Some(dp) = download_path {
|
||||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||||
}
|
}
|
||||||
@@ -380,8 +380,8 @@ pub fn ensure_daemon(
|
|||||||
|
|
||||||
cmd.stdin(Stdio::null())
|
cmd.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null());
|
||||||
.spawn()
|
cmd.spawn()
|
||||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,10 +432,6 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(prof) = profile {
|
|
||||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(st) = state {
|
if let Some(st) = state {
|
||||||
cmd.env("AGENT_BROWSER_STATE", st);
|
cmd.env("AGENT_BROWSER_STATE", st);
|
||||||
}
|
}
|
||||||
@@ -452,6 +448,10 @@ pub fn ensure_daemon(
|
|||||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.env("AGENT_BROWSER_STEALTH", "1");
|
||||||
|
if debug {
|
||||||
|
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||||
|
}
|
||||||
if let Some(dp) = download_path {
|
if let Some(dp) = download_path {
|
||||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||||
}
|
}
|
||||||
@@ -463,8 +463,8 @@ pub fn ensure_daemon(
|
|||||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null());
|
||||||
.spawn()
|
cmd.spawn()
|
||||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
-66
@@ -19,7 +19,6 @@ pub struct Config {
|
|||||||
pub session_name: Option<String>,
|
pub session_name: Option<String>,
|
||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub extensions: Option<Vec<String>>,
|
pub extensions: Option<Vec<String>>,
|
||||||
pub profile: Option<String>,
|
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
pub proxy: Option<String>,
|
pub proxy: Option<String>,
|
||||||
pub proxy_bypass: Option<String>,
|
pub proxy_bypass: Option<String>,
|
||||||
@@ -35,6 +34,7 @@ pub struct Config {
|
|||||||
pub annotate: Option<bool>,
|
pub annotate: Option<bool>,
|
||||||
pub color_scheme: Option<String>,
|
pub color_scheme: Option<String>,
|
||||||
pub download_path: Option<String>,
|
pub download_path: Option<String>,
|
||||||
|
pub risk_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -54,7 +54,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
(a, b) => b.or(a),
|
(a, b) => b.or(a),
|
||||||
},
|
},
|
||||||
profile: other.profile.or(self.profile),
|
|
||||||
state: other.state.or(self.state),
|
state: other.state.or(self.state),
|
||||||
proxy: other.proxy.or(self.proxy),
|
proxy: other.proxy.or(self.proxy),
|
||||||
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
|
proxy_bypass: other.proxy_bypass.or(self.proxy_bypass),
|
||||||
@@ -70,6 +69,7 @@ impl Config {
|
|||||||
annotate: other.annotate.or(self.annotate),
|
annotate: other.annotate.or(self.annotate),
|
||||||
color_scheme: other.color_scheme.or(self.color_scheme),
|
color_scheme: other.color_scheme.or(self.color_scheme),
|
||||||
download_path: other.download_path.or(self.download_path),
|
download_path: other.download_path.or(self.download_path),
|
||||||
|
risk_mode: other.risk_mode.or(self.risk_mode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,9 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
|||||||
"--device",
|
"--device",
|
||||||
"--session-name",
|
"--session-name",
|
||||||
"--color-scheme",
|
"--color-scheme",
|
||||||
|
"--channel",
|
||||||
"--download-path",
|
"--download-path",
|
||||||
|
"--risk-mode",
|
||||||
];
|
];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@@ -159,8 +161,7 @@ pub fn load_config(args: &[String]) -> Result<Config, String> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some((source, maybe_path)) = explicit {
|
if let Some((source, maybe_path)) = explicit {
|
||||||
let path_str =
|
let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
|
||||||
maybe_path.ok_or_else(|| format!("{} requires a file path", source))?;
|
|
||||||
let path = PathBuf::from(&path_str);
|
let path = PathBuf::from(&path_str);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(format!("config file not found: {}", path_str));
|
return Err(format!("config file not found: {}", path_str));
|
||||||
@@ -192,7 +193,6 @@ pub struct Flags {
|
|||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub cdp: Option<String>,
|
pub cdp: Option<String>,
|
||||||
pub extensions: Vec<String>,
|
pub extensions: Vec<String>,
|
||||||
pub profile: Option<String>,
|
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
pub proxy: Option<String>,
|
pub proxy: Option<String>,
|
||||||
pub proxy_bypass: Option<String>,
|
pub proxy_bypass: Option<String>,
|
||||||
@@ -207,12 +207,14 @@ pub struct Flags {
|
|||||||
pub annotate: bool,
|
pub annotate: bool,
|
||||||
pub color_scheme: Option<String>,
|
pub color_scheme: Option<String>,
|
||||||
pub download_path: 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
|
// Track which launch-time options were explicitly passed via CLI
|
||||||
// (as opposed to being set only via environment variables)
|
// (as opposed to being set only via environment variables)
|
||||||
pub cli_executable_path: bool,
|
pub cli_executable_path: bool,
|
||||||
pub cli_extensions: bool,
|
pub cli_extensions: bool,
|
||||||
pub cli_profile: bool,
|
|
||||||
pub cli_state: bool,
|
pub cli_state: bool,
|
||||||
pub cli_args: bool,
|
pub cli_args: bool,
|
||||||
pub cli_user_agent: bool,
|
pub cli_user_agent: bool,
|
||||||
@@ -246,55 +248,55 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut flags = Flags {
|
let mut flags = Flags {
|
||||||
json: env_var_is_truthy("AGENT_BROWSER_JSON")
|
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
||||||
|| config.json.unwrap_or(false),
|
full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false),
|
||||||
full: env_var_is_truthy("AGENT_BROWSER_FULL")
|
headed: match env::var("AGENT_BROWSER_HEADED") {
|
||||||
|| config.full.unwrap_or(false),
|
Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""),
|
||||||
headed: env_var_is_truthy("AGENT_BROWSER_HEADED")
|
Err(_) => config.headed.unwrap_or(true),
|
||||||
|| config.headed.unwrap_or(false),
|
},
|
||||||
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG")
|
debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false),
|
||||||
|| config.debug.unwrap_or(false),
|
session: env::var("AGENT_BROWSER_SESSION")
|
||||||
session: env::var("AGENT_BROWSER_SESSION").ok()
|
.ok()
|
||||||
.or(config.session)
|
.or(config.session)
|
||||||
.unwrap_or_else(|| "default".to_string()),
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok()
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
||||||
|
.ok()
|
||||||
.or(config.executable_path),
|
.or(config.executable_path),
|
||||||
cdp: config.cdp,
|
cdp: config.cdp,
|
||||||
extensions,
|
extensions,
|
||||||
profile: env::var("AGENT_BROWSER_PROFILE").ok()
|
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
|
||||||
.or(config.profile),
|
proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy),
|
||||||
state: env::var("AGENT_BROWSER_STATE").ok()
|
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS")
|
||||||
.or(config.state),
|
.ok()
|
||||||
proxy: env::var("AGENT_BROWSER_PROXY").ok()
|
|
||||||
.or(config.proxy),
|
|
||||||
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok()
|
|
||||||
.or(config.proxy_bypass),
|
.or(config.proxy_bypass),
|
||||||
args: env::var("AGENT_BROWSER_ARGS").ok()
|
args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args),
|
||||||
.or(config.args),
|
user_agent: env::var("AGENT_BROWSER_USER_AGENT")
|
||||||
user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok()
|
.ok()
|
||||||
.or(config.user_agent),
|
.or(config.user_agent),
|
||||||
provider: env::var("AGENT_BROWSER_PROVIDER").ok()
|
provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider),
|
||||||
.or(config.provider),
|
|
||||||
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|
ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS")
|
||||||
|| config.ignore_https_errors.unwrap_or(false),
|
|| config.ignore_https_errors.unwrap_or(false),
|
||||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||||
|| config.allow_file_access.unwrap_or(false),
|
|| config.allow_file_access.unwrap_or(false),
|
||||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok()
|
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||||
.or(config.device),
|
|
||||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||||
|| config.auto_connect.unwrap_or(false),
|
|| config.auto_connect.unwrap_or(false),
|
||||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok()
|
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||||
|
.ok()
|
||||||
.or(config.session_name),
|
.or(config.session_name),
|
||||||
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE")
|
annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false),
|
||||||
|| config.annotate.unwrap_or(false),
|
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME")
|
||||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok()
|
.ok()
|
||||||
.or(config.color_scheme),
|
.or(config.color_scheme),
|
||||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
|
||||||
.or(config.download_path),
|
.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_executable_path: false,
|
||||||
cli_extensions: false,
|
cli_extensions: false,
|
||||||
cli_profile: false,
|
|
||||||
cli_state: false,
|
cli_state: false,
|
||||||
cli_args: false,
|
cli_args: false,
|
||||||
cli_user_agent: false,
|
cli_user_agent: false,
|
||||||
@@ -311,22 +313,30 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
"--json" => {
|
"--json" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.json = val;
|
flags.json = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--full" | "-f" => {
|
"--full" | "-f" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.full = val;
|
flags.full = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--headed" => {
|
"--headed" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.headed = val;
|
flags.headed = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--debug" => {
|
"--debug" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.debug = val;
|
flags.debug = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--session" => {
|
"--session" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
@@ -360,13 +370,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--profile" => {
|
|
||||||
if let Some(s) = args.get(i + 1) {
|
|
||||||
flags.profile = Some(s.clone());
|
|
||||||
flags.cli_profile = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--state" => {
|
"--state" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
flags.state = Some(s.clone());
|
flags.state = Some(s.clone());
|
||||||
@@ -411,13 +414,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
"--ignore-https-errors" => {
|
"--ignore-https-errors" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.ignore_https_errors = val;
|
flags.ignore_https_errors = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--allow-file-access" => {
|
"--allow-file-access" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.allow_file_access = val;
|
flags.allow_file_access = val;
|
||||||
flags.cli_allow_file_access = true;
|
flags.cli_allow_file_access = true;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--device" => {
|
"--device" => {
|
||||||
if let Some(d) = args.get(i + 1) {
|
if let Some(d) = args.get(i + 1) {
|
||||||
@@ -428,7 +435,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
"--auto-connect" => {
|
"--auto-connect" => {
|
||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.auto_connect = val;
|
flags.auto_connect = val;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--session-name" => {
|
"--session-name" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
@@ -440,7 +449,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
let (val, consumed) = parse_bool_arg(args, i);
|
let (val, consumed) = parse_bool_arg(args, i);
|
||||||
flags.annotate = val;
|
flags.annotate = val;
|
||||||
flags.cli_annotate = true;
|
flags.cli_annotate = true;
|
||||||
if consumed { i += 1; }
|
if consumed {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--color-scheme" => {
|
"--color-scheme" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
@@ -455,6 +466,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--risk-mode" => {
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
flags.risk_mode = Some(s.to_ascii_lowercase());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
"--config" => {
|
"--config" => {
|
||||||
// Already handled by load_config(); skip the value
|
// Already handled by load_config(); skip the value
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -488,7 +505,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--executable-path",
|
"--executable-path",
|
||||||
"--cdp",
|
"--cdp",
|
||||||
"--extension",
|
"--extension",
|
||||||
"--profile",
|
|
||||||
"--state",
|
"--state",
|
||||||
"--proxy",
|
"--proxy",
|
||||||
"--proxy-bypass",
|
"--proxy-bypass",
|
||||||
@@ -500,6 +516,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
"--session-name",
|
"--session-name",
|
||||||
"--color-scheme",
|
"--color-scheme",
|
||||||
"--download-path",
|
"--download-path",
|
||||||
|
"--risk-mode",
|
||||||
"--config",
|
"--config",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -671,12 +688,6 @@ mod tests {
|
|||||||
assert!(flags.cli_extensions);
|
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]
|
#[test]
|
||||||
fn test_cli_annotate_tracking() {
|
fn test_cli_annotate_tracking() {
|
||||||
let flags = parse_flags(&args("--annotate screenshot"));
|
let flags = parse_flags(&args("--annotate screenshot"));
|
||||||
@@ -703,13 +714,24 @@ mod tests {
|
|||||||
assert!(!flags.cli_download_path);
|
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]
|
#[test]
|
||||||
fn test_cli_multiple_flags_tracking() {
|
fn test_cli_multiple_flags_tracking() {
|
||||||
let flags = parse_flags(&args(
|
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_executable_path);
|
||||||
assert!(flags.cli_profile);
|
|
||||||
assert!(flags.cli_proxy);
|
assert!(flags.cli_proxy);
|
||||||
assert!(!flags.cli_extensions);
|
assert!(!flags.cli_extensions);
|
||||||
assert!(!flags.cli_state);
|
assert!(!flags.cli_state);
|
||||||
@@ -728,7 +750,6 @@ mod tests {
|
|||||||
"sessionName": "my-app",
|
"sessionName": "my-app",
|
||||||
"executablePath": "/usr/bin/chromium",
|
"executablePath": "/usr/bin/chromium",
|
||||||
"extensions": ["/ext1", "/ext2"],
|
"extensions": ["/ext1", "/ext2"],
|
||||||
"profile": "/tmp/profile",
|
|
||||||
"state": "/tmp/state.json",
|
"state": "/tmp/state.json",
|
||||||
"proxy": "http://proxy:8080",
|
"proxy": "http://proxy:8080",
|
||||||
"proxyBypass": "localhost",
|
"proxyBypass": "localhost",
|
||||||
@@ -740,7 +761,8 @@ mod tests {
|
|||||||
"allowFileAccess": true,
|
"allowFileAccess": true,
|
||||||
"cdp": "9222",
|
"cdp": "9222",
|
||||||
"autoConnect": true,
|
"autoConnect": true,
|
||||||
"headers": "{\"Auth\":\"token\"}"
|
"headers": "{\"Auth\":\"token\"}",
|
||||||
|
"riskMode": "block"
|
||||||
}"#;
|
}"#;
|
||||||
let config: Config = serde_json::from_str(json).unwrap();
|
let config: Config = serde_json::from_str(json).unwrap();
|
||||||
assert_eq!(config.headed, Some(true));
|
assert_eq!(config.headed, Some(true));
|
||||||
@@ -750,8 +772,10 @@ mod tests {
|
|||||||
assert_eq!(config.session.as_deref(), Some("test-session"));
|
assert_eq!(config.session.as_deref(), Some("test-session"));
|
||||||
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
assert_eq!(config.session_name.as_deref(), Some("my-app"));
|
||||||
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
|
assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium"));
|
||||||
assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()]));
|
assert_eq!(
|
||||||
assert_eq!(config.profile.as_deref(), Some("/tmp/profile"));
|
config.extensions,
|
||||||
|
Some(vec!["/ext1".to_string(), "/ext2".to_string()])
|
||||||
|
);
|
||||||
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
|
assert_eq!(config.state.as_deref(), Some("/tmp/state.json"));
|
||||||
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
|
assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080"));
|
||||||
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
|
assert_eq!(config.proxy_bypass.as_deref(), Some("localhost"));
|
||||||
@@ -764,6 +788,7 @@ mod tests {
|
|||||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||||
assert_eq!(config.auto_connect, Some(true));
|
assert_eq!(config.auto_connect, Some(true));
|
||||||
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
assert_eq!(config.headers.as_deref(), Some("{\"Auth\":\"token\"}"));
|
||||||
|
assert_eq!(config.risk_mode.as_deref(), Some("block"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -797,7 +822,6 @@ mod tests {
|
|||||||
let user = Config {
|
let user = Config {
|
||||||
headed: Some(true),
|
headed: Some(true),
|
||||||
proxy: Some("http://user-proxy:8080".to_string()),
|
proxy: Some("http://user-proxy:8080".to_string()),
|
||||||
profile: Some("/user/profile".to_string()),
|
|
||||||
..Config::default()
|
..Config::default()
|
||||||
};
|
};
|
||||||
let project = Config {
|
let project = Config {
|
||||||
@@ -808,7 +832,6 @@ mod tests {
|
|||||||
let merged = user.merge(project);
|
let merged = user.merge(project);
|
||||||
assert_eq!(merged.headed, Some(true)); // kept from user
|
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.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
|
assert_eq!(merged.debug, Some(true)); // added by project
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1082,11 @@ mod tests {
|
|||||||
let merged = user.merge(project);
|
let merged = user.merge(project);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merged.extensions,
|
merged.extensions,
|
||||||
Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()])
|
Some(vec![
|
||||||
|
"/ext1".to_string(),
|
||||||
|
"/ext2".to_string(),
|
||||||
|
"/ext3".to_string()
|
||||||
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+136
-40
@@ -153,6 +153,62 @@ fn main() {
|
|||||||
return;
|
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() {
|
if clean.is_empty() {
|
||||||
print_help();
|
print_help();
|
||||||
return;
|
return;
|
||||||
@@ -221,11 +277,11 @@ fn main() {
|
|||||||
flags.proxy_bypass.as_deref(),
|
flags.proxy_bypass.as_deref(),
|
||||||
flags.ignore_https_errors,
|
flags.ignore_https_errors,
|
||||||
flags.allow_file_access,
|
flags.allow_file_access,
|
||||||
flags.profile.as_deref(),
|
|
||||||
flags.state.as_deref(),
|
flags.state.as_deref(),
|
||||||
flags.provider.as_deref(),
|
flags.provider.as_deref(),
|
||||||
flags.device.as_deref(),
|
flags.device.as_deref(),
|
||||||
flags.session_name.as_deref(),
|
flags.session_name.as_deref(),
|
||||||
|
flags.debug,
|
||||||
flags.download_path.as_deref(),
|
flags.download_path.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@@ -254,11 +310,6 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
if flags.cli_profile {
|
|
||||||
Some("--profile")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
if flags.cli_state {
|
if flags.cli_state {
|
||||||
Some("--state")
|
Some("--state")
|
||||||
} else {
|
} else {
|
||||||
@@ -489,38 +540,88 @@ fn main() {
|
|||||||
launch_cmd["colorScheme"] = json!(cs);
|
launch_cmd["colorScheme"] = json!(cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
let err = match send_command(launch_cmd, &flags.session) {
|
match send_command(launch_cmd, &flags.session) {
|
||||||
Ok(resp) if resp.success => None,
|
Ok(resp) => {
|
||||||
Ok(resp) => Some(
|
if !resp.success {
|
||||||
resp.error
|
let msg = resp
|
||||||
.unwrap_or_else(|| "Provider connection failed".to_string()),
|
.error
|
||||||
),
|
.unwrap_or_else(|| "Provider connection failed".to_string());
|
||||||
Err(e) => Some(e.to_string()),
|
if flags.json {
|
||||||
};
|
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
||||||
|
} else {
|
||||||
if let Some(msg) = err {
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
if flags.json {
|
}
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
|
exit(1);
|
||||||
} else {
|
}
|
||||||
eprintln!("{} {}", color::error_indicator(), msg);
|
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if flags.json {
|
||||||
|
println!(r#"{{"success":false,"error":"{}"}}"#, e);
|
||||||
|
} else {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Launch headed browser or configure browser options (without CDP or provider)
|
||||||
if (flags.headed
|
if (flags.headed
|
||||||
|| flags.executable_path.is_some()
|
|| flags.executable_path.is_some()
|
||||||
|| flags.profile.is_some()
|
|
||||||
|| flags.state.is_some()
|
|| flags.state.is_some()
|
||||||
|| flags.proxy.is_some()
|
|| flags.proxy.is_some()
|
||||||
|| flags.args.is_some()
|
|| flags.args.is_some()
|
||||||
|| flags.user_agent.is_some()
|
|| flags.user_agent.is_some()
|
||||||
|
|| flags.ignore_https_errors
|
||||||
|| flags.allow_file_access
|
|| flags.allow_file_access
|
||||||
|
|| flags.debug
|
||||||
|| flags.color_scheme.is_some()
|
|| flags.color_scheme.is_some()
|
||||||
|| flags.download_path.is_some())
|
|| flags.download_path.is_some())
|
||||||
&& flags.cdp.is_none()
|
&& flags.cdp.is_none()
|
||||||
&& flags.provider.is_none()
|
&& flags.provider.is_none()
|
||||||
|
&& !launched_via_default_cdp
|
||||||
{
|
{
|
||||||
let mut launch_cmd = json!({
|
let mut launch_cmd = json!({
|
||||||
"id": gen_id(),
|
"id": gen_id(),
|
||||||
@@ -537,11 +638,6 @@ fn main() {
|
|||||||
cmd_obj.insert("executablePath".to_string(), json!(exec_path));
|
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
|
// Add state path if specified
|
||||||
if let Some(ref state_path) = flags.state {
|
if let Some(ref state_path) = flags.state {
|
||||||
cmd_obj.insert("storageState".to_string(), json!(state_path));
|
cmd_obj.insert("storageState".to_string(), json!(state_path));
|
||||||
@@ -589,17 +685,20 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match send_command(launch_cmd, &flags.session) {
|
match send_command(launch_cmd, &flags.session) {
|
||||||
Ok(resp) if !resp.success => {
|
Ok(resp) => {
|
||||||
// Launch command failed (e.g., invalid state file, profile error)
|
if !resp.success {
|
||||||
let error_msg = resp
|
// Launch command failed (e.g., invalid state file)
|
||||||
.error
|
let error_msg = resp
|
||||||
.unwrap_or_else(|| "Browser launch failed".to_string());
|
.error
|
||||||
if flags.json {
|
.unwrap_or_else(|| "Browser launch failed".to_string());
|
||||||
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
|
if flags.json {
|
||||||
} else {
|
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
|
||||||
eprintln!("{} {}", color::error_indicator(), error_msg);
|
} else {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), error_msg);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if flags.json {
|
if flags.json {
|
||||||
@@ -613,9 +712,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
|
||||||
// Launch succeeded
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
include!("main.rs");
|
||||||
+132
-37
@@ -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()) {
|
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||||
println!("{} {}", color::success_indicator(), color::bold(title));
|
println!("{} {}", color::success_indicator(), color::bold(title));
|
||||||
println!(" {}", color::dim(url));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
println!("{}", url);
|
println!("{}", url);
|
||||||
@@ -39,15 +69,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Some("diff_url") => {
|
Some("diff_url") => {
|
||||||
if let Some(snap_data) =
|
if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) {
|
||||||
obj.get("snapshot").and_then(|v| v.as_object())
|
|
||||||
{
|
|
||||||
println!("{}", color::bold("Snapshot diff:"));
|
println!("{}", color::bold("Snapshot diff:"));
|
||||||
print_snapshot_diff(snap_data);
|
print_snapshot_diff(snap_data);
|
||||||
}
|
}
|
||||||
if let Some(ss_data) =
|
if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) {
|
||||||
obj.get("screenshot").and_then(|v| v.as_object())
|
|
||||||
{
|
|
||||||
println!("\n{}", color::bold("Screenshot diff:"));
|
println!("\n{}", color::bold("Screenshot diff:"));
|
||||||
print_screenshot_diff(ss_data);
|
print_screenshot_diff(ss_data);
|
||||||
}
|
}
|
||||||
@@ -310,11 +336,7 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||||
println!(
|
println!("{} Recording started: {}", color::success_indicator(), path);
|
||||||
"{} Recording started: {}",
|
|
||||||
color::success_indicator(),
|
|
||||||
path
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!("{} Recording started", color::success_indicator());
|
println!("{} Recording started", color::success_indicator());
|
||||||
}
|
}
|
||||||
@@ -497,7 +519,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
|
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
|
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
|
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
let encrypted = file
|
||||||
|
.get("encrypted")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
let size_str = if size > 1024 {
|
let size_str = if size > 1024 {
|
||||||
format!("{:.1}KB", size as f64 / 1024.0)
|
format!("{:.1}KB", size as f64 / 1024.0)
|
||||||
} else {
|
} else {
|
||||||
@@ -505,7 +530,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
};
|
};
|
||||||
let date_str = modified.split('T').next().unwrap_or(modified);
|
let date_str = modified.split('T').next().unwrap_or(modified);
|
||||||
let enc_str = if encrypted { " [encrypted]" } else { "" };
|
let enc_str = if encrypted { " [encrypted]" } else { "" };
|
||||||
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
filename,
|
||||||
|
color::dim(&format!("({}, {}){}", size_str, date_str, enc_str))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -515,13 +544,22 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
|
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
|
||||||
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
|
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
|
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
|
println!(
|
||||||
|
"{} Renamed {} -> {}",
|
||||||
|
color::success_indicator(),
|
||||||
|
old_name,
|
||||||
|
new_name
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State clear
|
// State clear
|
||||||
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
|
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
|
||||||
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
|
println!(
|
||||||
|
"{} Cleared {} state file(s)",
|
||||||
|
color::success_indicator(),
|
||||||
|
cleared
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +567,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
if let Some(summary) = data.get("summary") {
|
if let Some(summary) = data.get("summary") {
|
||||||
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
|
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
|
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
let encrypted = data
|
||||||
|
.get("encrypted")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
let enc_str = if encrypted { " (encrypted)" } else { "" };
|
let enc_str = if encrypted { " (encrypted)" } else { "" };
|
||||||
println!("State file summary{}:", enc_str);
|
println!("State file summary{}:", enc_str);
|
||||||
println!(" Cookies: {}", cookies);
|
println!(" Cookies: {}", cookies);
|
||||||
@@ -539,7 +580,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
|
|||||||
|
|
||||||
// State clean
|
// State clean
|
||||||
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
|
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
|
||||||
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
|
println!(
|
||||||
|
"{} Cleaned {} old state file(s)",
|
||||||
|
color::success_indicator(),
|
||||||
|
cleaned
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,10 +617,12 @@ Global Options:
|
|||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--headers <json> Set HTTP headers (scoped to this origin)
|
--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
|
--headed Show browser window
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser open example.com
|
agent-browser open example.com
|
||||||
|
agent-browser --risk-mode block open example.com
|
||||||
agent-browser open https://github.com
|
agent-browser open https://github.com
|
||||||
agent-browser open localhost:3000
|
agent-browser open localhost:3000
|
||||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
agent-browser open api.example.com --headers '{"Authorization": "Bearer token"}'
|
||||||
@@ -701,10 +748,11 @@ Examples:
|
|||||||
r##"
|
r##"
|
||||||
agent-browser type - Type text into an element
|
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.
|
Types text into the specified element character by character.
|
||||||
Unlike fill, this does not clear existing content first.
|
Unlike fill, this does not clear existing content first.
|
||||||
|
Use --delay to add per-character delay (milliseconds).
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -712,7 +760,9 @@ Global Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser type "#search" "hello"
|
agent-browser type "#search" "hello"
|
||||||
|
agent-browser type "#search" "iphone" --delay 120
|
||||||
agent-browser type @e2 "additional text"
|
agent-browser type @e2 "additional text"
|
||||||
|
agent-browser type @e2 -- "--delay 120 (literal text)"
|
||||||
|
|
||||||
See Also:
|
See Also:
|
||||||
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
|
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
|
||||||
@@ -943,7 +993,7 @@ the current focus — essential for contenteditable editors like
|
|||||||
Lexical, ProseMirror, CodeMirror, and Monaco.
|
Lexical, ProseMirror, CodeMirror, and Monaco.
|
||||||
|
|
||||||
Subcommands:
|
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)
|
key events (keydown, keypress, keyup per char)
|
||||||
inserttext <text> Insert text without key events (like paste)
|
inserttext <text> Insert text without key events (like paste)
|
||||||
|
|
||||||
@@ -956,6 +1006,7 @@ Global Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser keyboard type "Hello, World!"
|
agent-browser keyboard type "Hello, World!"
|
||||||
|
agent-browser keyboard type "human pacing" --delay 90
|
||||||
agent-browser keyboard type "# My Heading"
|
agent-browser keyboard type "# My Heading"
|
||||||
agent-browser keyboard inserttext "pasted content"
|
agent-browser keyboard inserttext "pasted content"
|
||||||
|
|
||||||
@@ -1021,13 +1072,14 @@ Examples:
|
|||||||
r##"
|
r##"
|
||||||
agent-browser wait - Wait for condition
|
agent-browser wait - Wait for condition
|
||||||
|
|
||||||
Usage: agent-browser wait <selector|ms|option>
|
Usage: agent-browser wait <selector|ms|min-max|option>
|
||||||
|
|
||||||
Waits for an element to appear, a timeout, or other conditions.
|
Waits for an element to appear, a timeout, or other conditions.
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
<selector> Wait for element to appear
|
<selector> Wait for element to appear
|
||||||
<ms> Wait for specified milliseconds
|
<ms> Wait for specified milliseconds
|
||||||
|
<min>-<max> Wait for random time between min and max ms
|
||||||
--url <pattern> Wait for URL to match pattern
|
--url <pattern> Wait for URL to match pattern
|
||||||
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
--load <state> Wait for load state (load, domcontentloaded, networkidle)
|
||||||
--fn <expression> Wait for JavaScript expression to be truthy
|
--fn <expression> Wait for JavaScript expression to be truthy
|
||||||
@@ -1044,6 +1096,7 @@ Global Options:
|
|||||||
Examples:
|
Examples:
|
||||||
agent-browser wait "#loading-spinner"
|
agent-browser wait "#loading-spinner"
|
||||||
agent-browser wait 2000
|
agent-browser wait 2000
|
||||||
|
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||||
agent-browser wait --url "**/dashboard"
|
agent-browser wait --url "**/dashboard"
|
||||||
agent-browser wait --load networkidle
|
agent-browser wait --load networkidle
|
||||||
agent-browser wait --fn "window.appReady === true"
|
agent-browser wait --fn "window.appReady === true"
|
||||||
@@ -2000,10 +2053,10 @@ Core Commands:
|
|||||||
open <url> Navigate to URL
|
open <url> Navigate to URL
|
||||||
click <sel> Click element (or @ref)
|
click <sel> Click element (or @ref)
|
||||||
dblclick <sel> Double-click element
|
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
|
fill <sel> <text> Clear and fill
|
||||||
press <key> Press key (Enter, Tab, Control+a)
|
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
|
keyboard inserttext <text> Insert text without key events
|
||||||
hover <sel> Hover element
|
hover <sel> Hover element
|
||||||
focus <sel> Focus element
|
focus <sel> Focus element
|
||||||
@@ -2015,7 +2068,7 @@ Core Commands:
|
|||||||
download <sel> <path> Download file by clicking element
|
download <sel> <path> Download file by clicking element
|
||||||
scroll <dir> [px] Scroll (up/down/left/right)
|
scroll <dir> [px] Scroll (up/down/left/right)
|
||||||
scrollintoview <sel> Scroll element into view
|
scrollintoview <sel> Scroll element into view
|
||||||
wait <sel|ms> Wait for element or time
|
wait <sel|ms|min-max> Wait for element, time, or random range
|
||||||
screenshot [path] Take screenshot
|
screenshot [path] Take screenshot
|
||||||
pdf <path> Save as PDF
|
pdf <path> Save as PDF
|
||||||
snapshot Accessibility tree with refs (for AI)
|
snapshot Accessibility tree with refs (for AI)
|
||||||
@@ -2087,7 +2140,6 @@ Snapshot Options:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--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)
|
--state <path> Load storage state from JSON file (or AGENT_BROWSER_STATE env)
|
||||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
@@ -2109,12 +2161,19 @@ Options:
|
|||||||
--headed Show browser window (not headless)
|
--headed Show browser window (not headless)
|
||||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||||
--auto-connect Auto-discover and connect to running Chrome
|
--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)
|
--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)
|
--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)
|
--session-name <name> Auto-save/restore session state (cookies, localStorage)
|
||||||
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
|
||||||
--debug Debug output
|
--debug Debug output
|
||||||
--version, -V Show version
|
--version, -V Show version (fork builds include upstream/fork info)
|
||||||
|
|
||||||
|
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:
|
Configuration:
|
||||||
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
|
||||||
@@ -2133,7 +2192,7 @@ Configuration:
|
|||||||
Extensions from user and project configs are merged (not replaced).
|
Extensions from user and project configs are merged (not replaced).
|
||||||
|
|
||||||
Example agent-browser.json:
|
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:
|
Environment:
|
||||||
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
AGENT_BROWSER_CONFIG Path to config file (or use --config)
|
||||||
@@ -2152,8 +2211,12 @@ Environment:
|
|||||||
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
|
||||||
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
|
||||||
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
|
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
|
||||||
|
|
||||||
|
AGENT_BROWSER_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_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
|
||||||
AGENT_BROWSER_DOWNLOAD_PATH Default download directory for browser downloads
|
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_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
|
||||||
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
|
||||||
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30)
|
||||||
@@ -2163,11 +2226,11 @@ Environment:
|
|||||||
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
AGENT_BROWSER_IOS_UDID Default iOS device UDID
|
||||||
|
|
||||||
Install (recommended, fastest - native Rust CLI):
|
Install (recommended, fastest - native Rust CLI):
|
||||||
npm install -g agent-browser
|
npm install -g agent-browser-stealth
|
||||||
agent-browser install # Download Chromium (first time)
|
agent-browser install # Download Chromium (first time)
|
||||||
|
|
||||||
Try without installing (slower, routes through Node.js):
|
Try without installing (slower, routes through Node.js):
|
||||||
npx agent-browser open example.com
|
npx agent-browser-stealth open example.com
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser open example.com
|
agent-browser open example.com
|
||||||
@@ -2182,7 +2245,7 @@ Examples:
|
|||||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||||
agent-browser --color-scheme dark open example.com # Dark mode
|
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
|
agent-browser --session-name myapp open example.com # Auto-save/restore state
|
||||||
|
|
||||||
Command Chaining:
|
Command Chaining:
|
||||||
@@ -2238,10 +2301,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
|||||||
.get("mismatchPercentage")
|
.get("mismatchPercentage")
|
||||||
.and_then(|v| v.as_f64())
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let is_match = data
|
let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
.get("match")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
let dim_mismatch = data
|
let dim_mismatch = data
|
||||||
.get("dimensionMismatch")
|
.get("dimensionMismatch")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@@ -2252,7 +2312,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
|||||||
color::error_indicator()
|
color::error_indicator()
|
||||||
);
|
);
|
||||||
} else if is_match {
|
} else if is_match {
|
||||||
println!("{} Images match (0% difference)", color::success_indicator());
|
println!(
|
||||||
|
"{} Images match (0% difference)",
|
||||||
|
color::success_indicator()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{} {:.2}% pixels differ",
|
"{} {:.2}% pixels differ",
|
||||||
@@ -2263,7 +2326,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
|||||||
if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
|
if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
|
||||||
println!(" Diff image: {}", color::green(diff_path));
|
println!(" Diff image: {}", color::green(diff_path));
|
||||||
}
|
}
|
||||||
let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0);
|
let total = data
|
||||||
|
.get("totalPixels")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
let different = data
|
let different = data
|
||||||
.get("differentPixels")
|
.get("differentPixels")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
@@ -2275,6 +2341,35 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_version() {
|
/// Parse fork version metadata from semver-like strings:
|
||||||
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
/// <upstream>-fork.<fork>
|
||||||
|
/// Example:
|
||||||
|
/// 0.14.0-fork.1 -> (0.14.0, 1)
|
||||||
|
fn parse_fork_version(version: &str) -> Option<(&str, &str)> {
|
||||||
|
let (upstream, fork) = version.split_once("-fork.")?;
|
||||||
|
if upstream.is_empty() || fork.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !upstream
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((upstream, fork))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_version() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
if let Some((upstream, fork)) = parse_fork_version(version) {
|
||||||
|
println!(
|
||||||
|
"agent-browser {} (upstream {}, fork {})",
|
||||||
|
version, upstream, fork
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("agent-browser {}", version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
|
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
|
||||||
pub fn is_valid_session_name(name: &str) -> bool {
|
pub fn is_valid_session_name(name: &str) -> bool {
|
||||||
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
!name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate error message for invalid session name
|
/// Generate error message for invalid session name
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
|||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
|
const SYSTEM_PROMPT = `You are a helpful documentation assistant for agent-browser, a headless browser automation CLI designed for AI agents.
|
||||||
|
|
||||||
GitHub repository: https://github.com/vercel-labs/agent-browser
|
GitHub repository: https://github.com/leeguooooo/agent-browser
|
||||||
Documentation: https://agent-browser.dev
|
Documentation: https://agent-browser.dev
|
||||||
npm package: agent-browser
|
npm package: agent-browser-stealth
|
||||||
|
|
||||||
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
|
You have access to the full agent-browser documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/ directory.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export const metadata = pageMetadata("cdp-mode")
|
|||||||
|
|
||||||
Connect to an existing browser via Chrome DevTools Protocol:
|
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
|
```bash
|
||||||
# Start Chrome with: google-chrome --remote-debugging-port=9222
|
# 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:
|
Auto-connect discovers Chrome by:
|
||||||
|
|
||||||
1. Reading Chrome's `DevToolsActivePort` file from the default user data directory
|
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:
|
This is useful when:
|
||||||
|
|
||||||
@@ -75,6 +82,23 @@ Or set it globally via config or environment variable:
|
|||||||
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
|
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stealth behavior
|
||||||
|
|
||||||
|
`--stealth` is enabled by default across connection modes, but capabilities depend on how you connect:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Local launch</td><td>Chromium launch args + context init scripts</td></tr>
|
||||||
|
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
|
||||||
|
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Use `--debug` to print the active connection type and applied stealth capabilities.
|
||||||
|
|
||||||
## Use cases
|
## Use cases
|
||||||
|
|
||||||
This enables control of:
|
This enables control of:
|
||||||
@@ -93,7 +117,6 @@ This enables control of:
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>--session <name></code></td><td>Use isolated session</td></tr>
|
<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>-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>--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>
|
<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
|
# Commands
|
||||||
|
|
||||||
@@ -8,12 +8,13 @@ export const metadata = pageMetadata("commands")
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
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 click <sel> # Click element (--new-tab to open in new tab)
|
||||||
agent-browser dblclick <sel> # Double-click
|
agent-browser dblclick <sel> # Double-click
|
||||||
agent-browser fill <sel> <text> # Clear and fill
|
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 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 keyboard inserttext <text> # Insert text without key events
|
||||||
agent-browser keydown <key> # Hold key down
|
agent-browser keydown <key> # Hold key down
|
||||||
agent-browser keyup <key> # Release key
|
agent-browser keyup <key> # Release key
|
||||||
@@ -32,9 +33,16 @@ agent-browser pdf <path> # Save page as PDF
|
|||||||
agent-browser snapshot # Accessibility tree with refs
|
agent-browser snapshot # Accessibility tree with refs
|
||||||
agent-browser eval <js> # Run JavaScript
|
agent-browser eval <js> # Run JavaScript
|
||||||
agent-browser connect <port|url> # Connect to browser via CDP
|
agent-browser connect <port|url> # Connect to browser via CDP
|
||||||
|
agent-browser --version # Show CLI version
|
||||||
agent-browser close # Close browser (aliases: quit, exit)
|
agent-browser close # Close browser (aliases: quit, exit)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fork builds print dual-version metadata with `--version`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1)
|
||||||
|
```
|
||||||
|
|
||||||
## Get info
|
## Get info
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -95,6 +103,7 @@ agent-browser find nth 2 ".card" hover
|
|||||||
```bash
|
```bash
|
||||||
agent-browser wait <selector> # Wait for element
|
agent-browser wait <selector> # Wait for element
|
||||||
agent-browser wait <ms> # Wait for time
|
agent-browser wait <ms> # Wait for time
|
||||||
|
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||||
agent-browser wait --text "Welcome" # Wait for text
|
agent-browser wait --text "Welcome" # Wait for text
|
||||||
agent-browser wait --url "**/dash" # Wait for URL pattern
|
agent-browser wait --url "**/dash" # Wait for URL pattern
|
||||||
agent-browser wait --load networkidle # Wait for load state
|
agent-browser wait --load networkidle # Wait for load state
|
||||||
@@ -102,6 +111,16 @@ agent-browser wait --fn "condition" # Wait for JS condition
|
|||||||
agent-browser wait --download [path] # Wait for download
|
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
|
## Downloads
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -234,7 +253,6 @@ agent-browser reload # Reload page
|
|||||||
```bash
|
```bash
|
||||||
--session <name> # Isolated browser session
|
--session <name> # Isolated browser session
|
||||||
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
--session-name <name> # Auto-save/restore session state (cookies, localStorage)
|
||||||
--profile <path> # Persistent browser profile directory
|
|
||||||
--state <path> # Load storage state from JSON file
|
--state <path> # Load storage state from JSON file
|
||||||
--headers <json> # HTTP headers scoped to URL's origin
|
--headers <json> # HTTP headers scoped to URL's origin
|
||||||
--executable-path <path> # Custom browser executable
|
--executable-path <path> # Custom browser executable
|
||||||
@@ -245,6 +263,7 @@ agent-browser reload # Reload page
|
|||||||
--proxy-bypass <hosts> # Hosts to bypass proxy
|
--proxy-bypass <hosts> # Hosts to bypass proxy
|
||||||
--ignore-https-errors # Ignore HTTPS certificate errors
|
--ignore-https-errors # Ignore HTTPS certificate errors
|
||||||
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
|
--allow-file-access # Allow file:// URLs to access local files (Chromium only)
|
||||||
|
--stealth # Stealth mode (always on by default)
|
||||||
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
|
-p, --provider <name> # Browser provider (ios, browserbase, kernel, browseruse)
|
||||||
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
|
--device <name> # iOS device name (e.g., "iPhone 15 Pro")
|
||||||
--json # JSON output (for scripts)
|
--json # JSON output (for scripts)
|
||||||
@@ -253,7 +272,7 @@ agent-browser reload # Reload page
|
|||||||
--headed # Show browser window (not headless)
|
--headed # Show browser window (not headless)
|
||||||
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
|
--cdp <port|url> # Connect via Chrome DevTools Protocol (port or WebSocket URL)
|
||||||
--auto-connect # Auto-discover and connect to running Chrome
|
--auto-connect # Auto-discover and connect to running Chrome
|
||||||
--debug # Debug output
|
--debug # Debug output (includes stealth connection type + capabilities)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Command chaining
|
## Command chaining
|
||||||
|
|||||||
@@ -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
|
# Configuration
|
||||||
|
|
||||||
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
|
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
|
## Config File Locations
|
||||||
|
|
||||||
agent-browser checks two locations, merged in priority order:
|
agent-browser checks two locations, merged in priority order:
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
|
<tr>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
|
<tr>
|
||||||
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
|
<td>1 (lowest)</td>
|
||||||
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
|
<td>
|
||||||
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -37,7 +65,6 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com
|
|||||||
{
|
{
|
||||||
"headed": true,
|
"headed": true,
|
||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080",
|
||||||
"profile": "./browser-data",
|
|
||||||
"userAgent": "my-agent/1.0",
|
"userAgent": "my-agent/1.0",
|
||||||
"ignoreHttpsErrors": true
|
"ignoreHttpsErrors": true
|
||||||
}
|
}
|
||||||
@@ -49,35 +76,229 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
|
<tr>
|
||||||
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
|
<td>
|
||||||
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
|
<code>headed</code>
|
||||||
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
</td>
|
||||||
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
<td>
|
||||||
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
<code>--headed</code>
|
||||||
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
</td>
|
||||||
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
<td>boolean</td>
|
||||||
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
|
</tr>
|
||||||
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
|
<tr>
|
||||||
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
|
<td>
|
||||||
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
|
<code>json</code>
|
||||||
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
|
</td>
|
||||||
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
|
<td>
|
||||||
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
|
<code>--json</code>
|
||||||
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
|
</td>
|
||||||
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
|
<td>boolean</td>
|
||||||
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
|
</tr>
|
||||||
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
|
<tr>
|
||||||
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
|
<td>
|
||||||
<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>
|
<code>full</code>
|
||||||
<tr><td><code>downloadPath</code></td><td><code>--download-path</code></td><td>string</td></tr>
|
</td>
|
||||||
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
`riskMode` defaults to `warn` when unset.
|
||||||
|
|
||||||
## Common Configurations
|
## Common Configurations
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
@@ -85,7 +306,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"headed": true,
|
"headed": true,
|
||||||
"profile": "./browser-data"
|
"sessionName": "local-dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,21 +367,125 @@ These environment variables configure additional daemon and runtime behavior:
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Variable</th><th>Description</th><th>Default</th></tr>
|
<tr>
|
||||||
|
<th>Variable</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Default</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
||||||
<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>
|
<td>
|
||||||
<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>
|
<code>AGENT_BROWSER_AUTO_CONNECT</code>
|
||||||
<tr><td><code>AGENT_BROWSER_DOWNLOAD_PATH</code></td><td>Default directory for browser downloads.</td><td>(temp directory)</td></tr>
|
</td>
|
||||||
<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>
|
<td>Auto-discover and connect to a running Chrome instance.</td>
|
||||||
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name.</td><td>(none)</td></tr>
|
<td>(disabled)</td>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
<td>
|
||||||
<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>
|
<code>AGENT_BROWSER_ALLOW_FILE_ACCESS</code>
|
||||||
<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>
|
</td>
|
||||||
<tr><td><code>AGENT_BROWSER_DEBUG</code></td><td>Enable debug output (<code>1</code> to enable).</td><td>(disabled)</td></tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const metadata = pageMetadata("installation")
|
|||||||
Installs the native Rust binary for maximum performance:
|
Installs the native Rust binary for maximum performance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g agent-browser
|
npm install -g agent-browser-stealth
|
||||||
agent-browser install # Download Chromium
|
agent-browser install # Download Chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ This is the fastest option -- commands run through the native Rust CLI directly
|
|||||||
Run directly with `npx` if you want to try it without installing globally:
|
Run directly with `npx` if you want to try it without installing globally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx agent-browser install # Download Chromium (first time only)
|
npx agent-browser-stealth install # Download Chromium (first time only)
|
||||||
npx agent-browser open example.com
|
npx agent-browser-stealth open example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
|
> **Note:** `npx` routes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
|
||||||
@@ -31,14 +31,14 @@ npx agent-browser open example.com
|
|||||||
For projects that want to pin the version in `package.json`:
|
For projects that want to pin the version in `package.json`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install agent-browser
|
npm install agent-browser-stealth
|
||||||
npx agent-browser install
|
npx agent-browser-stealth install
|
||||||
```
|
```
|
||||||
|
|
||||||
Then use via `npx` or `package.json` scripts:
|
Then use via `npx` or `package.json` scripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx agent-browser open example.com
|
npx agent-browser-stealth open example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Homebrew (macOS)
|
## Homebrew (macOS)
|
||||||
@@ -51,7 +51,7 @@ agent-browser install # Download Chromium
|
|||||||
## From source
|
## From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/vercel-labs/agent-browser
|
git clone https://github.com/leeguooooo/agent-browser
|
||||||
cd agent-browser
|
cd agent-browser
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
@@ -60,6 +60,15 @@ pnpm build:native
|
|||||||
pnpm link --global
|
pnpm link --global
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fork versioning
|
||||||
|
|
||||||
|
Fork releases use a dual-version format:
|
||||||
|
|
||||||
|
- `<upstream>-fork.<fork>`
|
||||||
|
- Example: `0.14.0-fork.1`
|
||||||
|
|
||||||
|
`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds.
|
||||||
|
|
||||||
## Linux dependencies
|
## Linux dependencies
|
||||||
|
|
||||||
On Linux, install system dependencies:
|
On Linux, install system dependencies:
|
||||||
@@ -89,7 +98,7 @@ AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chromium agent-browser open example.com
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import chromium from '@sparticuz/chromium';
|
import chromium from '@sparticuz/chromium';
|
||||||
import { BrowserManager } from 'agent-browser';
|
import { BrowserManager } from 'agent-browser-stealth';
|
||||||
|
|
||||||
export async function handler() {
|
export async function handler() {
|
||||||
const browser = new BrowserManager();
|
const browser = new BrowserManager();
|
||||||
@@ -110,7 +119,7 @@ agent-browser works with any AI agent out of the box. For richer context:
|
|||||||
Install the skill for your AI coding assistant:
|
Install the skill for your AI coding assistant:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx skills add vercel-labs/agent-browser
|
npx skills add leeguooooo/agent-browser
|
||||||
```
|
```
|
||||||
|
|
||||||
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
|
This works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf. The skill is fetched from the repository and stays up to date automatically.
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ export const metadata = pageMetadata("")
|
|||||||
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
|
Browser automation CLI designed for AI agents. Compact text output minimizes context usage. Fast Rust CLI with Node.js fallback.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g agent-browser # all platforms (fastest, native Rust CLI)
|
npm install -g agent-browser-stealth # all platforms (fastest, native Rust CLI)
|
||||||
brew install agent-browser # macOS
|
brew install agent-browser # macOS
|
||||||
|
|
||||||
# or try without installing
|
# or try without installing
|
||||||
npx agent-browser open example.com
|
npx agent-browser-stealth open example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -22,6 +22,8 @@ npx agent-browser open example.com
|
|||||||
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
|
- **Complete** - 50+ commands for navigation, forms, screenshots, network, storage
|
||||||
- **Sessions** - Multiple isolated browser instances with separate auth
|
- **Sessions** - Multiple isolated browser instances with separate auth
|
||||||
- **Cross-platform** - macOS, Linux, Windows with native binaries
|
- **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
|
## Works with
|
||||||
|
|
||||||
|
|||||||
@@ -34,29 +34,6 @@ Each session has its own:
|
|||||||
- Navigation history
|
- Navigation history
|
||||||
- Authentication state
|
- 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
|
## Session persistence
|
||||||
|
|
||||||
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
|
Use `--session-name` to automatically save and restore cookies and localStorage across browser restarts:
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ Send input events to control the browser remotely.
|
|||||||
For advanced use, control streaming directly via the TypeScript API:
|
For advanced use, control streaming directly via the TypeScript API:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { BrowserManager } from 'agent-browser';
|
import { BrowserManager } from 'agent-browser-stealth';
|
||||||
|
|
||||||
const browser = new BrowserManager();
|
const browser = new BrowserManager();
|
||||||
await browser.launch({ headless: true });
|
await browser.launch({ headless: true });
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/vercel-labs/agent-browser"
|
href="https://github.com/leeguooooo/agent-browser"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -69,7 +69,7 @@ export function Header() {
|
|||||||
<span>14k</span>
|
<span>14k</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://www.npmjs.com/package/agent-browser"
|
href="https://www.npmjs.com/package/agent-browser-stealth"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
|||||||
+13
-6
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.14.0",
|
"version": "0.14.0-fork.5",
|
||||||
"description": "Headless browser automation CLI for AI agents",
|
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"skills"
|
"skills"
|
||||||
],
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"agent-browser-stealth": "./bin/agent-browser.js",
|
||||||
"agent-browser": "./bin/agent-browser.js"
|
"agent-browser": "./bin/agent-browser.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"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",
|
"changeset": "changeset",
|
||||||
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
||||||
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
|
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
|
||||||
@@ -42,6 +46,9 @@
|
|||||||
"browser",
|
"browser",
|
||||||
"automation",
|
"automation",
|
||||||
"headless",
|
"headless",
|
||||||
|
"stealth",
|
||||||
|
"anti-bot",
|
||||||
|
"anti-detection",
|
||||||
"playwright",
|
"playwright",
|
||||||
"cli",
|
"cli",
|
||||||
"agent"
|
"agent"
|
||||||
@@ -49,12 +56,12 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/vercel-labs/agent-browser.git"
|
"url": "git+https://github.com/leeguooooo/agent-browser.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/vercel-labs/agent-browser/issues"
|
"url": "https://github.com/leeguooooo/agent-browser/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
|
"homepage": "https://github.com/leeguooooo/agent-browser#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-simctl": "^7.4.0",
|
"node-simctl": "^7.4.0",
|
||||||
"playwright-core": "^1.57.0",
|
"playwright-core": "^1.57.0",
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end check for CreepJS headless/stealth indicators.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-creepjs-headless.js
|
||||||
|
* node scripts/check-creepjs-headless.js --compare-stealth
|
||||||
|
* node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const getArgValue = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||||
|
return args[index + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser'));
|
||||||
|
const sessionPrefix = getArgValue('--session-prefix', 'creepjs-e2e');
|
||||||
|
const compareStealth = args.includes('--compare-stealth');
|
||||||
|
const targetUrl = getArgValue('--url', 'https://abrahamjuliot.github.io/creepjs/');
|
||||||
|
|
||||||
|
const extractionScript = `(() => {
|
||||||
|
const headless = globalThis.Fingerprint?.headless ?? null;
|
||||||
|
const toNumber = (value) => (typeof value === 'number' ? value : null);
|
||||||
|
return {
|
||||||
|
found: !!headless,
|
||||||
|
metrics: headless ? {
|
||||||
|
chromium: !!headless.chromium,
|
||||||
|
likeHeadless: toNumber(headless.likeHeadlessRating),
|
||||||
|
headless: toNumber(headless.headlessRating),
|
||||||
|
stealth: toNumber(headless.stealthRating),
|
||||||
|
raw: headless,
|
||||||
|
} : null,
|
||||||
|
navigator: {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
userAgentData: navigator.userAgentData ? navigator.userAgentData.toJSON?.() ?? null : null,
|
||||||
|
language: navigator.language,
|
||||||
|
languages: navigator.languages,
|
||||||
|
platform: navigator.platform,
|
||||||
|
webdriver: navigator.webdriver,
|
||||||
|
webdriverInNavigator: ('webdriver' in navigator),
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
innerHeight: window.innerHeight,
|
||||||
|
outerWidth: window.outerWidth,
|
||||||
|
outerHeight: window.outerHeight,
|
||||||
|
screenX: window.screenX,
|
||||||
|
screenY: window.screenY,
|
||||||
|
},
|
||||||
|
intl: {
|
||||||
|
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function runCommand(commandArgs, options = {}) {
|
||||||
|
const result = spawnSync(binary, commandArgs, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0 && !options.allowFailure) {
|
||||||
|
const stderr = (result.stderr || '').trim();
|
||||||
|
const stdout = (result.stdout || '').trim();
|
||||||
|
throw new Error(
|
||||||
|
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
|
||||||
|
`${stderr || stdout || `exit code ${result.status}`}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSessionArgs(session, stealth) {
|
||||||
|
const base = ['--session', session];
|
||||||
|
if (stealth === false) {
|
||||||
|
base.push('--stealth', 'false');
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSingleCheck({ stealth, runId }) {
|
||||||
|
const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`;
|
||||||
|
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
|
||||||
|
runCommand([
|
||||||
|
...withSessionArgs(session, stealth),
|
||||||
|
'wait',
|
||||||
|
'--fn',
|
||||||
|
'!!(window.Fingerprint && window.Fingerprint.headless)',
|
||||||
|
]);
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'wait', '2000']);
|
||||||
|
|
||||||
|
const evalResult = runCommand([
|
||||||
|
...withSessionArgs(session, stealth),
|
||||||
|
'eval',
|
||||||
|
'--json',
|
||||||
|
extractionScript,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = JSON.parse(evalResult.stdout);
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
stealth,
|
||||||
|
url: targetUrl,
|
||||||
|
extracted: payload?.data?.result ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const runId = Date.now();
|
||||||
|
const checks = compareStealth ? [true, false] : [true];
|
||||||
|
const results = checks.map((stealth) => runSingleCheck({ stealth, runId }));
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
binary,
|
||||||
|
compareStealth,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end check for bot.sannysoft.com WebDriver (New) result.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-sannysoft-webdriver.js
|
||||||
|
* node scripts/check-sannysoft-webdriver.js --compare-stealth
|
||||||
|
* node scripts/check-sannysoft-webdriver.js --binary ./cli/target/release/agent-browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const getArgValue = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index === -1 || index + 1 >= args.length) return fallback;
|
||||||
|
return args[index + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser'));
|
||||||
|
const sessionPrefix = getArgValue('--session-prefix', 'botcheck-e2e');
|
||||||
|
const compareStealth = args.includes('--compare-stealth');
|
||||||
|
const targetUrl = getArgValue('--url', 'https://bot.sannysoft.com');
|
||||||
|
|
||||||
|
const extractionScript = `(() => {
|
||||||
|
const normalize = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
const rows = Array.from(document.querySelectorAll('tr'));
|
||||||
|
const exact = rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase() === 'webdriver (new)');
|
||||||
|
const fallback = exact || rows.find((tr) => normalize(tr.cells?.[0]?.textContent).toLowerCase().includes('webdriver'));
|
||||||
|
return {
|
||||||
|
found: !!fallback,
|
||||||
|
label: fallback ? normalize(fallback.cells?.[0]?.textContent) : null,
|
||||||
|
valueText: fallback ? normalize(fallback.cells?.[1]?.textContent) : null,
|
||||||
|
statusText: fallback ? normalize(fallback.textContent) : null,
|
||||||
|
navigatorWebdriver: navigator.webdriver,
|
||||||
|
webdriverInNavigator: ('webdriver' in navigator),
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function runCommand(commandArgs, options = {}) {
|
||||||
|
const result = spawnSync(binary, commandArgs, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0 && !options.allowFailure) {
|
||||||
|
const stderr = (result.stderr || '').trim();
|
||||||
|
const stdout = (result.stdout || '').trim();
|
||||||
|
throw new Error(
|
||||||
|
`Command failed: ${binary} ${commandArgs.join(' ')}\n` +
|
||||||
|
`${stderr || stdout || `exit code ${result.status}`}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSessionArgs(session, stealth) {
|
||||||
|
const base = ['--session', session];
|
||||||
|
if (stealth === false) {
|
||||||
|
base.push('--stealth', 'false');
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSingleCheck({ stealth, runId }) {
|
||||||
|
const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`;
|
||||||
|
|
||||||
|
// Best-effort cleanup in case previous run left state behind.
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]);
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'wait', '--load', 'networkidle']);
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'wait', '5000']);
|
||||||
|
|
||||||
|
const evalResult = runCommand([
|
||||||
|
...withSessionArgs(session, stealth),
|
||||||
|
'eval',
|
||||||
|
'--json',
|
||||||
|
extractionScript,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = JSON.parse(evalResult.stdout);
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
stealth,
|
||||||
|
url: targetUrl,
|
||||||
|
extracted: payload?.data?.result ?? null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const runId = Date.now();
|
||||||
|
const checks = compareStealth ? [true, false] : [true];
|
||||||
|
const results = checks.map((stealth) => runSingleCheck({ stealth, runId }));
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
binary,
|
||||||
|
compareStealth,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -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."
|
||||||
+83
-43
@@ -9,7 +9,7 @@
|
|||||||
* - Mac/Linux: Replaces symlink to point to native binary
|
* - Mac/Linux: Replaces symlink to point to native binary
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync } from 'fs';
|
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync, readFileSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { platform, arch } from 'os';
|
import { platform, arch } from 'os';
|
||||||
@@ -27,15 +27,41 @@ const binaryName = `agent-browser-${platformKey}${ext}`;
|
|||||||
const binaryPath = join(binDir, binaryName);
|
const binaryPath = join(binDir, binaryName);
|
||||||
|
|
||||||
// Package info
|
// Package info
|
||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
|
||||||
(await import('fs')).readFileSync(join(projectRoot, 'package.json'), 'utf8')
|
|
||||||
);
|
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
|
const packageName = packageJson.name;
|
||||||
|
const binCommands = getBinCommands(packageJson);
|
||||||
|
|
||||||
// GitHub release URL
|
// GitHub release URL
|
||||||
const GITHUB_REPO = 'vercel-labs/agent-browser';
|
const GITHUB_REPO = getGitHubRepoFromPackage(packageJson);
|
||||||
const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
|
const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
|
||||||
|
|
||||||
|
function getGitHubRepoFromPackage(pkg) {
|
||||||
|
const repo = pkg?.repository;
|
||||||
|
const repoUrl = typeof repo === 'string' ? repo : repo?.url;
|
||||||
|
|
||||||
|
if (typeof repoUrl === 'string') {
|
||||||
|
const match = repoUrl.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/i);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy package metadata
|
||||||
|
return 'vercel-labs/agent-browser';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBinCommands(pkg) {
|
||||||
|
const bin = pkg?.bin;
|
||||||
|
if (typeof bin === 'string') {
|
||||||
|
return [pkg.name.replace(/^@[^/]+\//, '')];
|
||||||
|
}
|
||||||
|
if (bin && typeof bin === 'object') {
|
||||||
|
return Object.keys(bin);
|
||||||
|
}
|
||||||
|
return ['agent-browser'];
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadFile(url, dest) {
|
async function downloadFile(url, dest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const file = createWriteStream(dest);
|
const file = createWriteStream(dest);
|
||||||
@@ -107,7 +133,7 @@ async function main() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('To build the native binary locally:');
|
console.log('To build the native binary locally:');
|
||||||
console.log(' 1. Install Rust: https://rustup.rs');
|
console.log(' 1. Install Rust: https://rustup.rs');
|
||||||
console.log(' 2. Run: npm run build:native');
|
console.log(' 2. Run: pnpm run build:native');
|
||||||
}
|
}
|
||||||
|
|
||||||
// On global installs, fix npm's bin entry to use native binary directly
|
// On global installs, fix npm's bin entry to use native binary directly
|
||||||
@@ -157,27 +183,34 @@ async function fixUnixSymlink() {
|
|||||||
return; // npm not available
|
return; // npm not available
|
||||||
}
|
}
|
||||||
|
|
||||||
const symlinkPath = join(npmBinDir, 'agent-browser');
|
let optimized = false;
|
||||||
|
for (const commandName of binCommands) {
|
||||||
|
const symlinkPath = join(npmBinDir, commandName);
|
||||||
|
|
||||||
// Check if symlink exists (indicates global install)
|
// Check if symlink exists (indicates global install)
|
||||||
try {
|
try {
|
||||||
const stat = lstatSync(symlinkPath);
|
const stat = lstatSync(symlinkPath);
|
||||||
if (!stat.isSymbolicLink()) {
|
if (!stat.isSymbolicLink()) {
|
||||||
return; // Not a symlink, don't touch it
|
continue; // Not a symlink, don't touch it
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue; // Symlink doesn't exist, not a global install
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace symlink to point directly to native binary
|
||||||
|
try {
|
||||||
|
unlinkSync(symlinkPath);
|
||||||
|
symlinkSync(binaryPath, symlinkPath);
|
||||||
|
optimized = true;
|
||||||
|
} catch (err) {
|
||||||
|
// Permission error or other issue - not critical, JS wrapper still works
|
||||||
|
console.log(`⚠ Could not optimize symlink (${commandName}): ${err.message}`);
|
||||||
|
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
return; // Symlink doesn't exist, not a global install
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace symlink to point directly to native binary
|
if (optimized) {
|
||||||
try {
|
|
||||||
unlinkSync(symlinkPath);
|
|
||||||
symlinkSync(binaryPath, symlinkPath);
|
|
||||||
console.log('✓ Optimized: symlink points to native binary (zero overhead)');
|
console.log('✓ Optimized: symlink points to native binary (zero overhead)');
|
||||||
} catch (err) {
|
|
||||||
// Permission error or other issue - not critical, JS wrapper still works
|
|
||||||
console.log(`⚠ Could not optimize symlink: ${err.message}`);
|
|
||||||
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,25 +228,28 @@ async function fixWindowsShims() {
|
|||||||
return; // Not a global install or npm not available
|
return; // Not a global install or npm not available
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shims are in the npm prefix directory (not prefix/bin on Windows)
|
|
||||||
const cmdShim = join(npmBinDir, 'agent-browser.cmd');
|
|
||||||
const ps1Shim = join(npmBinDir, 'agent-browser.ps1');
|
|
||||||
|
|
||||||
// Only fix if shims exist (indicates global install)
|
|
||||||
if (!existsSync(cmdShim)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path to native binary relative to npm prefix
|
// Path to native binary relative to npm prefix
|
||||||
const relativeBinaryPath = 'node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe';
|
const packagePath = packageName.replace(/\//g, '\\');
|
||||||
|
const relativeBinaryPath = `node_modules\\${packagePath}\\bin\\${binaryName}`;
|
||||||
|
let optimized = false;
|
||||||
|
|
||||||
try {
|
for (const commandName of binCommands) {
|
||||||
// Overwrite .cmd shim
|
// The shims are in the npm prefix directory (not prefix/bin on Windows)
|
||||||
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
|
const cmdShim = join(npmBinDir, `${commandName}.cmd`);
|
||||||
writeFileSync(cmdShim, cmdContent);
|
const ps1Shim = join(npmBinDir, `${commandName}.ps1`);
|
||||||
|
|
||||||
// Overwrite .ps1 shim
|
// Only fix if shims exist (indicates global install)
|
||||||
const ps1Content = `#!/usr/bin/env pwsh
|
if (!existsSync(cmdShim)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Overwrite .cmd shim
|
||||||
|
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
|
||||||
|
writeFileSync(cmdShim, cmdContent);
|
||||||
|
|
||||||
|
// Overwrite .ps1 shim
|
||||||
|
const ps1Content = `#!/usr/bin/env pwsh
|
||||||
$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent
|
$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
$exe = ""
|
$exe = ""
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
@@ -222,13 +258,17 @@ if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|||||||
& "$basedir/${relativeBinaryPath.replace(/\\/g, '/')}" $args
|
& "$basedir/${relativeBinaryPath.replace(/\\/g, '/')}" $args
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
`;
|
`;
|
||||||
writeFileSync(ps1Shim, ps1Content);
|
writeFileSync(ps1Shim, ps1Content);
|
||||||
|
optimized = true;
|
||||||
|
} catch (err) {
|
||||||
|
// Permission error or other issue - not critical, JS wrapper still works
|
||||||
|
console.log(`⚠ Could not optimize shims (${commandName}): ${err.message}`);
|
||||||
|
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optimized) {
|
||||||
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
||||||
} catch (err) {
|
|
||||||
// Permission error or other issue - not critical, JS wrapper still works
|
|
||||||
console.log(`⚠ Could not optimize shims: ${err.message}`);
|
|
||||||
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UPSTREAM_REMOTE="upstream"
|
||||||
|
UPSTREAM_BRANCH="main"
|
||||||
|
BASE_BRANCH="main"
|
||||||
|
TRACK_BRANCH="upstream-main"
|
||||||
|
SYNC_BRANCH=""
|
||||||
|
PUSH_BRANCH=false
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Synchronize upstream changes into a dedicated sync branch.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/sync-upstream.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--push Push the created sync branch to origin
|
||||||
|
--upstream-remote <name> Upstream remote name (default: upstream)
|
||||||
|
--upstream-branch <name> Upstream branch to sync from (default: main)
|
||||||
|
--base-branch <name> Local base branch for sync branch (default: main)
|
||||||
|
--track-branch <name> Local branch tracking upstream (default: upstream-main)
|
||||||
|
--sync-branch <name> Explicit sync branch name (default: sync/YYYY-MM-DD)
|
||||||
|
-h, --help Show this help message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--push)
|
||||||
|
PUSH_BRANCH=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--upstream-remote)
|
||||||
|
UPSTREAM_REMOTE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--upstream-branch)
|
||||||
|
UPSTREAM_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--base-branch)
|
||||||
|
BASE_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--track-branch)
|
||||||
|
TRACK_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sync-branch)
|
||||||
|
SYNC_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for var_name in UPSTREAM_REMOTE UPSTREAM_BRANCH BASE_BRANCH TRACK_BRANCH; do
|
||||||
|
if [[ -z "${!var_name}" ]]; then
|
||||||
|
echo "Error: ${var_name} cannot be empty." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "Error: working tree is not clean. Commit or stash changes first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "Error: remote '$UPSTREAM_REMOTE' does not exist." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Fetching upstream branch: ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
|
||||||
|
git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$TRACK_BRANCH"; then
|
||||||
|
echo "Updating local track branch: $TRACK_BRANCH"
|
||||||
|
git switch "$TRACK_BRANCH" >/dev/null
|
||||||
|
git merge --ff-only "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
|
||||||
|
else
|
||||||
|
echo "Creating local track branch: $TRACK_BRANCH"
|
||||||
|
git branch "$TRACK_BRANCH" "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Switching to base branch: $BASE_BRANCH"
|
||||||
|
git switch "$BASE_BRANCH" >/dev/null
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
||||||
|
echo "Fast-forwarding ${BASE_BRANCH} from origin/${BASE_BRANCH}"
|
||||||
|
git fetch origin "$BASE_BRANCH"
|
||||||
|
git merge --ff-only "origin/${BASE_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$SYNC_BRANCH" ]]; then
|
||||||
|
SYNC_BRANCH="sync/$(date +%F)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$SYNC_BRANCH"; then
|
||||||
|
suffix=1
|
||||||
|
while git show-ref --verify --quiet "refs/heads/${SYNC_BRANCH}-${suffix}"; do
|
||||||
|
suffix=$((suffix + 1))
|
||||||
|
done
|
||||||
|
SYNC_BRANCH="${SYNC_BRANCH}-${suffix}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating sync branch: $SYNC_BRANCH"
|
||||||
|
git switch -c "$SYNC_BRANCH" "$BASE_BRANCH" >/dev/null
|
||||||
|
|
||||||
|
merge_message="chore(sync): merge ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH} into ${BASE_BRANCH}"
|
||||||
|
echo "Merging $TRACK_BRANCH into $SYNC_BRANCH"
|
||||||
|
if ! git merge --no-ff "$TRACK_BRANCH" -m "$merge_message"; then
|
||||||
|
echo ""
|
||||||
|
echo "Merge conflict detected. Resolve conflicts, then run:"
|
||||||
|
echo " git add <resolved-files>"
|
||||||
|
echo " git commit"
|
||||||
|
if [[ "$PUSH_BRANCH" == true ]]; then
|
||||||
|
echo " git push -u origin $SYNC_BRANCH"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Upstream merge completed on branch: $SYNC_BRANCH"
|
||||||
|
|
||||||
|
if [[ "$PUSH_BRANCH" == true ]]; then
|
||||||
|
echo "Pushing branch to origin: $SYNC_BRANCH"
|
||||||
|
git push -u origin "$SYNC_BRANCH"
|
||||||
|
echo "Done. Open a PR: ${SYNC_BRANCH} -> ${BASE_BRANCH}"
|
||||||
|
else
|
||||||
|
echo "Branch is local only. Push when ready:"
|
||||||
|
echo " git push -u origin $SYNC_BRANCH"
|
||||||
|
fi
|
||||||
+21
-3
@@ -20,13 +20,31 @@ const packageJson = JSON.parse(
|
|||||||
);
|
);
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
|
|
||||||
console.log(`Syncing version ${version} to all config files...`);
|
function parseForkVersion(raw) {
|
||||||
|
const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
upstream: match[1],
|
||||||
|
fork: match[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const forkVersion = parseForkVersion(version);
|
||||||
|
if (forkVersion) {
|
||||||
|
console.log(
|
||||||
|
`Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Syncing version ${version} to all config files...`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update Cargo.toml
|
// Update Cargo.toml
|
||||||
const cargoTomlPath = join(cliDir, "Cargo.toml");
|
const cargoTomlPath = join(cliDir, "Cargo.toml");
|
||||||
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
||||||
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
||||||
const newCargoVersion = `version = "${version}"`;
|
const newCargoVersion = `version = "${version}"`;
|
||||||
|
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
|
||||||
|
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
|
||||||
|
|
||||||
let cargoTomlUpdated = false;
|
let cargoTomlUpdated = false;
|
||||||
if (cargoVersionRegex.test(cargoToml)) {
|
if (cargoVersionRegex.test(cargoToml)) {
|
||||||
@@ -47,7 +65,7 @@ if (cargoVersionRegex.test(cargoToml)) {
|
|||||||
// Update Cargo.lock to match Cargo.toml
|
// Update Cargo.lock to match Cargo.toml
|
||||||
if (cargoTomlUpdated) {
|
if (cargoTomlUpdated) {
|
||||||
try {
|
try {
|
||||||
execSync("cargo update -p agent-browser --offline", {
|
execSync(`cargo update -p ${cargoPackageName} --offline`, {
|
||||||
cwd: cliDir,
|
cwd: cliDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
@@ -55,7 +73,7 @@ if (cargoTomlUpdated) {
|
|||||||
} catch {
|
} catch {
|
||||||
// --offline may fail if package not in cache, try without it
|
// --offline may fail if package not in cache, try without it
|
||||||
try {
|
try {
|
||||||
execSync("cargo update -p agent-browser", {
|
execSync(`cargo update -p ${cargoPackageName}`, {
|
||||||
cwd: cliDir,
|
cwd: cliDir,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: agent-browser
|
name: agent-browser
|
||||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
||||||
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
|
allowed-tools: Bash(npx agent-browser-stealth:*), Bash(npx agent-browser:*), Bash(agent-browser:*)
|
||||||
---
|
---
|
||||||
|
|
||||||
# Browser Automation with agent-browser
|
# Browser Automation with agent-browser
|
||||||
|
|
||||||
|
Install package: `npm install -g agent-browser-stealth` (CLI command remains `agent-browser` for compatibility).
|
||||||
|
|
||||||
## Core Workflow
|
## Core Workflow
|
||||||
|
|
||||||
Every browser automation follows this pattern:
|
Every browser automation follows this pattern:
|
||||||
@@ -49,7 +51,9 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
|
|||||||
```bash
|
```bash
|
||||||
# Navigation
|
# Navigation
|
||||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
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 close # Close browser
|
||||||
|
agent-browser --version # Show CLI version (fork builds include upstream/fork)
|
||||||
|
|
||||||
# Snapshot
|
# Snapshot
|
||||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||||
@@ -60,11 +64,11 @@ agent-browser snapshot -s "#selector" # Scope to CSS selector
|
|||||||
agent-browser click @e1 # Click element
|
agent-browser click @e1 # Click element
|
||||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||||
agent-browser fill @e2 "text" # Clear and type text
|
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 select @e1 "option" # Select dropdown option
|
||||||
agent-browser check @e1 # Check checkbox
|
agent-browser check @e1 # Check checkbox
|
||||||
agent-browser press Enter # Press key
|
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 keyboard inserttext "text" # Insert without key events
|
||||||
agent-browser scroll down 500 # Scroll page
|
agent-browser scroll down 500 # Scroll page
|
||||||
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
||||||
@@ -79,6 +83,7 @@ agent-browser wait @e1 # Wait for element
|
|||||||
agent-browser wait --load networkidle # Wait for network idle
|
agent-browser wait --load networkidle # Wait for network idle
|
||||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||||
agent-browser wait 2000 # Wait milliseconds
|
agent-browser wait 2000 # Wait milliseconds
|
||||||
|
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
|
||||||
|
|
||||||
# Downloads
|
# Downloads
|
||||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||||
@@ -181,6 +186,8 @@ agent-browser session list
|
|||||||
|
|
||||||
### Connect to Existing Chrome
|
### 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
|
```bash
|
||||||
# Auto-discover running Chrome with remote debugging enabled
|
# Auto-discover running Chrome with remote debugging enabled
|
||||||
agent-browser --auto-connect open https://example.com
|
agent-browser --auto-connect open https://example.com
|
||||||
@@ -222,6 +229,42 @@ agent-browser --allow-file-access open file:///path/to/page.html
|
|||||||
agent-browser screenshot output.png
|
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.).
|
||||||
|
|
||||||
|
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)
|
### iOS Simulator (Mobile Safari)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -293,10 +336,23 @@ agent-browser wait --fn "document.readyState === 'complete'"
|
|||||||
|
|
||||||
# Wait a fixed duration (milliseconds) as a last resort
|
# Wait a fixed duration (milliseconds) as a last resort
|
||||||
agent-browser wait 5000
|
agent-browser wait 5000
|
||||||
|
|
||||||
|
# Random wait between 2-5 seconds (useful for anti-detection)
|
||||||
|
agent-browser wait 2000-5000
|
||||||
```
|
```
|
||||||
|
|
||||||
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
|
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
|
||||||
|
|
||||||
|
### Humanized Interactions
|
||||||
|
|
||||||
|
agent-browser automatically humanizes interactions to avoid behavioral detection:
|
||||||
|
|
||||||
|
- **Randomized typing**: `type --delay` varies each keystroke delay by +-40%
|
||||||
|
- **Random wait ranges**: `wait 2000-5000` pauses for a random duration in that range
|
||||||
|
- **Bezier curve mouse**: Before every `click`, the mouse moves along a natural-looking curve
|
||||||
|
|
||||||
|
These behaviors are always active. For sensitive sites, combine with `--headed` and `--session-name` for best results.
|
||||||
|
|
||||||
## Session Management and Cleanup
|
## Session Management and Cleanup
|
||||||
|
|
||||||
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
|
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
|
||||||
@@ -347,6 +403,7 @@ agent-browser click @e2 # Click using ref from annotated screenshot
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use annotated screenshots when:
|
Use annotated screenshots when:
|
||||||
|
|
||||||
- The page has unlabeled icon buttons or visual-only elements
|
- The page has unlabeled icon buttons or visual-only elements
|
||||||
- You need to verify visual layout or styling
|
- You need to verify visual layout or styling
|
||||||
- Canvas or chart elements are present (invisible to text snapshots)
|
- Canvas or chart elements are present (invisible to text snapshots)
|
||||||
@@ -389,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.
|
**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:**
|
**Rules of thumb:**
|
||||||
|
|
||||||
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
|
- 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'`
|
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||||
@@ -400,8 +458,7 @@ Create `agent-browser.json` in the project root for persistent settings:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"headed": true,
|
"headed": true,
|
||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080"
|
||||||
"profile": "./browser-data"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -409,23 +466,23 @@ Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.
|
|||||||
|
|
||||||
## Deep-Dive Documentation
|
## Deep-Dive Documentation
|
||||||
|
|
||||||
| Reference | When to Use |
|
| Reference | When to Use |
|
||||||
|-----------|-------------|
|
| -------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
| [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/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/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/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/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/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/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||||
|
|
||||||
## Ready-to-Use Templates
|
## Ready-to-Use Templates
|
||||||
|
|
||||||
| Template | Description |
|
| Template | Description |
|
||||||
|----------|-------------|
|
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
| [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/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./templates/form-automation.sh https://example.com/form
|
./templates/form-automation.sh https://example.com/form
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { toAIFriendlyError } from './actions.js';
|
import { detectRiskSignals, toAIFriendlyError } from './actions.js';
|
||||||
|
|
||||||
describe('toAIFriendlyError', () => {
|
describe('toAIFriendlyError', () => {
|
||||||
describe('element blocked by overlay', () => {
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+190
-9
@@ -127,7 +127,6 @@ import type {
|
|||||||
DiffScreenshotCommand,
|
DiffScreenshotCommand,
|
||||||
DiffUrlCommand,
|
DiffUrlCommand,
|
||||||
Annotation,
|
Annotation,
|
||||||
NavigateData,
|
|
||||||
ScreenshotData,
|
ScreenshotData,
|
||||||
EvaluateData,
|
EvaluateData,
|
||||||
DiffSnapshotData,
|
DiffSnapshotData,
|
||||||
@@ -145,6 +144,8 @@ import type {
|
|||||||
RecordingRestartData,
|
RecordingRestartData,
|
||||||
InputEventData,
|
InputEventData,
|
||||||
StylesData,
|
StylesData,
|
||||||
|
RiskMode,
|
||||||
|
RiskSignal,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { successResponse, errorResponse } from './protocol.js';
|
import { successResponse, errorResponse } from './protocol.js';
|
||||||
import { diffSnapshots, diffScreenshots } from './diff.js';
|
import { diffSnapshots, diffScreenshots } from './diff.js';
|
||||||
@@ -517,30 +518,179 @@ async function handleLaunch(
|
|||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
await browser.launch(command);
|
await browser.launch(command);
|
||||||
return successResponse(command.id, { launched: true });
|
return successResponse(command.id, {
|
||||||
|
launched: true,
|
||||||
|
stealth: browser.getStealthStatus(command.browser ?? 'chromium'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNavigate(
|
async function handleNavigate(
|
||||||
command: NavigateCommand,
|
command: NavigateCommand,
|
||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response<NavigateData>> {
|
): Promise<Response> {
|
||||||
const page = browser.getPage();
|
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 headers are provided, set up scoped headers for this origin
|
||||||
if (command.headers && Object.keys(command.headers).length > 0) {
|
if (command.headers && Object.keys(command.headers).length > 0) {
|
||||||
await browser.setScopedHeaders(command.url, command.headers);
|
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, {
|
await page.goto(command.url, {
|
||||||
waitUntil: command.waitUntil ?? 'load',
|
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, {
|
return successResponse(command.id, {
|
||||||
url: page.url(),
|
url: page.url(),
|
||||||
title: await page.title(),
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function humanMouseMove(page: Page, toX: number, toY: number): Promise<void> {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
const fromX = viewport ? Math.random() * viewport.width * 0.3 : 100;
|
||||||
|
const fromY = viewport ? Math.random() * viewport.height * 0.3 : 100;
|
||||||
|
|
||||||
|
const cp1x = fromX + (toX - fromX) * (0.2 + Math.random() * 0.3);
|
||||||
|
const cp1y = fromY + (Math.random() - 0.5) * 200;
|
||||||
|
const cp2x = fromX + (toX - fromX) * (0.5 + Math.random() * 0.3);
|
||||||
|
const cp2y = toY + (Math.random() - 0.5) * 200;
|
||||||
|
|
||||||
|
const steps = 15 + Math.floor(Math.random() * 15);
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const x = bezierPoint(t, fromX, cp1x, cp2x, toX);
|
||||||
|
const y = bezierPoint(t, fromY, cp1y, cp2y, toY);
|
||||||
|
await page.mouse.move(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleClick(command: ClickCommand, browser: BrowserManager): Promise<Response> {
|
async function handleClick(command: ClickCommand, browser: BrowserManager): Promise<Response> {
|
||||||
// Support both refs (@e1) and regular selectors
|
// Support both refs (@e1) and regular selectors
|
||||||
const locator = browser.getLocator(command.selector);
|
const locator = browser.getLocator(command.selector);
|
||||||
@@ -572,6 +722,14 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-like: move mouse along a Bezier curve before clicking
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
const targetX = box.x + box.width * (0.3 + Math.random() * 0.4);
|
||||||
|
const targetY = box.y + box.height * (0.3 + Math.random() * 0.4);
|
||||||
|
await humanMouseMove(browser.getPage(), targetX, targetY);
|
||||||
|
}
|
||||||
|
|
||||||
await locator.click({
|
await locator.click({
|
||||||
button: command.button,
|
button: command.button,
|
||||||
clickCount: command.clickCount,
|
clickCount: command.clickCount,
|
||||||
@@ -592,9 +750,18 @@ async function handleType(command: TypeCommand, browser: BrowserManager): Promis
|
|||||||
await locator.fill('');
|
await locator.fill('');
|
||||||
}
|
}
|
||||||
|
|
||||||
await locator.pressSequentially(command.text, {
|
if (command.delay) {
|
||||||
delay: command.delay,
|
// Humanized: type char-by-char with randomized delay (+-40%)
|
||||||
});
|
await locator.focus();
|
||||||
|
const page = browser.getPage();
|
||||||
|
for (const char of command.text) {
|
||||||
|
const jitter = command.delay * (0.6 + Math.random() * 0.8);
|
||||||
|
await page.keyboard.type(char, { delay: 0 });
|
||||||
|
await page.waitForTimeout(jitter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await locator.pressSequentially(command.text, {});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toAIFriendlyError(error, command.selector);
|
throw toAIFriendlyError(error, command.selector);
|
||||||
}
|
}
|
||||||
@@ -870,7 +1037,11 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
|
|||||||
timeout: command.timeout,
|
timeout: command.timeout,
|
||||||
});
|
});
|
||||||
} else if (command.timeout) {
|
} else if (command.timeout) {
|
||||||
await page.waitForTimeout(command.timeout);
|
// Random range: wait between [timeout, timeoutMax]
|
||||||
|
const min = command.timeout;
|
||||||
|
const max = command.timeoutMax ?? min;
|
||||||
|
const delay = max > min ? min + Math.random() * (max - min) : min;
|
||||||
|
await page.waitForTimeout(Math.round(delay));
|
||||||
} else {
|
} else {
|
||||||
// Default: wait for load state
|
// Default: wait for load state
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
@@ -1897,9 +2068,19 @@ async function handleKeyboard(
|
|||||||
const sub = command.subaction ?? 'press';
|
const sub = command.subaction ?? 'press';
|
||||||
|
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
case 'type':
|
case 'type': {
|
||||||
await page.keyboard.type(command.text ?? '', { delay: command.delay });
|
const text = command.text ?? '';
|
||||||
|
if (command.delay) {
|
||||||
|
for (const char of text) {
|
||||||
|
const jitter = command.delay * (0.6 + Math.random() * 0.8);
|
||||||
|
await page.keyboard.type(char, { delay: 0 });
|
||||||
|
await page.waitForTimeout(jitter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
}
|
||||||
return successResponse(command.id, { typed: true, text: command.text });
|
return successResponse(command.id, { typed: true, text: command.text });
|
||||||
|
}
|
||||||
case 'press':
|
case 'press':
|
||||||
await page.keyboard.press(command.keys ?? '');
|
await page.keyboard.press(command.keys ?? '');
|
||||||
return successResponse(command.id, { pressed: command.keys });
|
return successResponse(command.id, { pressed: command.keys });
|
||||||
|
|||||||
+139
-6
@@ -9,11 +9,11 @@ describe('BrowserManager', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
browser = new BrowserManager();
|
browser = new BrowserManager();
|
||||||
await browser.launch({ headless: true });
|
await browser.launch({ headless: true });
|
||||||
});
|
}, 30000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
}, 30000);
|
||||||
|
|
||||||
describe('launch and close', () => {
|
describe('launch and close', () => {
|
||||||
it('should report as launched', () => {
|
it('should report as launched', () => {
|
||||||
@@ -53,6 +53,79 @@ describe('BrowserManager', () => {
|
|||||||
expect(newBrowser.getBrowser()).toBeNull();
|
expect(newBrowser.getBrowser()).toBeNull();
|
||||||
await newBrowser.close();
|
await newBrowser.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should report local stealth policy capabilities', async () => {
|
||||||
|
const testBrowser = new BrowserManager();
|
||||||
|
await testBrowser.launch({ headless: true });
|
||||||
|
|
||||||
|
const status = testBrowser.getStealthStatus('chromium');
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.connectionKind).toBe('local');
|
||||||
|
expect(status.capabilities).toContain('chromium-launch-args');
|
||||||
|
expect(status.capabilities).toContain('context-init-scripts');
|
||||||
|
|
||||||
|
await testBrowser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply init-script stealth policy for CDP connections', async () => {
|
||||||
|
const addInitScript = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
|
||||||
|
const mockContext = {
|
||||||
|
pages: () => [mockPage],
|
||||||
|
on: vi.fn(),
|
||||||
|
setDefaultTimeout: vi.fn(),
|
||||||
|
addInitScript,
|
||||||
|
};
|
||||||
|
const mockBrowser = {
|
||||||
|
contexts: () => [mockContext],
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
isConnected: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||||
|
|
||||||
|
const cdpBrowser = new BrowserManager();
|
||||||
|
await cdpBrowser.launch({ cdpPort: 9222 });
|
||||||
|
|
||||||
|
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||||
|
const status = cdpBrowser.getStealthStatus();
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.connectionKind).toBe('cdp');
|
||||||
|
expect(status.capabilities).toContain('context-init-scripts');
|
||||||
|
expect(status.capabilities).not.toContain('chromium-launch-args');
|
||||||
|
|
||||||
|
await cdpBrowser.close();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should 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(), isClosed: () => false };
|
||||||
|
const mockContext = {
|
||||||
|
pages: () => [mockPage],
|
||||||
|
on: vi.fn(),
|
||||||
|
setDefaultTimeout: vi.fn(),
|
||||||
|
addInitScript,
|
||||||
|
};
|
||||||
|
const mockBrowser = {
|
||||||
|
contexts: () => [mockContext],
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
isConnected: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||||
|
|
||||||
|
const cdpBrowser = new BrowserManager();
|
||||||
|
await cdpBrowser.launch({ cdpPort: 9222, stealth: false });
|
||||||
|
|
||||||
|
expect(addInitScript).toHaveBeenCalledTimes(1);
|
||||||
|
const status = cdpBrowser.getStealthStatus();
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.connectionKind).toBe('cdp');
|
||||||
|
expect(status.capabilities).toContain('context-init-scripts');
|
||||||
|
expect(status.capabilities).not.toContain('chromium-launch-args');
|
||||||
|
|
||||||
|
await cdpBrowser.close();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stale session recovery (all pages closed)', () => {
|
describe('stale session recovery (all pages closed)', () => {
|
||||||
@@ -854,15 +927,16 @@ describe('BrowserManager', () => {
|
|||||||
contexts: () => [
|
contexts: () => [
|
||||||
{
|
{
|
||||||
pages: () => [
|
pages: () => [
|
||||||
{ url: () => 'http://example.com', on: vi.fn() },
|
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
|
||||||
{ url: () => '', on: vi.fn() }, // This page should be filtered out
|
{ url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out
|
||||||
{ url: () => 'http://anothersite.com', on: vi.fn() },
|
{ url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false },
|
||||||
],
|
],
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
setDefaultTimeout: 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);
|
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||||
|
|
||||||
@@ -878,6 +952,65 @@ describe('BrowserManager', () => {
|
|||||||
expect(urls).toContain('http://example.com');
|
expect(urls).toContain('http://example.com');
|
||||||
spy.mockRestore();
|
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', () => {
|
describe('screencast', () => {
|
||||||
|
|||||||
+516
-47
@@ -27,6 +27,12 @@ import {
|
|||||||
decryptData,
|
decryptData,
|
||||||
ENCRYPTION_KEY_ENV,
|
ENCRYPTION_KEY_ENV,
|
||||||
} from './state-utils.js';
|
} from './state-utils.js';
|
||||||
|
import {
|
||||||
|
STEALTH_CHROMIUM_ARGS,
|
||||||
|
applyStealthScripts,
|
||||||
|
applyBrowserLevelStealth,
|
||||||
|
type StealthScriptOptions,
|
||||||
|
} from './stealth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default Playwright timeout in milliseconds for standard operations.
|
* Returns the default Playwright timeout in milliseconds for standard operations.
|
||||||
@@ -89,6 +95,38 @@ interface PageError {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BrowserType = NonNullable<LaunchCommand['browser']>;
|
||||||
|
type StealthConnectionKind =
|
||||||
|
| 'local'
|
||||||
|
| 'cdp'
|
||||||
|
| 'provider-browserbase'
|
||||||
|
| 'provider-browseruse'
|
||||||
|
| 'provider-kernel';
|
||||||
|
|
||||||
|
interface StealthPolicy {
|
||||||
|
enabled: boolean;
|
||||||
|
connectionKind: StealthConnectionKind;
|
||||||
|
applyChromiumArgs: boolean;
|
||||||
|
applyInitScripts: boolean;
|
||||||
|
providerManaged: boolean;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StealthStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
connectionKind: StealthConnectionKind;
|
||||||
|
capabilities: string[];
|
||||||
|
providerManaged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StealthContextDefaults {
|
||||||
|
locale?: string;
|
||||||
|
timezoneId?: string;
|
||||||
|
extraHTTPHeaders?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +154,12 @@ export class BrowserManager {
|
|||||||
private lastSnapshot: string = '';
|
private lastSnapshot: string = '';
|
||||||
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||||
|
private stealthEnabled: boolean = true;
|
||||||
|
private stealthConnectionKind: StealthConnectionKind = 'local';
|
||||||
|
private contextLocale: string | undefined = undefined;
|
||||||
|
private contextTimezoneId: string | undefined = undefined;
|
||||||
|
private contextHeaders: Record<string, string> | undefined = undefined;
|
||||||
|
private contextUserAgent: string | undefined = undefined;
|
||||||
private downloadPath: string | null = null;
|
private downloadPath: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +170,289 @@ export class BrowserManager {
|
|||||||
this.colorScheme = scheme;
|
this.colorScheme = scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized stealth policy so launch mode semantics stay consistent.
|
||||||
|
* Local Chromium gets args + init scripts; CDP/providers get init scripts only.
|
||||||
|
*/
|
||||||
|
private getStealthPolicy(browserType: BrowserType = 'chromium'): StealthPolicy {
|
||||||
|
const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium';
|
||||||
|
const applyInitScripts = true;
|
||||||
|
const providerManaged = this.stealthConnectionKind === 'provider-kernel';
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
|
||||||
|
if (applyChromiumArgs) {
|
||||||
|
capabilities.push('chromium-launch-args');
|
||||||
|
}
|
||||||
|
if (applyInitScripts) {
|
||||||
|
capabilities.push('context-init-scripts');
|
||||||
|
}
|
||||||
|
if (providerManaged) {
|
||||||
|
capabilities.push('provider-managed-stealth');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
connectionKind: this.stealthConnectionKind,
|
||||||
|
applyChromiumArgs,
|
||||||
|
applyInitScripts,
|
||||||
|
providerManaged,
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private logStealthPolicy(phase: string, browserType: BrowserType = 'chromium'): void {
|
||||||
|
if (process.env.AGENT_BROWSER_DEBUG !== '1') return;
|
||||||
|
const policy = this.getStealthPolicy(browserType);
|
||||||
|
const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none';
|
||||||
|
console.error(
|
||||||
|
`[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStealthStatus(browserType: BrowserType = 'chromium'): StealthStatus {
|
||||||
|
const policy = this.getStealthPolicy(browserType);
|
||||||
|
return {
|
||||||
|
enabled: policy.enabled,
|
||||||
|
connectionKind: policy.connectionKind,
|
||||||
|
capabilities: policy.capabilities,
|
||||||
|
providerManaged: policy.providerManaged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLocaleTag(locale?: string): string | undefined {
|
||||||
|
if (!locale) return undefined;
|
||||||
|
const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
|
||||||
|
if (!cleaned) return undefined;
|
||||||
|
try {
|
||||||
|
return new Intl.Locale(cleaned).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAcceptLanguageHeader(locale: string): string {
|
||||||
|
const baseLanguage = locale.split('-')[0];
|
||||||
|
if (!baseLanguage || baseLanguage === locale) {
|
||||||
|
return `${locale};q=0.9`;
|
||||||
|
}
|
||||||
|
return `${locale},${baseLanguage};q=0.9`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaderValue(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
name: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === target) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.LC_ALL,
|
||||||
|
process.env.LC_MESSAGES,
|
||||||
|
process.env.LANG,
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().locale,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = this.normalizeLocaleTag(candidate);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveStealthTimezoneId(): string | undefined {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStealthContextDefaults(
|
||||||
|
policy: StealthPolicy,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): StealthContextDefaults {
|
||||||
|
if (!policy.enabled) {
|
||||||
|
return { extraHTTPHeaders: headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = this.resolveStealthLocale(headers);
|
||||||
|
const timezoneId = this.resolveStealthTimezoneId();
|
||||||
|
const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined;
|
||||||
|
const extraHTTPHeaders = hasAcceptLanguage
|
||||||
|
? headers
|
||||||
|
: {
|
||||||
|
...(headers ?? {}),
|
||||||
|
'Accept-Language': this.buildAcceptLanguageHeader(locale),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
timezoneId,
|
||||||
|
extraHTTPHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractChromiumVersion(versionText: string): string | undefined {
|
||||||
|
const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStealthChromiumUserAgent(chromeVersion: string): string {
|
||||||
|
const platform = os.platform();
|
||||||
|
let osToken = 'X11; Linux x86_64';
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
osToken = 'Macintosh; Intel Mac OS X 10_15_7';
|
||||||
|
} else if (platform === 'win32') {
|
||||||
|
osToken = 'Windows NT 10.0; Win64; x64';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` +
|
||||||
|
`(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStealthUserAgentVersionHint(): string | undefined {
|
||||||
|
const deviceUA = devices['Desktop Chrome']?.userAgent;
|
||||||
|
if (!deviceUA) return undefined;
|
||||||
|
return this.extractChromiumVersion(deviceUA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply context init-script stealth patches when policy allows.
|
||||||
|
*/
|
||||||
|
private async applyStealthIfEnabled(
|
||||||
|
context: BrowserContext,
|
||||||
|
options: StealthScriptOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const policy = this.getStealthPolicy();
|
||||||
|
if (!policy.applyInitScripts) return;
|
||||||
|
await applyStealthScripts(context, {
|
||||||
|
...options,
|
||||||
|
userAgent: this.contextUserAgent,
|
||||||
|
});
|
||||||
|
this.logStealthPolicy('init-script applied');
|
||||||
|
}
|
||||||
|
|
||||||
// CDP session for screencast and input injection
|
// CDP session for screencast and input injection
|
||||||
private cdpSession: CDPSession | null = null;
|
private cdpSession: CDPSession | null = null;
|
||||||
private screencastActive: boolean = false;
|
private screencastActive: boolean = false;
|
||||||
@@ -266,6 +593,33 @@ export class BrowserManager {
|
|||||||
return this.pages.length > 0;
|
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
|
* 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.
|
* were closed (stale session), creates a new page on the existing context.
|
||||||
@@ -281,8 +635,13 @@ export class BrowserManager {
|
|||||||
context = this.contexts[this.contexts.length - 1];
|
context = this.contexts[this.contexts.length - 1];
|
||||||
} else if (this.browser) {
|
} else if (this.browser) {
|
||||||
context = await this.browser.newContext({
|
context = await this.browser.newContext({
|
||||||
|
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
|
||||||
|
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -305,6 +664,24 @@ export class BrowserManager {
|
|||||||
if (this.pages.length === 0) {
|
if (this.pages.length === 0) {
|
||||||
throw new Error('Browser not launched. Call launch first.');
|
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];
|
return this.pages[this.activePageIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +1163,7 @@ export class BrowserManager {
|
|||||||
try {
|
try {
|
||||||
const contexts = this.browser.contexts();
|
const contexts = this.browser.contexts();
|
||||||
if (contexts.length === 0) return false;
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -853,6 +1230,7 @@ export class BrowserManager {
|
|||||||
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
|
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
|
||||||
*/
|
*/
|
||||||
private async connectToBrowserbase(): Promise<void> {
|
private async connectToBrowserbase(): Promise<void> {
|
||||||
|
this.stealthConnectionKind = 'provider-browserbase';
|
||||||
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
|
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
|
||||||
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
|
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
|
||||||
|
|
||||||
@@ -890,6 +1268,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const context = contexts[0];
|
const context = contexts[0];
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
const page = pages[0] ?? (await context.newPage());
|
const page = pages[0] ?? (await context.newPage());
|
||||||
|
|
||||||
@@ -960,6 +1339,7 @@ export class BrowserManager {
|
|||||||
* Requires KERNEL_API_KEY environment variable.
|
* Requires KERNEL_API_KEY environment variable.
|
||||||
*/
|
*/
|
||||||
private async connectToKernel(): Promise<void> {
|
private async connectToKernel(): Promise<void> {
|
||||||
|
this.stealthConnectionKind = 'provider-kernel';
|
||||||
const kernelApiKey = process.env.KERNEL_API_KEY;
|
const kernelApiKey = process.env.KERNEL_API_KEY;
|
||||||
if (!kernelApiKey) {
|
if (!kernelApiKey) {
|
||||||
throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
|
throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
|
||||||
@@ -1027,9 +1407,11 @@ export class BrowserManager {
|
|||||||
// Kernel browsers launch with a default context and page
|
// Kernel browsers launch with a default context and page
|
||||||
if (contexts.length === 0) {
|
if (contexts.length === 0) {
|
||||||
context = await browser.newContext();
|
context = await browser.newContext();
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
} else {
|
} else {
|
||||||
context = contexts[0];
|
context = contexts[0];
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
page = pages[0] ?? (await context.newPage());
|
page = pages[0] ?? (await context.newPage());
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1438,7 @@ export class BrowserManager {
|
|||||||
* Requires BROWSER_USE_API_KEY environment variable.
|
* Requires BROWSER_USE_API_KEY environment variable.
|
||||||
*/
|
*/
|
||||||
private async connectToBrowserUse(): Promise<void> {
|
private async connectToBrowserUse(): Promise<void> {
|
||||||
|
this.stealthConnectionKind = 'provider-browseruse';
|
||||||
const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
|
const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
|
||||||
if (!browserUseApiKey) {
|
if (!browserUseApiKey) {
|
||||||
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
|
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
|
||||||
@@ -1100,9 +1483,11 @@ export class BrowserManager {
|
|||||||
|
|
||||||
if (contexts.length === 0) {
|
if (contexts.length === 0) {
|
||||||
context = await browser.newContext();
|
context = await browser.newContext();
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
} else {
|
} else {
|
||||||
context = contexts[0];
|
context = contexts[0];
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
const pages = context.pages();
|
const pages = context.pages();
|
||||||
page = pages[0] ?? (await context.newPage());
|
page = pages[0] ?? (await context.newPage());
|
||||||
}
|
}
|
||||||
@@ -1132,23 +1517,12 @@ export class BrowserManager {
|
|||||||
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
||||||
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
||||||
const hasExtensions = !!options.extensions?.length;
|
const hasExtensions = !!options.extensions?.length;
|
||||||
const hasProfile = !!options.profile;
|
|
||||||
const hasStorageState = !!options.storageState;
|
const hasStorageState = !!options.storageState;
|
||||||
|
|
||||||
if (hasExtensions && cdpEndpoint) {
|
if (hasExtensions && cdpEndpoint) {
|
||||||
throw new Error('Extensions cannot be used with CDP connection');
|
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) {
|
if (hasStorageState && hasExtensions) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Storage state cannot be used with extensions (extensions require persistent context)'
|
'Storage state cannot be used with extensions (extensions require persistent context)'
|
||||||
@@ -1173,6 +1547,26 @@ export class BrowserManager {
|
|||||||
if (options.colorScheme) {
|
if (options.colorScheme) {
|
||||||
this.colorScheme = options.colorScheme;
|
this.colorScheme = options.colorScheme;
|
||||||
}
|
}
|
||||||
|
this.stealthEnabled = true;
|
||||||
|
this.contextLocale = this.resolveStealthLocale(options.headers);
|
||||||
|
this.contextTimezoneId = this.resolveStealthTimezoneId();
|
||||||
|
this.contextHeaders = undefined;
|
||||||
|
this.contextUserAgent = options.userAgent;
|
||||||
|
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||||
|
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||||
|
|
||||||
|
if (cdpEndpoint || options.autoConnect) {
|
||||||
|
this.stealthConnectionKind = 'cdp';
|
||||||
|
} else if (provider === 'browserbase') {
|
||||||
|
this.stealthConnectionKind = 'provider-browserbase';
|
||||||
|
} else if (provider === 'browseruse') {
|
||||||
|
this.stealthConnectionKind = 'provider-browseruse';
|
||||||
|
} else if (provider === 'kernel') {
|
||||||
|
this.stealthConnectionKind = 'provider-kernel';
|
||||||
|
} else {
|
||||||
|
this.stealthConnectionKind = 'local';
|
||||||
|
}
|
||||||
|
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
||||||
|
|
||||||
if (options.downloadPath) {
|
if (options.downloadPath) {
|
||||||
this.downloadPath = options.downloadPath;
|
this.downloadPath = options.downloadPath;
|
||||||
@@ -1196,8 +1590,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
||||||
// -p flag takes precedence over env var
|
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
|
||||||
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
|
||||||
if (this.downloadPath && provider) {
|
if (this.downloadPath && provider) {
|
||||||
const warning =
|
const warning =
|
||||||
"--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
|
"--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
|
||||||
@@ -1249,16 +1642,45 @@ export class BrowserManager {
|
|||||||
const launcher =
|
const launcher =
|
||||||
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
||||||
|
|
||||||
// Build base args array with file access flags if enabled
|
// Chromium launches always use the Chrome channel unless a custom executable is provided.
|
||||||
// --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
|
const chromeChannel =
|
||||||
// --allow-file-access: allows the browser to access local files in general
|
browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined;
|
||||||
|
|
||||||
|
const stealthPolicy = this.getStealthPolicy(browserType);
|
||||||
|
const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
|
||||||
|
const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
|
||||||
|
this.contextLocale = contextDefaults.locale;
|
||||||
|
this.contextTimezoneId = contextDefaults.timezoneId;
|
||||||
|
this.contextHeaders = contextDefaults.extraHTTPHeaders;
|
||||||
|
|
||||||
|
let contextUserAgent = options.userAgent;
|
||||||
|
if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||||
|
const versionHint = this.getStealthUserAgentVersionHint();
|
||||||
|
if (versionHint) {
|
||||||
|
contextUserAgent = this.buildStealthChromiumUserAgent(versionHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.contextUserAgent = contextUserAgent;
|
||||||
|
|
||||||
|
// Build base args array with file access flags and stealth args when policy allows.
|
||||||
const fileAccessArgs = options.allowFileAccess
|
const fileAccessArgs = options.allowFileAccess
|
||||||
? ['--allow-file-access-from-files', '--allow-file-access']
|
? ['--allow-file-access-from-files', '--allow-file-access']
|
||||||
: [];
|
: [];
|
||||||
|
const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
|
||||||
|
const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent='));
|
||||||
|
const launchUserAgentArgs =
|
||||||
|
!hasUserAgentArg &&
|
||||||
|
!options.userAgent &&
|
||||||
|
stealthPolicy.enabled &&
|
||||||
|
browserType === 'chromium' &&
|
||||||
|
contextUserAgent
|
||||||
|
? [`--user-agent=${contextUserAgent}`]
|
||||||
|
: [];
|
||||||
|
const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs];
|
||||||
const baseArgs = options.args
|
const baseArgs = options.args
|
||||||
? [...fileAccessArgs, ...options.args]
|
? [...implicitArgs, ...options.args]
|
||||||
: fileAccessArgs.length > 0
|
: implicitArgs.length > 0
|
||||||
? fileAccessArgs
|
? implicitArgs
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Auto-detect args that control window size and disable viewport emulation
|
// Auto-detect args that control window size and disable viewport emulation
|
||||||
@@ -1286,10 +1708,13 @@ export class BrowserManager {
|
|||||||
{
|
{
|
||||||
headless: false,
|
headless: false,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
|
...(chromeChannel && { channel: chromeChannel }),
|
||||||
args: allArgs,
|
args: allArgs,
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders,
|
||||||
userAgent: options.userAgent,
|
userAgent: contextUserAgent,
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
@@ -1297,33 +1722,31 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.isPersistentContext = true;
|
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 ?? true,
|
|
||||||
executablePath: options.executablePath,
|
|
||||||
args: baseArgs,
|
|
||||||
viewport,
|
|
||||||
extraHTTPHeaders: options.headers,
|
|
||||||
userAgent: options.userAgent,
|
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
|
||||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
|
||||||
});
|
|
||||||
this.isPersistentContext = true;
|
|
||||||
} else {
|
} else {
|
||||||
// Regular ephemeral browser
|
// Regular ephemeral browser
|
||||||
this.browser = await launcher.launch({
|
this.browser = await launcher.launch({
|
||||||
headless: options.headless ?? true,
|
headless: options.headless ?? false,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
|
...(chromeChannel && { channel: chromeChannel }),
|
||||||
args: baseArgs,
|
args: baseArgs,
|
||||||
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
...(this.downloadPath && { downloadsPath: this.downloadPath }),
|
||||||
});
|
});
|
||||||
this.cdpEndpoint = null;
|
this.cdpEndpoint = null;
|
||||||
|
|
||||||
|
if (stealthPolicy.enabled && browserType === 'chromium') {
|
||||||
|
await applyBrowserLevelStealth(this.browser, {
|
||||||
|
userAgent: contextUserAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
|
||||||
|
const runtimeVersion = this.extractChromiumVersion(this.browser.version());
|
||||||
|
if (runtimeVersion) {
|
||||||
|
contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion);
|
||||||
|
this.contextUserAgent = contextUserAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for auto-load state file (supports encrypted files)
|
// Check for auto-load state file (supports encrypted files)
|
||||||
let storageState:
|
let storageState:
|
||||||
| string
|
| string
|
||||||
@@ -1393,15 +1816,19 @@ export class BrowserManager {
|
|||||||
|
|
||||||
context = await this.browser.newContext({
|
context = await this.browser.newContext({
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders,
|
||||||
userAgent: options.userAgent,
|
userAgent: contextUserAgent,
|
||||||
storageState,
|
storageState,
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(options.proxy && { proxy: options.proxy }),
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
|
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -1423,6 +1850,7 @@ export class BrowserManager {
|
|||||||
cdpEndpoint: string | undefined,
|
cdpEndpoint: string | undefined,
|
||||||
options?: { timeout?: number }
|
options?: { timeout?: number }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.stealthConnectionKind = 'cdp';
|
||||||
if (!cdpEndpoint) {
|
if (!cdpEndpoint) {
|
||||||
throw new Error('CDP endpoint is required for CDP connection');
|
throw new Error('CDP endpoint is required for CDP connection');
|
||||||
}
|
}
|
||||||
@@ -1465,11 +1893,32 @@ export class BrowserManager {
|
|||||||
throw new Error('No browser context found. Make sure the app has an open window.');
|
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
|
let allPages = this.collectUsableCDPPages(contexts);
|
||||||
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
|
|
||||||
|
|
||||||
if (allPages.length === 0) {
|
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
|
// All validation passed - commit state
|
||||||
@@ -1477,6 +1926,7 @@ export class BrowserManager {
|
|||||||
this.cdpEndpoint = cdpEndpoint;
|
this.cdpEndpoint = cdpEndpoint;
|
||||||
|
|
||||||
for (const context of contexts) {
|
for (const context of contexts) {
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(10000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -1573,7 +2023,7 @@ export class BrowserManager {
|
|||||||
* Discovery strategy:
|
* Discovery strategy:
|
||||||
* 1. Read DevToolsActivePort from Chrome's default user data directories
|
* 1. Read DevToolsActivePort from Chrome's default user data directories
|
||||||
* 2. If found, connect using the port and WebSocket path from that file
|
* 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
|
* 4. If a port responds, connect via CDP
|
||||||
*/
|
*/
|
||||||
private async autoConnectViaCDP(): Promise<void> {
|
private async autoConnectViaCDP(): Promise<void> {
|
||||||
@@ -1608,7 +2058,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Probe common debugging ports
|
// Strategy 2: Probe common debugging ports
|
||||||
const commonPorts = [9222, 9229];
|
const commonPorts = [9222, 9229, 9333];
|
||||||
for (const port of commonPorts) {
|
for (const port of commonPorts) {
|
||||||
const wsUrl = await this.probeDebugPort(port);
|
const wsUrl = await this.probeDebugPort(port);
|
||||||
if (wsUrl) {
|
if (wsUrl) {
|
||||||
@@ -1664,6 +2114,9 @@ export class BrowserManager {
|
|||||||
const index = this.pages.indexOf(page);
|
const index = this.pages.indexOf(page);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.pages.splice(index, 1);
|
this.pages.splice(index, 1);
|
||||||
|
if (index < this.activePageIndex) {
|
||||||
|
this.activePageIndex--;
|
||||||
|
}
|
||||||
if (this.activePageIndex >= this.pages.length) {
|
if (this.activePageIndex >= this.pages.length) {
|
||||||
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
||||||
}
|
}
|
||||||
@@ -1677,6 +2130,11 @@ export class BrowserManager {
|
|||||||
*/
|
*/
|
||||||
private setupContextTracking(context: BrowserContext): void {
|
private setupContextTracking(context: BrowserContext): void {
|
||||||
context.on('page', (page) => {
|
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)
|
// Only add if not already tracked (avoids duplicates when newTab() creates pages)
|
||||||
if (!this.pages.includes(page)) {
|
if (!this.pages.includes(page)) {
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
@@ -1732,8 +2190,13 @@ export class BrowserManager {
|
|||||||
|
|
||||||
const context = await this.browser.newContext({
|
const context = await this.browser.newContext({
|
||||||
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
|
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
|
||||||
|
...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
|
||||||
|
...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
|
||||||
|
...(this.contextLocale && { locale: this.contextLocale }),
|
||||||
|
...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
|
||||||
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
...(this.colorScheme && { colorScheme: this.colorScheme }),
|
||||||
});
|
});
|
||||||
|
await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
|
||||||
context.setDefaultTimeout(getDefaultTimeout());
|
context.setDefaultTimeout(getDefaultTimeout());
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
this.setupContextTracking(context);
|
this.setupContextTracking(context);
|
||||||
@@ -2473,6 +2936,12 @@ export class BrowserManager {
|
|||||||
this.isPersistentContext = false;
|
this.isPersistentContext = false;
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.colorScheme = null;
|
this.colorScheme = null;
|
||||||
|
this.stealthEnabled = true;
|
||||||
|
this.stealthConnectionKind = 'local';
|
||||||
|
this.contextLocale = undefined;
|
||||||
|
this.contextTimezoneId = undefined;
|
||||||
|
this.contextHeaders = undefined;
|
||||||
|
this.contextUserAgent = undefined;
|
||||||
this.refMap = {};
|
this.refMap = {};
|
||||||
this.lastSnapshot = '';
|
this.lastSnapshot = '';
|
||||||
this.frameCallback = null;
|
this.frameCallback = null;
|
||||||
|
|||||||
+40
-5
@@ -405,7 +405,9 @@ export async function startDaemon(options?: {
|
|||||||
continue;
|
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 (
|
if (
|
||||||
!manager.isLaunched() &&
|
!manager.isLaunched() &&
|
||||||
parseResult.command.action !== 'launch' &&
|
parseResult.command.action !== 'launch' &&
|
||||||
@@ -450,29 +452,62 @@ export async function startDaemon(options?: {
|
|||||||
|
|
||||||
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
|
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
|
||||||
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
||||||
|
// Stealth is always enabled in agent-browser-stealth
|
||||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||||
const colorScheme =
|
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
|
||||||
colorSchemeEnv === 'dark' ||
|
colorSchemeEnv === 'dark' ||
|
||||||
colorSchemeEnv === 'light' ||
|
colorSchemeEnv === 'light' ||
|
||||||
colorSchemeEnv === 'no-preference'
|
colorSchemeEnv === 'no-preference'
|
||||||
? colorSchemeEnv
|
? colorSchemeEnv
|
||||||
: undefined;
|
: undefined;
|
||||||
await manager.launch({
|
const launchOptions = {
|
||||||
id: 'auto',
|
id: 'auto',
|
||||||
action: 'launch' as const,
|
action: 'launch' as const,
|
||||||
headless: process.env.AGENT_BROWSER_HEADED !== '1',
|
headless: process.env.AGENT_BROWSER_HEADED !== '1',
|
||||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
profile: process.env.AGENT_BROWSER_PROFILE,
|
|
||||||
storageState: process.env.AGENT_BROWSER_STATE,
|
storageState: process.env.AGENT_BROWSER_STATE,
|
||||||
args,
|
args,
|
||||||
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
|
||||||
proxy,
|
proxy,
|
||||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||||
allowFileAccess: allowFileAccess,
|
allowFileAccess: allowFileAccess,
|
||||||
|
|
||||||
colorScheme,
|
colorScheme,
|
||||||
autoStateFilePath: getSessionAutoStatePath(),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import { parseCommand } from './protocol.js';
|
|||||||
const cmd = (obj: object) => JSON.stringify(obj);
|
const cmd = (obj: object) => JSON.stringify(obj);
|
||||||
|
|
||||||
describe('parseCommand', () => {
|
describe('parseCommand', () => {
|
||||||
|
describe('launch', () => {
|
||||||
|
it('should parse launch command 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 as any).stealth).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('navigation', () => {
|
describe('navigation', () => {
|
||||||
it('should parse navigate command', () => {
|
it('should parse navigate command', () => {
|
||||||
const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' }));
|
const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' }));
|
||||||
@@ -31,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', () => {
|
it('should reject navigate without url', () => {
|
||||||
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
|
const result = parseCommand(cmd({ id: '1', action: 'navigate' }));
|
||||||
expect(result.success).toBe(false);
|
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', () => {
|
it('should parse back command', () => {
|
||||||
const result = parseCommand(cmd({ id: '1', action: 'back' }));
|
const result = parseCommand(cmd({ id: '1', action: 'back' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,6 @@ const launchSchema = baseCommandSchema.extend({
|
|||||||
allowFileAccess: z.boolean().optional(),
|
allowFileAccess: z.boolean().optional(),
|
||||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||||
downloadPath: z.string().optional(),
|
downloadPath: z.string().optional(),
|
||||||
profile: z.string().optional(),
|
|
||||||
storageState: z.string().optional(),
|
storageState: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +59,7 @@ const navigateSchema = baseCommandSchema.extend({
|
|||||||
url: z.string().min(1),
|
url: z.string().min(1),
|
||||||
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
|
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
|
riskMode: z.enum(['off', 'warn', 'block']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const clickSchema = baseCommandSchema.extend({
|
const clickSchema = baseCommandSchema.extend({
|
||||||
@@ -810,6 +810,7 @@ const waitSchema = baseCommandSchema.extend({
|
|||||||
action: z.literal('wait'),
|
action: z.literal('wait'),
|
||||||
selector: z.string().min(1).optional(),
|
selector: z.string().min(1).optional(),
|
||||||
timeout: z.number().positive().optional(),
|
timeout: z.number().positive().optional(),
|
||||||
|
timeoutMax: z.number().positive().optional(),
|
||||||
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
|
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { BrowserManager } from './browser.js';
|
||||||
|
|
||||||
|
async function readWebdriverSignals(browser: BrowserManager): Promise<{
|
||||||
|
value: boolean | undefined;
|
||||||
|
inNavigator: boolean;
|
||||||
|
ownNavigator: boolean;
|
||||||
|
ownPrototype: boolean;
|
||||||
|
}> {
|
||||||
|
const page = browser.getPage();
|
||||||
|
await page.goto('about:blank');
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const prototype = Object.getPrototypeOf(navigator);
|
||||||
|
return {
|
||||||
|
value: navigator.webdriver,
|
||||||
|
inNavigator: 'webdriver' in navigator,
|
||||||
|
ownNavigator: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'),
|
||||||
|
ownPrototype: Object.prototype.hasOwnProperty.call(prototype, 'webdriver'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Stealth mode', () => {
|
||||||
|
let browser: BrowserManager;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (browser?.isLaunched()) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes navigator.webdriver when stealth is enabled', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await readWebdriverSignals(browser);
|
||||||
|
expect(signals.value).toBeUndefined();
|
||||||
|
expect(signals.inNavigator).toBe(false);
|
||||||
|
expect(signals.ownNavigator).toBe(false);
|
||||||
|
expect(signals.ownPrototype).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies stealth patches to contexts created by newWindow', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
await browser.newWindow();
|
||||||
|
|
||||||
|
const signals = await readWebdriverSignals(browser);
|
||||||
|
expect(signals.value).toBeUndefined();
|
||||||
|
expect(signals.inNavigator).toBe(false);
|
||||||
|
expect(signals.ownNavigator).toBe(false);
|
||||||
|
expect(signals.ownPrototype).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => {
|
||||||
|
const previousLocale = process.env.AGENT_BROWSER_LOCALE;
|
||||||
|
process.env.AGENT_BROWSER_LOCALE = 'fr-FR';
|
||||||
|
|
||||||
|
try {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const languageSignals = await browser.getPage().evaluate(() => ({
|
||||||
|
language: navigator.language,
|
||||||
|
languages: navigator.languages,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(languageSignals.language).toBe('fr-FR');
|
||||||
|
expect(languageSignals.languages).toEqual(['fr-FR', 'fr']);
|
||||||
|
} finally {
|
||||||
|
if (previousLocale === undefined) {
|
||||||
|
delete process.env.AGENT_BROWSER_LOCALE;
|
||||||
|
} else {
|
||||||
|
process.env.AGENT_BROWSER_LOCALE = previousLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const userAgentSignals = await browser.getPage().evaluate(async () => {
|
||||||
|
const pageUA = navigator.userAgent;
|
||||||
|
const workerUA = await new Promise<string>((resolve) => {
|
||||||
|
const source = 'postMessage(navigator.userAgent);';
|
||||||
|
const blob = new Blob([source], { type: 'application/javascript' });
|
||||||
|
const worker = new Worker(URL.createObjectURL(blob));
|
||||||
|
worker.onmessage = (event) => resolve(String(event.data));
|
||||||
|
});
|
||||||
|
return { pageUA, workerUA };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome');
|
||||||
|
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralizes the css webdriver heuristic probe', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => ({
|
||||||
|
probe: CSS.supports('border-end-end-radius: initial'),
|
||||||
|
baseline: CSS.supports('display: block'),
|
||||||
|
webdriver: navigator.webdriver,
|
||||||
|
inNavigator: 'webdriver' in navigator,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(signals.probe).toBe(false);
|
||||||
|
expect(signals.baseline).toBe(true);
|
||||||
|
expect(signals.webdriver).toBeUndefined();
|
||||||
|
expect(signals.inNavigator).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralizes creepjs prefers-color-scheme light probe', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.setAttribute('style', 'background-color: ActiveText');
|
||||||
|
document.body.appendChild(node);
|
||||||
|
const activeTextColor = getComputedStyle(node).backgroundColor;
|
||||||
|
node.remove();
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTextColor,
|
||||||
|
prefersLight: matchMedia('(prefers-color-scheme: light)').matches,
|
||||||
|
prefersDark: matchMedia('(prefers-color-scheme: dark)').matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)');
|
||||||
|
expect(signals.prefersLight).toBe(false);
|
||||||
|
expect(typeof signals.prefersDark).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes realistic mimeTypes/pdf/share signals', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => ({
|
||||||
|
mimeTypesLength: navigator.mimeTypes ? navigator.mimeTypes.length : 0,
|
||||||
|
pdfViewerEnabled: navigator.pdfViewerEnabled,
|
||||||
|
hasShare: typeof navigator.share === 'function',
|
||||||
|
hasCanShare: typeof navigator.canShare === 'function',
|
||||||
|
hasConnectionDownlinkMax:
|
||||||
|
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
|
||||||
|
hasConnectionDownlinkMaxOnProto:
|
||||||
|
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(signals.mimeTypesLength).toBeGreaterThan(0);
|
||||||
|
expect(signals.pdfViewerEnabled).toBe(true);
|
||||||
|
expect(signals.hasShare).toBe(true);
|
||||||
|
expect(signals.hasCanShare).toBe(true);
|
||||||
|
expect(signals.hasConnectionDownlinkMax).toBe(true);
|
||||||
|
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes contacts manager and content index APIs', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const signals = await browser.getPage().evaluate(() => ({
|
||||||
|
hasContacts: 'contacts' in navigator,
|
||||||
|
contactsManagerCtor: typeof (window as any).ContactsManager === 'function',
|
||||||
|
hasContentIndexCtor: typeof (window as any).ContentIndex === 'function',
|
||||||
|
hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined',
|
||||||
|
hasContentIndexOnSWR:
|
||||||
|
typeof ServiceWorkerRegistration !== 'undefined' &&
|
||||||
|
('contentIndex' in ServiceWorkerRegistration.prototype ||
|
||||||
|
'index' in ServiceWorkerRegistration.prototype),
|
||||||
|
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null,
|
||||||
|
screenMatchesViewport:
|
||||||
|
screen.width === window.innerWidth && screen.height === window.innerHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(signals.hasContacts).toBe(true);
|
||||||
|
expect(signals.contactsManagerCtor).toBe(true);
|
||||||
|
expect(signals.hasContentIndexCtor).toBe(true);
|
||||||
|
if (signals.hasServiceWorkerRegistration) {
|
||||||
|
expect(signals.hasContentIndexOnSWR).toBe(true);
|
||||||
|
}
|
||||||
|
expect(signals.notificationPermission).toBe('default');
|
||||||
|
expect(signals.screenMatchesViewport).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes downlinkMax inside dedicated workers', async () => {
|
||||||
|
browser = new BrowserManager();
|
||||||
|
await browser.launch({ headless: true, stealth: true });
|
||||||
|
|
||||||
|
const workerSignals = await browser.getPage().evaluate(async () => {
|
||||||
|
return new Promise<{
|
||||||
|
hasConnection: boolean;
|
||||||
|
hasDownlinkMax: boolean;
|
||||||
|
hasDownlinkMaxOnProto: boolean;
|
||||||
|
downlinkMax: unknown;
|
||||||
|
}>((resolve) => {
|
||||||
|
const source =
|
||||||
|
"postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});";
|
||||||
|
const blob = new Blob([source], { type: 'application/javascript' });
|
||||||
|
const worker = new Worker(URL.createObjectURL(blob));
|
||||||
|
worker.onmessage = (event) => resolve(event.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workerSignals.hasConnection).toBe(true);
|
||||||
|
expect(workerSignals.hasDownlinkMax).toBe(true);
|
||||||
|
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
|
||||||
|
expect(typeof workerSignals.downlinkMax).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
+1150
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -6,6 +6,15 @@ export interface BaseCommand {
|
|||||||
action: string;
|
action: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RiskMode = 'off' | 'warn' | 'block';
|
||||||
|
|
||||||
|
export interface RiskSignal {
|
||||||
|
code: string;
|
||||||
|
source: 'url' | 'title';
|
||||||
|
evidence: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Action-specific command types
|
// Action-specific command types
|
||||||
export interface LaunchCommand extends BaseCommand {
|
export interface LaunchCommand extends BaseCommand {
|
||||||
action: 'launch';
|
action: 'launch';
|
||||||
@@ -18,7 +27,6 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
autoConnect?: boolean; // Auto-discover and connect to running Chrome via DevToolsActivePort
|
autoConnect?: boolean; // Auto-discover and connect to running Chrome via DevToolsActivePort
|
||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
profile?: string; // Path to persistent browser profile directory
|
|
||||||
storageState?: string; // Path to storage state JSON file
|
storageState?: string; // Path to storage state JSON file
|
||||||
proxy?: {
|
proxy?: {
|
||||||
server: string;
|
server: string;
|
||||||
@@ -42,6 +50,8 @@ export interface NavigateCommand extends BaseCommand {
|
|||||||
url: string;
|
url: string;
|
||||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
// off: skip detection/retry, warn: retry then return warning+riskSignals, block: fail fast
|
||||||
|
riskMode?: RiskMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClickCommand extends BaseCommand {
|
export interface ClickCommand extends BaseCommand {
|
||||||
@@ -829,6 +839,7 @@ export interface WaitCommand extends BaseCommand {
|
|||||||
action: 'wait';
|
action: 'wait';
|
||||||
selector?: string;
|
selector?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
timeoutMax?: number; // When set with timeout, waits a random duration in [timeout, timeoutMax]
|
||||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1084,9 @@ export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
|||||||
export interface NavigateData {
|
export interface NavigateData {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
warning?: string;
|
||||||
|
// Structured evidence emitted when verification/captcha patterns are detected.
|
||||||
|
riskSignals?: RiskSignal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Annotation {
|
export interface Annotation {
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ describe('File Access (Issue #345)', () => {
|
|||||||
const content = await page.locator('h1').textContent();
|
const content = await page.locator('h1').textContent();
|
||||||
expect(content).toBe('Test File Access');
|
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);
|
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||||
expect(webdriver).toBe(false);
|
expect(webdriver).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('Launch Options', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('browser args', () => {
|
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();
|
browser = new BrowserManager();
|
||||||
await browser.launch({
|
await browser.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -21,9 +21,9 @@ describe('Launch Options', () => {
|
|||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
await page.goto('about:blank');
|
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);
|
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||||
expect(webdriver).toBe(false);
|
expect(webdriver).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should launch with multiple args', async () => {
|
it('should launch with multiple args', async () => {
|
||||||
@@ -39,7 +39,7 @@ describe('Launch Options', () => {
|
|||||||
expect(browser.isLaunched()).toBe(true);
|
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();
|
browser = new BrowserManager();
|
||||||
await browser.launch({
|
await browser.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -48,9 +48,9 @@ describe('Launch Options', () => {
|
|||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
await page.goto('about:blank');
|
await page.goto('about:blank');
|
||||||
|
|
||||||
// Default Playwright behavior - webdriver is true
|
// Stealth default behavior - webdriver is hidden
|
||||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
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
|
// Verify webdriver is hidden
|
||||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||||
expect(webdriver).toBe(false);
|
expect(webdriver).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user