diff --git a/README.md b/README.md index 512f1a5..9bd22b5 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,7 @@ agent-browser provides multiple ways to persist login sessions so you don't re-a | Approach | Best for | Flag / Env | |----------|----------|------------| +| **Chrome profile reuse** | Reuse your existing Chrome login state (cookies, sessions) with zero setup | `--profile ` / `AGENT_BROWSER_PROFILE` | | **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile ` / `AGENT_BROWSER_PROFILE` | | **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name ` / `AGENT_BROWSER_SESSION_NAME` | | **Import from your browser** | Grab auth from a Chrome session you already logged into | `--auto-connect` + `state save` | @@ -437,9 +438,31 @@ Each session has its own: - Navigation history - Authentication state +## Chrome Profile Reuse + +The fastest way to use your existing login state: pass a Chrome profile name to `--profile`: + +```bash +# List available Chrome profiles +agent-browser profiles + +# Reuse your default Chrome profile's login state +agent-browser --profile Default open https://gmail.com + +# Use a named profile (by display name or directory name) +agent-browser --profile "Work" open https://app.example.com + +# Or via environment variable +AGENT_BROWSER_PROFILE=Default agent-browser open https://gmail.com +``` + +This copies your Chrome profile to a temp directory (read-only snapshot, no changes to your original profile), so the browser launches with your existing cookies and sessions. + +> **Note:** On Windows, close Chrome before using `--profile ` if Chrome is running, as some profile files may be locked. + ## Persistent Profiles -By default, browser state (cookies, localStorage, login sessions) is ephemeral and lost when the browser closes. Use `--profile` to persist state across browser restarts: +For a persistent custom profile directory that stores state across browser restarts, pass a path to `--profile`: ```bash # Use a persistent profile directory @@ -567,7 +590,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl |--------|-------------| | `--session ` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | | `--session-name ` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) | -| `--profile ` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) | +| `--profile ` | Chrome profile name or persistent directory path (or `AGENT_BROWSER_PROFILE` env) | | `--state ` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) | | `--headers ` | Set HTTP headers scoped to the URL's origin | | `--executable-path ` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | diff --git a/cli/src/main.rs b/cli/src/main.rs index e43d6a7..cf93eac 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -117,6 +117,64 @@ fn parse_proxy(proxy_str: &str) -> ParsedProxy { } } +fn run_profiles(json_mode: bool) { + use crate::native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles}; + + let user_data_dir = match find_chrome_user_data_dir() { + Some(dir) => dir, + None => { + if json_mode { + print_json_error("No Chrome user data directory found"); + } else { + eprintln!("{}", color::red("No Chrome user data directory found")); + } + exit(1); + } + }; + + let profiles = list_chrome_profiles(&user_data_dir); + if profiles.is_empty() { + if json_mode { + print_json_value(json!({ + "success": true, + "data": [] + })); + } else { + println!("No Chrome profiles found"); + } + return; + } + + if json_mode { + let items: Vec = profiles + .iter() + .map(|p| { + json!({ + "directory": p.directory, + "name": p.name + }) + }) + .collect(); + print_json_value(json!({ + "success": true, + "data": items + })); + } else { + println!( + "{} ({}):\n", + color::bold("Chrome profiles"), + user_data_dir.display() + ); + for p in &profiles { + println!( + " {} {}", + color::bold(&p.directory), + color::dim(&format!("({})", p.name)) + ); + } + } +} + fn run_session(args: &[String], session: &str, json_mode: bool) { let subcommand = args.get(1).map(|s| s.as_str()); @@ -624,6 +682,12 @@ fn main() { } } + // Handle profiles command (doesn't need daemon) + if clean.first().map(|s| s.as_str()) == Some("profiles") { + run_profiles(flags.json); + 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); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 012d5cd..d16a871 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1620,6 +1620,7 @@ fn launch_options_from_env() -> LaunchOptions { .unwrap_or(false), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(), download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(), + use_real_keychain: false, } } @@ -1727,6 +1728,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result, @@ -102,6 +103,10 @@ pub struct LaunchOptions { pub ignore_https_errors: bool, pub color_scheme: Option, pub download_path: Option, + /// When true, omit `--password-store=basic` and `--use-mock-keychain` so + /// Chrome uses the real system keychain. Set automatically when launching + /// with a copied Chrome profile. + pub use_real_keychain: bool, } impl Default for LaunchOptions { @@ -122,6 +127,7 @@ impl Default for LaunchOptions { ignore_https_errors: false, color_scheme: None, download_path: None, + use_real_keychain: false, } } } @@ -148,10 +154,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result { "--disable-features=Translate".to_string(), "--enable-features=NetworkService,NetworkServiceInProcess".to_string(), "--metrics-recording-only".to_string(), - "--password-store=basic".to_string(), - "--use-mock-keychain".to_string(), ]; + if !options.use_real_keychain { + args.push("--password-store=basic".to_string()); + args.push("--use-mock-keychain".to_string()); + } + let has_extensions = options .extensions .as_ref() @@ -250,12 +259,47 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result { })?, }; + // Profile name preprocessing: if --profile is a Chrome profile name (not a + // path), resolve it to a directory, copy the profile to a temp dir, and + // rewrite options so the retry loop uses the copied profile. + let mut resolved_options: Option = None; + let mut profile_temp_dir: Option = None; + + if let Some(ref profile) = options.profile { + if is_chrome_profile_name(profile) { + let user_data_dir = find_chrome_user_data_dir().ok_or_else(|| { + "No Chrome user data directory found. Cannot resolve profile name.\n\ + If you meant a directory path, use a full path (e.g., /path/to/profile)." + .to_string() + })?; + let resolved = resolve_chrome_profile(&user_data_dir, profile)?; + let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?; + + let mut opts = options.clone(); + opts.profile = Some(temp_path.display().to_string()); + opts.use_real_keychain = true; + opts.args.push(format!("--profile-directory={}", resolved)); + profile_temp_dir = Some(temp_path); + resolved_options = Some(opts); + } + } + + let effective_options = resolved_options.as_ref().unwrap_or(options); + let max_attempts = 3; let mut last_err = String::new(); for attempt in 1..=max_attempts { - match try_launch_chrome(&chrome_path, options) { - Ok(process) => return Ok(process), + match try_launch_chrome(&chrome_path, effective_options) { + Ok(mut process) => { + // Transfer profile temp dir ownership to ChromeProcess for cleanup on Drop. + // The try_launch_chrome temp_user_data_dir is None here because we set profile + // to the temp path (treated as a user-supplied path, no second temp dir). + if let Some(ref dir) = profile_temp_dir { + process.temp_user_data_dir = Some(dir.clone()); + } + return Ok(process); + } Err(e) => { last_err = e; if attempt < max_attempts { @@ -273,6 +317,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result { } } + // All retries failed: clean up profile temp dir if we created one + if let Some(ref dir) = profile_temp_dir { + let _ = std::fs::remove_dir_all(dir); + } + Err(last_err) } @@ -639,7 +688,9 @@ fn is_port_reachable(port: u16) -> bool { TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(500)).is_ok() } -fn get_chrome_user_data_dirs() -> Vec { +/// Returns the default Chrome user-data directory paths for the current platform. +/// Includes Chrome, Chrome Canary, Chromium, and Brave. +pub fn get_chrome_user_data_dirs() -> Vec { let mut dirs = Vec::new(); #[cfg(target_os = "macos")] @@ -690,6 +741,251 @@ fn get_chrome_user_data_dirs() -> Vec { dirs } +/// Returns true if the given string looks like a Chrome profile name rather than +/// a file path. A profile name contains no `/`, `\`, or `~` characters. +pub fn is_chrome_profile_name(s: &str) -> bool { + !s.contains('/') && !s.contains('\\') && !s.contains('~') +} + +/// Returns the first existing Chrome user-data directory that contains a +/// `Local State` file. +pub fn find_chrome_user_data_dir() -> Option { + get_chrome_user_data_dirs() + .into_iter() + .find(|dir| dir.join("Local State").is_file()) +} + +/// A Chrome profile entry parsed from `Local State`. +#[derive(Debug, Clone)] +pub struct ChromeProfile { + /// The directory name (e.g., "Default", "Profile 1"). + pub directory: String, + /// The user-visible display name (e.g., "Person 1"). + pub name: String, +} + +/// Lists all Chrome profiles found in the given user-data directory by reading +/// the `Local State` JSON file. Returns an empty vec if the file is missing, +/// malformed, or lacks the expected `profile.info_cache` key. +pub fn list_chrome_profiles(user_data_dir: &Path) -> Vec { + let local_state_path = user_data_dir.join("Local State"); + let content = match std::fs::read_to_string(&local_state_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let json: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let info_cache = match json.get("profile").and_then(|p| p.get("info_cache")) { + Some(obj) if obj.is_object() => obj.as_object().unwrap(), + _ => return Vec::new(), + }; + + let mut profiles: Vec = info_cache + .iter() + .map(|(dir_name, info)| { + let display_name = info + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(dir_name) + .to_string(); + ChromeProfile { + directory: dir_name.clone(), + name: display_name, + } + }) + .collect(); + profiles.sort_by(|a, b| a.directory.cmp(&b.directory)); + profiles +} + +/// Resolves a profile input string to a Chrome profile directory name using +/// three-tier matching: +/// 1. Exact directory name match +/// 2. Case-insensitive display name match (error if ambiguous) +/// 3. Case-insensitive directory name match +/// +/// Returns the resolved directory name, or an error with available profiles. +pub fn resolve_chrome_profile(user_data_dir: &Path, input: &str) -> Result { + let profiles = list_chrome_profiles(user_data_dir); + + if profiles.is_empty() { + return Err(format!( + "No Chrome profiles found in {}.\n\ + If you meant a directory path, use a full path (e.g., /path/to/profile).", + user_data_dir.display() + )); + } + + // Tier 1: exact directory name match + if let Some(p) = profiles.iter().find(|p| p.directory == input) { + return Ok(p.directory.clone()); + } + + // Tier 2: case-insensitive display name match + let input_lower = input.to_lowercase(); + let display_matches: Vec<&ChromeProfile> = profiles + .iter() + .filter(|p| p.name.to_lowercase() == input_lower) + .collect(); + match display_matches.len() { + 1 => return Ok(display_matches[0].directory.clone()), + n if n > 1 => { + return Err(format!( + "Ambiguous profile name \"{}\". Multiple profiles match:\n{}\n\ + Use the directory name instead.", + input, + format_profile_list(&display_matches) + )); + } + _ => {} + } + + // Tier 3: case-insensitive directory name match + if let Some(p) = profiles + .iter() + .find(|p| p.directory.to_lowercase() == input_lower) + { + return Ok(p.directory.clone()); + } + + let all_profiles: Vec<&ChromeProfile> = profiles.iter().collect(); + Err(format!( + "Chrome profile \"{}\" not found. Available profiles:\n{}\n\ + If you meant a directory path, use a full path (e.g., /path/to/profile).", + input, + format_profile_list(&all_profiles) + )) +} + +fn format_profile_list(profiles: &[&ChromeProfile]) -> String { + profiles + .iter() + .map(|p| format!(" {} ({})", p.directory, p.name)) + .collect::>() + .join("\n") +} + +/// Directories to exclude when copying a Chrome profile. These are large +/// non-auth directories that are not needed for reusing login state. +const PROFILE_COPY_EXCLUDE_DIRS: &[&str] = &[ + "Cache", + "Code Cache", + "GPUCache", + "Service Worker", + "blob_storage", + "File System", + "GCM Store", + "optimization_guide", + "ShaderCache", + "component_crx_cache", +]; + +/// Copies a Chrome profile subdirectory and `Local State` to a temp directory +/// with a two-level structure suitable for `--user-data-dir`. Returns the temp +/// directory path on success. +/// +/// The copy is best-effort: individual file failures (e.g., `SingletonLock`) +/// are skipped with a warning. If the source profile directory is missing or +/// the temp dir cannot be created, returns an error after cleaning up. +pub fn copy_chrome_profile( + user_data_dir: &Path, + profile_directory: &str, +) -> Result { + let temp_dir = + std::env::temp_dir().join(format!("agent-browser-profile-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create temp profile dir: {}", e))?; + + // Copy Local State (non-fatal if missing or unreadable) + let local_state_src = user_data_dir.join("Local State"); + if let Err(e) = std::fs::copy(&local_state_src, temp_dir.join("Local State")) { + let _ = writeln!( + std::io::stderr(), + "Warning: could not copy Local State from {}: {}", + local_state_src.display(), + e + ); + } + + // Copy profile subdirectory + let src_profile = user_data_dir.join(profile_directory); + if !src_profile.is_dir() { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!( + "Profile directory not found: {}", + src_profile.display() + )); + } + let dst_profile = temp_dir.join(profile_directory); + if let Err(e) = copy_dir_recursive(&src_profile, &dst_profile) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to copy profile: {}", e)); + } + + Ok(temp_dir) +} + +/// Recursively copies a directory, skipping entries in [`PROFILE_COPY_EXCLUDE_DIRS`]. +/// Individual file copy failures are logged to stderr but do not fail the operation. +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + std::fs::create_dir_all(dst) + .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; + + let entries = std::fs::read_dir(src) + .map_err(|e| format!("Failed to read directory {}: {}", src.display(), e))?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + let _ = writeln!( + std::io::stderr(), + "Warning: failed to read entry in {}: {}", + src.display(), + e + ); + continue; + } + }; + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let src_path = entry.path(); + let dst_path = dst.join(&name); + + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(e) => { + let _ = writeln!( + std::io::stderr(), + "Warning: failed to get file type for {}: {}", + src_path.display(), + e + ); + continue; + } + }; + + if file_type.is_dir() { + if PROFILE_COPY_EXCLUDE_DIRS.contains(&name_str.as_ref()) { + continue; + } + copy_dir_recursive(&src_path, &dst_path)?; + } else if let Err(e) = std::fs::copy(&src_path, &dst_path) { + let _ = writeln!( + std::io::stderr(), + "Warning: failed to copy {}: {}", + src_path.display(), + e + ); + } + } + + Ok(()) +} + /// Returns true if Chrome's sandbox should be disabled because the environment /// doesn't support it (containers, VMs, CI runners, running as root). fn should_disable_sandbox(existing_args: &[String]) -> bool { @@ -1228,4 +1524,306 @@ mod tests { assert!(!dir.exists(), "Temp dir should be cleaned up on drop"); } + + #[test] + fn test_is_chrome_profile_name_simple() { + assert!(is_chrome_profile_name("Default")); + assert!(is_chrome_profile_name("Profile 1")); + assert!(is_chrome_profile_name("")); + } + + #[test] + fn test_is_chrome_profile_name_paths() { + assert!(!is_chrome_profile_name("/tmp/dir")); + assert!(!is_chrome_profile_name("~/my-profile")); + assert!(!is_chrome_profile_name("C:\\Users\\foo")); + assert!(!is_chrome_profile_name("relative/path")); + } + + /// Helper to create a fake Chrome user-data dir with a `Local State` file. + fn create_fake_local_state(base: &Path, profiles: &[(&str, &str)]) { + let mut info_cache = serde_json::Map::new(); + for (dir_name, display_name) in profiles { + let mut entry = serde_json::Map::new(); + entry.insert( + "name".to_string(), + serde_json::Value::String(display_name.to_string()), + ); + info_cache.insert(dir_name.to_string(), serde_json::Value::Object(entry)); + } + + let local_state = serde_json::json!({ + "profile": { + "info_cache": info_cache + } + }); + + std::fs::create_dir_all(base).unwrap(); + std::fs::write( + base.join("Local State"), + serde_json::to_string_pretty(&local_state).unwrap(), + ) + .unwrap(); + } + + /// RAII guard that removes the temp directory on drop (even on panic). + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + Self(std::env::temp_dir().join(format!( + "agent-browser-test-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + impl std::ops::Deref for TempDir { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } + } + + #[test] + fn test_list_chrome_profiles_valid() { + let dir = TempDir::new("list-profiles"); + create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); + + let profiles = list_chrome_profiles(&dir); + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].directory, "Default"); + assert_eq!(profiles[0].name, "Person 1"); + assert_eq!(profiles[1].directory, "Profile 1"); + assert_eq!(profiles[1].name, "Work"); + } + + #[test] + fn test_list_chrome_profiles_missing_local_state() { + let dir = TempDir::new("list-profiles-missing"); + std::fs::create_dir_all(&*dir).unwrap(); + let profiles = list_chrome_profiles(&dir); + assert!(profiles.is_empty()); + } + + #[test] + fn test_list_chrome_profiles_malformed_json() { + let dir = TempDir::new("list-profiles-malformed"); + std::fs::create_dir_all(&*dir).unwrap(); + std::fs::write(dir.join("Local State"), "not json").unwrap(); + let profiles = list_chrome_profiles(&dir); + assert!(profiles.is_empty()); + } + + #[test] + fn test_list_chrome_profiles_missing_info_cache() { + let dir = TempDir::new("list-profiles-no-cache"); + std::fs::create_dir_all(&*dir).unwrap(); + std::fs::write(dir.join("Local State"), r#"{"profile": {}}"#).unwrap(); + let profiles = list_chrome_profiles(&dir); + assert!(profiles.is_empty()); + } + + #[test] + fn test_resolve_chrome_profile_exact_directory() { + let dir = TempDir::new("resolve-exact"); + create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); + + let result = resolve_chrome_profile(&dir, "Default"); + assert_eq!(result.unwrap(), "Default"); + } + + #[test] + fn test_resolve_chrome_profile_display_name_case_insensitive() { + let dir = TempDir::new("resolve-display"); + create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); + + let result = resolve_chrome_profile(&dir, "work"); + assert_eq!(result.unwrap(), "Profile 1"); + } + + #[test] + fn test_resolve_chrome_profile_directory_name_case_insensitive() { + let dir = TempDir::new("resolve-dir-ci"); + create_fake_local_state(&dir, &[("Default", "Person 1"), ("Profile 1", "Work")]); + + let result = resolve_chrome_profile(&dir, "default"); + assert_eq!(result.unwrap(), "Default"); + } + + #[test] + fn test_resolve_chrome_profile_not_found() { + let dir = TempDir::new("resolve-notfound"); + create_fake_local_state(&dir, &[("Default", "Person 1")]); + + let result = resolve_chrome_profile(&dir, "Nonexistent"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("not found")); + assert!(err.contains("Default")); + assert!(err.contains("full path")); + } + + #[test] + fn test_resolve_chrome_profile_ambiguous_display_name() { + let dir = TempDir::new("resolve-ambiguous"); + create_fake_local_state(&dir, &[("Default", "Work"), ("Profile 1", "Work")]); + + let result = resolve_chrome_profile(&dir, "Work"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Ambiguous")); + assert!(err.contains("Default")); + assert!(err.contains("Profile 1")); + } + + /// Helper to create a fake Chrome profile directory with some files. + fn create_fake_profile(user_data_dir: &Path, profile_dir: &str) { + let profile_path = user_data_dir.join(profile_dir); + std::fs::create_dir_all(profile_path.join("Local Storage/leveldb")).unwrap(); + std::fs::write(profile_path.join("Cookies"), "fake-cookies").unwrap(); + std::fs::write( + profile_path.join("Local Storage/leveldb/CURRENT"), + "fake-leveldb", + ) + .unwrap(); + // Create an excluded directory to verify it's skipped + std::fs::create_dir_all(profile_path.join("Cache")).unwrap(); + std::fs::write(profile_path.join("Cache/data_0"), "cache-data").unwrap(); + } + + #[test] + fn test_copy_chrome_profile_structure() { + let src = TempDir::new("copy-src"); + create_fake_local_state(&src, &[("Default", "Person 1")]); + create_fake_profile(&src, "Default"); + + let temp_path = copy_chrome_profile(&src, "Default").unwrap(); + let temp = TempDir(temp_path); + + assert!(temp.join("Local State").is_file()); + assert!(temp.join("Default/Cookies").is_file()); + assert!(temp.join("Default/Local Storage/leveldb/CURRENT").is_file()); + assert_eq!( + std::fs::read_to_string(temp.join("Default/Cookies")).unwrap(), + "fake-cookies" + ); + assert_eq!( + std::fs::read_to_string(temp.join("Default/Local Storage/leveldb/CURRENT")).unwrap(), + "fake-leveldb" + ); + assert!(!temp.join("Default/Cache").exists()); + } + + #[test] + fn test_copy_chrome_profile_missing_source() { + let src = TempDir::new("copy-missing-src"); + std::fs::create_dir_all(&*src).unwrap(); + + let result = copy_chrome_profile(&src, "Nonexistent"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Profile directory not found")); + } + + #[test] + fn test_copy_chrome_profile_missing_local_state() { + let src = TempDir::new("copy-no-ls"); + let profile_path = src.join("Default"); + std::fs::create_dir_all(&profile_path).unwrap(); + std::fs::write(profile_path.join("Cookies"), "data").unwrap(); + + let temp_path = copy_chrome_profile(&src, "Default").unwrap(); + let temp = TempDir(temp_path); + + assert!(!temp.join("Local State").exists()); + assert!(temp.join("Default/Cookies").is_file()); + } + + #[test] + fn test_copy_dir_recursive_excludes() { + let src = TempDir::new("copy-excludes-src"); + let dst = TempDir::new("copy-excludes-dst"); + std::fs::create_dir_all(src.join("keep")).unwrap(); + std::fs::write(src.join("keep/data"), "keep-data").unwrap(); + for excluded in PROFILE_COPY_EXCLUDE_DIRS { + std::fs::create_dir_all(src.join(excluded)).unwrap(); + std::fs::write(src.join(excluded).join("file"), "excluded").unwrap(); + } + + copy_dir_recursive(&src, &dst).unwrap(); + + assert!(dst.join("keep/data").is_file()); + for excluded in PROFILE_COPY_EXCLUDE_DIRS { + assert!( + !dst.join(excluded).exists(), + "{} should be excluded", + excluded + ); + } + } + + #[test] + fn test_build_args_use_real_keychain_true() { + let opts = LaunchOptions { + use_real_keychain: true, + ..Default::default() + }; + let result = build_chrome_args(&opts).unwrap(); + assert!( + !result.args.iter().any(|a| a == "--password-store=basic"), + "should NOT have --password-store=basic when use_real_keychain is true" + ); + assert!( + !result.args.iter().any(|a| a == "--use-mock-keychain"), + "should NOT have --use-mock-keychain when use_real_keychain is true" + ); + if let Some(ref dir) = result.temp_user_data_dir { + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn test_build_args_use_real_keychain_false_default() { + let opts = LaunchOptions::default(); + let result = build_chrome_args(&opts).unwrap(); + assert!( + result.args.iter().any(|a| a == "--password-store=basic"), + "should have --password-store=basic by default" + ); + assert!( + result.args.iter().any(|a| a == "--use-mock-keychain"), + "should have --use-mock-keychain by default" + ); + if let Some(ref dir) = result.temp_user_data_dir { + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn test_build_args_profile_path_preserves_keychain_flags() { + let opts = LaunchOptions { + profile: Some("/tmp/my-profile".to_string()), + ..Default::default() + }; + let result = build_chrome_args(&opts).unwrap(); + assert!(result + .args + .iter() + .any(|a| a == "--user-data-dir=/tmp/my-profile")); + assert!( + result.args.iter().any(|a| a == "--password-store=basic"), + "profile path should keep keychain flags" + ); + } } diff --git a/cli/src/output.rs b/cli/src/output.rs index fa23267..770fad7 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2652,6 +2652,26 @@ Examples: "## } + "profiles" => { + r##" +agent-browser profiles - List available Chrome profiles + +Usage: agent-browser profiles + +Lists all Chrome profiles found in your Chrome user data directory, showing +the directory name and display name for each profile. Use the directory name +with --profile to launch Chrome with that profile's login state. + +Global Options: + --json Output as JSON + +Examples: + agent-browser profiles + agent-browser profiles --json + agent-browser --profile Default open https://gmail.com +"## + } + _ => return false, }; println!("{}", help.trim()); @@ -2777,6 +2797,7 @@ Setup: install --with-deps Also install system dependencies (Linux) upgrade Upgrade to the latest version dashboard install Install the observability dashboard + profiles List available Chrome profiles Snapshot Options: -i, --interactive Only interactive elements @@ -2785,7 +2806,8 @@ Snapshot Options: -s, --selector Scope to CSS selector Authentication: - --profile Persist login sessions across restarts (cookies, IndexedDB, cache) + --profile Chrome profile name (e.g., Default) to reuse login state, + or a directory path for a persistent custom profile (or AGENT_BROWSER_PROFILE env) --session-name Auto-save/restore cookies and localStorage by name (or AGENT_BROWSER_SESSION_NAME env) @@ -2912,7 +2934,9 @@ Examples: agent-browser stream enable # Start runtime streaming on an auto-selected port agent-browser stream status # Inspect runtime streaming state agent-browser --color-scheme dark open example.com # Dark mode - agent-browser --profile ~/.myapp open example.com # Persistent profile + agent-browser --profile Default open gmail.com # Reuse Chrome login state + agent-browser --profile ~/.myapp open example.com # Persistent custom profile + agent-browser profiles # List available Chrome profiles agent-browser --session-name myapp open example.com # Auto-save/restore state Command Chaining: diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index 0801fee..227dbff 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -30,9 +30,40 @@ Each session has its own: - Navigation history - Authentication state +## Chrome profile reuse + +The simplest way to reuse your existing login state: pass a Chrome profile name to `--profile`. agent-browser copies the profile to a temp directory (read-only snapshot) and launches Chrome with your existing cookies and sessions. + +```bash +# List available Chrome profiles +agent-browser profiles + +# Reuse your default Chrome profile's login state +agent-browser --profile Default open https://gmail.com + +# Use a named profile (by display name or directory name) +agent-browser --profile "Work" open https://app.example.com + +# Or via environment variable +AGENT_BROWSER_PROFILE=Default agent-browser open https://gmail.com +``` + + + + + + + + + + + + +
DetailDescription
Supported browsersChrome, Chrome Canary, Chromium, Brave
What's copiedCookies, local storage, extensions state (cache dirs excluded for speed)
Original profileNever modified (read-only snapshot)
CleanupTemp copy deleted when browser closes
Windows noteClose Chrome before using --profile <name> if Chrome is running
+ ## Persistent profiles -By default, browser state is lost when the browser closes. Use `--profile` to persist state across restarts: +For a custom profile directory that persists state across browser restarts, pass a path to `--profile`: ```bash # Use a persistent profile directory diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 4a0293d..ba93b3d 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -61,7 +61,17 @@ agent-browser --state ./auth.json open https://app.example.com/dashboard State files contain session tokens in plaintext -- add to `.gitignore` and delete when no longer needed. Set `AGENT_BROWSER_ENCRYPTION_KEY` for encryption at rest. -**Option 2: Persistent profile (simplest for recurring tasks)** +**Option 2: Chrome profile reuse (zero setup)** + +```bash +# List available Chrome profiles +agent-browser profiles + +# Reuse the user's existing Chrome login state +agent-browser --profile Default open https://gmail.com +``` + +**Option 3: Persistent profile (for recurring tasks)** ```bash # First run: login manually or via automation @@ -72,7 +82,7 @@ agent-browser --profile ~/.myapp open https://app.example.com/login agent-browser --profile ~/.myapp open https://app.example.com/dashboard ``` -**Option 3: Session name (auto-save/restore cookies + localStorage)** +**Option 4: Session name (auto-save/restore cookies + localStorage)** ```bash agent-browser --session-name myapp open https://app.example.com/login