diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2844e12..cd3738a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ steel browser start - `--stealth` (boolean, optional): Enable stealth mode (humanize interactions + auto CAPTCHA) - `-p, --proxy` (string, optional): Use a residential proxy -- `--session-timeout` (string, optional): Session timeout in milliseconds (create-time only) +- `--session-timeout` (string, optional): Session timeout in milliseconds (create-time only; defaults to browser.sessionTimeoutMs in config.json) - `--inactivity-timeout` (string, optional): Inactivity timeout in milliseconds: release the session when no CDP command or remote input is received for this long. Defaults to 120000 (2 minutes); pass 0 to disable (create-time only) - `--session-solve-captcha` (boolean, optional): Enable manual CAPTCHA solving on new sessions (create-time only) - `--profile` (string, optional): Named profile to persist browser state across sessions diff --git a/src/commands/browser/start.rs b/src/commands/browser/start.rs index 210eb5a..83e709d 100644 --- a/src/commands/browser/start.rs +++ b/src/commands/browser/start.rs @@ -8,6 +8,7 @@ use crate::browser::daemon::protocol::{ }; use crate::browser::lifecycle::sanitize_connect_url; use crate::browser::profile_store; +use crate::config::settings::read_config; use crate::status; use crate::util::{api, output}; @@ -21,7 +22,7 @@ pub struct Args { #[arg(short = 'p', long)] pub proxy: Option, - /// Session timeout in milliseconds (create-time only) + /// Session timeout in milliseconds (create-time only; defaults to browser.sessionTimeoutMs in config.json) #[arg(long = "session-timeout")] pub session_timeout: Option, @@ -60,6 +61,10 @@ pub struct Args { pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); + let configured_session_timeout = read_config() + .ok() + .and_then(|config| config.browser_session_timeout_ms()); + let session_timeout = resolve_session_timeout(args.session_timeout, configured_session_timeout); let session_name = session.unwrap_or("default").to_string(); @@ -82,11 +87,11 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { let proxy_enabled = args.proxy.is_some(); let namespace_set = args.namespace.is_some(); let inactivity_timeout_ms = resolve_inactivity_timeout(args.inactivity_timeout); - if let (Some(timeout), Some(inactivity)) = (args.session_timeout, inactivity_timeout_ms) + if let (Some(timeout), Some(inactivity)) = (session_timeout, inactivity_timeout_ms) && inactivity >= timeout { eprintln!( - "warning: --inactivity-timeout ({inactivity}ms) >= --session-timeout ({timeout}ms); inactivity timeout has no effect because the session timeout elapses first" + "warning: --inactivity-timeout ({inactivity}ms) >= session timeout ({timeout}ms); inactivity timeout has no effect because the session timeout elapses first" ); } @@ -109,7 +114,7 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { session_name: session_name.clone(), stealth: args.stealth, proxy_url: args.proxy, - timeout_ms: args.session_timeout, + timeout_ms: session_timeout, inactivity_timeout_ms, headless: args.session_headless, region: args.session_region, @@ -235,6 +240,13 @@ fn humanize_secs(secs: u64) -> String { } } +const fn resolve_session_timeout(explicit: Option, configured: Option) -> Option { + match explicit { + Some(timeout) => Some(timeout), + None => configured, + } +} + const fn resolve_inactivity_timeout(explicit: Option) -> Option { match explicit { None => Some(DEFAULT_INACTIVITY_TIMEOUT_MS), @@ -247,6 +259,24 @@ const fn resolve_inactivity_timeout(explicit: Option) -> Option { mod tests { use super::*; + #[test] + fn session_timeout_uses_config_when_flag_is_unset() { + assert_eq!(resolve_session_timeout(None, Some(900000)), Some(900000)); + } + + #[test] + fn session_timeout_flag_overrides_config() { + assert_eq!( + resolve_session_timeout(Some(300000), Some(900000)), + Some(300000) + ); + } + + #[test] + fn session_timeout_explicit_zero_overrides_config() { + assert_eq!(resolve_session_timeout(Some(0), Some(900000)), Some(0)); + } + #[test] fn inactivity_default_when_unset() { assert_eq!( diff --git a/src/commands/config.rs b/src/commands/config.rs index 29acac7..53a3e1a 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -33,9 +33,13 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { if let Some(ref cfg) = config && let Some(ref browser) = cfg.browser - && let Some(ref url) = browser.api_url { - println!("browser.apiUrl: {url}"); + if let Some(ref url) = browser.api_url { + println!("browser.apiUrl: {url}"); + } + if let Some(timeout_ms) = cfg.browser_session_timeout_ms() { + println!("browser.sessionTimeoutMs: {timeout_ms}"); + } } if let Some(ref cfg) = config { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f450a35..a9e2190 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -52,7 +52,7 @@ Browser Sessions: steel browser start [--session ] Create or attach to a browser session --stealth Enable stealth mode (humanize + auto CAPTCHA) -p, --proxy Use residential proxy - --session-timeout Session timeout + --session-timeout Session timeout (defaults to browser.sessionTimeoutMs in config.json) --session-solve-captcha Enable manual CAPTCHA solving --profile Named profile to persist browser state --update-profile Save state back to profile on session end diff --git a/src/config/settings.rs b/src/config/settings.rs index 5656879..12b8925 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -137,6 +137,8 @@ pub struct ComputerConfig { pub struct BrowserConfig { #[serde(skip_serializing_if = "Option::is_none")] pub api_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_timeout_ms: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -155,6 +157,14 @@ impl Config { .filter(|s| !s.trim().is_empty()) } + /// Extract the default browser session timeout from config.browser.sessionTimeoutMs. + pub fn browser_session_timeout_ms(&self) -> Option { + self.browser + .as_ref() + .and_then(|b| b.session_timeout_ms) + .filter(|timeout| *timeout > 0) + } + pub fn telemetry_disabled(&self) -> bool { self.telemetry .as_ref() @@ -229,6 +239,7 @@ mod tests { instance: Some("cloud".into()), browser: Some(BrowserConfig { api_url: Some("http://localhost:4000/v1".into()), + session_timeout_ms: Some(3_600_000), }), telemetry: Some(TelemetryConfig { disabled: Some(true), @@ -246,6 +257,7 @@ mod tests { assert_eq!(loaded.name.as_deref(), Some("CLI")); assert_eq!(loaded.instance.as_deref(), Some("cloud")); assert_eq!(loaded.local_api_url(), Some("http://localhost:4000/v1")); + assert_eq!(loaded.browser_session_timeout_ms(), Some(3_600_000)); assert!(loaded.telemetry_disabled()); assert_eq!(loaded.onboarding_source(), Some("claude-code")); } @@ -279,13 +291,14 @@ mod tests { let path = dir.path().join("config.json"); std::fs::write( &path, - r#"{"apiKey":"k","name":"n","instance":"cloud","browser":{"apiUrl":"http://x"},"telemetry":{"disabled":true}}"#, + r#"{"apiKey":"k","name":"n","instance":"cloud","browser":{"apiUrl":"http://x","sessionTimeoutMs":900000},"telemetry":{"disabled":true}}"#, ) .unwrap(); let config = read_config_from(&path).unwrap(); assert_eq!(config.api_key.as_deref(), Some("k")); assert_eq!(config.local_api_url(), Some("http://x")); + assert_eq!(config.browser_session_timeout_ms(), Some(900000)); assert!(config.telemetry_disabled()); } @@ -307,10 +320,12 @@ mod tests { let config = Config { browser: Some(BrowserConfig { api_url: Some(" ".into()), + session_timeout_ms: Some(0), }), ..Default::default() }; assert_eq!(config.local_api_url(), None); + assert_eq!(config.browser_session_timeout_ms(), None); } #[test]