diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 364781c..46d879a 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -903,6 +903,7 @@ mod tests { executable_path: None, extensions: Vec::new(), cdp: None, + proxy: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 5cd3a8d..a77e389 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -174,16 +174,23 @@ pub fn ensure_daemon( let exe_path = env::current_exe().map_err(|e| e.to_string())?; let exe_dir = exe_path.parent().unwrap(); - let daemon_paths = [ + let mut daemon_paths = vec![ exe_dir.join("daemon.js"), exe_dir.join("../dist/daemon.js"), PathBuf::from("dist/daemon.js"), ]; + // Check AGENT_BROWSER_HOME environment variable + if let Ok(home) = env::var("AGENT_BROWSER_HOME") { + let home_path = PathBuf::from(&home); + daemon_paths.insert(0, home_path.join("dist/daemon.js")); + daemon_paths.insert(1, home_path.join("daemon.js")); + } + let daemon_path = daemon_paths .iter() .find(|p| p.exists()) - .ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?; + .ok_or("Daemon not found. Set AGENT_BROWSER_HOME environment variable or run from project directory.")?; // Spawn daemon as a fully detached background process #[cfg(unix)] diff --git a/cli/src/flags.rs b/cli/src/flags.rs index df627ba..52692db 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -10,6 +10,7 @@ pub struct Flags { pub executable_path: Option, pub cdp: Option, pub extensions: Vec, + pub proxy: Option, } pub fn parse_flags(args: &[String]) -> Flags { @@ -28,6 +29,7 @@ pub fn parse_flags(args: &[String]) -> Flags { executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), cdp: None, extensions: extensions_env, + proxy: None, }; let mut i = 0; @@ -67,6 +69,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--proxy" => { + if let Some(p) = args.get(i + 1) { + flags.proxy = Some(p.clone()); + i += 1; + } + } _ => {} } i += 1; @@ -81,7 +89,7 @@ pub fn clean_args(args: &[String]) -> Vec { // Global flags that should be stripped from command args const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"]; // Global flags that take a value (need to skip the next arg too) - const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension"]; + const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--proxy"]; for arg in args.iter() { if skip_next { diff --git a/cli/src/main.rs b/cli/src/main.rs index 12b6d86..09b1ed7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -23,6 +23,36 @@ use flags::{clean_args, parse_flags}; use install::run_install; use output::{print_command_help, print_help, print_response, print_version}; +fn parse_proxy(proxy_str: &str) -> serde_json::Value { + let Some(protocol_end) = proxy_str.find("://") else { + return json!({ "server": proxy_str }); + }; + let protocol = &proxy_str[..protocol_end + 3]; + let rest = &proxy_str[protocol_end + 3..]; + + let Some(at_pos) = rest.rfind('@') else { + return json!({ "server": proxy_str }); + }; + + let creds = &rest[..at_pos]; + let server_part = &rest[at_pos + 1..]; + let server = format!("{}{}", protocol, server_part); + + let Some(colon_pos) = creds.find(':') else { + return json!({ + "server": server, + "username": creds, + "password": "" + }); + }; + + json!({ + "server": server, + "username": &creds[..colon_pos], + "password": &creds[colon_pos + 1..] + }) +} + fn run_session(args: &[String], session: &str, json_mode: bool) { let subcommand = args.get(1).map(|s| s.as_str()); @@ -234,17 +264,24 @@ fn main() { } } - // Launch headed browser if --headed flag is set (without CDP) - if flags.headed && flags.cdp.is_none() { - let launch_cmd = json!({ + // Launch headed browser or proxy if flags are set (without CDP) + if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() { + let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", - "headless": false + "headless": !flags.headed }); + if let Some(ref proxy_str) = flags.proxy { + let proxy_obj = parse_proxy(proxy_str); + launch_cmd.as_object_mut() + .expect("json! macro guarantees object type") + .insert("proxy".to_string(), proxy_obj); + } + if let Err(e) = send_command(launch_cmd, &flags.session) { if !flags.json { - eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e); + eprintln!("\x1b[33m⚠\x1b[0m Could not configure browser: {}", e); } } } @@ -267,3 +304,62 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_proxy_simple() { + let result = parse_proxy("http://proxy.com:8080"); + assert_eq!(result["server"], "http://proxy.com:8080"); + assert!(result.get("username").is_none()); + assert!(result.get("password").is_none()); + } + + #[test] + fn test_parse_proxy_with_auth() { + let result = parse_proxy("http://user:pass@proxy.com:8080"); + assert_eq!(result["server"], "http://proxy.com:8080"); + assert_eq!(result["username"], "user"); + assert_eq!(result["password"], "pass"); + } + + #[test] + fn test_parse_proxy_username_only() { + let result = parse_proxy("http://user@proxy.com:8080"); + assert_eq!(result["server"], "http://proxy.com:8080"); + assert_eq!(result["username"], "user"); + assert_eq!(result["password"], ""); + } + + #[test] + fn test_parse_proxy_no_protocol() { + let result = parse_proxy("proxy.com:8080"); + assert_eq!(result["server"], "proxy.com:8080"); + assert!(result.get("username").is_none()); + } + + #[test] + fn test_parse_proxy_socks5() { + let result = parse_proxy("socks5://proxy.com:1080"); + assert_eq!(result["server"], "socks5://proxy.com:1080"); + assert!(result.get("username").is_none()); + } + + #[test] + fn test_parse_proxy_socks5_with_auth() { + let result = parse_proxy("socks5://admin:secret@proxy.com:1080"); + assert_eq!(result["server"], "socks5://proxy.com:1080"); + assert_eq!(result["username"], "admin"); + assert_eq!(result["password"], "secret"); + } + + #[test] + fn test_parse_proxy_complex_password() { + let result = parse_proxy("http://user:p@ss:w0rd@proxy.com:8080"); + assert_eq!(result["server"], "http://proxy.com:8080"); + assert_eq!(result["username"], "user"); + assert_eq!(result["password"], "p@ss:w0rd"); + } +} diff --git a/cli/src/output.rs b/cli/src/output.rs index f97fa43..d63af2e 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1192,6 +1192,7 @@ Options: --headers HTTP headers scoped to URL's origin (for auth) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --extension Load browser extensions (repeatable). + --proxy Proxy server (http://[user:pass@]host:port) --json JSON output --full, -f Full page screenshot --headed Show browser window (not headless) diff --git a/src/browser.ts b/src/browser.ts index c6c27f9..9e4a109 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -682,6 +682,7 @@ export class BrowserManager { args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`], viewport, extraHTTPHeaders: options.headers, + ...(options.proxy && { proxy: options.proxy }), } ); this.isPersistentContext = true; @@ -691,10 +692,14 @@ export class BrowserManager { executablePath: options.executablePath, }); this.cdpPort = null; - context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers }); + context = await this.browser.newContext({ + viewport, + extraHTTPHeaders: options.headers, + ...(options.proxy && { proxy: options.proxy }), + }); } - context.setDefaultTimeout(10000); + context.setDefaultTimeout(60000); this.contexts.push(context); const page = context.pages()[0] ?? (await context.newPage()); @@ -828,7 +833,7 @@ export class BrowserManager { const context = await this.browser.newContext({ viewport: viewport ?? { width: 1280, height: 720 }, }); - context.setDefaultTimeout(10000); + context.setDefaultTimeout(60000); this.contexts.push(context); const page = await context.newPage(); diff --git a/src/protocol.ts b/src/protocol.ts index d4a36c3..dc79f34 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -19,6 +19,17 @@ const launchSchema = baseCommandSchema.extend({ .optional(), browser: z.enum(['chromium', 'firefox', 'webkit']).optional(), cdpPort: z.number().positive().optional(), + executablePath: z.string().optional(), + extensions: z.array(z.string()).optional(), + headers: z.record(z.string()).optional(), + proxy: z + .object({ + server: z.string().min(1), + bypass: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + }) + .optional(), }); const navigateSchema = baseCommandSchema.extend({ diff --git a/src/types.ts b/src/types.ts index 30aa155..1201fdd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,12 @@ export interface LaunchCommand extends BaseCommand { executablePath?: string; cdpPort?: number; extensions?: string[]; + proxy?: { + server: string; + bypass?: string; + username?: string; + password?: string; + }; } export interface NavigateCommand extends BaseCommand {