Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27dff19105 | ||
|
|
21d591ee65 | ||
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.9"
|
||||
version = "0.27.0-fork.11"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.9"
|
||||
version = "0.27.0-fork.11"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1066,6 +1066,18 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// === Get ===
|
||||
"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" => parse_is(&rest, &id),
|
||||
|
||||
|
||||
+77
-34
@@ -633,6 +633,8 @@ impl DaemonState {
|
||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||
.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 {
|
||||
@@ -1605,11 +1607,14 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
// Return a helpful error guiding the user to enable it.
|
||||
return Err(format!(
|
||||
"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\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||
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.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
@@ -1758,45 +1763,62 @@ fn chrome_relaunch_hint() -> &'static str {
|
||||
/// Called after every successful launch / CDP connect / auto-connect.
|
||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
||||
return; // Explicitly disabled
|
||||
}
|
||||
let Some(ref mgr) = state.browser else {
|
||||
return;
|
||||
};
|
||||
let Ok(session_id) = mgr.active_session_id() else {
|
||||
return;
|
||||
};
|
||||
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||
fn stealth_enabled() -> bool {
|
||||
!env::var("AGENT_BROWSER_STEALTH")
|
||||
.map(|v| v == "0")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
||||
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||
///
|
||||
/// 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() {
|
||||
stealth::StealthMode::CdpAttach
|
||||
} else {
|
||||
stealth::StealthMode::FullLaunch
|
||||
};
|
||||
|
||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||
if let Err(e) = stealth::apply_stealth(
|
||||
&mgr.client,
|
||||
session_id,
|
||||
mode,
|
||||
locale.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
||||
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||
eprintln!("[stealth] failed to apply patches to session {session_id}: {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) =
|
||||
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
|
||||
/// 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
|
||||
@@ -2148,11 +2170,14 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
Err(_e) => {
|
||||
return Err(format!(
|
||||
"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\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||
Note: remote debugging is a startup flag, not a Chrome setting — \
|
||||
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.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
@@ -2293,6 +2318,11 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
|
||||
apply_launch_init_scripts(state).await;
|
||||
// Apply stealth patches (the 32 JS patches + HeadlessChrome UA strip in
|
||||
// FullLaunch mode). The fresh-launch path was missing this — only the launch
|
||||
// FLAGS (e.g. --disable-blink-features) were applied, so the JS patches never
|
||||
// ran and navigator.userAgent kept the HeadlessChrome marker.
|
||||
apply_stealth_to_browser(state).await;
|
||||
|
||||
Ok(json!({ "launched": true }))
|
||||
}
|
||||
@@ -3948,13 +3978,26 @@ async fn handle_tab_list(state: &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 label = cmd.get("label").and_then(|v| v.as_str());
|
||||
state.ref_map.clear();
|
||||
state.iframe_sessions.clear();
|
||||
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> {
|
||||
|
||||
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
||||
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> {
|
||||
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.
|
||||
|
||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
"All CDP discovery methods failed for {host}:{port}. \
|
||||
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 _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||
(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;
|
||||
try { delete target.webdriver; } catch {}
|
||||
try {
|
||||
if (target.webdriver === true) {
|
||||
Object.defineProperty(target, 'webdriver', {
|
||||
get: () => false,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
removeWebdriver(navigator);
|
||||
removeWebdriver(Object.getPrototypeOf(navigator));
|
||||
removeWebdriver(Navigator.prototype);
|
||||
forceWebdriverFalse(navigator);
|
||||
forceWebdriverFalse(Object.getPrototypeOf(navigator));
|
||||
forceWebdriverFalse(Navigator.prototype);
|
||||
if (typeof WorkerNavigator !== 'undefined') {
|
||||
removeWebdriver(WorkerNavigator.prototype);
|
||||
forceWebdriverFalse(WorkerNavigator.prototype);
|
||||
}
|
||||
})();
|
||||
(function(){
|
||||
|
||||
@@ -1042,6 +1042,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
|
||||
// Default success
|
||||
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);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.9",
|
||||
"version": "0.27.0-fork.11",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
Reference in New Issue
Block a user