Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 68 additions & 37 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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());
Expand Down Expand Up @@ -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::<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);
// 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::<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!("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()),
};

Expand Down
63 changes: 44 additions & 19 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -815,25 +815,27 @@ export class BrowserManager {
* If already launched, this is a no-op (browser stays open)
*/
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;

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 {
return;
}
}

if (cdpPort) {
await this.connectViaCDP(cdpPort);
if (cdpEndpoint) {
await this.connectViaCDP(cdpEndpoint);
return;
}

Expand Down Expand Up @@ -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,
Expand All @@ -899,16 +901,39 @@ export class BrowserManager {

/**
* 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');
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
*/
private async connectViaCDP(cdpEndpoint: string | undefined): Promise<void> {
if (!cdpEndpoint) {
throw new Error('CDP endpoint is required for CDP connection');
}

// 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(`http://localhost:${cdpPort}`).catch(() => {
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.')
);
});

Expand All @@ -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);
Expand Down Expand Up @@ -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(() => {});
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
headers?: Record<string, string>;
executablePath?: string;
cdpPort?: number;
cdpUrl?: string;
extensions?: string[];
proxy?: {
server: string;
Expand Down