mod color; mod commands; mod connection; mod flags; mod install; mod native; mod output; #[cfg(test)] mod test_utils; mod validation; use serde_json::json; use std::env; use std::process::exit; use commands::{gen_id, parse_command, ParseError}; use connection::{ensure_daemon, list_live_sessions, send_command}; 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()); match subcommand { Some("list") => { let sessions = list_live_sessions(); if json_mode { println!( r#"{{"success":true,"data":{{"sessions":{}}}}}"#, serde_json::to_string(&sessions).unwrap_or_default() ); } else if sessions.is_empty() { println!("No active sessions"); } else { println!("Active sessions:"); for s in &sessions { let marker = if s == session { color::cyan("→") } else { " ".to_string() }; println!("{} {}", marker, s); } } } None | Some(_) => { // Just show current session if json_mode { println!(r#"{{"success":true,"data":{{"session":"{}"}}}}"#, session); } else { println!("{}", session); } } } } fn main() { if env::var("AGENT_BROWSER_DAEMON").is_ok() { #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN); } let session = env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()); let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); rt.block_on(native::daemon::run_daemon(&session)); return; } // Ignore SIGPIPE to prevent panic when piping to head/tail #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } let args: Vec = env::args().skip(1).collect(); let mut flags = parse_flags(&args); let clean = clean_args(&args); if flags.engine.is_some() && !flags.native { flags.native = true; } let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_version = args.iter().any(|a| a == "--version" || a == "-V"); if has_help { if let Some(cmd) = clean.first() { if print_command_help(cmd) { return; } } print_help(); return; } if has_version { print_version(); return; } if let Some(ref risk_mode) = flags.risk_mode { if !matches!(risk_mode.as_str(), "off" | "warn" | "block") { let msg = format!( "Invalid --risk-mode value: {} (expected off, warn, or block)", risk_mode ); if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } } if let Some(ref parallel) = flags.parallel { if !validation::is_valid_session_name(parallel) { let msg = format!( "Invalid --parallel value '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.", parallel ); if flags.json { println!( r#"{{"success":false,"error":"{}","type":"invalid_parallel_name"}}"#, msg.replace('"', "\\\"") ); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } } if args.iter().any(|a| a == "--profile") { let msg = "Project policy: --profile is forbidden. Use your existing browser and --session-name for state persistence."; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if env::var("AGENT_BROWSER_PROFILE").is_ok() { let msg = "Project policy: AGENT_BROWSER_PROFILE is forbidden. Remove it and use --session-name."; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if args.iter().any(|a| a == "--channel") { let msg = "Project policy: --channel is forbidden. Browser selection follows your existing browser session."; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if env::var("AGENT_BROWSER_CHANNEL").is_ok() { let msg = "Project policy: AGENT_BROWSER_CHANNEL is forbidden. Remove it and use your existing browser session."; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if clean.is_empty() { print_help(); return; } // Handle install separately if clean.first().map(|s| s.as_str()) == Some("install") { let with_deps = args.iter().any(|a| a == "--with-deps" || a == "-d"); run_install(with_deps); return; } // Handle session separately (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("session") { run_session(&clean, &flags.session, flags.json); return; } let cmd = match parse_command(&clean, &flags) { Ok(c) => c, Err(e) => { if flags.json { let error_type = match &e { ParseError::UnknownCommand { .. } => "unknown_command", ParseError::UnknownSubcommand { .. } => "unknown_subcommand", ParseError::MissingArguments { .. } => "missing_arguments", ParseError::InvalidValue { .. } => "invalid_value", ParseError::InvalidSessionName { .. } => "invalid_session_name", }; println!( r#"{{"success":false,"error":"{}","type":"{}"}}"#, e.format().replace('\n', " "), error_type ); } else { eprintln!("{}", color::red(&e.format())); } exit(1); } }; // Validate session name before starting daemon if let Some(ref name) = flags.session_name { if !validation::is_valid_session_name(name) { let msg = validation::session_name_error(name); if flags.json { println!( r#"{{"success":false,"error":"{}","type":"invalid_session_name"}}"#, msg.replace('"', "\\\"") ); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } } let daemon_result = match ensure_daemon( &flags.session, flags.headed, flags.resident, flags.executable_path.as_deref(), &flags.extensions, flags.args.as_deref(), flags.user_agent.as_deref(), flags.proxy.as_deref(), flags.proxy_bypass.as_deref(), flags.ignore_https_errors, flags.allow_file_access, flags.state.as_deref(), flags.provider.as_deref(), flags.device.as_deref(), flags.session_name.as_deref(), flags.debug, flags.native, flags.engine.as_deref(), flags.download_path.as_deref(), flags.tab_group.as_deref(), flags.tab_group_plugin_id.as_deref(), ) { Ok(result) => result, Err(e) => { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!("{} {}", color::error_indicator(), e); } exit(1); } }; // Warn if launch-time options were explicitly passed via CLI but daemon was already running // Only warn about flags that were passed on the command line, not those set via environment // variables (since the daemon already uses the env vars when it starts). if daemon_result.already_running { let ignored_flags: Vec<&str> = [ if flags.cli_executable_path { Some("--executable-path") } else { None }, if flags.cli_extensions { Some("--extension") } else { None }, if flags.cli_state { Some("--state") } else { None }, if flags.cli_args { Some("--args") } else { None }, if flags.cli_user_agent { Some("--user-agent") } else { None }, if flags.cli_proxy { Some("--proxy") } else { None }, if flags.cli_proxy_bypass { Some("--proxy-bypass") } else { None }, flags.ignore_https_errors.then_some("--ignore-https-errors"), flags.cli_allow_file_access.then_some("--allow-file-access"), flags.cli_download_path.then_some("--download-path"), flags.cli_native.then_some("--native"), flags.cli_engine.then_some("--engine"), flags.cli_tab_group.then_some("--tab-group"), flags .cli_tab_group_plugin_id .then_some("--tab-group-plugin-id"), flags.cli_resident.then_some("--resident"), ] .into_iter() .flatten() .collect(); if !ignored_flags.is_empty() && !flags.json { eprintln!( "{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.", color::warning_indicator(), ignored_flags.join(", ") ); } } // Validate mutually exclusive options if flags.cdp.is_some() && flags.provider.is_some() { let msg = "Cannot use --cdp and -p/--provider together"; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if flags.auto_connect && flags.cdp.is_some() { let msg = "Cannot use --auto-connect and --cdp together"; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if flags.auto_connect && flags.provider.is_some() { let msg = "Cannot use --auto-connect and -p/--provider together"; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if flags.provider.is_some() && !flags.extensions.is_empty() { let msg = "Cannot use --extension with -p/--provider (extensions require local browser)"; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } if flags.cdp.is_some() && !flags.extensions.is_empty() { let msg = "Cannot use --extension with --cdp (extensions require local browser)"; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } let mut attached_to_existing_browser = false; // Auto-connect to existing browser if flags.auto_connect { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", "autoConnect": true }); if flags.ignore_https_errors { launch_cmd["ignoreHTTPSErrors"] = json!(true); } if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } if let Some(ref dp) = flags.download_path { launch_cmd["downloadPath"] = json!(dp); } if let Some(ref engine) = flags.engine { launch_cmd["engine"] = json!(engine); } if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { launch_cmd["tabGroupPluginId"] = json!(plugin_id); } let err = match send_command(launch_cmd, &flags.session) { Ok(resp) if resp.success => None, Ok(resp) => Some( resp.error .unwrap_or_else(|| "Auto-connect failed".to_string()), ), Err(e) => Some(e.to_string()), }; if let Some(msg) = err { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } attached_to_existing_browser = true; } // Connect via CDP if --cdp flag is set // 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 mut 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::() { Ok(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); } 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); } 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); } }; json!({ "id": gen_id(), "action": "launch", "cdpPort": cdp_port }) }; if flags.ignore_https_errors { launch_cmd["ignoreHTTPSErrors"] = json!(true); } if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } if let Some(ref dp) = flags.download_path { launch_cmd["downloadPath"] = json!(dp); } if let Some(ref engine) = flags.engine { launch_cmd["engine"] = json!(engine); } if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { launch_cmd["tabGroupPluginId"] = json!(plugin_id); } 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()), ), Err(e) => Some(e.to_string()), }; if let Some(msg) = err { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } attached_to_existing_browser = true; } // Launch with cloud provider if -p flag is set if let Some(ref provider) = flags.provider { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", "provider": provider }); if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { launch_cmd["tabGroupPluginId"] = json!(plugin_id); } match send_command(launch_cmd, &flags.session) { Ok(resp) => { if !resp.success { let msg = resp .error .unwrap_or_else(|| "Provider connection failed".to_string()); if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } } Err(e) => { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!("{} {}", color::error_indicator(), e); } exit(1); } } } // Project policy: when no explicit connection mode is provided, // commands should attach to an existing browser. // Try CDP :9333 first, then fall back to auto-connect discovery. let can_try_default_cdp = flags.cdp.is_none() && !flags.auto_connect && flags.provider.is_none() && flags.executable_path.is_none() && flags.state.is_none() && flags.proxy.is_none() && flags.args.is_none() && flags.user_agent.is_none() && !flags.ignore_https_errors && !flags.allow_file_access && flags.extensions.is_empty(); if can_try_default_cdp { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", "cdpPort": 9333 }); if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { launch_cmd["tabGroupPluginId"] = json!(plugin_id); } if let Ok(resp) = send_command(launch_cmd, &flags.session) { attached_to_existing_browser = resp.success; } if !attached_to_existing_browser { let mut auto_connect_cmd = json!({ "id": gen_id(), "action": "launch", "autoConnect": true }); if let Some(ref cs) = flags.color_scheme { auto_connect_cmd["colorScheme"] = json!(cs); } if let Some(ref tg) = flags.tab_group { auto_connect_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { auto_connect_cmd["tabGroupPluginId"] = json!(plugin_id); } if let Ok(resp) = send_command(auto_connect_cmd, &flags.session) { attached_to_existing_browser = resp.success; } } } if can_try_default_cdp && !attached_to_existing_browser { let msg = "Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed. Start Chrome with remote debugging (for example, --remote-debugging-port=9333), or pass --cdp ."; if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } // Launch headed browser or configure browser options (without CDP or provider) if (flags.headed || flags.executable_path.is_some() || flags.state.is_some() || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some() || flags.ignore_https_errors || flags.allow_file_access || flags.debug || flags.color_scheme.is_some() || flags.download_path.is_some() || flags.engine.is_some()) && flags.cdp.is_none() && flags.provider.is_none() && !attached_to_existing_browser { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", "headless": !flags.headed }); let cmd_obj = launch_cmd .as_object_mut() .expect("json! macro guarantees object type"); // Add executable path if specified if let Some(ref exec_path) = flags.executable_path { cmd_obj.insert("executablePath".to_string(), json!(exec_path)); } // Add state path if specified if let Some(ref state_path) = flags.state { cmd_obj.insert("storageState".to_string(), json!(state_path)); } if let Some(ref proxy_str) = flags.proxy { let mut proxy_obj = parse_proxy(proxy_str); // Add bypass if specified if let Some(ref bypass) = flags.proxy_bypass { if let Some(obj) = proxy_obj.as_object_mut() { obj.insert("bypass".to_string(), json!(bypass)); } } cmd_obj.insert("proxy".to_string(), proxy_obj); } if let Some(ref ua) = flags.user_agent { cmd_obj.insert("userAgent".to_string(), json!(ua)); } if let Some(ref a) = flags.args { // Parse args (comma or newline separated) let args_vec: Vec = a .split(&[',', '\n'][..]) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); cmd_obj.insert("args".to_string(), json!(args_vec)); } if flags.ignore_https_errors { launch_cmd["ignoreHTTPSErrors"] = json!(true); } if flags.allow_file_access { launch_cmd["allowFileAccess"] = json!(true); } if let Some(ref cs) = flags.color_scheme { launch_cmd["colorScheme"] = json!(cs); } if let Some(ref dp) = flags.download_path { launch_cmd["downloadPath"] = json!(dp); } if let Some(ref tg) = flags.tab_group { launch_cmd["tabGroup"] = json!(tg); } if let Some(ref plugin_id) = flags.tab_group_plugin_id { launch_cmd["tabGroupPluginId"] = json!(plugin_id); } match send_command(launch_cmd, &flags.session) { Ok(resp) => { if !resp.success { // Launch command failed (e.g., invalid state file) let error_msg = resp .error .unwrap_or_else(|| "Browser launch failed".to_string()); if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, error_msg); } else { eprintln!("{} {}", color::error_indicator(), error_msg); } exit(1); } } Err(e) => { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!( "{} Could not configure browser: {}", color::error_indicator(), e ); } exit(1); } } } match send_command(cmd.clone(), &flags.session) { Ok(resp) => { let success = resp.success; // Extract action for context-specific output handling let action = cmd.get("action").and_then(|v| v.as_str()); print_response(&resp, flags.json, action); if !success { exit(1); } } Err(e) => { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { eprintln!("{} {}", color::error_indicator(), e); } exit(1); } } } #[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"); } }