diff --git a/.gitignore b/.gitignore
index fc8bb44..bedcd3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,10 @@ __pycache__/
*.webm
test/e2e/.dogfood-output/
+# ...but these are real repo assets, not test artifacts — keep them tracked
+!assets/*.png
+!extensions/ab-connect/icons/*.png
+
# Package manager
package-lock.json
yarn.lock
diff --git a/README.md b/README.md
index b6ac856..2aedf53 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,15 @@
# agent-browser-stealth
+
+
Stealth fork of [agent-browser](https://github.com/vercel-labs/agent-browser) — connects to your real Chrome, shares your login sessions, and is undetectable by anti-bot systems.
For basic usage, commands, and API reference, see the [upstream documentation](https://github.com/vercel-labs/agent-browser).
## Why this fork?
+
+
**agent-browser** launches a fresh browser with an empty profile. You need to log in again, and websites can detect it's automated.
**agent-browser-stealth** connects to your existing Chrome. Your cookies, sessions, and browser fingerprint are all real — because it IS your real browser.
@@ -115,6 +119,8 @@ In CI environments, standalone mode is used automatically.
## Anti-detection
+
+
When connected to your real Chrome, we inject **zero** JavaScript patches. Your browser's fingerprint is completely genuine. The guiding rule is **native CDP/Chrome overrides over JS lies** — a re-defined getter is itself detectable; a native override isn't.
- `navigator.webdriver = false` via `Emulation.setAutomationOverride` (native, undetectable by CreepJS-style lie tests).
@@ -124,11 +130,27 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
| Test site | Result |
|---|---|
-| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | 0% stealth, 0% headless |
-| [bot.sannysoft.com](https://bot.sannysoft.com) | All green |
-| [Cloudflare Turnstile](https://nowsecure.nl) | Passed |
+| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | **0% stealth · 0% headless** (no override traces at all) |
+| [bot.incolumitas.com](https://bot.incolumitas.com/) | all checks OK — `overflowTest`, `overrideTest`, `puppeteerExtraStealthUsed`, worker consistency |
+| [bot.sannysoft.com](https://bot.sannysoft.com) | all green |
+| [BrowserScan](https://www.browserscan.net/bot-detection) | Webdriver · User-Agent · CDP all clean |
+| [Cloudflare Turnstile](https://nowsecure.nl) | passed |
-When using `--launch` mode (standalone browser), a full suite of 32 stealth patches is applied for headless Chrome.
+`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
+
+When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
+
+### Verify it yourself
+
+Don't take our word for it — point your connected Chrome at the toughest public detectors and compare:
+
+- **[CreepJS](https://abrahamjuliot.github.io/creepjs/)** — the most thorough fingerprint / lie detector
+- **[bot.incolumitas.com](https://bot.incolumitas.com/)** — behavioral + fingerprint scoring with a public methodology
+- **[BrowserScan](https://www.browserscan.net/bot-detection)** — Webdriver / User-Agent / CDP / Navigator
+- **[bot.sannysoft.com](https://bot.sannysoft.com)** — the classic automation-marker checklist
+- **[pixelscan.net](https://pixelscan.net/)** · **[iphey.com](https://iphey.com/)** — consistency & identity
+
+We deliberately **don't ship our own bot detector** — the strongest, most honest benchmark is the market's best detectors run against your real browser.
### Tuning knobs (environment variables)
diff --git a/assets/fingerprint.png b/assets/fingerprint.png
new file mode 100644
index 0000000..d222fe5
Binary files /dev/null and b/assets/fingerprint.png differ
diff --git a/assets/hero.png b/assets/hero.png
new file mode 100644
index 0000000..df7bc6b
Binary files /dev/null and b/assets/hero.png differ
diff --git a/assets/shield.png b/assets/shield.png
new file mode 100644
index 0000000..a21c9eb
Binary files /dev/null and b/assets/shield.png differ
diff --git a/cli/Cargo.lock b/cli/Cargo.lock
index b8b8bd6..daefb7f 100644
--- a/cli/Cargo.lock
+++ b/cli/Cargo.lock
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
-version = "0.27.0-fork.28"
+version = "0.27.0-fork.29"
dependencies = [
"aes-gcm",
"async-trait",
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 44d4c95..e5be426 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
-version = "0.27.0-fork.28"
+version = "0.27.0-fork.29"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs
index 432e880..27da305 100644
--- a/cli/src/native/cdp/chrome.rs
+++ b/cli/src/native/cdp/chrome.rs
@@ -773,6 +773,26 @@ fn running_process_cmdlines() -> Option> {
}
pub async fn auto_connect_cdp() -> Result {
+ // Prefer the dialog-free `ab-connect` extension relay when it is live.
+ // The relay drives the user's REAL Chrome via the extension's
+ // `chrome.debugger` permission, which — unlike a raw `--remote-debugging-port`
+ // CDP attach — never triggers Chrome 136+'s per-connection
+ // "Allow remote debugging?" consent modal. The native-messaging host writes
+ // ~/.agent-browser/relay-cdp-url while connected and removes it on exit, so a
+ // present URL means the relay is up. This must win over the DevToolsActivePort
+ // / :9222 probes below: if the user's Chrome happens to also be listening on a
+ // debug port, attaching there would pop the consent dialog and defeat the
+ // whole zero-interaction extension path.
+ if let Some(relay) = crate::connect::relay_url() {
+ // The relay is a local CDP-over-WS endpoint we connect to like Chrome.
+ // A bare TCP liveness check (no WS upgrade) confirms it is actually
+ // accepting before we commit, mirroring the consent-free probe used for
+ // DevToolsActivePort.
+ if relay_is_live(&relay).await {
+ return Ok(relay);
+ }
+ }
+
let user_data_dirs = get_chrome_user_data_dirs();
for dir in &user_data_dirs {
@@ -850,6 +870,22 @@ async fn tcp_port_alive(port: u16) -> bool {
)
}
+/// Consent-free liveness for the `ab-connect` relay ws URL (`ws://127.0.0.1:/…`).
+/// Parses the port and does a bare TCP connect — a stale relay-cdp-url file
+/// (host exited without cleanup) must not divert auto-connect away from the
+/// working port path.
+async fn relay_is_live(ws_url: &str) -> bool {
+ let port = ws_url
+ .strip_prefix("ws://")
+ .and_then(|rest| rest.split('/').next())
+ .and_then(|hostport| hostport.rsplit(':').next())
+ .and_then(|p| p.parse::().ok());
+ match port {
+ Some(p) => tcp_port_alive(p).await,
+ None => false,
+ }
+}
+
/// Returns the default Chrome user-data directory paths for the current platform.
/// Includes Chrome, Chrome Canary, Chromium, and Brave.
pub fn get_chrome_user_data_dirs() -> Vec {
@@ -2200,4 +2236,35 @@ mod tests {
let result = resolve_cdp_from_active_port(port, "/devtools/browser/dead").await;
assert!(result.is_err(), "should fail when nothing is listening");
}
+
+ #[tokio::test]
+ async fn test_relay_is_live_true_when_listening() {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ let url = format!("ws://127.0.0.1:{}/abc-guid", port);
+ assert!(
+ relay_is_live(&url).await,
+ "relay_is_live should be true while the port is accepting"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_relay_is_live_false_when_dead() {
+ // Bind to grab a free port, then drop so nothing is listening.
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ drop(listener);
+ let url = format!("ws://127.0.0.1:{}/abc-guid", port);
+ assert!(
+ !relay_is_live(&url).await,
+ "relay_is_live must be false for a stale relay-cdp-url (host exited)"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_relay_is_live_false_on_malformed_url() {
+ assert!(!relay_is_live("not-a-ws-url").await);
+ assert!(!relay_is_live("ws://127.0.0.1/no-port").await);
+ assert!(!relay_is_live("ws://127.0.0.1:notaport/x").await);
+ }
}
diff --git a/cli/src/native/stealth_scripts.js b/cli/src/native/stealth_scripts.js
index e93539d..d6a94de 100644
--- a/cli/src/native/stealth_scripts.js
+++ b/cli/src/native/stealth_scripts.js
@@ -394,6 +394,24 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
defineVendor(navigator);
})();
(function(){
+ // Native > JS lies: a real headed Chrome already exposes the correct, fully
+ // native navigator.plugins (5 PDF-viewer aliases, a native item() that does
+ // the WebIDL uint32-index wrap, length on the prototype). Overriding that
+ // with a JS fake is strictly worse — it ships a non-native item() whose
+ // .toString() reveals the patch, breaks the uint32 wrap (incolumitas
+ // overflowTest), and pins an anachronistic "Native Client" plugin that modern
+ // Chrome removed. Since this fork forbids headless and always launches headed,
+ // the native plugins are present, so we leave them alone. We only fall back to
+ // a synthetic list when native plugins are genuinely empty (e.g. the
+ // discouraged AGENT_BROWSER_ALLOW_HEADLESS escape on old headless).
+ try {
+ const np = navigator.plugins;
+ const itemNative =
+ np && typeof np.item === 'function' &&
+ /\[native code\]/.test(Function.prototype.toString.call(np.item));
+ if (np && np.length > 0 && itemNative) return;
+ } catch (e) {}
+
const makeMimeType = (type, suffixes, description) => {
const mime = Object.create(MimeType.prototype);
Object.defineProperties(mime, {
@@ -427,40 +445,54 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
return plugin;
};
+ // Make a fake method masquerade as native: name + `[native code]` toString.
+ const maskNative = (fn, name) => {
+ Object.defineProperty(fn, 'name', { value: name, configurable: true });
+ Object.defineProperty(fn, 'toString', {
+ value: () => `function ${name}() { [native code] }`,
+ configurable: true,
+ writable: true,
+ });
+ return fn;
+ };
+
+ // Modern Chrome (since ~v109) exposes exactly these 5 PDF-viewer aliases and
+ // two mimeTypes (application/pdf, text/pdf). Native Client was removed years
+ // ago, so it must NOT appear. Each plugin carries both mimeTypes.
const pdfMime = makeMimeType('application/pdf', 'pdf', 'Portable Document Format');
- const chromePdfMime = makeMimeType(
- 'application/x-google-chrome-pdf',
- 'pdf',
- 'Portable Document Format'
- );
- const naclMime = makeMimeType('application/x-nacl', '', 'Native Client Executable');
- const pnaclMime = makeMimeType('application/x-pnacl', '', 'Portable Native Client Executable');
+ const textPdfMime = makeMimeType('text/pdf', 'pdf', 'Portable Document Format');
+ const mimes = [pdfMime, textPdfMime];
const plugins = [
- makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer', [chromePdfMime]),
- makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', [pdfMime]),
- makePlugin('Native Client', '', 'internal-nacl-plugin', [naclMime, pnaclMime]),
- ];
+ 'PDF Viewer',
+ 'Chrome PDF Viewer',
+ 'Chromium PDF Viewer',
+ 'Microsoft Edge PDF Viewer',
+ 'WebKit built-in PDF',
+ ].map((name) => makePlugin(name, 'Portable Document Format', 'internal-pdf-viewer', mimes));
+
const pluginArray = Object.create(PluginArray.prototype);
plugins.forEach((p, i) => {
pluginArray[i] = p;
pluginArray[p.name] = p;
});
Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
- pluginArray.item = (i) => plugins[i] || null;
- pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
- pluginArray.refresh = () => {};
+ // `i >>> 0` replicates the WebIDL unsigned-long index coercion, so
+ // item(2**32) wraps to item(0) like the real native PluginArray.item.
+ pluginArray.item = maskNative((i) => plugins[i >>> 0] || null, 'item');
+ pluginArray.namedItem = maskNative((name) => plugins.find(p => p.name === name) || null, 'namedItem');
+ pluginArray.refresh = maskNative(() => {}, 'refresh');
pluginArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
- const mimeTypes = [chromePdfMime, pdfMime, naclMime, pnaclMime];
+ const mimeTypes = [pdfMime, textPdfMime];
const mimeTypeArray = Object.create(MimeTypeArray.prototype);
mimeTypes.forEach((m, i) => {
mimeTypeArray[i] = m;
mimeTypeArray[m.type] = m;
});
Object.defineProperty(mimeTypeArray, 'length', { get: () => mimeTypes.length });
- mimeTypeArray.item = (i) => mimeTypes[i] || null;
- mimeTypeArray.namedItem = (name) => mimeTypes.find(m => m.type === name) || null;
+ mimeTypeArray.item = maskNative((i) => mimeTypes[i >>> 0] || null, 'item');
+ mimeTypeArray.namedItem = maskNative((name) => mimeTypes.find(m => m.type === name) || null, 'namedItem');
mimeTypeArray[Symbol.iterator] = function*() { for (const m of mimeTypes) yield m; };
Object.defineProperty(navigator, 'plugins', {
diff --git a/extensions/ab-connect/icons/icon128.png b/extensions/ab-connect/icons/icon128.png
new file mode 100644
index 0000000..c14e8f0
Binary files /dev/null and b/extensions/ab-connect/icons/icon128.png differ
diff --git a/extensions/ab-connect/icons/icon16.png b/extensions/ab-connect/icons/icon16.png
new file mode 100644
index 0000000..b5f457b
Binary files /dev/null and b/extensions/ab-connect/icons/icon16.png differ
diff --git a/extensions/ab-connect/icons/icon32.png b/extensions/ab-connect/icons/icon32.png
new file mode 100644
index 0000000..2d49850
Binary files /dev/null and b/extensions/ab-connect/icons/icon32.png differ
diff --git a/extensions/ab-connect/icons/icon48.png b/extensions/ab-connect/icons/icon48.png
new file mode 100644
index 0000000..d03233a
Binary files /dev/null and b/extensions/ab-connect/icons/icon48.png differ
diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json
index 73d0954..aca03e4 100644
--- a/extensions/ab-connect/manifest.json
+++ b/extensions/ab-connect/manifest.json
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
- "name": "agent-browser connect",
+ "name": "agent-browser-stealth",
"version": "0.4.0",
"description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
@@ -24,6 +24,6 @@
"type": "module"
},
"action": {
- "default_title": "agent-browser connect"
+ "default_title": "agent-browser-stealth"
}
}
diff --git a/package.json b/package.json
index 8ab6de6..4be9891 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "agent-browser-stealth",
- "version": "0.27.0-fork.28",
- "description": "Browser automation CLI for AI agents \u2014 stealth fork with anti-detection",
+ "version": "0.27.0-fork.29",
+ "description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
"files": [