diff --git a/README.md b/README.md index 4652ff7..12f1ef1 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ agent-browser snapshot -i -c -d 5 # Combine options | `--name, -n` | Locator name filter | | `--exact` | Exact text match | | `--headed` | Show browser window (not headless) | +| `--cdp ` | Connect via Chrome DevTools Protocol | | `--debug` | Debug output | ## Selectors @@ -459,6 +460,25 @@ export async function handler() { } ``` +## CDP Mode + +Connect to an existing browser via Chrome DevTools Protocol: + +```bash +# Connect to Electron app +agent-browser --cdp 9222 snapshot + +# Connect to Chrome with remote debugging +# (Start Chrome with: google-chrome --remote-debugging-port=9222) +agent-browser --cdp 9222 open about:blank +``` + +This enables control of: +- Electron apps +- Chrome/Chromium instances with remote debugging +- WebView2 applications +- Any browser exposing a CDP endpoint + ## Architecture agent-browser uses a client-daemon architecture: diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 3882cda..89a1527 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -8,6 +8,7 @@ pub struct Flags { pub session: String, pub headers: Option, pub executable_path: Option, + pub cdp: Option, } pub fn parse_flags(args: &[String]) -> Flags { @@ -19,6 +20,7 @@ pub fn parse_flags(args: &[String]) -> Flags { session: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), headers: None, executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), + cdp: None, }; let mut i = 0; @@ -46,6 +48,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--cdp" => { + if let Some(s) = args.get(i + 1) { + flags.cdp = Some(s.clone()); + i += 1; + } + } _ => {} } i += 1; @@ -60,7 +68,7 @@ pub fn clean_args(args: &[String]) -> Vec { // Global flags that should be stripped from command args const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; // Global flags that take a value (need to skip the next arg too) - const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path"]; + const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp"]; for arg in args.iter() { if skip_next { diff --git a/cli/src/main.rs b/cli/src/main.rs index 0413d3b..c167a82 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -168,12 +168,72 @@ fn main() { } } - // If --headed flag is set, send launch command first to switch to headed mode - if flags.headed { - let launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": false }); + // Connect via CDP if --cdp flag is set + if let Some(ref port) = flags.cdp { + let cdp_port: u16 = match port.parse::() { + Ok(p) if p == 0 => { + let msg = "Invalid CDP port: port must be greater than 0".to_string(); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + Ok(p) if p > 65535 => { + let msg = format!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + Ok(p) => p as u16, + Err(_) => { + let msg = format!("Invalid CDP port: '{}' is not a valid number. Port must be a number between 1 and 65535", port); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + }; + + let launch_cmd = json!({ + "id": gen_id(), + "action": "launch", + "cdpPort": cdp_port + }); + + let err = match send_command(launch_cmd, &flags.session) { + Ok(resp) if resp.success => None, + Ok(resp) => Some(resp.error.unwrap_or_else(|| "CDP connection failed".to_string())), + Err(e) => Some(e.to_string()), + }; + + if let Some(msg) = err { + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, msg); + } else { + eprintln!("\x1b[31m✗\x1b[0m {}", msg); + } + exit(1); + } + } + + // Launch headed browser if --headed flag is set (without CDP) + if flags.headed && flags.cdp.is_none() { + let launch_cmd = json!({ + "id": gen_id(), + "action": "launch", + "headless": false + }); + if let Err(e) = send_command(launch_cmd, &flags.session) { if !flags.json { - eprintln!("\x1b[33m⚠\x1b[0m Could not switch to headed mode: {}", e); + eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e); } } } diff --git a/cli/src/output.rs b/cli/src/output.rs index 15a3d4c..080b763 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1194,6 +1194,7 @@ Options: --json JSON output --full, -f Full page screenshot --headed Show browser window (not headless) + --cdp Connect via CDP (Chrome DevTools Protocol) --debug Debug output Examples: @@ -1204,6 +1205,7 @@ Examples: agent-browser find role button click --name Submit agent-browser get text @e1 agent-browser screenshot --full + agent-browser --cdp 9222 snapshot # Connect via CDP port "# ); } diff --git a/src/browser.test.ts b/src/browser.test.ts index 2789eb7..ec61581 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -32,6 +32,25 @@ describe('BrowserManager', () => { }) ).rejects.toThrow(); }); + + it('should be no-op when relaunching with same options', async () => { + const browserInstance = browser.getBrowser(); + await browser.launch({ id: 'test', action: 'launch', headless: true }); + expect(browser.getBrowser()).toBe(browserInstance); + }); + + it('should reconnect when CDP port changes', async () => { + const newBrowser = new BrowserManager(); + await newBrowser.launch({ id: 'test', action: 'launch', headless: true }); + expect(newBrowser.getBrowser()).not.toBeNull(); + + await expect( + newBrowser.launch({ id: 'test', action: 'launch', cdpPort: 59999 }) + ).rejects.toThrow(); + + expect(newBrowser.getBrowser()).toBeNull(); + await newBrowser.close(); + }); }); describe('navigation', () => { diff --git a/src/browser.ts b/src/browser.ts index 75fd241..c948273 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -39,6 +39,7 @@ interface PageError { */ export class BrowserManager { private browser: Browser | null = null; + private cdpPort: number | null = null; private contexts: BrowserContext[] = []; private pages: Page[] = []; private activePageIndex: number = 0; @@ -573,13 +574,51 @@ export class BrowserManager { return this.browser; } + /** + * Check if an existing CDP connection is still alive + * by verifying we can access browser contexts and that at least one has pages + */ + private isCdpConnectionAlive(): boolean { + if (!this.browser) return false; + try { + const contexts = this.browser.contexts(); + if (contexts.length === 0) return false; + return contexts.some((context) => context.pages().length > 0); + } catch { + return false; + } + } + + /** + * Check if CDP connection needs to be re-established + */ + private needsCdpReconnect(cdpPort: number): boolean { + if (!this.browser?.isConnected()) return true; + if (this.cdpPort !== cdpPort) return true; + if (!this.isCdpConnectionAlive()) return true; + return false; + } + /** * Launch the browser with the specified options * If already launched, this is a no-op (browser stays open) */ async launch(options: LaunchCommand): Promise { - // If already launched, don't relaunch + const cdpPort = options.cdpPort; + if (this.browser) { + const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null; + const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort); + + if (switchingFromCdpToBrowser || needsCdpReconnect) { + await this.close(); + } else { + return; + } + } + + if (cdpPort) { + await this.connectViaCDP(cdpPort); return; } @@ -593,6 +632,7 @@ export class BrowserManager { headless: options.headless ?? true, executablePath: options.executablePath, }); + this.cdpPort = null; // Create context with viewport and optional headers const context = await this.browser.newContext({ @@ -615,7 +655,56 @@ export class BrowserManager { } /** - * Set up console and error tracking for a page + * Connect to a running browser via CDP (Chrome DevTools Protocol) + */ + private async connectViaCDP(cdpPort: number | undefined): Promise { + if (!cdpPort) { + throw new Error('cdpPort is required for CDP connection'); + } + + const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => { + throw new Error( + `Failed to connect via CDP on port ${cdpPort}. ` + + `Make sure the app is running with --remote-debugging-port=${cdpPort}` + ); + }); + + // Validate and set up state, cleaning up browser connection if anything fails + try { + const contexts = browser.contexts(); + if (contexts.length === 0) { + throw new Error('No browser context found. Make sure the app has an open window.'); + } + + const allPages = contexts.flatMap((context) => context.pages()); + if (allPages.length === 0) { + throw new Error('No page found. Make sure the app has loaded content.'); + } + + // All validation passed - commit state + this.browser = browser; + this.cdpPort = cdpPort; + + for (const context of contexts) { + this.contexts.push(context); + this.setupContextTracking(context); + } + + for (const page of allPages) { + this.pages.push(page); + this.setupPageTracking(page); + } + + this.activePageIndex = 0; + } catch (error) { + // Clean up browser connection if validation or setup failed + await browser.close().catch(() => {}); + throw error; + } + } + + /** + * Set up console, error, and close tracking for a page */ private setupPageTracking(page: Page): void { page.on('console', (msg) => { @@ -632,6 +721,26 @@ export class BrowserManager { timestamp: Date.now(), }); }); + + page.on('close', () => { + const index = this.pages.indexOf(page); + if (index !== -1) { + this.pages.splice(index, 1); + if (this.activePageIndex >= this.pages.length) { + this.activePageIndex = Math.max(0, this.pages.length - 1); + } + } + }); + } + + /** + * Set up tracking for new pages in a context (for CDP connections) + */ + private setupContextTracking(context: BrowserContext): void { + context.on('page', (page) => { + this.pages.push(page); + this.setupPageTracking(page); + }); } /** @@ -745,21 +854,29 @@ export class BrowserManager { * Close the browser and clean up */ async close(): Promise { - for (const page of this.pages) { - await page.close().catch(() => {}); + // CDP: only disconnect, don't close external app's pages + if (this.cdpPort !== null) { + if (this.browser) { + await this.browser.close().catch(() => {}); + this.browser = null; + } + } else { + // Regular browser: close everything + for (const page of this.pages) { + await page.close().catch(() => {}); + } + for (const context of this.contexts) { + await context.close().catch(() => {}); + } + if (this.browser) { + await this.browser.close().catch(() => {}); + this.browser = null; + } } + this.pages = []; - - for (const context of this.contexts) { - await context.close().catch(() => {}); - } this.contexts = []; - - if (this.browser) { - await this.browser.close().catch(() => {}); - this.browser = null; - } - + this.cdpPort = null; this.activePageIndex = 0; this.refMap = {}; this.lastSnapshot = ''; diff --git a/src/protocol.test.ts b/src/protocol.test.ts index fe4c70e..c7a0556 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -461,6 +461,24 @@ describe('parseCommand', () => { expect(result.command.headless).toBe(false); } }); + + it('should parse launch with cdpPort', () => { + const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 9222 })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.cdpPort).toBe(9222); + } + }); + + it('should reject launch with invalid cdpPort', () => { + const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: -1 })); + expect(result.success).toBe(false); + }); + + it('should reject launch with non-numeric cdpPort', () => { + const result = parseCommand(cmd({ id: '1', action: 'launch', cdpPort: 'invalid' })); + expect(result.success).toBe(false); + }); }); describe('mouse actions', () => { diff --git a/src/protocol.ts b/src/protocol.ts index a2be79f..bafdf53 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -18,6 +18,7 @@ const launchSchema = baseCommandSchema.extend({ }) .optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).optional(), + cdpPort: z.number().positive().optional(), }); const navigateSchema = baseCommandSchema.extend({ diff --git a/src/types.ts b/src/types.ts index baf8408..b46f728 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface LaunchCommand extends BaseCommand { browser?: 'chromium' | 'firefox' | 'webkit'; headers?: Record; executablePath?: string; + cdpPort?: number; } export interface NavigateCommand extends BaseCommand {