diff --git a/README.md b/README.md index 19b10f31c..2309e98c6 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,19 @@ agent-browser install --with-deps # Also install system deps (Linux) agent-browser upgrade # Upgrade agent-browser to the latest version ``` +### Authentication Vault + +```bash +agent-browser auth save --url --username --password +agent-browser auth save --url --username --password-stdin +agent-browser auth login +agent-browser auth list +agent-browser auth show +agent-browser auth delete +``` + +Use these commands to store credentials locally (encrypted), then perform a login flow without exposing passwords to your agent or shell history. For shell safety, prefer `--password-stdin` over `--password` when possible. + ## Authentication agent-browser provides multiple ways to persist login sessions so you don't re-authenticate every run. @@ -377,6 +390,8 @@ agent-browser provides multiple ways to persist login sessions so you don't re-a | **State file** | Load a previously saved state JSON on launch | `--state ` / `AGENT_BROWSER_STATE` | | **Auth vault** | Store credentials locally (encrypted), login by name | `auth save` / `auth login` | +If you are scanning the command list first, note that the auth vault commands live under `agent-browser auth ...`. + ### Import auth from your browser If you are already logged in to a site in Chrome, you can grab that auth state and reuse it: diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e41b98461..4501426a3 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -30,6 +30,7 @@ socket2 = "0.6" similar = "2" zip = { version = "8.2.0", default-features = false, features = ["deflate"] } time = { version = "0.3", features = ["formatting"] } +regex = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 2cf07ea4d..60eabacbb 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -2769,16 +2769,82 @@ async fn wait_for_selector( poll_until_true(client, session_id, &check_fn, timeout_ms).await } +fn url_glob_to_regex(pattern: &str) -> String { + let mut regex = String::from("^"); + let mut chars = pattern.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '*' => { + if matches!(chars.peek(), Some('*')) { + chars.next(); + regex.push_str(".*"); + } else { + regex.push_str("[^/]*"); + } + } + // Minimal brace alternation support for patterns like {a,b}. + '{' => { + let mut alt = String::new(); + let mut found_closing = false; + while let Some(next) = chars.next() { + if next == '}' { + found_closing = true; + break; + } + alt.push(next); + } + + if found_closing { + let variants = alt + .split(',') + .map(regex::escape) + .collect::>() + .join("|"); + regex.push('('); + regex.push_str(&variants); + regex.push(')'); + } else { + regex.push_str("\\{"); + regex.push_str(®ex::escape(&alt)); + } + } + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '[' | ']' | '\\' | '?' => { + regex.push('\\'); + regex.push(ch); + } + _ => regex.push(ch), + } + } + + regex.push('$'); + regex +} + +fn url_matches_pattern(url: &str, pattern: &str) -> bool { + if !pattern.contains('*') && !pattern.contains('{') { + return url == pattern; + } + + regex::Regex::new(&url_glob_to_regex(pattern)) + .map(|re| re.is_match(url)) + .unwrap_or(false) +} + async fn wait_for_url( client: &super::cdp::client::CdpClient, session_id: &str, pattern: &str, timeout_ms: u64, ) -> Result<(), String> { - let check_fn = format!( - "location.href.includes({})", - serde_json::to_string(pattern).unwrap_or_default() - ); + let pattern_json = serde_json::to_string(pattern).unwrap_or_default(); + let regex_json = serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default(); + let exact_match = !pattern.contains('*') && !pattern.contains('{'); + let check_fn = if exact_match { + format!("location.href === {}", pattern_json) + } else { + format!("(new RegExp({})).test(location.href)", regex_json) + }; poll_until_true(client, session_id, &check_fn, timeout_ms).await } @@ -8138,4 +8204,56 @@ mod tests { assert_eq!(key, "+"); assert_eq!(mods, None); } + + #[test] + fn test_url_matches_pattern_exact_match_without_wildcards() { + assert!(url_matches_pattern( + "https://example.com/settings/profile", + "https://example.com/settings/profile" + )); + assert!(!url_matches_pattern( + "https://example.com/settings/profile?tab=1", + "https://example.com/settings/profile" + )); + } + + #[test] + fn test_url_matches_pattern_single_star_does_not_cross_slashes() { + assert!(url_matches_pattern( + "https://example.com/settings", + "https://example.com/*" + )); + assert!(!url_matches_pattern( + "https://example.com/settings/profile", + "https://example.com/*" + )); + } + + #[test] + fn test_url_matches_pattern_double_star_crosses_slashes() { + assert!(url_matches_pattern( + "https://github.com/settings/profile", + "**/settings/profile" + )); + assert!(url_matches_pattern( + "https://example.com/a/b/c", + "https://example.com/**" + )); + } + + #[test] + fn test_url_matches_pattern_brace_alternation() { + assert!(url_matches_pattern( + "https://example.com/login", + "https://example.com/{login,signup}" + )); + assert!(url_matches_pattern( + "https://example.com/signup", + "https://example.com/{login,signup}" + )); + assert!(!url_matches_pattern( + "https://example.com/logout", + "https://example.com/{login,signup}" + )); + } }