Skip to content

Commit e892bce

Browse files
rgarciaclaude
andauthored
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>
1 parent c4139fa commit e892bce

5 files changed

Lines changed: 132 additions & 56 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,15 @@ agent-browser close
476476
477477
# Or pass --cdp on each command
478478
agent-browser --cdp 9222 snapshot
479+
480+
# Connect to remote browser via WebSocket URL
481+
agent-browser --cdp "wss://your-browser-service.com/cdp?token=..." snapshot
479482
```
480483
484+
The `--cdp` flag accepts either:
485+
- A port number (e.g., `9222`) for local connections via `http://localhost:{port}`
486+
- A full WebSocket URL (e.g., `wss://...` or `ws://...`) for remote browser services
487+
481488
This enables control of:
482489
- Electron apps
483490
- Chrome/Chromium instances with remote debugging

cli/src/main.rs

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
8080
let running = unsafe { libc::kill(pid as i32, 0) == 0 };
8181
#[cfg(windows)]
8282
let running = unsafe {
83-
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
83+
let handle =
84+
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
8485
if handle != 0 {
8586
CloseHandle(handle);
8687
true
@@ -192,7 +193,12 @@ fn main() {
192193
}
193194
};
194195

195-
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) {
196+
let daemon_result = match ensure_daemon(
197+
&flags.session,
198+
flags.headed,
199+
flags.executable_path.as_deref(),
200+
&flags.extensions,
201+
) {
196202
Ok(result) => result,
197203
Err(e) => {
198204
if flags.json {
@@ -205,7 +211,9 @@ fn main() {
205211
};
206212

207213
// Warn if executable_path was specified but daemon was already running
208-
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
214+
if daemon_result.already_running
215+
&& (flags.executable_path.is_some() || !flags.extensions.is_empty())
216+
{
209217
if !flags.json {
210218
if flags.executable_path.is_some() {
211219
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() {
238246
}
239247

240248
// Connect via CDP if --cdp flag is set
241-
if let Some(ref port) = flags.cdp {
242-
let cdp_port: u16 = match port.parse::<u32>() {
243-
Ok(p) if p == 0 => {
244-
let msg = "Invalid CDP port: port must be greater than 0".to_string();
245-
if flags.json {
246-
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
247-
} else {
248-
eprintln!("{} {}", color::error_indicator(), msg);
249+
// Accepts either a port number (e.g., "9222") or a full URL (e.g., "ws://..." or "wss://...")
250+
if let Some(ref cdp_value) = flags.cdp {
251+
let launch_cmd = if cdp_value.starts_with("ws://")
252+
|| cdp_value.starts_with("wss://")
253+
|| cdp_value.starts_with("http://")
254+
|| cdp_value.starts_with("https://")
255+
{
256+
// It's a URL - use cdpUrl field
257+
json!({
258+
"id": gen_id(),
259+
"action": "launch",
260+
"cdpUrl": cdp_value
261+
})
262+
} else {
263+
// It's a port number - validate and use cdpPort field
264+
let cdp_port: u16 = match cdp_value.parse::<u32>() {
265+
Ok(p) if p == 0 => {
266+
let msg = "Invalid CDP port: port must be greater than 0".to_string();
267+
if flags.json {
268+
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
269+
} else {
270+
eprintln!("{} {}", color::error_indicator(), msg);
271+
}
272+
exit(1);
249273
}
250-
exit(1);
251-
}
252-
Ok(p) if p > 65535 => {
253-
let msg = format!("Invalid CDP port: {} is out of range (valid range: 1-65535)", p);
254-
if flags.json {
255-
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
256-
} else {
257-
eprintln!("{} {}", color::error_indicator(), msg);
274+
Ok(p) if p > 65535 => {
275+
let msg = format!(
276+
"Invalid CDP port: {} is out of range (valid range: 1-65535)",
277+
p
278+
);
279+
if flags.json {
280+
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
281+
} else {
282+
eprintln!("{} {}", color::error_indicator(), msg);
283+
}
284+
exit(1);
258285
}
259-
exit(1);
260-
}
261-
Ok(p) => p as u16,
262-
Err(_) => {
263-
let msg = format!("Invalid CDP port: '{}' is not a valid number. Port must be a number between 1 and 65535", port);
264-
if flags.json {
265-
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
266-
} else {
267-
eprintln!("{} {}", color::error_indicator(), msg);
286+
Ok(p) => p as u16,
287+
Err(_) => {
288+
let msg = format!(
289+
"Invalid CDP value: '{}' is not a valid port number or URL",
290+
cdp_value
291+
);
292+
if flags.json {
293+
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
294+
} else {
295+
eprintln!("{} {}", color::error_indicator(), msg);
296+
}
297+
exit(1);
268298
}
269-
exit(1);
270-
}
299+
};
300+
json!({
301+
"id": gen_id(),
302+
"action": "launch",
303+
"cdpPort": cdp_port
304+
})
271305
};
272306

273-
let launch_cmd = json!({
274-
"id": gen_id(),
275-
"action": "launch",
276-
"cdpPort": cdp_port
277-
});
278-
279307
let err = match send_command(launch_cmd, &flags.session) {
280308
Ok(resp) if resp.success => None,
281-
Ok(resp) => Some(resp.error.unwrap_or_else(|| "CDP connection failed".to_string())),
309+
Ok(resp) => Some(
310+
resp.error
311+
.unwrap_or_else(|| "CDP connection failed".to_string()),
312+
),
282313
Err(e) => Some(e.to_string()),
283314
};
284315

src/browser.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ interface PageError {
6868
*/
6969
export class BrowserManager {
7070
private browser: Browser | null = null;
71-
private cdpPort: number | null = null;
71+
private cdpEndpoint: string | null = null; // stores port number or full URL
7272
private isPersistentContext: boolean = false;
7373
private browserbaseSessionId: string | null = null;
7474
private browserbaseApiKey: string | null = null;
@@ -639,9 +639,9 @@ export class BrowserManager {
639639
/**
640640
* Check if CDP connection needs to be re-established
641641
*/
642-
private needsCdpReconnect(cdpPort: number): boolean {
642+
private needsCdpReconnect(cdpEndpoint: string): boolean {
643643
if (!this.browser?.isConnected()) return true;
644-
if (this.cdpPort !== cdpPort) return true;
644+
if (this.cdpEndpoint !== cdpEndpoint) return true;
645645
if (!this.isCdpConnectionAlive()) return true;
646646
return false;
647647
}
@@ -815,25 +815,27 @@ export class BrowserManager {
815815
* If already launched, this is a no-op (browser stays open)
816816
*/
817817
async launch(options: LaunchCommand): Promise<void> {
818-
const cdpPort = options.cdpPort;
818+
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
819+
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
819820
const hasExtensions = !!options.extensions?.length;
820821

821-
if (hasExtensions && cdpPort) {
822+
if (hasExtensions && cdpEndpoint) {
822823
throw new Error('Extensions cannot be used with CDP connection');
823824
}
824825

825826
if (this.isLaunched()) {
826827
const needsRelaunch =
827-
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
828+
(!cdpEndpoint && this.cdpEndpoint !== null) ||
829+
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
828830
if (needsRelaunch) {
829831
await this.close();
830832
} else {
831833
return;
832834
}
833835
}
834836

835-
if (cdpPort) {
836-
await this.connectViaCDP(cdpPort);
837+
if (cdpEndpoint) {
838+
await this.connectViaCDP(cdpEndpoint);
837839
return;
838840
}
839841

@@ -880,7 +882,7 @@ export class BrowserManager {
880882
headless: options.headless ?? true,
881883
executablePath: options.executablePath,
882884
});
883-
this.cdpPort = null;
885+
this.cdpEndpoint = null;
884886
context = await this.browser.newContext({
885887
viewport,
886888
extraHTTPHeaders: options.headers,
@@ -899,16 +901,39 @@ export class BrowserManager {
899901

900902
/**
901903
* Connect to a running browser via CDP (Chrome DevTools Protocol)
902-
*/
903-
private async connectViaCDP(cdpPort: number | undefined): Promise<void> {
904-
if (!cdpPort) {
905-
throw new Error('cdpPort is required for CDP connection');
904+
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
905+
*/
906+
private async connectViaCDP(cdpEndpoint: string | undefined): Promise<void> {
907+
if (!cdpEndpoint) {
908+
throw new Error('CDP endpoint is required for CDP connection');
909+
}
910+
911+
// Determine the connection URL:
912+
// - If it starts with ws://, wss://, http://, or https://, use it directly
913+
// - If it's a numeric string (e.g., "9222"), treat as port for localhost
914+
// - Otherwise, treat it as a port number for localhost
915+
let cdpUrl: string;
916+
if (
917+
cdpEndpoint.startsWith('ws://') ||
918+
cdpEndpoint.startsWith('wss://') ||
919+
cdpEndpoint.startsWith('http://') ||
920+
cdpEndpoint.startsWith('https://')
921+
) {
922+
cdpUrl = cdpEndpoint;
923+
} else if (/^\d+$/.test(cdpEndpoint)) {
924+
// Numeric string - treat as port number (handles JSON serialization quirks)
925+
cdpUrl = `http://localhost:${cdpEndpoint}`;
926+
} else {
927+
// Unknown format - still try as port for backward compatibility
928+
cdpUrl = `http://localhost:${cdpEndpoint}`;
906929
}
907930

908-
const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => {
931+
const browser = await chromium.connectOverCDP(cdpUrl).catch(() => {
909932
throw new Error(
910-
`Failed to connect via CDP on port ${cdpPort}. ` +
911-
`Make sure the app is running with --remote-debugging-port=${cdpPort}`
933+
`Failed to connect via CDP to ${cdpUrl}. ` +
934+
(cdpUrl.includes('localhost')
935+
? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
936+
: 'Make sure the remote browser is accessible and the URL is correct.')
912937
);
913938
});
914939

@@ -928,7 +953,7 @@ export class BrowserManager {
928953

929954
// All validation passed - commit state
930955
this.browser = browser;
931-
this.cdpPort = cdpPort;
956+
this.cdpEndpoint = cdpEndpoint;
932957

933958
for (const context of contexts) {
934959
this.contexts.push(context);
@@ -1554,7 +1579,7 @@ export class BrowserManager {
15541579
}
15551580
);
15561581
this.browser = null;
1557-
} else if (this.cdpPort !== null) {
1582+
} else if (this.cdpEndpoint !== null) {
15581583
// CDP: only disconnect, don't close external app's pages
15591584
if (this.browser) {
15601585
await this.browser.close().catch(() => {});
@@ -1576,7 +1601,7 @@ export class BrowserManager {
15761601

15771602
this.pages = [];
15781603
this.contexts = [];
1579-
this.cdpPort = null;
1604+
this.cdpEndpoint = null;
15801605
this.browserbaseSessionId = null;
15811606
this.browserbaseApiKey = null;
15821607
this.browserUseSessionId = null;

src/protocol.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ const launchSchema = baseCommandSchema.extend({
1919
.optional(),
2020
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
2121
cdpPort: z.number().positive().optional(),
22+
cdpUrl: z
23+
.string()
24+
.url()
25+
.refine(
26+
(url) =>
27+
url.startsWith('ws://') ||
28+
url.startsWith('wss://') ||
29+
url.startsWith('http://') ||
30+
url.startsWith('https://'),
31+
{ message: 'CDP URL must start with ws://, wss://, http://, or https://' }
32+
)
33+
.optional(),
2234
executablePath: z.string().optional(),
2335
extensions: z.array(z.string()).optional(),
2436
headers: z.record(z.string()).optional(),

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
1515
headers?: Record<string, string>;
1616
executablePath?: string;
1717
cdpPort?: number;
18+
cdpUrl?: string;
1819
extensions?: string[];
1920
proxy?: {
2021
server: string;

0 commit comments

Comments
 (0)