Compare commits

...
Author SHA1 Message Date
leeguooooo 27dff19105 chore(release): bump to 0.27.0-fork.11 — FullLaunch stealth now fully applied
Fixes the longstanding FullLaunch (--launch) stealth gap: handle_launch's
fresh-launch path now calls apply_stealth_to_browser, so the 32 JS fingerprint
patches and the HeadlessChrome→Chrome UA strip run on launched browsers (they
never did before — only the launch flags applied).

Verified FullLaunch headless: navigator.webdriver=false,
navigator.userAgent=Chrome/<v> (no HeadlessChrome), new tabs + initial page
clean, bot.sannysoft.com 0 failed / 31 passed.
2026-06-01 14:45:12 +09:00
leeguooooo 21d591ee65 fix(stealth): apply stealth on the --launch path (FullLaunch JS patches + UA strip)
handle_launch's fresh-launch path (the path `--launch open <url>` takes) never
called apply_stealth_to_browser — only the launch FLAGS were applied (e.g.
--disable-blink-features=AutomationControlled, which is why navigator.webdriver
was already false). As a result the 32 JS fingerprint patches and the
Emulation.setUserAgentOverride HeadlessChrome→Chrome UA strip NEVER ran on a
launched browser: navigator.userAgent kept the HeadlessChrome marker (a
longstanding bug — identical on the prior prebuilt binary).

Add the apply_stealth_to_browser call after launch (the auto_launch path
already had it; only the explicit-launch path was missing it).

Verified, FullLaunch headless:
- navigator.webdriver === false, navigator.userAgent => Chrome/<v> (no Headless)
- new tabs and the initial page both clean
- bot.sannysoft.com: 0 failed / 31 passed
2026-06-01 14:39:03 +09:00
leeguooooo a6b2f5a192 chore(release): bump to 0.27.0-fork.10 — UX batch + stealth coverage/webdriver
Fixes since fork.9 (UX audit batch):
- stealth: per-session coverage so new tabs (tab new) and cross-origin iframe
  sessions get patched (were unpatched/detectable)
- stealth: navigator.webdriver = false (boolean), not undefined — never delete
  the property (undefined is itself a detection tell)
- hygiene: sweep orphaned temp Chrome profiles on daemon startup (only dirs no
  live process references) — fixes the kill -9 temp-dir disk leak
- ux: success-with-no-data prints "Done" instead of a silent exit 0
- ux: top-level aliases for `get` reads (url, cdp-url, title, html, text, ...)
- ux: clearer connect errors (consent dialog, "startup flag" guidance, and
  --cdp on Chrome 136+ points to auto-connect)

Known follow-up (not in this release): FullLaunch (--launch) browsers don't get
the JS patches / UA-strip applied (navigator.userAgent still shows
HeadlessChrome); secondary to the primary CdpAttach mode. Tracked for a
dedicated fix.
2026-06-01 14:25:08 +09:00
leeguooooo 7a1ca90416 fix(stealth): webdriver = false (not undefined) — never delete the property
The webdriver patch deleted navigator.webdriver, leaving it `undefined`. Real
Chrome reports `false`, so `undefined` is itself a detection tell, and deleting
it also removes the native `false` that Emulation.setAutomationOverride sets.

Now we rely on setAutomationOverride for a native (undetectable) `false` and
only force `false` via a getter as a fallback when webdriver is still `true`
(older Chrome without that override) — never delete it. Verified: FullLaunch
headless now reports navigator.webdriver === false (boolean), consistently.
2026-06-01 13:41:49 +09:00
leeguooooo ad0fb424c3 fix(ux): silent-output, command aliases, and clearer connection errors
- output: a success response with no data payload now prints "Done" instead of
  nothing (a silent exit 0 looked like a no-op).
- commands: add top-level aliases for `get` status reads — `url`, `cdp-url`
  (and `cdp_url`), `title`, `html`, `text`, `value`, `count`, `box`, `styles`,
  `attr` — so `agent-browser url` no longer errors "Unknown command".
- connect errors now explain the Chrome 136+ realities:
  - connect-failure mentions the "Allow remote debugging?" consent dialog and
    that remote debugging is a startup flag, not a setting.
  - no-Chrome error tells the user to relaunch Chrome with
    --remote-debugging-port (auto-connect then works).
  - --cdp discovery failure explains Chrome 136+ dropped the HTTP discovery
    endpoints and to use the default auto-connect instead.
2026-06-01 12:49:35 +09:00
leeguooooo f62e204038 fix(stealth,hygiene): per-session stealth coverage + orphaned temp-profile sweep
Stealth coverage (the fork's core value was leaking on secondary surfaces):
- stealth scripts are registered per CDP session, so new tabs (`tab new`) and
  cross-origin iframe sessions created after the initial page had NO patches.
  Extract apply_stealth_via_mgr/apply_stealth_to_session and re-apply on
  tab_new and on iframe attach. Fixes automation markers (and FullLaunch UA)
  leaking in new tabs / cross-origin frames.

Resource hygiene (temp profiles filled the disk):
- ChromeProcess::drop already cleans the temp user-data-dir on normal exit, but
  a hard kill (kill -9 / version-mismatch restart / crash) skips Drop and leaks
  ~50MB per session. Add cleanup_orphaned_chrome_profiles() on daemon startup
  that sweeps agent-browser-chrome-* temp dirs NOT referenced by any live
  process (so an in-use profile is never deleted).
2026-06-01 12:38:57 +09:00
10 changed files with 189 additions and 46 deletions
+1 -1
View File
@@ -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
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -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> {
+61 -1
View File
@@ -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.
+6 -2
View File
@@ -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})"
)),
}
}
+4
View File
@@ -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());
+21 -6
View File
@@ -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(){
+5
View File
@@ -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
View File
@@ -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",