Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f9c24fdb | ||
|
|
42ade7b4e8 | ||
|
|
2dabed973e | ||
|
|
dd2deff06c | ||
|
|
340886293a | ||
|
|
fc1699a526 | ||
|
|
4bcfe74514 | ||
|
|
bb41c24c08 | ||
|
|
75bd1d21a7 | ||
|
|
06c75af46a |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.23"
|
version = "0.27.0-fork.28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.23"
|
version = "0.27.0-fork.28"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -672,7 +672,10 @@ impl DaemonState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tab_id = mgr.assign_tab_id();
|
let tab_id = mgr.assign_tab_id();
|
||||||
mgr.add_page(super::browser::PageInfo {
|
// Passively discovered (event-driven) — must NOT steal the
|
||||||
|
// active tab, or a foreign/user/other-session tab opening
|
||||||
|
// hijacks this session's eval/screenshot target.
|
||||||
|
mgr.add_background_page(super::browser::PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
label: None,
|
label: None,
|
||||||
target_id: te.target_info.target_id.clone(),
|
target_id: te.target_info.target_id.clone(),
|
||||||
|
|||||||
@@ -1532,6 +1532,21 @@ impl BrowserManager {
|
|||||||
self.active_page_index = index;
|
self.active_page_index = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a passively-discovered page WITHOUT changing the active tab.
|
||||||
|
///
|
||||||
|
/// On a shared browser (ab-connect), `Target.targetCreated` events stream in
|
||||||
|
/// for tabs the user or OTHER agent sessions open. Those are drained on every
|
||||||
|
/// command; routing them through `add_page` made the active tab silently jump
|
||||||
|
/// to a foreign tab, so the session's own `eval`/`get title`/`screenshot`
|
||||||
|
/// landed on the wrong page. Passively-tracked pages must not steal focus —
|
||||||
|
/// only explicit opens (`tab new`, switch) set the active tab.
|
||||||
|
pub fn add_background_page(&mut self, page: PageInfo) {
|
||||||
|
if self.pages.iter().any(|p| p.target_id == page.target_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pages.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
|
pub fn update_page_target_info(&mut self, target: &TargetInfo) -> bool {
|
||||||
update_page_target_info_in_pages(&mut self.pages, target)
|
update_page_target_info_in_pages(&mut self.pages, target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,16 @@ struct ChromeArgs {
|
|||||||
temp_user_data_dir: Option<PathBuf>,
|
temp_user_data_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to launch Chrome headless. The stealth fork FORBIDS headless (it's a
|
||||||
|
/// bot-detection tell), so this is `false` unless an operator explicitly opts in
|
||||||
|
/// via `AGENT_BROWSER_ALLOW_HEADLESS=1` for a display-less server. The `headless`
|
||||||
|
/// LaunchOption is intentionally ignored — headed is non-negotiable for stealth.
|
||||||
|
fn launch_headless() -> bool {
|
||||||
|
std::env::var("AGENT_BROWSER_ALLOW_HEADLESS")
|
||||||
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
|
/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
|
||||||
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
|
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
|
||||||
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
|
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
|
||||||
@@ -202,9 +212,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|exts| !exts.is_empty());
|
.is_some_and(|exts| !exts.is_empty());
|
||||||
|
|
||||||
// Extensions require headed mode in native Chrome (content scripts are not
|
// Stealth fork: NEVER launch headless. Headless Chrome is a detectable tell
|
||||||
// injected in headless mode). Skip --headless when extensions are loaded.
|
// (creepjs scores ~33% headless even with new-headless; a real GPU and a
|
||||||
if options.headless && !has_extensions {
|
// headed window score 0%). So we always launch headed and ignore the
|
||||||
|
// `headless` option. The only escape is an explicit AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||||
|
// for genuinely display-less servers (discouraged — it forfeits stealth).
|
||||||
|
// Extensions also require headed mode (content scripts aren't injected headless).
|
||||||
|
if launch_headless() && !has_extensions {
|
||||||
args.push("--headless=new".to_string());
|
args.push("--headless=new".to_string());
|
||||||
// Linux paints native scrollbars into viewport screenshots unless
|
// Linux paints native scrollbars into viewport screenshots unless
|
||||||
// Chrome is launched with this flag. `--hide-scrollbars` is
|
// Chrome is launched with this flag. `--hide-scrollbars` is
|
||||||
@@ -278,7 +292,7 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||||
|
|
||||||
if !has_window_size && options.headless && !has_extensions {
|
if !has_window_size && launch_headless() && !has_extensions {
|
||||||
let (w, h) = options.viewport_size.unwrap_or((1280, 720));
|
let (w, h) = options.viewport_size.unwrap_or((1280, 720));
|
||||||
args.push(format!("--window-size={},{}", w, h));
|
args.push(format!("--window-size={},{}", w, h));
|
||||||
}
|
}
|
||||||
@@ -1520,24 +1534,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_args_headless_includes_headless_flag() {
|
fn test_build_args_forbids_headless_by_default() {
|
||||||
|
// Stealth fork: headless is FORBIDDEN. `headless: true` is ignored — the
|
||||||
|
// launch is always headed (no --headless / swiftshader / forced size).
|
||||||
|
let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
|
||||||
|
g.remove("AGENT_BROWSER_ALLOW_HEADLESS");
|
||||||
|
let opts = LaunchOptions {
|
||||||
|
headless: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = build_chrome_args(&opts).unwrap();
|
||||||
|
assert!(
|
||||||
|
!result.args.iter().any(|a| a.contains("--headless")),
|
||||||
|
"headless must be forbidden even when the headless option is true"
|
||||||
|
);
|
||||||
|
assert!(!result
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.any(|a| a == "--enable-unsafe-swiftshader"));
|
||||||
|
if let Some(dir) = result.temp_user_data_dir {
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_args_allow_headless_escape() {
|
||||||
|
// The only way back to headless: an explicit opt-in for display-less servers.
|
||||||
|
let g = EnvGuard::new(&["AGENT_BROWSER_ALLOW_HEADLESS"]);
|
||||||
|
g.set("AGENT_BROWSER_ALLOW_HEADLESS", "1");
|
||||||
let opts = LaunchOptions {
|
let opts = LaunchOptions {
|
||||||
headless: true,
|
headless: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let result = build_chrome_args(&opts).unwrap();
|
let result = build_chrome_args(&opts).unwrap();
|
||||||
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
assert!(result.args.iter().any(|a| a == "--headless=new"));
|
||||||
assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
|
|
||||||
assert!(result
|
|
||||||
.args
|
|
||||||
.iter()
|
|
||||||
.any(|a| a == "--enable-unsafe-swiftshader"));
|
|
||||||
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
|
||||||
// Temp dir created when no profile
|
if let Some(dir) = result.temp_user_data_dir {
|
||||||
assert!(result.temp_user_data_dir.is_some());
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
let dir = result.temp_user_data_dir.unwrap();
|
}
|
||||||
assert!(dir.exists());
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ impl RelayState {
|
|||||||
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
|
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
|
||||||
|
|
||||||
match method {
|
match method {
|
||||||
|
// Browser-level command the daemon uses as its liveness probe
|
||||||
|
// (`is_connection_alive` → `Browser.getVersion`). The extension only
|
||||||
|
// speaks per-tab `chrome.debugger`, so forwarding it errors → the
|
||||||
|
// daemon would deem the connection dead and reconnect+re-discover on
|
||||||
|
// EVERY command, resetting the active tab (eval/screenshot drift).
|
||||||
|
// Answer it locally so the relay connection reads as alive.
|
||||||
|
"Browser.getVersion" => ClientRoute::Local(json!({
|
||||||
|
"id": id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "1.3",
|
||||||
|
"product": "Chrome/ab-connect-relay",
|
||||||
|
"revision": "",
|
||||||
|
"userAgent": "",
|
||||||
|
"jsVersion": ""
|
||||||
|
}
|
||||||
|
})),
|
||||||
// Discovery is best-effort and event-driven in real CDP; abs only
|
// Discovery is best-effort and event-driven in real CDP; abs only
|
||||||
// reads the getTargets result, so an empty ack is enough here.
|
// reads the getTargets result, so an empty ack is enough here.
|
||||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||||
@@ -302,6 +318,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_get_version_is_answered_locally() {
|
||||||
|
// Liveness probe must NOT be forwarded (the extension can't do
|
||||||
|
// browser-level commands) — else the daemon reconnects on every command.
|
||||||
|
let mut s = RelayState::new();
|
||||||
|
let route = s.route_client_command(1, &json!({ "id": 7, "method": "Browser.getVersion" }));
|
||||||
|
match route {
|
||||||
|
ClientRoute::Local(v) => {
|
||||||
|
assert_eq!(v["id"], 7);
|
||||||
|
assert!(v["result"]["protocolVersion"].is_string());
|
||||||
|
}
|
||||||
|
_ => panic!("Browser.getVersion must be answered locally"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_to_target_returns_known_session() {
|
fn attach_to_target_returns_known_session() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
|
|||||||
@@ -270,13 +270,18 @@ pub fn strip_source_url_labels(input: &str) -> String {
|
|||||||
re_block.replace_all(&output, "").to_string()
|
re_block.replace_all(&output, "").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legacy `navigator.platform` value (set via the CDP
|
||||||
|
/// `Emulation.setUserAgentOverride` `platform` field). This is NOT the UA-CH
|
||||||
|
/// platform (see `platform_hint`): real Chrome reports `MacIntel` on macOS and
|
||||||
|
/// `Linux x86_64` on Linux, so emitting the UA-CH form ("macOS"/"Linux") here is
|
||||||
|
/// a detectable mismatch against the UA's "Intel Mac OS X" / Linux strings.
|
||||||
fn platform_string() -> &'static str {
|
fn platform_string() -> &'static str {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
"macOS"
|
"MacIntel"
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") {
|
||||||
"Win32"
|
"Win32"
|
||||||
} else {
|
} else {
|
||||||
"Linux"
|
"Linux x86_64"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1082,7 +1082,7 @@ 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)
|
||||||
--headed Show browser window
|
--headed Show browser window (default; headless is forbidden — it's a bot tell)
|
||||||
--enable react-devtools Inject the React DevTools hook before any page JS
|
--enable react-devtools Inject the React DevTools hook before any page JS
|
||||||
--init-script <path> Register a page init script (repeatable)
|
--init-script <path> Register a page init script (repeatable)
|
||||||
|
|
||||||
@@ -3114,7 +3114,8 @@ Options:
|
|||||||
--screenshot-dir <path> Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
|
--screenshot-dir <path> Default screenshot output directory (or AGENT_BROWSER_SCREENSHOT_DIR)
|
||||||
--screenshot-quality <n> JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY)
|
--screenshot-quality <n> JPEG quality 0-100; ignored for PNG (or AGENT_BROWSER_SCREENSHOT_QUALITY)
|
||||||
--screenshot-format <fmt> Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
|
--screenshot-format <fmt> Screenshot format: png, jpeg (or AGENT_BROWSER_SCREENSHOT_FORMAT)
|
||||||
--headed Show browser window (not headless) (or AGENT_BROWSER_HEADED env)
|
--headed Always on (default). Headless is forbidden (bot-detection tell);
|
||||||
|
display-less servers can opt back in with AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||||
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
|
||||||
--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)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.23",
|
"version": "0.27.0-fork.28",
|
||||||
"description": "Browser automation CLI for AI agents \u2014 stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents \u2014 stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ GUI-automation tool** like the `cua-driver` skill — see
|
|||||||
`agent-browser extension connect`. After that it's zero-confirmation, zero-token
|
`agent-browser extension connect`. After that it's zero-confirmation, zero-token
|
||||||
CLI. Use `--launch` instead when a fresh, isolated browser is fine.
|
CLI. Use `--launch` instead when a fresh, isolated browser is fine.
|
||||||
|
|
||||||
|
Each `--session` that connects gets its **own colored Chrome tab group** (named
|
||||||
|
after the session) and drives only its own tabs — multiple agents share the one
|
||||||
|
real browser without cross-talk, and the user's own tabs are never grouped. CDP
|
||||||
|
drives the page without moving the user's mouse/keyboard, so it doesn't fight
|
||||||
|
them for control. **Anti-detection ranking: this real logged-in Chrome (extension
|
||||||
|
connect) > a headed launched browser > headless (forbidden).** A genuine human
|
||||||
|
browser has no headless/automation tells at all, so prefer it for anything
|
||||||
|
anti-bot-sensitive.
|
||||||
|
|
||||||
## Two ways to drive a page — and when to drop to `eval`
|
## Two ways to drive a page — and when to drop to `eval`
|
||||||
|
|
||||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||||
@@ -506,7 +515,9 @@ and [references/authentication.md](references/authentication.md).
|
|||||||
```bash
|
```bash
|
||||||
--session <name> # isolated browser session
|
--session <name> # isolated browser session
|
||||||
--json # JSON output (for machine parsing)
|
--json # JSON output (for machine parsing)
|
||||||
--headed # show the window (default is headless)
|
--headed # default & always-on for stealth — headless is FORBIDDEN
|
||||||
|
# (a bot tell: creepjs flags ~33% headless vs 0% headed).
|
||||||
|
# Display-less servers only: AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||||
--auto-connect # connect to an already-running Chrome
|
--auto-connect # connect to an already-running Chrome
|
||||||
--cdp <port> # connect to a specific CDP port
|
--cdp <port> # connect to a specific CDP port
|
||||||
--profile <name|path> # use a Chrome profile (login state survives)
|
--profile <name|path> # use a Chrome profile (login state survives)
|
||||||
|
|||||||
@@ -302,7 +302,8 @@ agent-browser state load auth.json # Restore saved state
|
|||||||
```bash
|
```bash
|
||||||
agent-browser --session <name> ... # Isolated browser session
|
agent-browser --session <name> ... # Isolated browser session
|
||||||
agent-browser --json ... # JSON output for parsing
|
agent-browser --json ... # JSON output for parsing
|
||||||
agent-browser --headed ... # Show browser window (not headless)
|
agent-browser --headed ... # Default & always-on (stealth). Headless is FORBIDDEN
|
||||||
|
# (bot tell); display-less servers: AGENT_BROWSER_ALLOW_HEADLESS=1
|
||||||
agent-browser --full ... # Full page screenshot (-f)
|
agent-browser --full ... # Full page screenshot (-f)
|
||||||
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
|
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
|
||||||
agent-browser -p <provider> ... # Cloud browser provider (--provider)
|
agent-browser -p <provider> ... # Cloud browser provider (--provider)
|
||||||
|
|||||||
Reference in New Issue
Block a user