Update readme with stealth FAQ

This commit is contained in:
leeguooooo
2026-03-02 18:32:28 +09:00
parent 907ca8c808
commit 44c0361fcd
15 changed files with 548 additions and 66 deletions
+8
View File
@@ -1,5 +1,13 @@
# agent-browser
## 0.15.1-fork.11
### Patch Changes
- Auto-attach existing browser more reliably by trying CDP localhost:9333 first, then falling back to auto-discovery before failing.
Align daemon behavior and user-facing docs/skill guidance with the same attachment policy.
## 0.15.1
### Patch Changes
+25 -7
View File
@@ -15,6 +15,24 @@ This README focuses on stealth architecture and principles. For full command cov
- Region signals are auto-aligned (locale/timezone/Accept-Language) to reduce mismatch risk.
- Verification/captcha handling is policy-driven (`--risk-mode off|warn|block`).
## FAQ: `agent-browser` vs `agent-browser-stealth`
People often ask this: "What's the anti-detection approach compared to `agent-browser-stealth` on npm?"
- `agent-browser-stealth` on npm is the package name for this fork.
- The CLI keeps upstream-compatible command names (`agent-browser` is still the main executable, with `agent-browser-stealth` as an alias).
- The practical difference vs upstream `agent-browser` is not one single "stealth switch"; it is a defense-in-depth stack designed for anti-bot pressure.
The core idea is layered hardening across the full automation lifecycle:
1. Connection-aware policy: choose the best available stealth capability by mode (local launch/CDP/cloud provider).
2. Fingerprint hardening: patch launch args, CDP metadata, and init-script surfaces before page code runs.
3. Behavioral humanization: non-uniform typing/mouse/wait patterns instead of perfectly mechanical actions.
4. Region coherence: auto-align locale/timezone/language signals to target geography.
5. Risk-aware control loop: detect verification/captcha signals and handle them with explicit `risk-mode` policy.
Goal: reduce detection probability and improve stability in production automation. Non-goal: "guaranteed bypass" on every target.
## Quick Start
### Install
@@ -50,12 +68,12 @@ flowchart TD
### Policy by Connection Mode
| Mode | Stealth Capabilities | Notes |
|---|---|---|
| Local Chromium launch | Chromium launch args + CDP UA override + context init scripts | Most complete stack |
| Existing browser via CDP | CDP UA override + context init scripts | No local Chromium arg injection |
| Cloud provider (browserbase/browseruse) | Context init scripts | Remote browser runtime controls launch layer |
| Kernel provider | Context init scripts + provider-managed stealth | Provider-side stealth may also apply |
| Mode | Stealth Capabilities | Notes |
| --------------------------------------- | ------------------------------------------------------------- | -------------------------------------------- |
| Local Chromium launch | Chromium launch args + CDP UA override + context init scripts | Most complete stack |
| Existing browser via CDP | CDP UA override + context init scripts | No local Chromium arg injection |
| Cloud provider (browserbase/browseruse) | Context init scripts | Remote browser runtime controls launch layer |
| Kernel provider | Context init scripts + provider-managed stealth | Provider-side stealth may also apply |
## Principle 1: Always-On Stealth with Explicit Boundaries
@@ -63,7 +81,7 @@ flowchart TD
- Project policy forbids:
- `--profile` / `AGENT_BROWSER_PROFILE`
- `--channel` / `AGENT_BROWSER_CHANNEL`
- Default CLI policy expects an existing browser on CDP `localhost:9333` unless explicit connection options are provided.
- Default CLI policy auto-attaches an existing browser: try CDP `localhost:9333` first, then auto-discovery unless explicit connection options are provided.
## Principle 2: Multi-Layer Fingerprint Hardening
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-browser-stealth"
version = "0.15.1-fork.7"
version = "0.15.1-fork.11"
dependencies = [
"base64",
"dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.15.1-fork.7"
version = "0.15.1-fork.11"
edition = "2021"
description = "Stealth browser automation CLI for AI agents with anti-bot evasions"
license = "Apache-2.0"
+28 -7
View File
@@ -399,6 +399,8 @@ fn main() {
exit(1);
}
let mut attached_to_existing_browser = false;
// Auto-connect to existing browser
if flags.auto_connect {
let mut launch_cmd = json!({
@@ -436,6 +438,8 @@ fn main() {
}
exit(1);
}
attached_to_existing_browser = true;
}
// Connect via CDP if --cdp flag is set
@@ -526,6 +530,8 @@ fn main() {
}
exit(1);
}
attached_to_existing_browser = true;
}
// Launch with cloud provider if -p flag is set
@@ -567,8 +573,8 @@ fn main() {
}
// Project policy: when no explicit connection mode is provided,
// commands must attach to an existing browser on CDP :9333.
// If unavailable, fail fast instead of launching a managed browser.
// commands should attach to an existing browser.
// Try CDP :9333 first, then fall back to auto-connect discovery.
let can_try_default_cdp = flags.cdp.is_none()
&& !flags.auto_connect
&& flags.provider.is_none()
@@ -581,7 +587,6 @@ fn main() {
&& !flags.allow_file_access
&& flags.extensions.is_empty();
let mut launched_via_default_cdp = false;
if can_try_default_cdp {
let mut launch_cmd = json!({
"id": gen_id(),
@@ -594,11 +599,27 @@ fn main() {
}
if let Ok(resp) = send_command(launch_cmd, &flags.session) {
launched_via_default_cdp = resp.success;
attached_to_existing_browser = resp.success;
}
if !attached_to_existing_browser {
let mut auto_connect_cmd = json!({
"id": gen_id(),
"action": "launch",
"autoConnect": true
});
if let Some(ref cs) = flags.color_scheme {
auto_connect_cmd["colorScheme"] = json!(cs);
}
if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) {
attached_to_existing_browser = resp.success;
}
}
}
if can_try_default_cdp && !launched_via_default_cdp {
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333. Start your browser with remote debugging on port 9333, or pass --cdp <port|url>.";
if can_try_default_cdp && !attached_to_existing_browser {
let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp <port|url>.";
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
@@ -621,7 +642,7 @@ fn main() {
|| flags.download_path.is_some())
&& flags.cdp.is_none()
&& flags.provider.is_none()
&& !launched_via_default_cdp
&& !attached_to_existing_browser
{
let mut launch_cmd = json!({
"id": gen_id(),
+2 -2
View File
@@ -2398,7 +2398,7 @@ Options:
--headed Show browser window (not headless)
--cdp <port> Connect via CDP (Chrome DevTools Protocol)
--auto-connect Auto-discover and connect to running Chrome
Project default: require existing browser at localhost:9333 (no auto local fallback)
Project default: try localhost:9333 first, then auto-discovery (no managed local-launch fallback)
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--risk-mode <mode> Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE)
@@ -2416,7 +2416,7 @@ Options:
Policy:
--profile / AGENT_BROWSER_PROFILE are forbidden
--channel / AGENT_BROWSER_CHANNEL are forbidden
Use existing browser session (CDP localhost:9333) or pass --cdp explicitly
Auto-attach existing browser (prefer CDP localhost:9333, then auto-discovery), or pass --cdp explicitly
Configuration:
agent-browser looks for agent-browser.json in these locations (lowest to highest priority):
+130 -25
View File
@@ -1,12 +1,12 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from '@/lib/page-metadata';
export const metadata = pageMetadata("cdp-mode")
export const metadata = pageMetadata('cdp-mode');
# CDP Mode
Connect to an existing browser via Chrome DevTools Protocol:
Default behavior in this fork: when `--cdp` is omitted, agent-browser requires an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no local-launch fallback).
Default behavior in this fork: when `--cdp` is omitted, agent-browser auto-attaches to an existing browser by trying `localhost:9333` first, then auto-discovery. If both fail, the command exits (no managed local-launch fallback).
Project policy:
@@ -88,12 +88,24 @@ AGENT_BROWSER_COLOR_SCHEME=dark agent-browser --cdp 9222 open https://example.co
<table>
<thead>
<tr><th>Connection type</th><th>Stealth capabilities</th></tr>
<tr>
<th>Connection type</th>
<th>Stealth capabilities</th>
</tr>
</thead>
<tbody>
<tr><td>Local launch</td><td>Chromium launch args + 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>
<tr>
<td>Local launch</td>
<td>Chromium launch args + 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>
@@ -113,26 +125,119 @@ This enables control of:
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr><td><code>--session &lt;name&gt;</code></td><td>Use isolated session</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
<tr><td><code>--headers &lt;json&gt;</code></td><td>HTTP headers scoped to origin</td></tr>
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
<tr><td><code>--args &lt;args&gt;</code></td><td>Browser launch args (comma-separated)</td></tr>
<tr><td><code>--user-agent &lt;ua&gt;</code></td><td>Custom User-Agent string</td></tr>
<tr><td><code>--proxy &lt;url&gt;</code></td><td>Proxy server URL</td></tr>
<tr><td><code>--proxy-bypass &lt;hosts&gt;</code></td><td>Hosts to bypass proxy</td></tr>
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
<tr><td><code>{"--cdp <port|url>"}</code></td><td>CDP connection (port or WebSocket URL)</td></tr>
<tr><td><code>--auto-connect</code></td><td>Auto-discover and connect to running Chrome</td></tr>
<tr><td><code>--color-scheme &lt;scheme&gt;</code></td><td>Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)</td></tr>
<tr><td><code>--debug</code></td><td>Debug output</td></tr>
<tr>
<td>
<code>--session &lt;name&gt;</code>
</td>
<td>Use isolated session</td>
</tr>
<tr>
<td>
<code>-p &lt;provider&gt;</code>
</td>
<td>
Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>,{' '}
<code>kernel</code>)
</td>
</tr>
<tr>
<td>
<code>--headers &lt;json&gt;</code>
</td>
<td>HTTP headers scoped to origin</td>
</tr>
<tr>
<td>
<code>--executable-path</code>
</td>
<td>Custom browser executable</td>
</tr>
<tr>
<td>
<code>--args &lt;args&gt;</code>
</td>
<td>Browser launch args (comma-separated)</td>
</tr>
<tr>
<td>
<code>--user-agent &lt;ua&gt;</code>
</td>
<td>Custom User-Agent string</td>
</tr>
<tr>
<td>
<code>--proxy &lt;url&gt;</code>
</td>
<td>Proxy server URL</td>
</tr>
<tr>
<td>
<code>--proxy-bypass &lt;hosts&gt;</code>
</td>
<td>Hosts to bypass proxy</td>
</tr>
<tr>
<td>
<code>--json</code>
</td>
<td>JSON output for scripts</td>
</tr>
<tr>
<td>
<code>--full, -f</code>
</td>
<td>Full page screenshot</td>
</tr>
<tr>
<td>
<code>--name, -n</code>
</td>
<td>Locator name filter</td>
</tr>
<tr>
<td>
<code>--exact</code>
</td>
<td>Exact text match</td>
</tr>
<tr>
<td>
<code>--headed</code>
</td>
<td>Show browser window</td>
</tr>
<tr>
<td>
<code>{'--cdp <port|url>'}</code>
</td>
<td>CDP connection (port or WebSocket URL)</td>
</tr>
<tr>
<td>
<code>--auto-connect</code>
</td>
<td>Auto-discover and connect to running Chrome</td>
</tr>
<tr>
<td>
<code>--color-scheme &lt;scheme&gt;</code>
</td>
<td>
Persistent color scheme (<code>dark</code>, <code>light</code>, <code>no-preference</code>)
</td>
</tr>
<tr>
<td>
<code>--debug</code>
</td>
<td>Debug output</td>
</tr>
</tbody>
</table>
+1 -1
View File
@@ -6,7 +6,7 @@ export const metadata = pageMetadata('configuration');
Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command.
In this fork, default launch behavior requires a resident browser at `localhost:9333` (CDP). If unavailable, commands fail fast instead of launching a managed browser.
In this fork, default launch behavior auto-attaches to an existing browser by trying `localhost:9333` (CDP) first, then auto-discovery. If both fail, commands exit instead of launching a managed browser.
## Config File Locations
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.15.1-fork.7",
"version": "0.15.1-fork.11",
"description": "Stealth browser automation CLI for AI agents with anti-bot evasions",
"type": "module",
"main": "dist/daemon.js",
@@ -35,12 +35,13 @@
"test:watch": "vitest",
"test:e2e:dogfood": "vitest run test/e2e/dogfood.eval.ts",
"postinstall": "node scripts/postinstall.js",
"verify:native-version": "node scripts/verify-native-version.js",
"clawhub:sync": "bash scripts/clawhub-sync.sh",
"sync:upstream": "bash scripts/sync-upstream.sh",
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
"changeset": "changeset",
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run version:sync && pnpm run build && changeset publish"
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:native-version && changeset publish"
},
"keywords": [
"browser",
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Verifies that the bundled native binary version matches package.json version.
* This prevents publishing npm tarballs where package version and native binary
* version drift (e.g. package is fork.8 but binary still reports fork.7).
*/
import { existsSync, readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { arch, platform } from 'os';
import { execFileSync } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
const expectedVersion = pkg.version;
const ext = platform() === 'win32' ? '.exe' : '';
const platformBinary = join(projectRoot, 'bin', `agent-browser-${platform()}-${arch()}${ext}`);
if (!existsSync(platformBinary)) {
console.error(`Error: native binary not found for current platform: ${platformBinary}`);
console.error('Run `pnpm run build:native` before publishing.');
process.exit(1);
}
let versionOutput = '';
try {
versionOutput = execFileSync(platformBinary, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: failed to execute native binary --version: ${message}`);
process.exit(1);
}
if (!versionOutput.includes(expectedVersion)) {
console.error(`Version mismatch: package.json=${expectedVersion}, native='${versionOutput}'.`);
console.error('Run `pnpm run build:native` and retry publishing.');
process.exit(1);
}
console.log(`✓ Native binary version matches package.json (${expectedVersion})`);
+1 -1
View File
@@ -23,7 +23,7 @@ agent-browser install
agent-browser --version
```
If default CDP mode is used in your environment, ensure a browser is available at `localhost:9333`, or pass `--cdp` / `--auto-connect` explicitly.
If default CDP mode is used in your environment, the CLI first tries `localhost:9333` and then auto-discovery. You can still pass `--cdp` / `--auto-connect` explicitly when needed.
## Standard execution workflow
+11 -3
View File
@@ -216,7 +216,11 @@ agent-browser session list
### Connect to Existing Chrome
By default in this fork, commands without `--cdp` require an existing browser at `localhost:9333`. If CDP is unavailable, the command fails fast (no automatic local browser launch).
By default in this fork, commands without `--cdp` auto-attach to your existing browser with this order:
1. Try CDP at `localhost:9333`
2. If unavailable, fall back to `--auto-connect`-style discovery
3. If both fail, exit with guidance (no automatic managed local browser launch on this path)
```bash
# Auto-discover running Chrome with remote debugging enabled
@@ -225,6 +229,9 @@ agent-browser --auto-connect snapshot
# Or with explicit CDP port
agent-browser --cdp 9222 snapshot
# Debug auto-attach behavior
agent-browser --debug snapshot
```
### Color Scheme (Dark Mode)
@@ -263,7 +270,7 @@ agent-browser screenshot output.png
- `--profile` / `AGENT_BROWSER_PROFILE` are forbidden
- `--channel` / `AGENT_BROWSER_CHANNEL` are forbidden
- Use existing browser sessions (default CDP `localhost:9333`) or pass `--cdp` explicitly
- Use existing browser sessions (default attach path: CDP `localhost:9333` then auto-discovery) or pass `--cdp` explicitly
### Stealth Mode (Always On)
@@ -357,8 +364,9 @@ export AGENT_BROWSER_ACTION_POLICY=./policy.json
```
Example `policy.json`:
```json
{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]}
{ "default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] }
```
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
+134
View File
@@ -54,6 +54,56 @@ describe('BrowserManager', () => {
await newBrowser.close();
});
it('should switch from local session when auto-connect is explicitly requested', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ id: 'test', action: 'launch', headless: true });
const closeSpy = vi.spyOn(testBrowser, 'close');
const autoConnectSpy = vi
.spyOn(testBrowser as any, 'autoConnectViaCDP')
.mockResolvedValue(undefined);
await testBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(autoConnectSpy).toHaveBeenCalledTimes(1);
autoConnectSpy.mockRestore();
closeSpy.mockRestore();
await testBrowser.close();
});
it('should not relaunch when already connected via healthy CDP and auto-connect is requested', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ id: 'test', action: 'launch', cdpPort: 9222 });
expect(connectSpy).toHaveBeenCalledTimes(1);
const closeSpy = vi.spyOn(cdpBrowser, 'close');
await cdpBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).not.toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalledTimes(1);
closeSpy.mockRestore();
await cdpBrowser.close();
connectSpy.mockRestore();
});
it('should report local stealth policy capabilities', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ headless: true });
@@ -97,6 +147,90 @@ describe('BrowserManager', () => {
spy.mockRestore();
});
it('should reject CDP endpoints with only blank pages when meaningful tabs are required', async () => {
const mockPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await expect(
(cdpBrowser as any).connectViaCDP('9222', {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
})
).rejects.toThrow('No existing user tabs found on this CDP endpoint.');
expect(mockBrowser.close).toHaveBeenCalledTimes(1);
connectSpy.mockRestore();
});
it('should skip auto-connect candidates without user tabs and continue discovery', async () => {
const cdpBrowser = new BrowserManager();
const dirsSpy = vi
.spyOn(cdpBrowser as any, 'getChromeUserDataDirs')
.mockReturnValue(['/tmp/chrome-a', '/tmp/chrome-b']);
const activePortSpy = vi.spyOn(cdpBrowser as any, 'readDevToolsActivePort');
activePortSpy
.mockReturnValueOnce({ port: 9222, wsPath: '/devtools/browser/a' })
.mockReturnValueOnce({ port: 9333, wsPath: '/devtools/browser/b' });
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy
.mockResolvedValueOnce('ws://127.0.0.1:9222/devtools/browser/a')
.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/b');
const connectViaCDPSpy = vi.spyOn(cdpBrowser as any, 'connectViaCDP');
connectViaCDPSpy
.mockRejectedValueOnce(new Error('No existing user tabs found on this CDP endpoint.'))
.mockResolvedValueOnce(undefined);
await (cdpBrowser as any).autoConnectViaCDP();
expect(connectViaCDPSpy).toHaveBeenCalledTimes(2);
expect(connectViaCDPSpy.mock.calls[0][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
expect(connectViaCDPSpy.mock.calls[1][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
dirsSpy.mockRestore();
activePortSpy.mockRestore();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
});
it('should prefer port 9333 before DevToolsActivePort discovery in auto-connect', async () => {
const cdpBrowser = new BrowserManager();
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/preferred');
const connectViaCDPSpy = vi
.spyOn(cdpBrowser as any, 'connectViaCDP')
.mockResolvedValue(undefined);
const dirsSpy = vi.spyOn(cdpBrowser as any, 'getChromeUserDataDirs');
await (cdpBrowser as any).autoConnectViaCDP();
expect(probeSpy).toHaveBeenCalledWith(9333);
expect(connectViaCDPSpy).toHaveBeenCalledTimes(1);
expect(connectViaCDPSpy.mock.calls[0][0]).toContain('9333');
expect(dirsSpy).not.toHaveBeenCalled();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
dirsSpy.mockRestore();
});
it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
+124 -8
View File
@@ -671,6 +671,16 @@ export class BrowserManager {
return !this.isIgnoredCDPPageUrl(url);
}
private isMeaningfulCDPPage(page: Page): boolean {
if (page.isClosed()) return false;
const url = this.getSafePageUrl(page).trim().toLowerCase();
if (!url) return false;
if (url === 'about:blank' || url.startsWith('about:blank#')) return false;
if (url === 'chrome://newtab/' || url.startsWith('chrome://newtab')) return false;
if (url === 'chrome://new-tab-page/' || url.startsWith('chrome://new-tab-page')) return false;
return !this.isIgnoredCDPPageUrl(url);
}
private collectUsableCDPPages(contexts: BrowserContext[]): Page[] {
return contexts
.flatMap((context) => context.pages())
@@ -1594,7 +1604,13 @@ export class BrowserManager {
}
if (this.isLaunched()) {
// Explicit --auto-connect should switch away from managed/local/provider sessions
// so commands always target a discovered user browser.
const shouldSwitchToAutoConnect =
!!options.autoConnect &&
(this.cdpEndpoint === null || this.stealthConnectionKind !== 'cdp');
const needsRelaunch =
shouldSwitchToAutoConnect ||
(!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) ||
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) ||
(!!options.autoConnect && !this.isCdpConnectionAlive());
@@ -1923,7 +1939,11 @@ export class BrowserManager {
*/
private async connectViaCDP(
cdpEndpoint: string | undefined,
options?: { timeout?: number }
options?: {
timeout?: number;
allowCreatePageFallback?: boolean;
requireMeaningfulPage?: boolean;
}
): Promise<void> {
this.stealthConnectionKind = 'cdp';
if (!cdpEndpoint) {
@@ -1969,8 +1989,12 @@ export class BrowserManager {
}
let allPages = this.collectUsableCDPPages(contexts);
const allowCreatePageFallback = options?.allowCreatePageFallback ?? true;
if (allPages.length === 0) {
if (!allowCreatePageFallback) {
throw new Error('No existing user tabs found on this CDP endpoint.');
}
// Some Chrome instances (especially with custom UI pages) expose only internal/transient
// pages over CDP. Create a fresh page so commands always have a stable target.
let fallbackPage: Page | null = null;
@@ -1996,6 +2020,14 @@ export class BrowserManager {
allPages = [fallbackPage];
}
if (options?.requireMeaningfulPage) {
const meaningfulPages = allPages.filter((page) => this.isMeaningfulCDPPage(page));
if (meaningfulPages.length === 0) {
throw new Error('No existing user tabs found on this CDP endpoint.');
}
allPages = meaningfulPages;
}
// All validation passed - commit state
this.browser = browser;
this.cdpEndpoint = cdpEndpoint;
@@ -2105,6 +2137,35 @@ export class BrowserManager {
* 4. If a port responds, connect via CDP
*/
private async autoConnectViaCDP(): Promise<void> {
let sawEndpointWithoutUserTabs = false;
// Strategy 0: Prefer project-default resident CDP port first.
// This keeps user + agent on the same browser session when 9333 is available.
{
const wsUrl = await this.probeDebugPort(9333);
if (wsUrl) {
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping preferred CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed preferred CDP candidate (${wsUrl}): ${message}`);
}
}
}
}
// Strategy 1: Check DevToolsActivePort files
const userDataDirs = this.getChromeUserDataDirs();
for (const dir of userDataDirs) {
@@ -2113,8 +2174,25 @@ export class BrowserManager {
// Try HTTP discovery first (works with --remote-debugging-port mode)
const wsUrl = await this.probeDebugPort(activePort.port);
if (wsUrl) {
await this.connectViaCDP(wsUrl);
return;
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
}
}
}
// HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
// WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
@@ -2127,24 +2205,62 @@ export class BrowserManager {
`attempting direct WebSocket connection to ${directWsUrl}`
);
}
await this.connectViaCDP(directWsUrl, { timeout: 60_000 });
await this.connectViaCDP(directWsUrl, {
timeout: 60_000,
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${directWsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${directWsUrl}): ${message}`);
}
// Direct WebSocket also failed, try next directory
}
}
}
// Strategy 2: Probe common debugging ports
const commonPorts = [9222, 9229, 9333];
const commonPorts = [9222, 9229];
for (const port of commonPorts) {
const wsUrl = await this.probeDebugPort(port);
if (wsUrl) {
await this.connectViaCDP(wsUrl);
return;
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
}
}
}
}
if (sawEndpointWithoutUserTabs) {
throw new Error(
'Found CDP endpoints, but none exposed existing user tabs. Ensure you are attaching to the same Chrome instance/profile you are using manually.'
);
}
// Nothing found
const platform = os.platform();
let hint: string;
+31 -8
View File
@@ -406,8 +406,7 @@ export async function startDaemon(options?: {
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: first try attaching to a resident Chrome on CDP :9333,
// then fall back to launching a local Playwright browser if CDP is unavailable.
// Default behavior for this fork: attach to an existing browser only.
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
@@ -477,10 +476,10 @@ export async function startDaemon(options?: {
autoStateFilePath: getSessionAutoStatePath(),
};
let launchedViaDefaultCdp = false;
let attachedToExistingBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause a false-negative fallback.
// are incompatible with CDP and can cause false-negative attach failures.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
@@ -492,7 +491,7 @@ export async function startDaemon(options?: {
await manager.launch({
...cdpLaunchOptions,
});
launchedViaDefaultCdp = true;
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
@@ -500,13 +499,37 @@ export async function startDaemon(options?: {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[DEBUG] Default CDP port 9333 unavailable, falling back to local launch: ${message}`
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
}
}
if (!launchedViaDefaultCdp) {
await manager.launch(launchOptions);
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
);
}
}
}