mod color; mod commands; mod connection; mod flags; mod install; mod output; mod validation; use serde_json::json; use std::env; use std::fs; use std::process::exit; #[cfg(windows)] use windows_sys::Win32::Foundation::CloseHandle; #[cfg(windows)] use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; use commands::{gen_id, parse_command, ParseError}; use connection::{ensure_daemon, get_socket_dir, 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 socket_dir = get_socket_dir(); let mut sessions: Vec = Vec::new(); if let Ok(entries) = fs::read_dir(&socket_dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); // Look for pid files in socket directory if name.ends_with(".pid") { let session_name = name.strip_suffix(".pid").unwrap_or(""); if !session_name.is_empty() { // Check if session is actually running let pid_path = socket_dir.join(&name); if let Ok(pid_str) = fs::read_to_string(&pid_path) { if let Ok(pid) = pid_str.trim().parse::() { #[cfg(unix)] let running = unsafe { libc::kill(pid as i32, 0) == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) }; #[cfg(windows)] let running = unsafe { let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); if handle != 0 { CloseHandle(handle); true } else { false } }; if running { sessions.push(session_name.to_string()); } } } } } } } 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() { // 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 flags = parse_flags(&args); let clean = clean_args(&args); 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 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.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.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_tab_group.then_some("--tab-group"), flags .cli_tab_group_plugin_id .then_some("--tab-group-plugin-id"), ] .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 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 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.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"); } }