From 44c0361fcd3c74d643d1ffbd64f538df3e3c7c36 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Mon, 2 Mar 2026 18:32:28 +0900 Subject: [PATCH] Update readme with stealth FAQ --- CHANGELOG.md | 8 ++ README.md | 32 ++++-- cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/main.rs | 35 ++++-- cli/src/output.rs | 4 +- docs/src/app/cdp-mode/page.mdx | 155 +++++++++++++++++++++----- docs/src/app/configuration/page.mdx | 2 +- package.json | 5 +- scripts/verify-native-version.js | 48 ++++++++ skills/agent-browser-stealth/SKILL.md | 2 +- skills/agent-browser/SKILL.md | 14 ++- src/browser.test.ts | 134 ++++++++++++++++++++++ src/browser.ts | 132 ++++++++++++++++++++-- src/daemon.ts | 39 +++++-- 15 files changed, 548 insertions(+), 66 deletions(-) create mode 100644 scripts/verify-native-version.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fe019ad..c252004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3a17e19..3016bac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/Cargo.lock b/cli/Cargo.lock index e41cb41..d2f4916 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -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", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ab6c84c..c6f32f4 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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" diff --git a/cli/src/main.rs b/cli/src/main.rs index a46f1ab..11c7c04 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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 ."; + 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 ."; 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(), diff --git a/cli/src/output.rs b/cli/src/output.rs index 7fb3989..01968a8 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2398,7 +2398,7 @@ Options: --headed Show browser window (not headless) --cdp 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 Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --risk-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): diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index 45606da..9294961 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -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 - + + + + - - - + + + + + + + + + + + +
Connection typeStealth capabilities
Connection typeStealth capabilities
Local launchChromium launch args + context init scripts
CDP / auto-connectContext init scripts
Cloud providersContext init scripts (Kernel may also apply provider-managed stealth)
Local launchChromium launch args + context init scripts
CDP / auto-connectContext init scripts
Cloud providersContext init scripts (Kernel may also apply provider-managed stealth)
@@ -113,26 +125,119 @@ This enables control of: - + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDescription
OptionDescription
--session <name>Use isolated session
-p <provider>Cloud browser provider (browserbase, browseruse, kernel)
--headers <json>HTTP headers scoped to origin
--executable-pathCustom browser executable
--args <args>Browser launch args (comma-separated)
--user-agent <ua>Custom User-Agent string
--proxy <url>Proxy server URL
--proxy-bypass <hosts>Hosts to bypass proxy
--jsonJSON output for scripts
--full, -fFull page screenshot
--name, -nLocator name filter
--exactExact text match
--headedShow browser window
{"--cdp "}CDP connection (port or WebSocket URL)
--auto-connectAuto-discover and connect to running Chrome
--color-scheme <scheme>Persistent color scheme (dark, light, no-preference)
--debugDebug output
+ --session <name> + Use isolated session
+ -p <provider> + + Cloud browser provider (browserbase, browseruse,{' '} + kernel) +
+ --headers <json> + HTTP headers scoped to origin
+ --executable-path + Custom browser executable
+ --args <args> + Browser launch args (comma-separated)
+ --user-agent <ua> + Custom User-Agent string
+ --proxy <url> + Proxy server URL
+ --proxy-bypass <hosts> + Hosts to bypass proxy
+ --json + JSON output for scripts
+ --full, -f + Full page screenshot
+ --name, -n + Locator name filter
+ --exact + Exact text match
+ --headed + Show browser window
+ {'--cdp '} + CDP connection (port or WebSocket URL)
+ --auto-connect + Auto-discover and connect to running Chrome
+ --color-scheme <scheme> + + Persistent color scheme (dark, light, no-preference) +
+ --debug + Debug output
diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index fdae90d..d864b58 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -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 diff --git a/package.json b/package.json index 6ca17e0..14504f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-native-version.js b/scripts/verify-native-version.js new file mode 100644 index 0000000..57ab084 --- /dev/null +++ b/scripts/verify-native-version.js @@ -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})`); diff --git a/skills/agent-browser-stealth/SKILL.md b/skills/agent-browser-stealth/SKILL.md index 4c5b522..c19035b 100644 --- a/skills/agent-browser-stealth/SKILL.md +++ b/skills/agent-browser-stealth/SKILL.md @@ -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 diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 1b724e2..e039991 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -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. diff --git a/src/browser.test.ts b/src/browser.test.ts index a626593..01db3ce 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -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 }; diff --git a/src/browser.ts b/src/browser.ts index 933398a..4e9df7a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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 { 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 { + 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; diff --git a/src/daemon.ts b/src/daemon.ts index dbca4a3..fe06138 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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.' + ); } } }