feat: support remote CDP WebSocket URLs in --cdp flag (#99)

Previously, the --cdp flag only accepted a port number and connected via
http://localhost:{port}. This made it impossible to connect to remote
browser services like Kernel, Browserless, etc. that provide WebSocket URLs.

The --cdp flag now accepts either:
- A port number (e.g., 9222) for local connections
- A full WebSocket URL (e.g., wss://...) for remote browser services

Changes:
- Added cdpUrl field to LaunchCommand type
- Updated protocol validation to accept URL format with scheme validation
- Modified connectViaCDP to detect and handle both formats
- Handle numeric strings for JSON serialization edge cases
- Updated CLI to send cdpUrl or cdpPort based on input format
- Updated README with examples for remote connections

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Rafael
2026-01-21 21:11:14 -06:00
committed by GitHub
co-authored by Claude Opus 4.5
parent c4139fa389
commit e892bceadf
5 changed files with 131 additions and 55 deletions
+7
View File
@@ -476,8 +476,15 @@ agent-browser close
# Or pass --cdp on each command # Or pass --cdp on each command
agent-browser --cdp 9222 snapshot agent-browser --cdp 9222 snapshot
# Connect to remote browser via WebSocket URL
agent-browser --cdp "wss://your-browser-service.com/cdp?token=..." snapshot
``` ```
The `--cdp` flag accepts either:
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
This enables control of: This enables control of:
- Electron apps - Electron apps
- Chrome/Chromium instances with remote debugging - Chrome/Chromium instances with remote debugging
+68 -37
View File
@@ -80,7 +80,8 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
let running = unsafe { libc::kill(pid as i32, 0) == 0 }; let running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)] #[cfg(windows)]
let running = unsafe { let running = unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); let handle =
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle != 0 { if handle != 0 {
CloseHandle(handle); CloseHandle(handle);
true true
@@ -192,7 +193,12 @@ fn main() {
} }
}; };
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) { let daemon_result = match ensure_daemon(
&flags.session,
flags.headed,
flags.executable_path.as_deref(),
&flags.extensions,
) {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
if flags.json { if flags.json {
@@ -205,7 +211,9 @@ fn main() {
}; };
// Warn if executable_path was specified but daemon was already running // Warn if executable_path was specified but daemon was already running
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) { if daemon_result.already_running
&& (flags.executable_path.is_some() || !flags.extensions.is_empty())
{
if !flags.json { if !flags.json {
if flags.executable_path.is_some() { if flags.executable_path.is_some() {
eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator()); eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator());
@@ -238,47 +246,70 @@ fn main() {
} }
// Connect via CDP if --cdp flag is set // Connect via CDP if --cdp flag is set
if let Some(ref port) = flags.cdp { // Accepts either a port number (e.g., "9222") or a full URL (e.g., "ws://..." or "wss://...")
let cdp_port: u16 = match port.parse::<u32>() { if let Some(ref cdp_value) = flags.cdp {
Ok(p) if p == 0 => { let launch_cmd = if cdp_value.starts_with("ws://")
let msg = "Invalid CDP port: port must be greater than 0".to_string(); || cdp_value.starts_with("wss://")
if flags.json { || cdp_value.starts_with("http://")
println!(r#"{{"success":false,"error":"{}"}}"#, msg); || cdp_value.starts_with("https://")
} else { {
eprintln!("{} {}", color::error_indicator(), msg); // It's a URL - use cdpUrl field
json!({
"id": gen_id(),
"action": "launch",
"cdpUrl": cdp_value
})
} else {
// It's a port number - validate and use cdpPort field
let cdp_port: u16 = match cdp_value.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!("{} {}", color::error_indicator(), msg);
}
exit(1);
} }
exit(1); Ok(p) if p > 65535 => {
} let msg = format!(
Ok(p) if p > 65535 => { "Invalid CDP port: {} is out of range (valid range: 1-65535)",
let msg = format!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p); p
if flags.json { );
println!(r#"{{"success":false,"error":"{}"}}"#, msg); if flags.json {
} else { println!(r#"{{"success":false,"error":"{}"}}"#, msg);
eprintln!("{} {}", color::error_indicator(), msg); } else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
} }
exit(1); Ok(p) => p as u16,
} Err(_) => {
Ok(p) => p as u16, let msg = format!(
Err(_) => { "Invalid CDP value: '{}' is not a valid port number or URL",
let msg = format!("Invalid CDP port: '{}' is not a valid number. Port must be a number between 1 and 65535", port); cdp_value
if flags.json { );
println!(r#"{{"success":false,"error":"{}"}}"#, msg); if flags.json {
} else { println!(r#"{{"success":false,"error":"{}"}}"#, msg);
eprintln!("{} {}", color::error_indicator(), msg); } else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
} }
exit(1); };
} json!({
"id": gen_id(),
"action": "launch",
"cdpPort": cdp_port
})
}; };
let launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"cdpPort": cdp_port
});
let err = match send_command(launch_cmd, &flags.session) { let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None, Ok(resp) if resp.success => None,
Ok(resp) => Some(resp.error.unwrap_or_else(|| "CDP connection failed".to_string())), Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "CDP connection failed".to_string()),
),
Err(e) => Some(e.to_string()), Err(e) => Some(e.to_string()),
}; };
+43 -18
View File
@@ -68,7 +68,7 @@ interface PageError {
*/ */
export class BrowserManager { export class BrowserManager {
private browser: Browser | null = null; private browser: Browser | null = null;
private cdpPort: number | null = null; private cdpEndpoint: string | null = null; // stores port number or full URL
private isPersistentContext: boolean = false; private isPersistentContext: boolean = false;
private browserbaseSessionId: string | null = null; private browserbaseSessionId: string | null = null;
private browserbaseApiKey: string | null = null; private browserbaseApiKey: string | null = null;
@@ -639,9 +639,9 @@ export class BrowserManager {
/** /**
* Check if CDP connection needs to be re-established * Check if CDP connection needs to be re-established
*/ */
private needsCdpReconnect(cdpPort: number): boolean { private needsCdpReconnect(cdpEndpoint: string): boolean {
if (!this.browser?.isConnected()) return true; if (!this.browser?.isConnected()) return true;
if (this.cdpPort !== cdpPort) return true; if (this.cdpEndpoint !== cdpEndpoint) return true;
if (!this.isCdpConnectionAlive()) return true; if (!this.isCdpConnectionAlive()) return true;
return false; return false;
} }
@@ -815,16 +815,18 @@ export class BrowserManager {
* If already launched, this is a no-op (browser stays open) * If already launched, this is a no-op (browser stays open)
*/ */
async launch(options: LaunchCommand): Promise<void> { async launch(options: LaunchCommand): Promise<void> {
const cdpPort = options.cdpPort; // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
const hasExtensions = !!options.extensions?.length; const hasExtensions = !!options.extensions?.length;
if (hasExtensions && cdpPort) { if (hasExtensions && cdpEndpoint) {
throw new Error('Extensions cannot be used with CDP connection'); throw new Error('Extensions cannot be used with CDP connection');
} }
if (this.isLaunched()) { if (this.isLaunched()) {
const needsRelaunch = const needsRelaunch =
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort)); (!cdpEndpoint && this.cdpEndpoint !== null) ||
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
if (needsRelaunch) { if (needsRelaunch) {
await this.close(); await this.close();
} else { } else {
@@ -832,8 +834,8 @@ export class BrowserManager {
} }
} }
if (cdpPort) { if (cdpEndpoint) {
await this.connectViaCDP(cdpPort); await this.connectViaCDP(cdpEndpoint);
return; return;
} }
@@ -880,7 +882,7 @@ export class BrowserManager {
headless: options.headless ?? true, headless: options.headless ?? true,
executablePath: options.executablePath, executablePath: options.executablePath,
}); });
this.cdpPort = null; this.cdpEndpoint = null;
context = await this.browser.newContext({ context = await this.browser.newContext({
viewport, viewport,
extraHTTPHeaders: options.headers, extraHTTPHeaders: options.headers,
@@ -899,16 +901,39 @@ export class BrowserManager {
/** /**
* Connect to a running browser via CDP (Chrome DevTools Protocol) * Connect to a running browser via CDP (Chrome DevTools Protocol)
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
*/ */
private async connectViaCDP(cdpPort: number | undefined): Promise<void> { private async connectViaCDP(cdpEndpoint: string | undefined): Promise<void> {
if (!cdpPort) { if (!cdpEndpoint) {
throw new Error('cdpPort is required for CDP connection'); throw new Error('CDP endpoint is required for CDP connection');
} }
const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => { // Determine the connection URL:
// - If it starts with ws://, wss://, http://, or https://, use it directly
// - If it's a numeric string (e.g., "9222"), treat as port for localhost
// - Otherwise, treat it as a port number for localhost
let cdpUrl: string;
if (
cdpEndpoint.startsWith('ws://') ||
cdpEndpoint.startsWith('wss://') ||
cdpEndpoint.startsWith('http://') ||
cdpEndpoint.startsWith('https://')
) {
cdpUrl = cdpEndpoint;
} else if (/^\d+$/.test(cdpEndpoint)) {
// Numeric string - treat as port number (handles JSON serialization quirks)
cdpUrl = `http://localhost:${cdpEndpoint}`;
} else {
// Unknown format - still try as port for backward compatibility
cdpUrl = `http://localhost:${cdpEndpoint}`;
}
const browser = await chromium.connectOverCDP(cdpUrl).catch(() => {
throw new Error( throw new Error(
`Failed to connect via CDP on port ${cdpPort}. ` + `Failed to connect via CDP to ${cdpUrl}. ` +
`Make sure the app is running with --remote-debugging-port=${cdpPort}` (cdpUrl.includes('localhost')
? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
: 'Make sure the remote browser is accessible and the URL is correct.')
); );
}); });
@@ -928,7 +953,7 @@ export class BrowserManager {
// All validation passed - commit state // All validation passed - commit state
this.browser = browser; this.browser = browser;
this.cdpPort = cdpPort; this.cdpEndpoint = cdpEndpoint;
for (const context of contexts) { for (const context of contexts) {
this.contexts.push(context); this.contexts.push(context);
@@ -1554,7 +1579,7 @@ export class BrowserManager {
} }
); );
this.browser = null; this.browser = null;
} else if (this.cdpPort !== null) { } else if (this.cdpEndpoint !== null) {
// CDP: only disconnect, don't close external app's pages // CDP: only disconnect, don't close external app's pages
if (this.browser) { if (this.browser) {
await this.browser.close().catch(() => {}); await this.browser.close().catch(() => {});
@@ -1576,7 +1601,7 @@ export class BrowserManager {
this.pages = []; this.pages = [];
this.contexts = []; this.contexts = [];
this.cdpPort = null; this.cdpEndpoint = null;
this.browserbaseSessionId = null; this.browserbaseSessionId = null;
this.browserbaseApiKey = null; this.browserbaseApiKey = null;
this.browserUseSessionId = null; this.browserUseSessionId = null;
+12
View File
@@ -19,6 +19,18 @@ const launchSchema = baseCommandSchema.extend({
.optional(), .optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
cdpPort: z.number().positive().optional(), cdpPort: z.number().positive().optional(),
cdpUrl: z
.string()
.url()
.refine(
(url) =>
url.startsWith('ws://') ||
url.startsWith('wss://') ||
url.startsWith('http://') ||
url.startsWith('https://'),
{ message: 'CDP URL must start with ws://, wss://, http://, or https://' }
)
.optional(),
executablePath: z.string().optional(), executablePath: z.string().optional(),
extensions: z.array(z.string()).optional(), extensions: z.array(z.string()).optional(),
headers: z.record(z.string()).optional(), headers: z.record(z.string()).optional(),
+1
View File
@@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
headers?: Record<string, string>; headers?: Record<string, string>;
executablePath?: string; executablePath?: string;
cdpPort?: number; cdpPort?: number;
cdpUrl?: string;
extensions?: string[]; extensions?: string[];
proxy?: { proxy?: {
server: string; server: string;