From 3675e6bd7afa48623392abd75c7d0212dc773c26 Mon Sep 17 00:00:00 2001 From: NMW <9tz7p5zqjv@privaterelay.appleid.com> Date: Fri, 16 Jan 2026 12:56:35 -0500 Subject: [PATCH] feat: add --proxy flag for browser proxy configuration (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add --proxy flag for browser proxy support Add CLI flag to configure HTTP/SOCKS proxy for Playwright browser context. Supports URL format with optional credentials: http://user:pass@host:port * fix: improve proxy parsing error handling - Handle malformed credentials (@ without :) by ignoring incomplete creds - Replace unwrap() with expect() for better error messages - Addresses Vercel bot code review suggestions * Restaura cambios locales: soporte AGENT_BROWSER_HOME y timeout aumentado - Agrega soporte para variable de entorno AGENT_BROWSER_HOME en connection.rs - Aumenta timeout por defecto de 10s a 60s para conexiones más lentas * feat: add --proxy flag for browser proxy configuration Implements proxy support based on PR #16 with reviewer feedback: Features: - Parse proxy URLs: http://[user:pass@]host:port - Support for HTTP, HTTPS, and SOCKS5 protocols - Handle username-only auth (preserves username with empty password) - Apply proxy to both standard and persistent contexts Changes: - cli/src/flags.rs: Add proxy flag parsing - cli/src/main.rs: Add parse_proxy() with comprehensive tests - cli/src/output.rs: Add --proxy to help output - cli/src/commands.rs: Fix test helper to include proxy field - src/types.ts: Add proxy to LaunchCommand interface - src/protocol.ts: Add proxy validation schema - src/browser.ts: Apply proxy to context creation Tests: - 7 unit tests for parse_proxy() covering all edge cases - All Rust tests passing (69 tests) - All TypeScript tests passing (168 tests) - TypeScript typecheck passing Resolves feedback from PR #16: - Fixed username-only proxy handling (issue #2681046975) - Added comprehensive unit tests - Added --proxy to help documentation - Used expect() instead of unwrap() for better error messages * refactor: simplify parse_proxy function - Remove redundant comments - Extract server variable to reduce duplication - Inline trivial username/password variables All 7 proxy tests still passing. --- cli/src/commands.rs | 1 + cli/src/connection.rs | 11 ++++- cli/src/flags.rs | 10 +++- cli/src/main.rs | 106 ++++++++++++++++++++++++++++++++++++++++-- cli/src/output.rs | 1 + src/browser.ts | 11 +++-- src/protocol.ts | 11 +++++ src/types.ts | 6 +++ 8 files changed, 146 insertions(+), 11 deletions(-) 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 {