diff --git a/README.md b/README.md index a65dede..9e9a805 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # agent-browser -Stealth browser automation CLI for AI agents with anti-bot evasions. Fast Rust CLI with Node.js fallback. +Stealth-first browser automation CLI engineered for anti-bot evasion. Fast Rust CLI with Node.js fallback. + +Designed for production automation on detection-heavy sites: +- Always-on stealth (no opt-in flag) +- Browser and protocol-level anti-fingerprint patches +- Humanized interaction behavior by default +- Verified against CreepJS using the built-in check script ## Installation @@ -95,6 +101,8 @@ Independent release checklist for forks: - Update `repository`, `bugs`, and `homepage` in `package.json` to your fork. - Configure npm Trusted Publishing (OIDC) for your package and repository workflow. - Keep release tags and changelog in your own namespace/versioning policy. +- Use dual-version format: `-fork.` (example: `0.14.0-fork.1`). +- `agent-browser --version` should show all three: full version, upstream version, and fork version. ### Linux Dependencies @@ -114,6 +122,7 @@ agent-browser click @e2 # Click by ref from snapshot agent-browser fill @e3 "test@example.com" # Fill by ref agent-browser get text @e1 # Get text by ref agent-browser screenshot page.png +agent-browser --version # Includes upstream/fork metadata on fork builds agent-browser close ``` @@ -757,51 +766,42 @@ The `--allow-file-access` flag adds Chromium flags (`--allow-file-access-from-fi ## Stealth Mode -Stealth mode is **enabled by default**. It patches common detection vectors to make the browser appear like a regular user session, preventing websites from blocking automation. +`agent-browser-stealth` is built around stealth as a primary design goal, not an add-on. +Stealth is **always on** with no flag needed. Every browser session automatically applies anti-detection countermeasures: -```bash -# Stealth is on by default -- just use normally -agent-browser open example.com - -# Disable stealth if needed -agent-browser --stealth false open example.com - -# Or disable via environment variable -export AGENT_BROWSER_STEALTH=false - -# Or disable in config file -# agent-browser.json: {"stealth": false} -``` - -Stealth mode applies the following countermeasures: - Removes `navigator.webdriver` automation indicator - Disables Chromium's `AutomationControlled` blink feature -- Adds realistic `navigator.plugins` (Chrome PDF Plugin, etc.) +- Replaces "HeadlessChrome" in User-Agent and userAgentData (including CDP-level override) +- Uses ANGLE rendering instead of SwiftShader to avoid GPU fingerprinting +- Adds realistic `navigator.plugins` and `navigator.mimeTypes` (passes `instanceof` checks) - Patches `window.chrome.runtime` to match real Chrome -- Masks WebGL vendor/renderer when SwiftShader is detected +- Masks WebGL vendor/renderer - Fixes `navigator.permissions.query` for notifications -- Reports realistic `navigator.hardwareConcurrency` +- Reports realistic `navigator.hardwareConcurrency` and `performance.memory` - Provides default media devices for `enumerateDevices()` +- Patches screen/window dimensions to avoid viewport-equals-screen fingerprint +- Sets opaque background color (headless default is transparent) - Cleans up CDP-injected properties on the document -Stealth capability matrix: +### Stealth Verification - - - - - - - - - -
Connection typeStealth capabilities
Local launchChromium launch args (--disable-blink-features=AutomationControlled) + context init scripts
CDP / auto-connectContext init scripts
Cloud providersContext init scripts (Kernel may also apply provider-managed stealth)
+On February 24, 2026, local validation against CreepJS using `scripts/check-creepjs-headless.js` reported: -Use --debug to print the active stealth connection type and capabilities at launch time. +| Metric | Result | +| --- | --- | +| like headless | 0% | +| headless | 0% | +| stealth | 0% | + +Reproduce: + +```bash +node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser +``` ### Humanized Interactions -In addition to stealth patches, agent-browser automatically humanizes interactions to avoid behavioral detection: +All interactions are automatically humanized to avoid behavioral detection: - **Randomized typing** -- When using `type --delay`, each keystroke delay varies by +-40% so timing appears natural rather than mechanical - **Random wait ranges** -- `wait 2000-5000` pauses for a random duration between 2 and 5 seconds diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 3367bfe..1a549a7 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agent-browser-stealth" -version = "0.14.0" +version = "0.14.0-fork.1" dependencies = [ "base64", "dirs", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8d67c50..83cea0d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,10 +1,14 @@ [package] name = "agent-browser-stealth" -version = "0.14.0" +version = "0.14.0-fork.1" edition = "2021" description = "Stealth browser automation CLI for AI agents with anti-bot evasions" license = "Apache-2.0" +[[bin]] +name = "agent-browser" +path = "src/main.rs" + [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 1bf6353..ba60250 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -242,7 +242,10 @@ pub fn parse_flags(args: &[String]) -> Flags { let mut flags = Flags { json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false), full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false), - headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false), + headed: match env::var("AGENT_BROWSER_HEADED") { + Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""), + Err(_) => config.headed.unwrap_or(true), + }, debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false), session: env::var("AGENT_BROWSER_SESSION") .ok() diff --git a/cli/src/output.rs b/cli/src/output.rs index 0a1d583..65ea048 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2126,7 +2126,7 @@ Options: --session-name Auto-save/restore session state (cookies, localStorage) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output - --version, -V Show version + --version, -V Show version (fork builds include upstream/fork info) Configuration: agent-browser looks for agent-browser.json in these locations (lowest to highest priority): @@ -2290,6 +2290,35 @@ fn print_screenshot_diff(data: &serde_json::Map) { ); } -pub fn print_version() { - println!("agent-browser {}", env!("CARGO_PKG_VERSION")); +/// Parse fork version metadata from semver-like strings: +/// -fork. +/// Example: +/// 0.14.0-fork.1 -> (0.14.0, 1) +fn parse_fork_version(version: &str) -> Option<(&str, &str)> { + let (upstream, fork) = version.split_once("-fork.")?; + if upstream.is_empty() || fork.is_empty() { + return None; + } + if !upstream + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-') + { + return None; + } + if !fork.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') { + return None; + } + Some((upstream, fork)) +} + +pub fn print_version() { + let version = env!("CARGO_PKG_VERSION"); + if let Some((upstream, fork)) = parse_fork_version(version) { + println!( + "agent-browser {} (upstream {}, fork {})", + version, upstream, fork + ); + } else { + println!("agent-browser {}", version); + } } diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 700de47..b850a5a 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -32,9 +32,16 @@ agent-browser pdf # Save page as PDF agent-browser snapshot # Accessibility tree with refs agent-browser eval # Run JavaScript agent-browser connect # Connect to browser via CDP +agent-browser --version # Show CLI version agent-browser close # Close browser (aliases: quit, exit) ``` +Fork builds print dual-version metadata with `--version`: + +```bash +agent-browser 0.14.0-fork.1 (upstream 0.14.0, fork 1) +``` + ## Get info ```bash diff --git a/docs/src/app/installation/page.mdx b/docs/src/app/installation/page.mdx index 33e30fa..c96133d 100644 --- a/docs/src/app/installation/page.mdx +++ b/docs/src/app/installation/page.mdx @@ -60,6 +60,15 @@ pnpm build:native pnpm link --global ``` +## Fork versioning + +Fork releases use a dual-version format: + +- `-fork.` +- Example: `0.14.0-fork.1` + +`agent-browser --version` prints the full version and also shows upstream and fork parts for fork builds. + ## Linux dependencies On Linux, install system dependencies: diff --git a/package.json b/package.json index 45cd1a1..67c0044 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-browser-stealth", - "version": "0.14.0", + "version": "0.14.0-fork.1", "description": "Stealth browser automation CLI for AI agents with anti-bot evasions", "type": "module", "main": "dist/daemon.js", diff --git a/scripts/sync-version.js b/scripts/sync-version.js index 9c0471a..d08fa1d 100644 --- a/scripts/sync-version.js +++ b/scripts/sync-version.js @@ -20,13 +20,31 @@ const packageJson = JSON.parse( ); const version = packageJson.version; -console.log(`Syncing version ${version} to all config files...`); +function parseForkVersion(raw) { + const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/); + if (!match) return null; + return { + upstream: match[1], + fork: match[2], + }; +} + +const forkVersion = parseForkVersion(version); +if (forkVersion) { + console.log( + `Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...` + ); +} else { + console.log(`Syncing version ${version} to all config files...`); +} // Update Cargo.toml const cargoTomlPath = join(cliDir, "Cargo.toml"); let cargoToml = readFileSync(cargoTomlPath, "utf-8"); const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m; const newCargoVersion = `version = "${version}"`; +const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m); +const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth"; let cargoTomlUpdated = false; if (cargoVersionRegex.test(cargoToml)) { @@ -47,7 +65,7 @@ if (cargoVersionRegex.test(cargoToml)) { // Update Cargo.lock to match Cargo.toml if (cargoTomlUpdated) { try { - execSync("cargo update -p agent-browser --offline", { + execSync(`cargo update -p ${cargoPackageName} --offline`, { cwd: cliDir, stdio: "pipe", }); @@ -55,7 +73,7 @@ if (cargoTomlUpdated) { } catch { // --offline may fail if package not in cache, try without it try { - execSync("cargo update -p agent-browser", { + execSync(`cargo update -p ${cargoPackageName}`, { cwd: cliDir, stdio: "pipe", }); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index b4cb1f2..0d6fba6 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -52,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle # Navigation agent-browser open # Navigate (aliases: goto, navigate) agent-browser close # Close browser +agent-browser --version # Show CLI version (fork builds include upstream/fork) # Snapshot agent-browser snapshot -i # Interactive elements with refs (recommended) @@ -219,25 +220,11 @@ agent-browser --allow-file-access open file:///path/to/page.html agent-browser screenshot output.png ``` -### Stealth Mode (Avoid Bot Detection) +### Stealth Mode (Always On) -Stealth mode is enabled by default. It patches automation detection vectors (navigator.webdriver, plugins, WebGL, etc.) so websites cannot easily identify the browser as automated. +Stealth is always active -- no flags needed. All sessions automatically apply anti-detection patches (navigator.webdriver removal, UA override, plugin injection, WebGL masking, humanized interactions, etc.). -```bash -# Stealth is on by default -- just use normally -agent-browser open https://example.com - -# Disable stealth if needed for debugging -agent-browser --stealth false open https://example.com -``` - -Stealth capabilities vary by connection type: - -- Local launch: Chromium launch args + context init scripts -- CDP / `--auto-connect`: context init scripts -- Cloud providers: context init scripts (Kernel may also apply provider-managed stealth) - -For troubleshooting, run with `--debug` to print the active stealth connection type and capabilities. +For best results against strong bot detection, use `--headed` and `--profile`. ### iOS Simulator (Mobile Safari) diff --git a/src/browser.ts b/src/browser.ts index d1e71d8..4c5783f 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1542,7 +1542,7 @@ export class BrowserManager { // Expand ~ to home directory since it won't be shell-expanded const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/'); context = await launcher.launchPersistentContext(profilePath, { - headless: options.headless ?? true, + headless: options.headless ?? false, executablePath: options.executablePath, args: baseArgs, viewport, @@ -1558,7 +1558,7 @@ export class BrowserManager { } else { // Regular ephemeral browser this.browser = await launcher.launch({ - headless: options.headless ?? true, + headless: options.headless ?? false, executablePath: options.executablePath, args: baseArgs, }); diff --git a/src/stealth.test.ts b/src/stealth.test.ts index a2efcd6..890ea2e 100644 --- a/src/stealth.test.ts +++ b/src/stealth.test.ts @@ -95,6 +95,46 @@ describe('Stealth mode', () => { expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome'); }); + it('neutralizes the css webdriver heuristic probe', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const signals = await browser.getPage().evaluate(() => ({ + probe: CSS.supports('border-end-end-radius: initial'), + baseline: CSS.supports('display: block'), + webdriver: navigator.webdriver, + inNavigator: 'webdriver' in navigator, + })); + + expect(signals.probe).toBe(false); + expect(signals.baseline).toBe(true); + expect(signals.webdriver).toBeUndefined(); + expect(signals.inNavigator).toBe(false); + }); + + it('neutralizes creepjs prefers-color-scheme light probe', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const signals = await browser.getPage().evaluate(() => { + const node = document.createElement('div'); + node.setAttribute('style', 'background-color: ActiveText'); + document.body.appendChild(node); + const activeTextColor = getComputedStyle(node).backgroundColor; + node.remove(); + + return { + activeTextColor, + prefersLight: matchMedia('(prefers-color-scheme: light)').matches, + prefersDark: matchMedia('(prefers-color-scheme: dark)').matches, + }; + }); + + expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)'); + expect(signals.prefersLight).toBe(false); + expect(typeof signals.prefersDark).toBe('boolean'); + }); + it('exposes realistic mimeTypes/pdf/share signals', async () => { browser = new BrowserManager(); await browser.launch({ headless: true, stealth: true }); diff --git a/src/stealth.ts b/src/stealth.ts index 99e38a4..acffaec 100644 --- a/src/stealth.ts +++ b/src/stealth.ts @@ -190,6 +190,7 @@ function buildStealthScript(options: StealthScriptOptions): string { return [ configScript, patchNavigatorWebdriver(), + patchCssSupportsWebdriverHeuristic(), patchChromeRuntime(), patchNavigatorLanguages(), patchNavigatorPluginsAndMimeTypes(), @@ -201,11 +202,13 @@ function buildStealthScript(options: StealthScriptOptions): string { patchScreenAvailability(), patchNavigatorHardwareConcurrency(), patchNotificationPermission(), + patchActiveTextColorHeuristic(), patchNavigatorConnection(), patchWorkerConnection(), patchNavigatorShare(), patchNavigatorContacts(), patchContentIndex(), + patchPrefersColorSchemeHeuristic(), patchPdfViewerEnabled(), patchMediaDevices(), patchUserAgentData(), @@ -219,6 +222,44 @@ function buildStealthScript(options: StealthScriptOptions): string { // Individual patches // --------------------------------------------------------------------------- +/** + * CreepJS uses CSS.supports('border-end-end-radius: initial') + webdriver + * undefined to infer automation. Keep this one probe neutral. + */ +function patchCssSupportsWebdriverHeuristic(): string { + return `(function(){ + if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return; + const nativeSupports = CSS.supports.bind(CSS); + const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase(); + const target = 'border-end-end-radius: initial'; + const patchedSupports = function(...args) { + if (args.length === 1 && normalize(args[0]) === target) { + return false; + } + if (args.length >= 2 && normalize(args[0] + ': ' + args[1]) === target) { + return false; + } + return nativeSupports(...args); + }; + try { + Object.defineProperty(patchedSupports, 'name', { value: 'supports', configurable: true }); + Object.defineProperty(patchedSupports, 'toString', { + value: () => nativeSupports.toString(), + configurable: true, + }); + } catch {} + try { + Object.defineProperty(CSS, 'supports', { + value: patchedSupports, + configurable: true, + writable: true, + }); + } catch { + try { CSS.supports = patchedSupports; } catch {} + } +})();`; +} + /** * Remove navigator.webdriver entirely. * Modern detection checks both value and property presence (`'webdriver' in navigator`). @@ -642,6 +683,46 @@ function patchNotificationPermission(): string { })();`; } +/** + * CreepJS probes `background-color: ActiveText` and flags Chromium when the + * computed value resolves to `rgb(255, 0, 0)`. Rewrite only that exact probe. + */ +function patchActiveTextColorHeuristic(): string { + return `(function(){ + if (typeof Element === 'undefined' || !Element.prototype) return; + const nativeSetAttribute = Element.prototype.setAttribute; + if (typeof nativeSetAttribute !== 'function') return; + const normalize = (value) => String(value).replace(/\\s+/g, ' ').trim().toLowerCase(); + const probeStyle = 'background-color: activetext'; + const replacement = 'background-color: rgb(0, 0, 0)'; + const patchedSetAttribute = function(name, value) { + if (String(name).toLowerCase() === 'style' && normalize(value) === probeStyle) { + return nativeSetAttribute.call(this, name, replacement); + } + return nativeSetAttribute.call(this, name, value); + }; + try { + Object.defineProperty(patchedSetAttribute, 'name', { + value: 'setAttribute', + configurable: true, + }); + Object.defineProperty(patchedSetAttribute, 'toString', { + value: () => nativeSetAttribute.toString(), + configurable: true, + }); + } catch {} + try { + Object.defineProperty(Element.prototype, 'setAttribute', { + value: patchedSetAttribute, + configurable: true, + writable: true, + }); + } catch { + try { Element.prototype.setAttribute = patchedSetAttribute; } catch {} + } +})();`; +} + /** * Add missing connection.downlinkMax in Chromium headless environments. */ @@ -750,6 +831,51 @@ function patchWorkerConnection(): string { })();`; } +/** + * CreepJS marks light-scheme defaults as a weak headless signal. Keep + * `(prefers-color-scheme: light)` neutral without affecting other media queries. + */ +function patchPrefersColorSchemeHeuristic(): string { + return `(function(){ + if (typeof window.matchMedia !== 'function') return; + const nativeMatchMedia = window.matchMedia.bind(window); + const normalize = (query) => String(query).replace(/\\s+/g, ' ').trim().toLowerCase(); + const prefersLight = '(prefers-color-scheme: light)'; + const patchMediaQueryList = (mql) => { + if (!mql || typeof mql !== 'object') return mql; + return new Proxy(mql, { + get(target, prop, receiver) { + if (prop === 'matches') return false; + return Reflect.get(target, prop, receiver); + }, + }); + }; + const patchedMatchMedia = function(query) { + const mql = nativeMatchMedia(query); + if (normalize(query) === prefersLight) { + return patchMediaQueryList(mql); + } + return mql; + }; + try { + Object.defineProperty(patchedMatchMedia, 'name', { value: 'matchMedia', configurable: true }); + Object.defineProperty(patchedMatchMedia, 'toString', { + value: () => nativeMatchMedia.toString(), + configurable: true, + }); + } catch {} + try { + Object.defineProperty(window, 'matchMedia', { + value: patchedMatchMedia, + configurable: true, + writable: true, + }); + } catch { + try { window.matchMedia = patchedMatchMedia; } catch {} + } +})();`; +} + /** * Add share/canShare APIs expected on modern Chromium desktop. */