feat: add CDP connection support for external browsers (#24)

* feat: add CDP connection support for external browsers

Add --cdp flag to connect to browsers via Chrome DevTools Protocol.
This enables control of Electron apps, Chrome instances, or any browser
exposing a CDP endpoint.

- Add cdpPort option to launch command schema
- Implement connectViaCDP() using chromium.connectOverCDP()
- Track browser connection type for proper reconnection handling
- Collect all pages from all contexts for CDP connections

Usage: agent-browser --cdp 9222 snapshot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: enhance CDP connection handling and improve page tracking

* main.rs update

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

* fix: verify CDP connection is alive before early return in launch()

Prevents misleading errors when the remote browser crashes by checking
isConnected() before reusing an existing browser reference.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: reconnect when CDP port changes instead of reusing existing browser

Ensures --cdp flag is respected even when a browser session already exists.
Adds tests for launch() reconnection behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update src/browser.ts

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

* fix: improve CDP connection handling and validation

* feat: add CDP connection validation to ensure browser context accessibility

* Update src/browser.ts

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

* feat: enhance CDP connection handling and add reconnect logic

* fix: improve CDP connection handling during browser closure

* fix: reset cdpPort to null during browser initialization

* feat: enhance browser launch logic to handle CDP connection switching

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
This commit is contained in:
Alan Jeon
2026-01-13 00:52:47 -06:00
committed by GitHub
co-authored by Claude Opus 4.5 vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
parent 97b17c98fb
commit 95675e9d55
9 changed files with 265 additions and 19 deletions
+20
View File
@@ -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 <port>` | 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:
+9 -1
View File
@@ -8,6 +8,7 @@ pub struct Flags {
pub session: String,
pub headers: Option<String>,
pub executable_path: Option<String>,
pub cdp: Option<String>,
}
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<String> {
// 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 {
+64 -4
View File
@@ -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::<u32>() {
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);
}
}
}
+2
View File
@@ -1194,6 +1194,7 @@ Options:
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
--cdp <port> 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
"#
);
}
+19
View File
@@ -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', () => {
+129 -12
View File
@@ -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<void> {
// 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<void> {
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<void> {
for (const page of this.pages) {
await page.close().catch(() => {});
}
this.pages = [];
for (const context of this.contexts) {
await context.close().catch(() => {});
}
this.contexts = [];
// 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 = [];
this.contexts = [];
this.cdpPort = null;
this.activePageIndex = 0;
this.refMap = {};
this.lastSnapshot = '';
+18
View File
@@ -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', () => {
+1
View File
@@ -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({
+1
View File
@@ -14,6 +14,7 @@ export interface LaunchCommand extends BaseCommand {
browser?: 'chromium' | 'firefox' | 'webkit';
headers?: Record<string, string>;
executablePath?: string;
cdpPort?: number;
}
export interface NavigateCommand extends BaseCommand {