diff --git a/README.md b/README.md index 55f564b..ebd9eb4 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,30 @@ Each session has its own: - Navigation history - Authentication state +## 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: + +```bash +# Use a persistent profile directory +agent-browser --profile ~/.myapp-profile open myapp.com + +# Login once, then reuse the authenticated session +agent-browser --profile ~/.myapp-profile open myapp.com/dashboard + +# Or via environment variable +AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com +``` + +The profile directory stores: +- Cookies and localStorage +- IndexedDB data +- Service workers +- Browser cache +- Login sessions + +**Tip**: Use different profile paths for different projects to keep their browser state isolated. + ## Snapshot Options The `snapshot` command supports filtering to reduce output size: @@ -296,6 +320,7 @@ agent-browser snapshot -i -c -d 5 # Combine options | Option | Description | |--------|-------------| | `--session ` | Use isolated session (or `AGENT_BROWSER_SESSION` env) | +| `--profile ` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) | | `--headers ` | Set HTTP headers scoped to the URL's origin | | `--executable-path ` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) | | `--args ` | Browser launch args, comma or newline separated (or `AGENT_BROWSER_ARGS` env) | diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 9d13b5f..d411b7b 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1114,6 +1114,7 @@ mod tests { executable_path: None, extensions: Vec::new(), cdp: None, + profile: None, proxy: None, proxy_bypass: None, args: None, diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 8bb63c2..713559a 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -10,6 +10,7 @@ pub struct Flags { pub executable_path: Option, pub cdp: Option, pub extensions: Vec, + pub profile: Option, pub proxy: Option, pub proxy_bypass: Option, pub args: Option, @@ -33,6 +34,7 @@ pub fn parse_flags(args: &[String]) -> Flags { executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(), cdp: None, extensions: extensions_env, + profile: env::var("AGENT_BROWSER_PROFILE").ok(), proxy: env::var("AGENT_BROWSER_PROXY").ok(), proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(), args: env::var("AGENT_BROWSER_ARGS").ok(), @@ -77,6 +79,12 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--profile" => { + if let Some(s) = args.get(i + 1) { + flags.profile = Some(s.clone()); + i += 1; + } + } "--proxy" => { if let Some(p) = args.get(i + 1) { flags.proxy = Some(p.clone()); @@ -127,6 +135,7 @@ pub fn clean_args(args: &[String]) -> Vec { "--executable-path", "--cdp", "--extension", + "--profile", "--proxy", "--proxy-bypass", "--args", diff --git a/cli/src/main.rs b/cli/src/main.rs index e774d20..7cf9816 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -217,6 +217,7 @@ fn main() { let ignored_flags: Vec<&str> = [ flags.executable_path.as_ref().map(|_| "--executable-path"), if has_extensions { Some("--extension") } else { None }, + flags.profile.as_ref().map(|_| "--profile"), flags.args.as_ref().map(|_| "--args"), flags.user_agent.as_ref().map(|_| "--user-agent"), flags.proxy.as_ref().map(|_| "--proxy"), @@ -358,8 +359,8 @@ fn main() { } } - // Launch headed browser or proxy if flags are set (without CDP or provider) - if (flags.headed || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { + // Launch headed browser or configure browser options (without CDP or provider) + if (flags.headed || flags.profile.is_some() || flags.proxy.is_some() || flags.args.is_some() || flags.user_agent.is_some()) && flags.cdp.is_none() && flags.provider.is_none() { let mut launch_cmd = json!({ "id": gen_id(), "action": "launch", @@ -369,6 +370,11 @@ fn main() { let cmd_obj = launch_cmd.as_object_mut() .expect("json! macro guarantees object type"); + // Add profile path if specified + if let Some(ref profile_path) = flags.profile { + cmd_obj.insert("profile".to_string(), json!(profile_path)); + } + if let Some(ref proxy_str) = flags.proxy { let mut proxy_obj = parse_proxy(proxy_str); // Add bypass if specified diff --git a/cli/src/output.rs b/cli/src/output.rs index 8fe472c..7e0410c 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1412,6 +1412,7 @@ Snapshot Options: Options: --session Isolated session (or AGENT_BROWSER_SESSION env) + --profile Persistent browser profile (or AGENT_BROWSER_PROFILE env) --headers HTTP headers scoped to URL's origin (for auth) --executable-path Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH) --extension Load browser extensions (repeatable) @@ -1443,6 +1444,7 @@ Examples: agent-browser get text @e1 agent-browser screenshot --full agent-browser --cdp 9222 snapshot # Connect via CDP port + agent-browser --profile ~/.myapp open example.com # Persistent profile "# ); } diff --git a/src/browser.ts b/src/browser.ts index 5c04dbf..04cfa66 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -818,11 +818,16 @@ export class BrowserManager { // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const hasExtensions = !!options.extensions?.length; + const hasProfile = !!options.profile; if (hasExtensions && cdpEndpoint) { throw new Error('Extensions cannot be used with CDP connection'); } + if (hasProfile && cdpEndpoint) { + throw new Error('Profile cannot be used with CDP connection'); + } + if (this.isLaunched()) { const needsRelaunch = (!cdpEndpoint && this.cdpEndpoint !== null) || @@ -863,6 +868,7 @@ export class BrowserManager { let context: BrowserContext; if (hasExtensions) { + // Extensions require persistent context in a temp directory const extPaths = options.extensions!.join(','); const session = process.env.AGENT_BROWSER_SESSION || 'default'; // Combine extension args with custom args @@ -881,7 +887,19 @@ export class BrowserManager { } ); this.isPersistentContext = true; + } else if (hasProfile) { + // Profile uses persistent context for durable cookies/storage + // Expand ~ to home directory since it won't be shell-expanded + const profilePath = options.profile!.replace(/^~\//, os.homedir() + '/'); + context = await launcher.launchPersistentContext(profilePath, { + headless: options.headless ?? true, + executablePath: options.executablePath, + viewport, + extraHTTPHeaders: options.headers, + }); + this.isPersistentContext = true; } else { + // Regular ephemeral browser this.browser = await launcher.launch({ headless: options.headless ?? true, executablePath: options.executablePath, diff --git a/src/types.ts b/src/types.ts index 1cbdd57..6fdcbb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export interface LaunchCommand extends BaseCommand { cdpPort?: number; cdpUrl?: string; extensions?: string[]; + profile?: string; // Path to persistent browser profile directory proxy?: { server: string; bypass?: string;