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 +![agent-browser-stealth](assets/hero.png) + 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? +real but undetectable fingerprint + **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 +stealth shield + 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": [