diff --git a/cli/src/commands.rs b/cli/src/commands.rs index fb1b257..e335b08 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -111,10 +111,12 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(headers_json) - .map_err(|_| ParseError::InvalidValue { - message: format!("Invalid JSON for --headers: {}", headers_json), - usage: "open --headers '{\"Key\": \"Value\"}'", + let headers = + serde_json::from_str::(headers_json).map_err(|_| { + ParseError::InvalidValue { + message: format!("Invalid JSON for --headers: {}", headers_json), + usage: "open --headers '{\"Key\": \"Value\"}'", + } })?; nav_cmd["headers"] = headers; } @@ -287,7 +289,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result", }); } - Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text })) + Ok( + json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }), + ) } _ => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), @@ -386,8 +390,16 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result(), max_str.parse::()) { + return Ok( + json!({ "id": id, "action": "wait", "timeout": min, "timeoutMax": max }), + ); + } + } if let Ok(timeout) = arg.parse::() { Ok(json!({ "id": id, "action": "wait", "timeout": timeout })) } else { @@ -396,7 +408,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result", + usage: "wait ", }) } } @@ -929,9 +941,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { - Ok(json!({ "id": id, "action": "state_list" })) - } + Some("list") => Ok(json!({ "id": id, "action": "state_list" })), Some("clear") => { let mut session_name: Option<&str> = None; let mut all = false; @@ -952,7 +962,9 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Result Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), @@ -1121,7 +1139,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result { return Err(ParseError::InvalidValue { - message: format!("Depth must be a non-negative integer, got: {}", d), + message: format!( + "Depth must be a non-negative integer, got: {}", + d + ), usage: "diff snapshot --depth ", }); } @@ -1187,7 +1208,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result { return Err(ParseError::InvalidValue { - message: format!("Threshold must be between 0 and 1, got {}", n), + message: format!( + "Threshold must be between 0 and 1, got {}", + n + ), usage: "diff screenshot --threshold <0-1>", }); } @@ -1304,7 +1328,10 @@ fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result { return Err(ParseError::InvalidValue { - message: format!("Depth must be a non-negative integer, got: {}", d), + message: format!( + "Depth must be a non-negative integer, got: {}", + d + ), usage: "diff url --depth ", }); } @@ -1896,8 +1923,10 @@ mod tests { cli_proxy_bypass: false, cli_allow_file_access: false, cli_annotate: false, + cli_stealth: false, annotate: false, color_scheme: None, + stealth: false, } } @@ -3029,8 +3058,11 @@ mod tests { #[test] fn test_diff_snapshot_baseline() { - let cmd = - parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap(); + let cmd = parse_command( + &args("diff snapshot --baseline before.txt"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "diff_snapshot"); assert_eq!(cmd["baseline"], "before.txt"); } @@ -3050,9 +3082,11 @@ mod tests { #[test] fn test_diff_snapshot_short_flags() { - let cmd = - parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags()) - .unwrap(); + let cmd = parse_command( + &args("diff snapshot -b snap.txt -s .content -c -d 2"), + &default_flags(), + ) + .unwrap(); assert_eq!(cmd["action"], "diff_snapshot"); assert_eq!(cmd["baseline"], "snap.txt"); assert_eq!(cmd["selector"], ".content"); @@ -3100,8 +3134,7 @@ mod tests { fn test_diff_screenshot_global_full_flag() { let mut flags = default_flags(); flags.full = true; - let cmd = - parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap(); + let cmd = parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap(); assert_eq!(cmd["action"], "diff_screenshot"); assert_eq!(cmd["fullPage"], true); } @@ -3145,8 +3178,7 @@ mod tests { fn test_diff_url_global_full_flag() { let mut flags = default_flags(); flags.full = true; - let cmd = - parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap(); + let cmd = parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap(); assert_eq!(cmd["fullPage"], true); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index c2a1e6d..b8d13d8 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -34,6 +34,7 @@ pub struct Config { pub headers: Option, pub annotate: Option, pub color_scheme: Option, + pub stealth: Option, } impl Config { @@ -68,6 +69,7 @@ impl Config { headers: other.headers.or(self.headers), annotate: other.annotate.or(self.annotate), color_scheme: other.color_scheme.or(self.color_scheme), + stealth: other.stealth.or(self.stealth), } } } @@ -156,8 +158,7 @@ pub fn load_config(args: &[String]) -> Result { }); if let Some((source, maybe_path)) = explicit { - let path_str = - maybe_path.ok_or_else(|| format!("{} requires a file path", source))?; + let path_str = maybe_path.ok_or_else(|| format!("{} requires a file path", source))?; let path = PathBuf::from(&path_str); if !path.exists() { return Err(format!("config file not found: {}", path_str)); @@ -203,6 +204,7 @@ pub struct Flags { pub session_name: Option, pub annotate: bool, pub color_scheme: Option, + pub stealth: bool, // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -216,6 +218,7 @@ pub struct Flags { pub cli_proxy_bypass: bool, pub cli_allow_file_access: bool, pub cli_annotate: bool, + pub cli_stealth: bool, } pub fn parse_flags(args: &[String]) -> Flags { @@ -241,50 +244,49 @@ pub fn parse_flags(args: &[String]) -> Flags { }; let mut flags = Flags { - json: env_var_is_truthy("AGENT_BROWSER_JSON") - || config.json.unwrap_or(false), - full: env_var_is_truthy("AGENT_BROWSER_FULL") - || config.full.unwrap_or(false), - headed: env_var_is_truthy("AGENT_BROWSER_HEADED") - || config.headed.unwrap_or(false), - debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") - || config.debug.unwrap_or(false), - session: env::var("AGENT_BROWSER_SESSION").ok() + json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false), + full: env_var_is_truthy("AGENT_BROWSER_FULL") || config.full.unwrap_or(false), + headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false), + debug: env_var_is_truthy("AGENT_BROWSER_DEBUG") || config.debug.unwrap_or(false), + session: env::var("AGENT_BROWSER_SESSION") + .ok() .or(config.session) .unwrap_or_else(|| "default".to_string()), headers: config.headers, - executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok() + executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH") + .ok() .or(config.executable_path), cdp: config.cdp, extensions, - profile: env::var("AGENT_BROWSER_PROFILE").ok() - .or(config.profile), - state: env::var("AGENT_BROWSER_STATE").ok() - .or(config.state), - proxy: env::var("AGENT_BROWSER_PROXY").ok() - .or(config.proxy), - proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok() + profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile), + state: env::var("AGENT_BROWSER_STATE").ok().or(config.state), + proxy: env::var("AGENT_BROWSER_PROXY").ok().or(config.proxy), + proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS") + .ok() .or(config.proxy_bypass), - args: env::var("AGENT_BROWSER_ARGS").ok() - .or(config.args), - user_agent: env::var("AGENT_BROWSER_USER_AGENT").ok() + args: env::var("AGENT_BROWSER_ARGS").ok().or(config.args), + user_agent: env::var("AGENT_BROWSER_USER_AGENT") + .ok() .or(config.user_agent), - provider: env::var("AGENT_BROWSER_PROVIDER").ok() - .or(config.provider), + provider: env::var("AGENT_BROWSER_PROVIDER").ok().or(config.provider), ignore_https_errors: env_var_is_truthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS") || config.ignore_https_errors.unwrap_or(false), allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS") || config.allow_file_access.unwrap_or(false), - device: env::var("AGENT_BROWSER_IOS_DEVICE").ok() - .or(config.device), + device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device), auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT") || config.auto_connect.unwrap_or(false), - session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok() + session_name: env::var("AGENT_BROWSER_SESSION_NAME") + .ok() .or(config.session_name), - annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") - || config.annotate.unwrap_or(false), - color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok() + annotate: env_var_is_truthy("AGENT_BROWSER_ANNOTATE") || config.annotate.unwrap_or(false), + color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME") + .ok() .or(config.color_scheme), + stealth: match env::var("AGENT_BROWSER_STEALTH") { + Ok(val) => !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""), + Err(_) => config.stealth.unwrap_or(true), + }, cli_executable_path: false, cli_extensions: false, cli_profile: false, @@ -295,6 +297,7 @@ pub fn parse_flags(args: &[String]) -> Flags { cli_proxy_bypass: false, cli_allow_file_access: false, cli_annotate: false, + cli_stealth: false, }; let mut i = 0; @@ -303,22 +306,30 @@ pub fn parse_flags(args: &[String]) -> Flags { "--json" => { let (val, consumed) = parse_bool_arg(args, i); flags.json = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--full" | "-f" => { let (val, consumed) = parse_bool_arg(args, i); flags.full = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--headed" => { let (val, consumed) = parse_bool_arg(args, i); flags.headed = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--debug" => { let (val, consumed) = parse_bool_arg(args, i); flags.debug = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--session" => { if let Some(s) = args.get(i + 1) { @@ -403,13 +414,17 @@ pub fn parse_flags(args: &[String]) -> Flags { "--ignore-https-errors" => { let (val, consumed) = parse_bool_arg(args, i); flags.ignore_https_errors = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--allow-file-access" => { let (val, consumed) = parse_bool_arg(args, i); flags.allow_file_access = val; flags.cli_allow_file_access = true; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--device" => { if let Some(d) = args.get(i + 1) { @@ -420,7 +435,9 @@ pub fn parse_flags(args: &[String]) -> Flags { "--auto-connect" => { let (val, consumed) = parse_bool_arg(args, i); flags.auto_connect = val; - if consumed { i += 1; } + if consumed { + i += 1; + } } "--session-name" => { if let Some(s) = args.get(i + 1) { @@ -432,7 +449,17 @@ pub fn parse_flags(args: &[String]) -> Flags { let (val, consumed) = parse_bool_arg(args, i); flags.annotate = val; flags.cli_annotate = true; - if consumed { i += 1; } + if consumed { + i += 1; + } + } + "--stealth" => { + let (val, consumed) = parse_bool_arg(args, i); + flags.stealth = val; + flags.cli_stealth = true; + if consumed { + i += 1; + } } "--color-scheme" => { if let Some(s) = args.get(i + 1) { @@ -465,6 +492,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--allow-file-access", "--auto-connect", "--annotate", + "--stealth", ]; // Global flags that always take a value (need to skip the next arg too) const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[ @@ -721,7 +749,10 @@ mod tests { assert_eq!(config.session.as_deref(), Some("test-session")); assert_eq!(config.session_name.as_deref(), Some("my-app")); assert_eq!(config.executable_path.as_deref(), Some("/usr/bin/chromium")); - assert_eq!(config.extensions, Some(vec!["/ext1".to_string(), "/ext2".to_string()])); + assert_eq!( + config.extensions, + Some(vec!["/ext1".to_string(), "/ext2".to_string()]) + ); assert_eq!(config.profile.as_deref(), Some("/tmp/profile")); assert_eq!(config.state.as_deref(), Some("/tmp/state.json")); assert_eq!(config.proxy.as_deref(), Some("http://proxy:8080")); @@ -1030,7 +1061,11 @@ mod tests { let merged = user.merge(project); assert_eq!( merged.extensions, - Some(vec!["/ext1".to_string(), "/ext2".to_string(), "/ext3".to_string()]) + Some(vec![ + "/ext1".to_string(), + "/ext2".to_string(), + "/ext3".to_string() + ]) ); } diff --git a/cli/src/validation.rs b/cli/src/validation.rs index 040c553..6ea6149 100644 --- a/cli/src/validation.rs +++ b/cli/src/validation.rs @@ -1,6 +1,9 @@ /// Check if a session name is valid (alphanumeric, hyphens, and underscores only) pub fn is_valid_session_name(name: &str) -> bool { - !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') + !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') } /// Generate error message for invalid session name diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index b7015a1..40acd0a 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -70,6 +70,7 @@ Every CLI flag can be set in the config file using its camelCase equivalent: device--devicestring ignoreHttpsErrors--ignore-https-errorsboolean allowFileAccess--allow-file-accessboolean + stealth--stealthboolean (default: true) cdp--cdpstring autoConnect--auto-connectboolean colorScheme--color-schemestring (dark, light, no-preference) @@ -131,7 +132,7 @@ agent-browser --headed open example.com # same as --headed true agent-browser --headed true open example.com # explicit ``` -This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--auto-connect`. +This applies to all boolean flags: `--headed`, `--debug`, `--json`, `--ignore-https-errors`, `--allow-file-access`, `--stealth`, `--auto-connect`. ## Extensions Merging diff --git a/src/daemon.ts b/src/daemon.ts index 9310ef0..e3eea75 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -450,6 +450,7 @@ export async function startDaemon(options?: { const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1'; const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1'; + const stealth = process.env.AGENT_BROWSER_STEALTH !== '0'; const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME; const colorScheme = colorSchemeEnv === 'dark' || @@ -470,6 +471,7 @@ export async function startDaemon(options?: { proxy, ignoreHTTPSErrors: ignoreHTTPSErrors, allowFileAccess: allowFileAccess, + stealth, colorScheme, autoStateFilePath: getSessionAutoStatePath(), }); diff --git a/src/types.ts b/src/types.ts index 857f282..d284416 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export interface LaunchCommand extends BaseCommand { ignoreHTTPSErrors?: boolean; allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override + stealth?: boolean; // Enable stealth mode to avoid automation detection // Auto-load state file for session persistence autoStateFilePath?: string; } @@ -828,6 +829,7 @@ export interface WaitCommand extends BaseCommand { action: 'wait'; selector?: string; timeout?: number; + timeoutMax?: number; // When set with timeout, waits a random duration in [timeout, timeoutMax] state?: 'attached' | 'detached' | 'visible' | 'hidden'; }