diff --git a/README.md b/README.md index e97be58..414f229 100644 --- a/README.md +++ b/README.md @@ -476,8 +476,15 @@ agent-browser close # Or pass --cdp on each command 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: - Electron apps - Chrome/Chromium instances with remote debugging diff --git a/cli/src/main.rs b/cli/src/main.rs index 53a5439..2940688 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -80,7 +80,8 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { let running = unsafe { libc::kill(pid as i32, 0) == 0 }; #[cfg(windows)] let running = unsafe { - let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + let handle = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); if handle != 0 { CloseHandle(handle); 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, Err(e) => { if flags.json { @@ -205,7 +211,9 @@ fn main() { }; // 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.executable_path.is_some() { 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 - 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!("{} {}", color::error_indicator(), msg); + // Accepts either a port number (e.g., "9222") or a full URL (e.g., "ws://..." or "wss://...") + if let Some(ref cdp_value) = flags.cdp { + let launch_cmd = if cdp_value.starts_with("ws://") + || cdp_value.starts_with("wss://") + || cdp_value.starts_with("http://") + || cdp_value.starts_with("https://") + { + // 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::() { + 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!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p); - if flags.json { - println!(r#"{{"success":false,"error":"{}"}}"#, msg); - } else { - eprintln!("{} {}", color::error_indicator(), msg); + 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!("{} {}", color::error_indicator(), msg); + } + exit(1); } - 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!("{} {}", color::error_indicator(), msg); + Ok(p) => p as u16, + Err(_) => { + let msg = format!( + "Invalid CDP value: '{}' is not a valid port number or URL", + cdp_value + ); + if flags.json { + println!(r#"{{"success":false,"error":"{}"}}"#, 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) { 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()), }; diff --git a/src/browser.ts b/src/browser.ts index 186665f..86bf943 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -68,7 +68,7 @@ interface PageError { */ export class BrowserManager { 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 browserbaseSessionId: string | null = null; private browserbaseApiKey: string | null = null; @@ -639,9 +639,9 @@ export class BrowserManager { /** * 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.cdpPort !== cdpPort) return true; + if (this.cdpEndpoint !== cdpEndpoint) return true; if (!this.isCdpConnectionAlive()) return true; return false; } @@ -815,16 +815,18 @@ export class BrowserManager { * If already launched, this is a no-op (browser stays open) */ async launch(options: LaunchCommand): Promise { - 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; - if (hasExtensions && cdpPort) { + if (hasExtensions && cdpEndpoint) { throw new Error('Extensions cannot be used with CDP connection'); } if (this.isLaunched()) { const needsRelaunch = - (!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort)); + (!cdpEndpoint && this.cdpEndpoint !== null) || + (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)); if (needsRelaunch) { await this.close(); } else { @@ -832,8 +834,8 @@ export class BrowserManager { } } - if (cdpPort) { - await this.connectViaCDP(cdpPort); + if (cdpEndpoint) { + await this.connectViaCDP(cdpEndpoint); return; } @@ -880,7 +882,7 @@ export class BrowserManager { headless: options.headless ?? true, executablePath: options.executablePath, }); - this.cdpPort = null; + this.cdpEndpoint = null; context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers, @@ -899,16 +901,39 @@ export class BrowserManager { /** * 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 { - if (!cdpPort) { - throw new Error('cdpPort is required for CDP connection'); + private async connectViaCDP(cdpEndpoint: string | undefined): Promise { + if (!cdpEndpoint) { + 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( - `Failed to connect via CDP on port ${cdpPort}. ` + - `Make sure the app is running with --remote-debugging-port=${cdpPort}` + `Failed to connect via CDP to ${cdpUrl}. ` + + (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 this.browser = browser; - this.cdpPort = cdpPort; + this.cdpEndpoint = cdpEndpoint; for (const context of contexts) { this.contexts.push(context); @@ -1554,7 +1579,7 @@ export class BrowserManager { } ); this.browser = null; - } else if (this.cdpPort !== null) { + } else if (this.cdpEndpoint !== null) { // CDP: only disconnect, don't close external app's pages if (this.browser) { await this.browser.close().catch(() => {}); @@ -1576,7 +1601,7 @@ export class BrowserManager { this.pages = []; this.contexts = []; - this.cdpPort = null; + this.cdpEndpoint = null; this.browserbaseSessionId = null; this.browserbaseApiKey = null; this.browserUseSessionId = null; diff --git a/src/protocol.ts b/src/protocol.ts index dd9819a..205afdd 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -19,6 +19,18 @@ const launchSchema = baseCommandSchema.extend({ .optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).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(), extensions: z.array(z.string()).optional(), headers: z.record(z.string()).optional(), diff --git a/src/types.ts b/src/types.ts index 6d22b9d..30c1efe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand { headers?: Record; executablePath?: string; cdpPort?: number; + cdpUrl?: string; extensions?: string[]; proxy?: { server: string;