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:
co-authored by
Claude Opus 4.5
parent
c4139fa389
commit
e892bceadf
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user