From 19808d08f89dcc09dff3c687bb2db4ebade9c92d Mon Sep 17 00:00:00 2001 From: Tom Dale Date: Thu, 16 Apr 2026 09:38:54 -0400 Subject: [PATCH] fix: load storage state at launch when --state / AGENT_BROWSER_STATE is set (#1241) * fix: load storage state at launch when --state / AGENT_BROWSER_STATE is set The `--state` flag and `AGENT_BROWSER_STATE` env var were documented as restoring saved browser state (cookies + localStorage) at launch, but `load_state()` was never called after the browser started. The feature has been broken since it was introduced. Adds `try_load_storage_state()` and calls it from every early-return path in `auto_launch()` (lazy launch triggered by commands like `navigate`) and from `handle_launch()` (explicit `launch` command). Also adds 4 e2e tests covering all state-persistence paths: - Explicit launch with `storageState` field - Auto-launch via `AGENT_BROWSER_STATE` env var - Session-name auto-restore via `try_auto_restore_state` - Explicit `state_load` command (baseline sanity check) Fixes #1164. * style: apply cargo fmt to e2e_tests.rs Reformats a single long format\! call to satisfy CI's rustfmt check. No behavior change. * fix: call try_load_storage_state in all handle_launch branches The CDP URL, CDP port, auto-connect, and provider early-return branches were skipping storage state loading because try_load_storage_state was only called in the normal BrowserManager::launch() path at the bottom of handle_launch(). Also compute storage_state_owned once and reuse it across all branches rather than borrowing storage_state (a &str tied to cmd) in a helper that needs an owned Option. * Fix storage state reload on reused launches * Fix storage-state launch errors * Fix storage state replay ordering * Align storage-state errors across launch paths * Fix storageState launch cleanup --- cli/src/native/actions.rs | 86 ++++- cli/src/native/e2e_tests.rs | 618 ++++++++++++++++++++++++++++++++++++ 2 files changed, 702 insertions(+), 2 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 2858bf0..09f713a 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -169,8 +169,12 @@ struct DrainedEvents { /// Compute a hash of the [`LaunchOptions`] fields that require a browser /// relaunch when changed (baked into the Chrome process at startup). /// -/// Fields NOT hashed (adjustable at runtime via CDP without relaunch): -/// ignore_https_errors, color_scheme, download_path, storage_state +/// Fields NOT hashed: +/// ignore_https_errors, color_scheme, download_path +/// +/// `storage_state` is handled separately in `handle_launch()`: explicit +/// `storageState` launches always require a clean local browser so the loaded +/// state replaces the prior session instead of merging into it. fn launch_hash(opts: &LaunchOptions) -> u64 { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -1504,6 +1508,9 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { } let engine = env::var("AGENT_BROWSER_ENGINE").ok(); + // Extract storage_state before options is moved into BrowserManager::launch. + let storage_state_path = options.storage_state.clone(); + // Store proxy credentials for Fetch.authRequired handling let has_proxy_auth = options.proxy_username.is_some(); if has_proxy_auth { @@ -1527,6 +1534,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.start_dialog_handler(); state.update_stream_client().await; try_auto_restore_state(state).await; + try_load_storage_state(state, &storage_state_path).await; return Ok(()); } @@ -1538,6 +1546,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.start_dialog_handler(); state.update_stream_client().await; try_auto_restore_state(state).await; + try_load_storage_state(state, &storage_state_path).await; return Ok(()); } @@ -1572,6 +1581,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.update_stream_client().await; write_provider_file(&state.session_id, &p); try_auto_restore_state(state).await; + try_load_storage_state(state, &storage_state_path).await; return Ok(()); } Err(e) => { @@ -1604,6 +1614,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { } try_auto_restore_state(state).await; + try_load_storage_state(state, &storage_state_path).await; Ok(()) } @@ -1665,6 +1676,64 @@ async fn try_auto_restore_state(state: &mut DaemonState) { } } +/// Load storage state if a path is configured. +/// +/// Explicit launch should surface this error. Best-effort callers can ignore +/// the returned `Result` and keep their previous behavior. +async fn load_storage_state(state: &DaemonState, path: &Option) -> Result<(), String> { + if let Some(ref path) = path { + if let Some(ref mgr) = state.browser { + if let Ok(session_id) = mgr.active_session_id() { + state::load_state(&mgr.client, session_id, path).await?; + } + } + } + + Ok(()) +} + +async fn rollback_failed_launch(state: &mut DaemonState) -> Result<(), String> { + let close_error = if let Some(mut mgr) = state.browser.take() { + mgr.close().await.err() + } else { + None + }; + + state.launch_hash = None; + state.screencasting = false; + state.reset_input_state(); + state.ref_map.clear(); + state.update_stream_client().await; + + if let Some(err) = close_error { + return Err(err); + } + + Ok(()) +} + +async fn load_storage_state_or_rollback( + state: &mut DaemonState, + path: &Option, +) -> Result<(), String> { + if let Err(err) = load_storage_state(state, path).await { + if let Err(close_err) = rollback_failed_launch(state).await { + return Err(format!( + "{} (also failed to roll back browser after launch: {})", + err, close_err + )); + } + return Err(err); + } + + Ok(()) +} + +/// Load storage state from AGENT_BROWSER_STATE if set. +async fn try_load_storage_state(state: &DaemonState, path: &Option) { + let _ = load_storage_state(state, path).await; +} + // --------------------------------------------------------------------------- // Phase 1 handlers // --------------------------------------------------------------------------- @@ -1688,6 +1757,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result Result Result Result Result String { ) } +async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_value: &str) { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ + "id": "1", + "action": "launch", + "headless": true, + "args": ["--no-sandbox", "--disable-dev-shm-usage"] + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ + "id": "3", + "action": "cookies_set", + "name": cookie_name, + "value": cookie_value, + "domain": ".example.com", + "path": "/", + "expires": 2000000000 + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "4", "action": "state_save", "path": path }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; + assert_success(&resp); +} + // --------------------------------------------------------------------------- // Core: launch, navigate, evaluate, url, title, close // --------------------------------------------------------------------------- @@ -4200,3 +4248,573 @@ async fn e2e_recording_inherits_viewport() { let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } + +// --------------------------------------------------------------------------- +// --state / storageState flag: cookies should be loaded at launch time +// --------------------------------------------------------------------------- + +/// Verify that launching with `storageState` in the launch command restores +/// cookies that were previously saved with `state_save`. +/// +/// This is the e2e equivalent of `agent-browser --state ./auth.json open `. +/// The launch command accepts a `storageState` field that should load the +/// state file (cookies + localStorage) before the first navigation. +#[tokio::test] +#[ignore] +async fn e2e_state_flag_restores_cookies() { + let state_path = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-state-flag-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + + // Session 1: launch, set a cookie, save state, close + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ + "id": "3", + "action": "cookies_set", + "name": "state_flag_test", + "value": "from_state_file", + "domain": ".example.com", + "path": "/", + "expires": 2000000000 + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "4", "action": "state_save", "path": &state_path }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + // Session 2: launch with storageState pointing to saved file, verify + // cookies are present before any explicit state_load call. + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ + "id": "10", + "action": "launch", + "headless": true, + "storageState": &state_path + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "11", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = + execute_command(&json!({ "id": "12", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + let found = cookies + .iter() + .any(|c| c["name"] == "state_flag_test" && c["value"] == "from_state_file"); + assert!( + found, + "Cookie from state file should be present after launch with storageState. \ + Cookies found: {:?}", + cookies + .iter() + .map(|c| c["name"].as_str().unwrap_or("?")) + .collect::>() + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + let _ = std::fs::remove_file(&state_path); +} + +/// Verify that explicit `launch` surfaces storageState load failures instead +/// of reporting success with an empty browser state. +#[tokio::test] +#[ignore] +async fn e2e_state_flag_missing_file_fails_launch() { + let guard = EnvGuard::new(&["CI"]); + guard.set("CI", "1"); + + let missing_path = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-missing-state-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ + "id": "10", + "action": "launch", + "headless": true, + "args": ["--no-sandbox", "--disable-dev-shm-usage"], + "storageState": &missing_path + }), + &mut state, + ) + .await; + + assert_eq!(resp["success"], false); + let error = resp["error"].as_str().unwrap_or_default(); + assert!( + error.contains("Failed to read state from") || error.contains("storage state"), + "Unexpected error for missing storageState file: {}", + error + ); + assert!( + state.browser.is_none(), + "failed storageState launch should roll back the browser" + ); +} + +/// Repeated launch calls with `storageState` should relaunch a clean browser so +/// stale cookies do not survive from the previous state file. +#[tokio::test] +#[ignore] +async fn e2e_storage_state_launch_restarts_clean_browser() { + let state_one = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-storage-reuse-1-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + let state_two = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-storage-reuse-2-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + + create_storage_state_with_cookie(&state_one, "storage_reload_first", "first").await; + create_storage_state_with_cookie(&state_two, "storage_reload_second", "second").await; + + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ + "id": "10", + "action": "launch", + "headless": true, + "args": ["--no-sandbox", "--disable-dev-shm-usage"], + "storageState": &state_one + }), + &mut state, + ) + .await; + assert_success(&resp); + assert!( + get_data(&resp).get("reused").is_none(), + "first launch must create the browser" + ); + + let resp = execute_command( + &json!({ "id": "11", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "12", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + assert!( + cookies + .iter() + .any(|c| c["name"] == "storage_reload_first" && c["value"] == "first"), + "first storageState should be applied on the initial launch" + ); + + let resp = execute_command( + &json!({ + "id": "13", + "action": "launch", + "headless": true, + "args": ["--no-sandbox", "--disable-dev-shm-usage"], + "storageState": &state_two + }), + &mut state, + ) + .await; + assert_success(&resp); + assert_eq!( + get_data(&resp).get("reused"), + None, + "storageState launch should start from a clean browser" + ); + + let resp = execute_command( + &json!({ "id": "14", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "15", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + assert!( + cookies + .iter() + .any(|c| c["name"] == "storage_reload_second" && c["value"] == "second"), + "second storageState should be applied after relaunch" + ); + assert!( + !cookies + .iter() + .any(|c| c["name"] == "storage_reload_first" && c["value"] == "first"), + "stale cookies from the first storageState should not survive relaunch" + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + + let _ = std::fs::remove_file(&state_one); + let _ = std::fs::remove_file(&state_two); +} + +/// Verify that AGENT_BROWSER_STATE env var restores cookies at auto-launch +/// time (when the browser is lazily launched by a command like `navigate` +/// rather than an explicit `launch` command). +#[tokio::test] +#[ignore] +async fn e2e_state_env_restores_cookies_on_auto_launch() { + let state_path = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-state-env-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + + // Session 1: launch, set a cookie, save state, close + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ + "id": "3", + "action": "cookies_set", + "name": "env_state_test", + "value": "from_env_state", + "domain": ".example.com", + "path": "/", + "expires": 2000000000 + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "4", "action": "state_save", "path": &state_path }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + // Session 2: set AGENT_BROWSER_STATE env var and let auto_launch pick it + // up. No explicit `launch` command — just navigate, which triggers + // auto_launch internally. + { + let env = EnvGuard::new(&["AGENT_BROWSER_STATE"]); + env.set("AGENT_BROWSER_STATE", &state_path); + + let mut state = DaemonState::new(); + + // Navigate without explicit launch — triggers auto_launch + let resp = execute_command( + &json!({ "id": "10", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = + execute_command(&json!({ "id": "11", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + let found = cookies + .iter() + .any(|c| c["name"] == "env_state_test" && c["value"] == "from_env_state"); + assert!( + found, + "Cookie should be restored via AGENT_BROWSER_STATE env on auto-launch. \ + Cookies found: {:?}", + cookies + .iter() + .map(|c| c["name"].as_str().unwrap_or("?")) + .collect::>() + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + let _ = std::fs::remove_file(&state_path); +} + +/// Verify that --session-name auto-restores cookies saved from a prior +/// session with the same name. +#[tokio::test] +#[ignore] +async fn e2e_session_name_auto_restores_cookies() { + let session_name = format!( + "e2e-session-name-{}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + + let env = EnvGuard::new(&["AGENT_BROWSER_SESSION_NAME"]); + env.set("AGENT_BROWSER_SESSION_NAME", &session_name); + + // Session 1: launch, set a cookie, close (which auto-saves state) + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ + "id": "3", + "action": "cookies_set", + "name": "session_name_test", + "value": "auto_restored", + "domain": ".example.com", + "path": "/", + "expires": 2000000000 + }), + &mut state, + ) + .await; + assert_success(&resp); + + // close triggers auto-save when session_name is set + let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + // Session 2: fresh DaemonState with same session_name. Navigate without + // explicit launch — this triggers auto_launch which calls + // try_auto_restore_state. + // + // NOTE: an explicit `launch` command skips auto_launch entirely, so + // session-name auto-restore only fires via the auto_launch path. + { + let mut state = DaemonState::new(); + + // Navigate without explicit launch — triggers auto_launch → try_auto_restore_state + let resp = execute_command( + &json!({ "id": "10", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = + execute_command(&json!({ "id": "12", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + let found = cookies + .iter() + .any(|c| c["name"] == "session_name_test" && c["value"] == "auto_restored"); + assert!( + found, + "Cookie should be auto-restored via --session-name. Cookies found: {:?}", + cookies + .iter() + .map(|c| c["name"].as_str().unwrap_or("?")) + .collect::>() + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + // Clean up auto-saved state files + let sessions_dir = dirs::home_dir() + .unwrap() + .join(".agent-browser") + .join("sessions"); + if let Ok(entries) = std::fs::read_dir(&sessions_dir) { + for entry in entries.flatten() { + let fname = entry.file_name().to_string_lossy().to_string(); + if fname.starts_with(&format!("{}-", session_name)) { + let _ = std::fs::remove_file(entry.path()); + } + } + } +} + +/// Verify that explicit `state_load` restores cookies into an existing +/// session (baseline sanity check — this path is known to work). +#[tokio::test] +#[ignore] +async fn e2e_explicit_state_load_restores_cookies() { + let state_path = std::env::temp_dir() + .join(format!( + "agent-browser-e2e-explicit-load-{}.json", + uuid::Uuid::new_v4() + )) + .to_string_lossy() + .to_string(); + + // Session 1: set cookie, save state + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "1", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ + "id": "3", + "action": "cookies_set", + "name": "explicit_load_test", + "value": "manually_loaded", + "domain": ".example.com", + "path": "/", + "expires": 2000000000 + }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "4", "action": "state_save", "path": &state_path }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + // Session 2: launch clean, then explicitly load state + { + let mut state = DaemonState::new(); + + let resp = execute_command( + &json!({ "id": "10", "action": "launch", "headless": true }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "11", "action": "state_load", "path": &state_path }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = execute_command( + &json!({ "id": "12", "action": "navigate", "url": "https://example.com" }), + &mut state, + ) + .await; + assert_success(&resp); + + let resp = + execute_command(&json!({ "id": "13", "action": "cookies_get" }), &mut state).await; + assert_success(&resp); + let cookies = get_data(&resp)["cookies"].as_array().unwrap(); + let found = cookies + .iter() + .any(|c| c["name"] == "explicit_load_test" && c["value"] == "manually_loaded"); + assert!( + found, + "Cookie should be present after explicit state_load. Cookies found: {:?}", + cookies + .iter() + .map(|c| c["name"].as_str().unwrap_or("?")) + .collect::>() + ); + + let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; + assert_success(&resp); + } + + let _ = std::fs::remove_file(&state_path); +}