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:
co-authored by
Claude Opus 4.6
ctate
parent
354dd8b615
commit
9f8e518a46
@@ -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) |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 <name></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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user