feat(stealth): 优化隐身对抗并引入双版本发布体系

- 将 CreepJS like headless 指标优化到 0%(headless/stealth 维持 0%)

- 新增 ActiveText 与 prefers-color-scheme 探针修复

- 版本号采用 <upstream>-fork.<fork> 格式并在 --version 输出 upstream/fork

- 更新 README、SKILL 与 docs 中的版本体系说明
This commit is contained in:
leeguooooo
2026-02-24 15:29:11 +09:00
parent 4c6afe3e69
commit 076520016a
13 changed files with 285 additions and 62 deletions
+33 -33
View File
@@ -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: `<upstream>-fork.<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
<table>
<thead>
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
</thead>
<tbody>
<tr><td>Local launch</td><td>Chromium launch args (<code>--disable-blink-features=AutomationControlled</code>) + context init scripts</td></tr>
<tr><td>CDP / auto-connect</td><td>Context init scripts</td></tr>
<tr><td>Cloud providers</td><td>Context init scripts (Kernel may also apply provider-managed stealth)</td></tr>
</tbody>
</table>
On February 24, 2026, local validation against CreepJS using `scripts/check-creepjs-headless.js` reported:
Use <code>--debug</code> 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
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-browser-stealth"
version = "0.14.0"
version = "0.14.0-fork.1"
dependencies = [
"base64",
"dirs",
+5 -1
View File
@@ -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"
+4 -1
View File
@@ -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()
+32 -3
View File
@@ -2126,7 +2126,7 @@ Options:
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--config <path> 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<String, serde_json::Value>) {
);
}
pub fn print_version() {
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
/// Parse fork version metadata from semver-like strings:
/// <upstream>-fork.<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);
}
}
+7
View File
@@ -32,9 +32,16 @@ agent-browser pdf <path> # Save page as PDF
agent-browser snapshot # Accessibility tree with refs
agent-browser eval <js> # Run JavaScript
agent-browser connect <port|url> # 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
+9
View File
@@ -60,6 +60,15 @@ pnpm build:native
pnpm link --global
```
## Fork versioning
Fork releases use a dual-version format:
- `<upstream>-fork.<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:
+1 -1
View File
@@ -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",
+21 -3
View File
@@ -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",
});
+4 -17
View File
@@ -52,6 +52,7 @@ agent-browser open https://example.com && agent-browser wait --load networkidle
# Navigation
agent-browser open <url> # 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)
+2 -2
View File
@@ -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,
});
+40
View File
@@ -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 });
+126
View File
@@ -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.
*/