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:
@@ -903,6 +903,7 @@ mod tests {
|
|||||||
executable_path: None,
|
executable_path: None,
|
||||||
extensions: Vec::new(),
|
extensions: Vec::new(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
|
proxy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,16 +174,23 @@ pub fn ensure_daemon(
|
|||||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||||
let exe_dir = exe_path.parent().unwrap();
|
let exe_dir = exe_path.parent().unwrap();
|
||||||
|
|
||||||
let daemon_paths = [
|
let mut daemon_paths = vec![
|
||||||
exe_dir.join("daemon.js"),
|
exe_dir.join("daemon.js"),
|
||||||
exe_dir.join("../dist/daemon.js"),
|
exe_dir.join("../dist/daemon.js"),
|
||||||
PathBuf::from("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
|
let daemon_path = daemon_paths
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.exists())
|
.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
|
// Spawn daemon as a fully detached background process
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
+9
-1
@@ -10,6 +10,7 @@ pub struct Flags {
|
|||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub cdp: Option<String>,
|
pub cdp: Option<String>,
|
||||||
pub extensions: Vec<String>,
|
pub extensions: Vec<String>,
|
||||||
|
pub proxy: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
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(),
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
extensions: extensions_env,
|
extensions: extensions_env,
|
||||||
|
proxy: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -67,6 +69,12 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--proxy" => {
|
||||||
|
if let Some(p) = args.get(i + 1) {
|
||||||
|
flags.proxy = Some(p.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
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
|
// Global flags that should be stripped from command args
|
||||||
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
||||||
// Global flags that take a value (need to skip the next arg too)
|
// 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() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
|
|||||||
+101
-5
@@ -23,6 +23,36 @@ use flags::{clean_args, parse_flags};
|
|||||||
use install::run_install;
|
use install::run_install;
|
||||||
use output::{print_command_help, print_help, print_response, print_version};
|
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) {
|
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||||
let subcommand = args.get(1).map(|s| s.as_str());
|
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)
|
// Launch headed browser or proxy if flags are set (without CDP)
|
||||||
if flags.headed && flags.cdp.is_none() {
|
if (flags.headed || flags.proxy.is_some()) && flags.cdp.is_none() {
|
||||||
let launch_cmd = json!({
|
let mut launch_cmd = json!({
|
||||||
"id": gen_id(),
|
"id": gen_id(),
|
||||||
"action": "launch",
|
"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 let Err(e) = send_command(launch_cmd, &flags.session) {
|
||||||
if !flags.json {
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1192,6 +1192,7 @@ Options:
|
|||||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
--extension <path> Load browser extensions (repeatable).
|
--extension <path> Load browser extensions (repeatable).
|
||||||
|
--proxy <url> Proxy server (http://[user:pass@]host:port)
|
||||||
--json JSON output
|
--json JSON output
|
||||||
--full, -f Full page screenshot
|
--full, -f Full page screenshot
|
||||||
--headed Show browser window (not headless)
|
--headed Show browser window (not headless)
|
||||||
|
|||||||
+8
-3
@@ -682,6 +682,7 @@ export class BrowserManager {
|
|||||||
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
|
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
|
||||||
viewport,
|
viewport,
|
||||||
extraHTTPHeaders: options.headers,
|
extraHTTPHeaders: options.headers,
|
||||||
|
...(options.proxy && { proxy: options.proxy }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.isPersistentContext = true;
|
this.isPersistentContext = true;
|
||||||
@@ -691,10 +692,14 @@ export class BrowserManager {
|
|||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
});
|
});
|
||||||
this.cdpPort = null;
|
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);
|
this.contexts.push(context);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? (await context.newPage());
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
@@ -828,7 +833,7 @@ export class BrowserManager {
|
|||||||
const context = await this.browser.newContext({
|
const context = await this.browser.newContext({
|
||||||
viewport: viewport ?? { width: 1280, height: 720 },
|
viewport: viewport ?? { width: 1280, height: 720 },
|
||||||
});
|
});
|
||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(60000);
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ 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(),
|
||||||
|
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({
|
const navigateSchema = baseCommandSchema.extend({
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
|
proxy?: {
|
||||||
|
server: string;
|
||||||
|
bypass?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateCommand extends BaseCommand {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
|
|||||||
Reference in New Issue
Block a user