feat: add --proxy flag for browser proxy configuration (#16)

* 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.
This commit is contained in:
NMW
2026-01-16 11:56:35 -06:00
committed by GitHub
parent fff9a146bd
commit 3675e6bd7a
8 changed files with 146 additions and 11 deletions
+1
View File
@@ -903,6 +903,7 @@ mod tests {
executable_path: None,
extensions: Vec::new(),
cdp: None,
proxy: None,
}
}
+9 -2
View File
@@ -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)]
+9 -1
View File
@@ -10,6 +10,7 @@ pub struct Flags {
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub proxy: Option<String>,
}
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<String> {
// 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 {
+101 -5
View File
@@ -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");
}
}
+1
View File
@@ -1192,6 +1192,7 @@ Options:
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--proxy <url> Proxy server (http://[user:pass@]host:port)
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
+8 -3
View File
@@ -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();
+11
View File
@@ -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({
+6
View File
@@ -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 {