From 893ddfd25926599afeb80305e966082c3a65d6ca Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 24 Feb 2026 16:22:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(cdp):=20=E9=BB=98=E8=AE=A4=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=BF=9E=E6=8E=A5=209333=20=E5=B8=B8=E9=A9=BB=20Chrom?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 无显式连接参数时先尝试 CDP 9333,失败后回退本地浏览器启动 - 修复 CDP 页选择稳定性:过滤 omnibox 系统页、无可用页时自动创建 fallback 页 - 调整页面关闭后的 active 索引维护,降低 No page found 问题 - 新增 agent-browser-stealth 二进制入口并保持与 agent-browser 行为一致 - 同步更新 CLI 帮助、README、技能文档与 docs 说明 --- README.md | 4 +- cli/Cargo.toml | 4 ++ cli/src/main.rs | 34 +++++++++++ cli/src/main_stealth.rs | 1 + cli/src/output.rs | 1 + docs/src/app/cdp-mode/page.mdx | 4 +- docs/src/app/configuration/page.mdx | 2 + skills/agent-browser/SKILL.md | 2 + src/browser.test.ts | 72 +++++++++++++++++++++-- src/browser.ts | 88 +++++++++++++++++++++++++++-- src/daemon.ts | 42 ++++++++++++-- 11 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 cli/src/main_stealth.rs diff --git a/README.md b/README.md index 9e9a805..8db70aa 100644 --- a/README.md +++ b/README.md @@ -813,6 +813,8 @@ These behaviors are always active and require no additional flags. Connect to an existing browser via Chrome DevTools Protocol: +By default in this fork, when you run commands without `--cdp`, agent-browser first tries `localhost:9333` (resident Chrome) and falls back to launching a local Playwright browser if CDP is unavailable. + ```bash # Start Chrome with: google-chrome --remote-debugging-port=9222 @@ -854,7 +856,7 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot Auto-connect discovers Chrome by: 1. Reading Chrome's `DevToolsActivePort` file from the default user data directory -2. Falling back to probing common debugging ports (9222, 9229) +2. Falling back to probing common debugging ports (9222, 9229, 9333) This is useful when: - Chrome 144+ has remote debugging enabled via `chrome://inspect/#remote-debugging` (which uses a dynamic port) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 83cea0d..d67c008 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,6 +9,10 @@ license = "Apache-2.0" name = "agent-browser" path = "src/main.rs" +[[bin]] +name = "agent-browser-stealth" +path = "src/main_stealth.rs" + [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/cli/src/main.rs b/cli/src/main.rs index 2b3bfa4..da7c7b4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -513,6 +513,39 @@ fn main() { } } + // Default fork behavior: when no explicit connection mode is provided, + // try attaching to resident Chrome on CDP :9333 first. If unavailable, + // silently fall back to local launch behavior below. + let can_try_default_cdp = flags.cdp.is_none() + && !flags.auto_connect + && flags.provider.is_none() + && flags.executable_path.is_none() + && flags.profile.is_none() + && flags.state.is_none() + && flags.proxy.is_none() + && flags.args.is_none() + && flags.user_agent.is_none() + && !flags.ignore_https_errors + && !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(), + "action": "launch", + "cdpPort": 9333 + }); + + if let Some(ref cs) = flags.color_scheme { + launch_cmd["colorScheme"] = json!(cs); + } + + if let Ok(resp) = send_command(launch_cmd, &flags.session) { + launched_via_default_cdp = resp.success; + } + } + // Launch headed browser or configure browser options (without CDP or provider) if (flags.headed || flags.executable_path.is_some() @@ -527,6 +560,7 @@ fn main() { || flags.color_scheme.is_some()) && flags.cdp.is_none() && flags.provider.is_none() + && !launched_via_default_cdp { let mut launch_cmd = json!({ "id": gen_id(), diff --git a/cli/src/main_stealth.rs b/cli/src/main_stealth.rs new file mode 100644 index 0000000..d5be06a --- /dev/null +++ b/cli/src/main_stealth.rs @@ -0,0 +1 @@ +include!("main.rs"); diff --git a/cli/src/output.rs b/cli/src/output.rs index 65ea048..96a91ab 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2122,6 +2122,7 @@ Options: --headed Show browser window (not headless) --cdp Connect via CDP (Chrome DevTools Protocol) --auto-connect Auto-discover and connect to running Chrome + Default launch tries CDP at localhost:9333 first, then falls back to local browser launch --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --session-name Auto-save/restore session state (cookies, localStorage) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index f0325c8..0e65dac 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -6,6 +6,8 @@ export const metadata = pageMetadata("cdp-mode") Connect to an existing browser via Chrome DevTools Protocol: +Default behavior in this fork: when `--cdp` is omitted, agent-browser first tries `localhost:9333` and falls back to local browser launch if CDP is unavailable. + ```bash # Start Chrome with: google-chrome --remote-debugging-port=9222 @@ -52,7 +54,7 @@ AGENT_BROWSER_AUTO_CONNECT=1 agent-browser snapshot Auto-connect discovers Chrome by: 1. Reading Chrome's `DevToolsActivePort` file from the default user data directory -2. Falling back to probing common debugging ports (9222, 9229) +2. Falling back to probing common debugging ports (9222, 9229, 9333) This is useful when: diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index b7015a1..7de8852 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -6,6 +6,8 @@ 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 already prefers a resident Chrome at `localhost:9333` (CDP) and falls back to local Playwright launch when unavailable. + ## Config File Locations agent-browser checks two locations, merged in priority order: diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 0d6fba6..95d7b0e 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -179,6 +179,8 @@ agent-browser session list ### Connect to Existing Chrome +By default in this fork, commands without `--cdp` try `localhost:9333` first and automatically fall back to a local browser launch if CDP is unavailable. + ```bash # Auto-discover running Chrome with remote debugging enabled agent-browser --auto-connect open https://example.com diff --git a/src/browser.test.ts b/src/browser.test.ts index 97be691..c209b27 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -69,7 +69,7 @@ describe('BrowserManager', () => { it('should apply init-script stealth policy for CDP connections', async () => { const addInitScript = vi.fn().mockResolvedValue(undefined); - const mockPage = { url: () => 'http://example.com', on: vi.fn() }; + const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false }; const mockContext = { pages: () => [mockPage], on: vi.fn(), @@ -99,7 +99,7 @@ describe('BrowserManager', () => { it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => { const addInitScript = vi.fn().mockResolvedValue(undefined); - const mockPage = { url: () => 'http://example.com', on: vi.fn() }; + const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false }; const mockContext = { pages: () => [mockPage], on: vi.fn(), @@ -926,15 +926,16 @@ describe('BrowserManager', () => { contexts: () => [ { pages: () => [ - { url: () => 'http://example.com', on: vi.fn() }, - { url: () => '', on: vi.fn() }, // This page should be filtered out - { url: () => 'http://anothersite.com', on: vi.fn() }, + { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false }, + { url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out + { url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false }, ], on: vi.fn(), setDefaultTimeout: vi.fn(), + addInitScript: vi.fn().mockResolvedValue(undefined), }, ], - close: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), }; const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); @@ -950,6 +951,65 @@ describe('BrowserManager', () => { expect(urls).toContain('http://example.com'); spy.mockRestore(); }); + + it('should ignore omnibox popup pages during CDP connection', async () => { + const mockBrowser = { + contexts: () => [ + { + pages: () => [ + { + url: () => 'chrome://omnibox-popup.top-chrome/', + on: vi.fn(), + isClosed: () => false, + }, + { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false }, + ], + on: vi.fn(), + setDefaultTimeout: vi.fn(), + addInitScript: vi.fn().mockResolvedValue(undefined), + }, + ], + close: vi.fn().mockResolvedValue(undefined), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + await cdpBrowser.launch({ cdpPort: 9222 }); + + expect(cdpBrowser.getPages().length).toBe(1); + expect(cdpBrowser.getPages()[0]?.url()).toBe('http://example.com'); + spy.mockRestore(); + }); + + it('should create a fallback page when CDP has only internal pages', async () => { + const newPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false }; + const context = { + pages: () => [ + { + url: () => 'chrome://omnibox-popup.top-chrome/omnibox_popup_aim.html', + on: vi.fn(), + isClosed: () => false, + }, + ], + newPage: vi.fn().mockResolvedValue(newPage), + on: vi.fn(), + setDefaultTimeout: vi.fn(), + addInitScript: vi.fn().mockResolvedValue(undefined), + }; + const mockBrowser = { + contexts: () => [context], + close: vi.fn().mockResolvedValue(undefined), + }; + const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any); + + const cdpBrowser = new BrowserManager(); + await cdpBrowser.launch({ cdpPort: 9222 }); + + expect(context.newPage).toHaveBeenCalledTimes(1); + expect(cdpBrowser.getPages().length).toBe(1); + expect(cdpBrowser.getPages()[0]?.url()).toBe('about:blank'); + spy.mockRestore(); + }); }); describe('screencast', () => { diff --git a/src/browser.ts b/src/browser.ts index 4c5783f..326cdc9 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -125,6 +125,8 @@ interface StealthContextDefaults { extraHTTPHeaders?: Record; } +const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/']; + /** * Manages the Playwright browser lifecycle with multiple tabs/windows */ @@ -483,6 +485,33 @@ export class BrowserManager { return this.pages.length > 0; } + private getSafePageUrl(page: Page): string { + try { + return page.url(); + } catch { + return ''; + } + } + + private isIgnoredCDPPageUrl(url: string): boolean { + if (!url) return false; + const normalizedUrl = url.toLowerCase(); + return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix)); + } + + private isUsableCDPPage(page: Page): boolean { + if (page.isClosed()) return false; + const url = this.getSafePageUrl(page); + if (!url) return false; + return !this.isIgnoredCDPPageUrl(url); + } + + private collectUsableCDPPages(contexts: BrowserContext[]): Page[] { + return contexts + .flatMap((context) => context.pages()) + .filter((page) => this.isUsableCDPPage(page)); + } + /** * Ensure at least one page exists. If the browser is launched but all pages * were closed (stale session), creates a new page on the existing context. @@ -527,6 +556,24 @@ export class BrowserManager { if (this.pages.length === 0) { throw new Error('Browser not launched. Call launch first.'); } + + const current = this.pages[this.activePageIndex]; + if (current && this.isUsableCDPPage(current)) { + return current; + } + + const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page)); + if (usableIndex !== -1) { + this.activePageIndex = usableIndex; + return this.pages[this.activePageIndex]; + } + + const openIndex = this.pages.findIndex((page) => !page.isClosed()); + if (openIndex !== -1) { + this.activePageIndex = openIndex; + return this.pages[this.activePageIndex]; + } + return this.pages[this.activePageIndex]; } @@ -1008,7 +1055,7 @@ export class BrowserManager { try { const contexts = this.browser.contexts(); if (contexts.length === 0) return false; - return contexts.some((context) => context.pages().length > 0); + return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page))); } catch { return false; } @@ -1722,11 +1769,32 @@ export class BrowserManager { throw new Error('No browser context found. Make sure the app has an open window.'); } - // Filter out pages with empty URLs, which can cause Playwright to hang - const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url()); + let allPages = this.collectUsableCDPPages(contexts); if (allPages.length === 0) { - throw new Error('No page found. Make sure the app has loaded content.'); + // 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; + for (const context of contexts) { + try { + const page = await context.newPage(); + if (!fallbackPage) { + fallbackPage = page; + } + if (this.isUsableCDPPage(page)) { + fallbackPage = page; + break; + } + } catch { + // Try next context + } + } + + if (!fallbackPage) { + throw new Error('No page found. Make sure the app has loaded content.'); + } + + allPages = [fallbackPage]; } // All validation passed - commit state @@ -1831,7 +1899,7 @@ export class BrowserManager { * Discovery strategy: * 1. Read DevToolsActivePort from Chrome's default user data directories * 2. If found, connect using the port and WebSocket path from that file - * 3. If not found, probe common debugging ports (9222, 9229) + * 3. If not found, probe common debugging ports (9222, 9229, 9333) * 4. If a port responds, connect via CDP */ private async autoConnectViaCDP(): Promise { @@ -1866,7 +1934,7 @@ export class BrowserManager { } // Strategy 2: Probe common debugging ports - const commonPorts = [9222, 9229]; + const commonPorts = [9222, 9229, 9333]; for (const port of commonPorts) { const wsUrl = await this.probeDebugPort(port); if (wsUrl) { @@ -1922,6 +1990,9 @@ export class BrowserManager { const index = this.pages.indexOf(page); if (index !== -1) { this.pages.splice(index, 1); + if (index < this.activePageIndex) { + this.activePageIndex--; + } if (this.activePageIndex >= this.pages.length) { this.activePageIndex = Math.max(0, this.pages.length - 1); } @@ -1935,6 +2006,11 @@ export class BrowserManager { */ private setupContextTracking(context: BrowserContext): void { context.on('page', (page) => { + const pageUrl = this.getSafePageUrl(page); + if (this.isIgnoredCDPPageUrl(pageUrl)) { + return; + } + // Only add if not already tracked (avoids duplicates when newTab() creates pages) if (!this.pages.includes(page)) { this.pages.push(page); diff --git a/src/daemon.ts b/src/daemon.ts index 9d1e57c..18c1fd3 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -405,7 +405,9 @@ export async function startDaemon(options?: { continue; } - // Auto-launch if not already launched and this isn't a launch/close/state_load command + // 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. if ( !manager.isLaunched() && parseResult.command.action !== 'launch' && @@ -452,13 +454,13 @@ export async function startDaemon(options?: { const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; // Stealth is always enabled in agent-browser-stealth const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME; - const colorScheme = + const colorScheme: 'dark' | 'light' | 'no-preference' | undefined = colorSchemeEnv === 'dark' || colorSchemeEnv === 'light' || colorSchemeEnv === 'no-preference' ? colorSchemeEnv : undefined; - await manager.launch({ + const launchOptions = { id: 'auto', action: 'launch' as const, headless: process.env.AGENT_BROWSER_HEADED !== '1', @@ -474,7 +476,39 @@ export async function startDaemon(options?: { colorScheme, autoStateFilePath: getSessionAutoStatePath(), - }); + }; + + let launchedViaDefaultCdp = false; + try { + // Keep default CDP attempt minimal. Launch-only options like profile/extensions + // are incompatible with CDP and can cause a false-negative fallback. + const cdpLaunchOptions = { + id: launchOptions.id, + action: launchOptions.action, + cdpPort: 9333, + ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors, + colorScheme: launchOptions.colorScheme, + userAgent: launchOptions.userAgent, + }; + await manager.launch({ + ...cdpLaunchOptions, + }); + launchedViaDefaultCdp = true; + if (process.env.AGENT_BROWSER_DEBUG === '1') { + console.error('[DEBUG] Auto-launch connected via default CDP port 9333'); + } + } catch (error) { + 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}` + ); + } + } + + if (!launchedViaDefaultCdp) { + await manager.launch(launchOptions); + } } }