Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.9"
|
version = "0.27.0-fork.10"
|
||||||
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.9"
|
version = "0.27.0-fork.10"
|
||||||
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"
|
||||||
|
|||||||
@@ -1066,6 +1066,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// === Get ===
|
// === Get ===
|
||||||
"get" => parse_get(&rest, &id),
|
"get" => parse_get(&rest, &id),
|
||||||
|
|
||||||
|
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||||
|
// `agent-browser url` / `cdp-url` / `title` without the `get` prefix
|
||||||
|
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||||
|
"url" | "cdp-url" | "cdp_url" | "title" | "html" | "text" | "value"
|
||||||
|
| "count" | "box" | "styles" | "attr" => {
|
||||||
|
let sub = if cmd == "cdp_url" { "cdp-url" } else { cmd };
|
||||||
|
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
|
||||||
|
get_args.push(sub);
|
||||||
|
get_args.extend_from_slice(&rest);
|
||||||
|
parse_get(&get_args, &id)
|
||||||
|
}
|
||||||
|
|
||||||
// === Is (state checks) ===
|
// === Is (state checks) ===
|
||||||
"is" => parse_is(&rest, &id),
|
"is" => parse_is(&rest, &id),
|
||||||
|
|
||||||
|
|||||||
+72
-34
@@ -633,6 +633,8 @@ impl DaemonState {
|
|||||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
// Hide automation markers in this cross-origin iframe session too.
|
||||||
|
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for sid in &drained.detached_iframe_sessions {
|
for sid in &drained.detached_iframe_sessions {
|
||||||
@@ -1605,11 +1607,14 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
// Return a helpful error guiding the user to enable it.
|
// Return a helpful error guiding the user to enable it.
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
|
Otherwise, to let agent-browser work with your existing Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
A full restart with --remote-debugging-port=<port> is required.",
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
@@ -1758,45 +1763,62 @@ fn chrome_relaunch_hint() -> &'static str {
|
|||||||
/// Called after every successful launch / CDP connect / auto-connect.
|
/// Called after every successful launch / CDP connect / auto-connect.
|
||||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
fn stealth_enabled() -> bool {
|
||||||
return; // Explicitly disabled
|
!env::var("AGENT_BROWSER_STEALTH")
|
||||||
}
|
.map(|v| v == "0")
|
||||||
let Some(ref mgr) = state.browser else {
|
.unwrap_or(false)
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
let Ok(session_id) = mgr.active_session_id() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
///
|
||||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
/// Stealth scripts are registered per-session via
|
||||||
|
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
|
||||||
|
/// tabs or cross-origin iframe sessions created after the initial page. We must
|
||||||
|
/// re-apply to every session the user can touch, otherwise automation markers
|
||||||
|
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
|
||||||
|
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
|
||||||
|
if !stealth_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Determine mode: an external attach uses minimal patches (the user's real
|
||||||
|
// Chrome already has a genuine fingerprint — heavy patches create detectable
|
||||||
|
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
|
||||||
let mode = if mgr.is_cdp_connection() {
|
let mode = if mgr.is_cdp_connection() {
|
||||||
stealth::StealthMode::CdpAttach
|
stealth::StealthMode::CdpAttach
|
||||||
} else {
|
} else {
|
||||||
stealth::StealthMode::FullLaunch
|
stealth::StealthMode::FullLaunch
|
||||||
};
|
};
|
||||||
|
|
||||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||||
if let Err(e) = stealth::apply_stealth(
|
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||||
&mgr.client,
|
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
|
||||||
session_id,
|
|
||||||
mode,
|
|
||||||
locale.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
|
||||||
}
|
}
|
||||||
// Also inject into the current page (already loaded before our init script)
|
// Also inject into the current page (already loaded before our init script).
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
|
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply stealth to a specific session of the active browser (no-op if no
|
||||||
|
/// browser or stealth disabled).
|
||||||
|
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
|
||||||
|
if let Some(ref mgr) = state.browser {
|
||||||
|
apply_stealth_via_mgr(mgr, session_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply stealth to the active page session (initial connect/launch).
|
||||||
|
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||||
|
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
|
||||||
|
Some(sid) => sid.to_string(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
apply_stealth_to_session(state, &session_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
||||||
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
||||||
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
||||||
@@ -2148,11 +2170,14 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
|||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Could not connect to your Chrome browser.\n\n\
|
"Could not connect to your Chrome browser.\n\n\
|
||||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
If Chrome showed an \"Allow remote debugging?\" dialog, click \
|
||||||
|
Allow and re-run — that consent is what lets agent-browser attach.\n\n\
|
||||||
|
Otherwise, to let agent-browser work with your existing Chrome (recommended):\n\
|
||||||
{}\n\n\
|
{}\n\n\
|
||||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
chrome://inspect/#remote-debugging only enables target discovery and \
|
||||||
|
does NOT expose the CDP HTTP API on /json/version. \
|
||||||
A full restart with --remote-debugging-port=<port> is required.",
|
A full restart with --remote-debugging-port=<port> is required.",
|
||||||
chrome_relaunch_hint(),
|
chrome_relaunch_hint(),
|
||||||
));
|
));
|
||||||
@@ -3948,13 +3973,26 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
|
||||||
let url = cmd.get("url").and_then(|v| v.as_str());
|
let url = cmd.get("url").and_then(|v| v.as_str());
|
||||||
let label = cmd.get("label").and_then(|v| v.as_str());
|
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
mgr.tab_new(url, label).await
|
let result = {
|
||||||
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
|
mgr.tab_new(url, label).await?
|
||||||
|
};
|
||||||
|
// A new tab is a new CDP session; stealth scripts registered on the prior
|
||||||
|
// session don't carry over, so patch the new tab too.
|
||||||
|
if let Some(sid) = state
|
||||||
|
.browser
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.active_session_id().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
|
apply_stealth_to_session(state, &sid).await;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
|||||||
Some((port, ws_path))
|
Some((port, ws_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove leftover Chrome temp profile directories from daemons that were
|
||||||
|
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
|
||||||
|
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
|
||||||
|
/// per session under the system temp dir. On daemon startup we sweep them — but
|
||||||
|
/// ONLY dirs that no running process still references as `--user-data-dir`, so
|
||||||
|
/// a profile in active use is never deleted.
|
||||||
|
pub fn cleanup_orphaned_chrome_profiles() {
|
||||||
|
let tmp = std::env::temp_dir();
|
||||||
|
let Ok(entries) = std::fs::read_dir(&tmp) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Snapshot live process command lines once. If we can't determine them,
|
||||||
|
// skip cleanup entirely rather than risk deleting an in-use profile.
|
||||||
|
let Some(live_cmdlines) = running_process_cmdlines() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let in_use = live_cmdlines
|
||||||
|
.iter()
|
||||||
|
.any(|cmd| cmd.contains(path_str.as_ref()));
|
||||||
|
if !in_use {
|
||||||
|
let _ = std::fs::remove_dir_all(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||||
|
let output = std::process::Command::new("ps")
|
||||||
|
.args(["-axww", "-o", "command="])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn running_process_cmdlines() -> Option<Vec<String>> {
|
||||||
|
// Best-effort: skip cleanup where we can't cheaply enumerate full process
|
||||||
|
// command lines, to avoid deleting a profile that is still in use.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn auto_connect_cdp() -> Result<String, String> {
|
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||||
let user_data_dirs = get_chrome_user_data_dirs();
|
let user_data_dirs = get_chrome_user_data_dirs();
|
||||||
|
|
||||||
@@ -685,7 +741,11 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
|
Err("No running Chrome with remote debugging found. Remote debugging is a \
|
||||||
|
startup flag, not a setting: fully quit Chrome and relaunch it with \
|
||||||
|
--remote-debugging-port=9222 (then agent-browser auto-connects), or pass \
|
||||||
|
--cdp <port>/--launch."
|
||||||
|
.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
|||||||
match discover_cdp_ws(host, port, timeout).await {
|
match discover_cdp_ws(host, port, timeout).await {
|
||||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||||
Err(ws_err) => Err(format!(
|
Err(ws_err) => Err(format!(
|
||||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
"All CDP discovery methods failed for {host}:{port}. \
|
||||||
host, port, version_err, list_err, ws_err
|
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
|
||||||
|
(/json/version, /json/list), so `--cdp <port>` cannot find the target — \
|
||||||
|
use the default auto-connect (just `agent-browser open <url>`), which reads \
|
||||||
|
DevToolsActivePort and attaches over WebSocket. \
|
||||||
|
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ pub async fn run_daemon(session: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
|
||||||
|
// run on kill -9). Only removes dirs no live process references.
|
||||||
|
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
|
||||||
|
|
||||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||||
let _ = fs::write(&pid_path, process::id().to_string());
|
let _ = fs::write(&pid_path, process::id().to_string());
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||||
(function(){
|
(function(){
|
||||||
const removeWebdriver = (target) => {
|
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||||
|
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||||
|
// lie-detection (creepjs). Only intervene when webdriver is still truthy
|
||||||
|
// (e.g. older Chrome without that override) and force it to FALSE.
|
||||||
|
//
|
||||||
|
// Never `delete` webdriver: real Chrome reports `false`, so `undefined` is
|
||||||
|
// itself a tell, and deleting it removes the native `false` the override set.
|
||||||
|
const forceWebdriverFalse = (target) => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
try { delete target.webdriver; } catch {}
|
try {
|
||||||
|
if (target.webdriver === true) {
|
||||||
|
Object.defineProperty(target, 'webdriver', {
|
||||||
|
get: () => false,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
removeWebdriver(navigator);
|
forceWebdriverFalse(navigator);
|
||||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
forceWebdriverFalse(Object.getPrototypeOf(navigator));
|
||||||
removeWebdriver(Navigator.prototype);
|
forceWebdriverFalse(Navigator.prototype);
|
||||||
if (typeof WorkerNavigator !== 'undefined') {
|
if (typeof WorkerNavigator !== 'undefined') {
|
||||||
removeWebdriver(WorkerNavigator.prototype);
|
forceWebdriverFalse(WorkerNavigator.prototype);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
(function(){
|
(function(){
|
||||||
|
|||||||
@@ -1042,6 +1042,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
|
|
||||||
// Default success
|
// Default success
|
||||||
println!("{} Done", color::success_indicator());
|
println!("{} Done", color::success_indicator());
|
||||||
|
} else {
|
||||||
|
// Success response with no data payload — still confirm the command ran
|
||||||
|
// instead of printing nothing (a silent exit 0 looks like a no-op and
|
||||||
|
// hides whether anything happened).
|
||||||
|
println!("{} Done", color::success_indicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
print_warning(resp);
|
print_warning(resp);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.9",
|
"version": "0.27.0-fork.10",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user