feat: reuse Chrome profile login state via --profile <name> (#1131)

* feat(chrome): add Chrome profile name resolution and copy for --profile flag

When --profile receives a name without path separators (e.g., "Default"),
it now resolves the name against installed Chrome profiles, copies the
profile to a temp directory (excluding large cache dirs), and launches
Chrome with the copied profile to reuse login state.

Key changes:
- Add profile resolution: is_chrome_profile_name, find_chrome_user_data_dir,
  list_chrome_profiles, resolve_chrome_profile (3-tier matching)
- Add copy_chrome_profile with best-effort copy and exclusion list
- Wire preprocessing into launch_chrome before retry loop
- Add use_real_keychain field to LaunchOptions for conditional keychain flags
- Make --password-store=basic and --use-mock-keychain conditional

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add `profiles` command to list available Chrome profiles

Adds `agent-browser profiles` command that reads Chrome's Local State
file to list available profiles with directory names and display names.
Supports --json output. Added help text in print_command_help and
print_help.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add Chrome profile reuse documentation across all locations

Update all 5 documentation locations per AGENTS.md:
- output.rs: updated --profile help text and examples
- README.md: added Chrome Profile Reuse section, updated options table
- SKILL.md: added profile reuse as Option 2
- docs/src/app/sessions/page.mdx: added Chrome profile reuse section
- chrome.rs: added doc comments to get_chrome_user_data_dirs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix formatting and clippy warning in chrome.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify profile resolution and launch integration

- Only clone LaunchOptions when profile name requires resolution
  (avoids unnecessary allocation on every Chrome launch)
- Remove redundant is_file() check before copy of Local State
  (copy() handles missing files naturally)
- Extract format_profile_list() to deduplicate error formatting
- Remove unnecessary section comments in tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(tests): use RAII TempDir guard for test cleanup

Replace manual remove_dir_all calls with a TempDir struct that
auto-cleans on drop, preventing temp dir leaks on test panics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Desen Meng
2026-04-04 11:21:11 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 ctate
parent 354dd8b615
commit 9f8e518a46
7 changed files with 764 additions and 12 deletions
+25 -2
View File
@@ -374,6 +374,7 @@ agent-browser provides multiple ways to persist login sessions so you don't re-a
| Approach | Best for | Flag / Env | | Approach | Best for | Flag / Env |
|----------|----------|------------| |----------|----------|------------|
| **Chrome profile reuse** | Reuse your existing Chrome login state (cookies, sessions) with zero setup | `--profile <name>` / `AGENT_BROWSER_PROFILE` |
| **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile <path>` / `AGENT_BROWSER_PROFILE` | | **Persistent profile** | Full browser state (cookies, IndexedDB, service workers, cache) across restarts | `--profile <path>` / `AGENT_BROWSER_PROFILE` |
| **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name <name>` / `AGENT_BROWSER_SESSION_NAME` | | **Session persistence** | Auto-save/restore cookies + localStorage by name | `--session-name <name>` / `AGENT_BROWSER_SESSION_NAME` |
| **Import from your browser** | Grab auth from a Chrome session you already logged into | `--auto-connect` + `state save` | | **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 - Navigation history
- Authentication state - 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 <name>` if Chrome is running, as some profile files may be locked.
## Persistent Profiles ## 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 ```bash
# Use a persistent profile directory # Use a persistent profile directory
@@ -567,7 +590,7 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|--------|-------------| |--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | | `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) | | `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) |
| `--profile <path>` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) | | `--profile <name\|path>` | Chrome profile name or persistent directory path (or `AGENT_BROWSER_PROFILE` env) |
| `--state <path>` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) | | `--state <path>` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin | | `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | | `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
+64
View File
@@ -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<serde_json::Value> = 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) { fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str()); 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) // Handle session separately (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("session") { if clean.first().map(|s| s.as_str()) == Some("session") {
run_session(&clean, &flags.session, flags.json); run_session(&clean, &flags.session, flags.json);
+2
View File
@@ -1620,6 +1620,7 @@ fn launch_options_from_env() -> LaunchOptions {
.unwrap_or(false), .unwrap_or(false),
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(), color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").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<Value, St
.get("downloadPath") .get("downloadPath")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
use_real_keychain: false,
}; };
let new_hash = launch_hash(&launch_options); let new_hash = launch_hash(&launch_options);
+603 -5
View File
@@ -86,6 +86,7 @@ impl Drop for ChromeProcess {
} }
} }
#[derive(Clone)]
pub struct LaunchOptions { pub struct LaunchOptions {
pub headless: bool, pub headless: bool,
pub executable_path: Option<String>, pub executable_path: Option<String>,
@@ -102,6 +103,10 @@ pub struct LaunchOptions {
pub ignore_https_errors: bool, pub ignore_https_errors: bool,
pub color_scheme: Option<String>, pub color_scheme: Option<String>,
pub download_path: Option<String>, pub download_path: Option<String>,
/// 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 { impl Default for LaunchOptions {
@@ -122,6 +127,7 @@ impl Default for LaunchOptions {
ignore_https_errors: false, ignore_https_errors: false,
color_scheme: None, color_scheme: None,
download_path: None, download_path: None,
use_real_keychain: false,
} }
} }
} }
@@ -148,10 +154,13 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
"--disable-features=Translate".to_string(), "--disable-features=Translate".to_string(),
"--enable-features=NetworkService,NetworkServiceInProcess".to_string(), "--enable-features=NetworkService,NetworkServiceInProcess".to_string(),
"--metrics-recording-only".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 let has_extensions = options
.extensions .extensions
.as_ref() .as_ref()
@@ -250,12 +259,47 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
})?, })?,
}; };
// 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<LaunchOptions> = None;
let mut profile_temp_dir: Option<PathBuf> = 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 max_attempts = 3;
let mut last_err = String::new(); let mut last_err = String::new();
for attempt in 1..=max_attempts { for attempt in 1..=max_attempts {
match try_launch_chrome(&chrome_path, options) { match try_launch_chrome(&chrome_path, effective_options) {
Ok(process) => return Ok(process), 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) => { Err(e) => {
last_err = e; last_err = e;
if attempt < max_attempts { if attempt < max_attempts {
@@ -273,6 +317,11 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
} }
} }
// 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) 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() TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(500)).is_ok()
} }
fn get_chrome_user_data_dirs() -> Vec<PathBuf> { /// 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<PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -690,6 +741,251 @@ fn get_chrome_user_data_dirs() -> Vec<PathBuf> {
dirs 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<PathBuf> {
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<ChromeProfile> {
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<ChromeProfile> = 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<String, String> {
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::<Vec<_>>()
.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<PathBuf, String> {
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 /// Returns true if Chrome's sandbox should be disabled because the environment
/// doesn't support it (containers, VMs, CI runners, running as root). /// doesn't support it (containers, VMs, CI runners, running as root).
fn should_disable_sandbox(existing_args: &[String]) -> bool { 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"); 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"
);
}
} }
+26 -2
View File
@@ -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, _ => return false,
}; };
println!("{}", help.trim()); println!("{}", help.trim());
@@ -2777,6 +2797,7 @@ Setup:
install --with-deps Also install system dependencies (Linux) install --with-deps Also install system dependencies (Linux)
upgrade Upgrade to the latest version upgrade Upgrade to the latest version
dashboard install Install the observability dashboard dashboard install Install the observability dashboard
profiles List available Chrome profiles
Snapshot Options: Snapshot Options:
-i, --interactive Only interactive elements -i, --interactive Only interactive elements
@@ -2785,7 +2806,8 @@ Snapshot Options:
-s, --selector <sel> Scope to CSS selector -s, --selector <sel> Scope to CSS selector
Authentication: Authentication:
--profile <path> Persist login sessions across restarts (cookies, IndexedDB, cache) --profile <name|path> Chrome profile name (e.g., Default) to reuse login state,
or a directory path for a persistent custom profile
(or AGENT_BROWSER_PROFILE env) (or AGENT_BROWSER_PROFILE env)
--session-name <name> Auto-save/restore cookies and localStorage by name --session-name <name> Auto-save/restore cookies and localStorage by name
(or AGENT_BROWSER_SESSION_NAME env) (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 enable # Start runtime streaming on an auto-selected port
agent-browser stream status # Inspect runtime streaming state agent-browser stream status # Inspect runtime streaming state
agent-browser --color-scheme dark open example.com # Dark mode 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 agent-browser --session-name myapp open example.com # Auto-save/restore state
Command Chaining: Command Chaining:
+32 -1
View File
@@ -30,9 +30,40 @@ Each session has its own:
- Navigation history - Navigation history
- Authentication state - 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
```
<table>
<thead>
<tr><th>Detail</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td>Supported browsers</td><td>Chrome, Chrome Canary, Chromium, Brave</td></tr>
<tr><td>What's copied</td><td>Cookies, local storage, extensions state (cache dirs excluded for speed)</td></tr>
<tr><td>Original profile</td><td>Never modified (read-only snapshot)</td></tr>
<tr><td>Cleanup</td><td>Temp copy deleted when browser closes</td></tr>
<tr><td>Windows note</td><td>Close Chrome before using <code>--profile &lt;name&gt;</code> if Chrome is running</td></tr>
</tbody>
</table>
## Persistent profiles ## 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 ```bash
# Use a persistent profile directory # Use a persistent profile directory
+12 -2
View File
@@ -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. 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 ```bash
# First run: login manually or via automation # 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 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 ```bash
agent-browser --session-name myapp open https://app.example.com/login agent-browser --session-name myapp open https://app.example.com/login