diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 5d99b65..a71ff3c 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2181,6 +2181,7 @@ mod tests { screenshot_dir: None, screenshot_quality: None, screenshot_format: None, + idle_timeout: None, } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index ff74ece..fe493cb 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -234,6 +234,7 @@ pub struct DaemonOptions<'a> { pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, + pub idle_timeout: Option<&'a str>, } fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { @@ -300,6 +301,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(engine) = opts.engine { cmd.env("AGENT_BROWSER_ENGINE", engine); } + if let Some(idle) = opts.idle_timeout { + cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle); + } } pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result { diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 9f8b2da..83896f7 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -8,6 +8,48 @@ const CONFIG_DIR: &str = ".agent-browser"; const CONFIG_FILENAME: &str = "config.json"; const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json"; +/// Parse idle timeout from user-friendly format. +/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds. +fn parse_idle_timeout(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("Empty idle timeout".to_string()); + } + + // If the value ends with a unit suffix, convert it to milliseconds. + if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) { + let (num_str, unit) = s.split_at(s.len() - 1); + let num: u64 = num_str.parse().map_err(|_| "Invalid number")?; + + let ms = match unit { + "s" => num * 1000, + "m" => num * 60 * 1000, + "h" => num * 60 * 60 * 1000, + _ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()), + }; + return Ok(ms.to_string()); + } + + // Pure numbers are already expressed in milliseconds. + s.parse::().map_err(|_| "Invalid idle timeout")?; + Ok(s.to_string()) +} + +fn parse_idle_timeout_value(value: Option, source: &str) -> Option { + value.and_then(|raw| match parse_idle_timeout(&raw) { + Ok(ms) => Some(ms), + Err(e) => { + eprintln!( + "{} invalid idle timeout from {}: {}", + color::warning_indicator(), + source, + e + ); + None + } + }) +} + #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Config { @@ -45,6 +87,7 @@ pub struct Config { pub screenshot_dir: Option, pub screenshot_quality: Option, pub screenshot_format: Option, + pub idle_timeout: Option, } impl Config { @@ -90,6 +133,7 @@ impl Config { screenshot_dir: other.screenshot_dir.or(self.screenshot_dir), screenshot_quality: other.screenshot_quality.or(self.screenshot_quality), screenshot_format: other.screenshot_format.or(self.screenshot_format), + idle_timeout: other.idle_timeout.or(self.idle_timeout), } } } @@ -97,7 +141,13 @@ impl Config { fn read_config_file(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; match serde_json::from_str::(&content) { - Ok(config) => Some(config), + Ok(mut config) => { + config.idle_timeout = parse_idle_timeout_value( + config.idle_timeout.take(), + &format!("config file {}", path.display()), + ); + Some(config) + } Err(e) => { eprintln!( "{} invalid config file {}: {}", @@ -168,6 +218,7 @@ fn extract_config_path(args: &[String]) -> Option> { "--screenshot-dir", "--screenshot-quality", "--screenshot-format", + "--idle-timeout", ]; let mut i = 0; while i < args.len() { @@ -249,6 +300,7 @@ pub struct Flags { pub screenshot_dir: Option, pub screenshot_quality: Option, pub screenshot_format: Option, + pub idle_timeout: Option, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -366,6 +418,11 @@ pub fn parse_flags(args: &[String]) -> Flags { .ok() .or(config.screenshot_format) .filter(|s| s == "png" || s == "jpeg"), + idle_timeout: parse_idle_timeout_value( + env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(), + "AGENT_BROWSER_IDLE_TIMEOUT_MS", + ) + .or(config.idle_timeout), cli_executable_path: false, cli_extensions: false, cli_profile: false, @@ -418,6 +475,19 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--idle-timeout" => { + if let Some(s) = args.get(i + 1) { + match parse_idle_timeout(s) { + Ok(ms) => flags.idle_timeout = Some(ms), + Err(e) => eprintln!( + "{} Invalid --idle-timeout: {}", + color::warning_indicator(), + e + ), + } + i += 1; + } + } "--headers" => { if let Some(h) = args.get(i + 1) { flags.headers = Some(h.clone()); @@ -690,6 +760,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--screenshot-dir", "--screenshot-quality", "--screenshot-format", + "--idle-timeout", ]; let mut i = 0; @@ -734,6 +805,36 @@ mod tests { assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string())); } + #[test] + fn test_parse_idle_timeout_raw_ms() { + assert_eq!(parse_idle_timeout("10").unwrap(), "10"); + } + + #[test] + fn test_parse_idle_timeout_seconds() { + assert_eq!(parse_idle_timeout("10s").unwrap(), "10000"); + } + + #[test] + fn test_parse_idle_timeout_minutes() { + assert_eq!(parse_idle_timeout("3m").unwrap(), "180000"); + } + + #[test] + fn test_parse_idle_timeout_hours() { + assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000"); + } + + #[test] + fn test_parse_idle_timeout_rejects_capital_m() { + assert!(parse_idle_timeout("10M").is_err()); + } + + #[test] + fn test_parse_idle_timeout_rejects_unknown_unit() { + assert!(parse_idle_timeout("10x").is_err()); + } + #[test] fn test_parse_headers_flag_with_spaces() { // Headers JSON is passed as a single quoted argument in shell @@ -829,6 +930,18 @@ mod tests { assert_eq!(cleaned, vec!["open", "example.com"]); } + #[test] + fn test_clean_args_removes_idle_timeout_before_command() { + let cleaned = clean_args(&args("--idle-timeout 10s open example.com")); + assert_eq!(cleaned, vec!["open", "example.com"]); + } + + #[test] + fn test_parse_idle_timeout_flag_converts_to_ms() { + let flags = parse_flags(&args("--idle-timeout 10s open example.com")); + assert_eq!(flags.idle_timeout.as_deref(), Some("10000")); + } + #[test] fn test_parse_flags_with_session_and_executable_path() { let flags = parse_flags(&args( @@ -1034,6 +1147,22 @@ mod tests { let _ = fs::remove_dir(&dir); } + #[test] + fn test_load_config_from_file_parses_idle_timeout() { + use std::io::Write; + let dir = std::env::temp_dir().join("ab-test-idle-timeout-config"); + let _ = fs::create_dir_all(&dir); + let config_path = dir.join("test-config.json"); + let mut f = fs::File::create(&config_path).unwrap(); + writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap(); + + let config = read_config_file(&config_path).unwrap(); + assert_eq!(config.idle_timeout.as_deref(), Some("10000")); + + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir(&dir); + } + #[test] fn test_load_config_missing_file_returns_none() { let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json")); diff --git a/cli/src/main.rs b/cli/src/main.rs index bb1b57e..f79151d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -316,6 +316,7 @@ fn main() { action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), engine: flags.engine.as_deref(), + idle_timeout: flags.idle_timeout.as_deref(), }; let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) { Ok(result) => result,