Compare commits

...
Author SHA1 Message Date
elsigh 447e6ece0b Expand tilde in profile path to home directory 2026-01-13 14:01:39 -08:00
elsigh d86de0e736 Add --profile flag for persistent browser profiles
Adds support for persistent browser profiles that preserve cookies,
localStorage, and login sessions across browser restarts.

Changes:
- Add --profile <path> CLI flag (flags.rs)
- Add AGENT_BROWSER_PROFILE environment variable support
- Add profile field to LaunchCommand type (types.ts)
- Use launchPersistentContext when profile is specified (browser.ts)
- Update help text and README with documentation

Usage:
  agent-browser --profile ~/.myapp-profile open myapp.com

This enables AI agents to maintain authenticated sessions across
browser restarts without re-authenticating each time.
2026-01-13 12:49:42 -08:00
Shirshak 673e2e266e feat: Add extension support (#48)
* Rebase: Add extension support

* Fix logs
2026-01-13 14:34:59 -06:00
9 changed files with 159 additions and 36 deletions
+25
View File
@@ -270,6 +270,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:
@@ -295,6 +319,7 @@ agent-browser snapshot -i -c -d 5 # Combine options
| Option | Description |
|--------|-------------|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
| `--profile <path>` | Persistent browser profile directory (or `AGENT_BROWSER_PROFILE` env) |
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
| `--executable-path <path>` | Custom browser executable (or `AGENT_BROWSER_EXECUTABLE_PATH` env) |
| `--json` | JSON output (for agents) |
+1
View File
@@ -901,6 +901,7 @@ mod tests {
debug: false,
headers: None,
executable_path: None,
extensions: Vec::new(),
cdp: None,
}
}
+17 -2
View File
@@ -159,9 +159,16 @@ pub struct DaemonResult {
pub already_running: bool,
}
pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result<DaemonResult, String> {
pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
) -> Result<DaemonResult, String> {
if is_daemon_running(session) && daemon_ready(session) {
return Ok(DaemonResult { already_running: true });
return Ok(DaemonResult {
already_running: true,
});
}
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
@@ -196,6 +203,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -234,6 +245,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
+23 -2
View File
@@ -9,9 +9,16 @@ pub struct Flags {
pub headers: Option<String>,
pub executable_path: Option<String>,
pub cdp: Option<String>,
pub extensions: Vec<String>,
pub profile: Option<String>,
}
pub fn parse_flags(args: &[String]) -> Flags {
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
.ok()
.map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>())
.unwrap_or_default();
let mut flags = Flags {
json: false,
full: false,
@@ -21,6 +28,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
headers: None,
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
cdp: None,
extensions: extensions_env,
profile: env::var("AGENT_BROWSER_PROFILE").ok(),
};
let mut i = 0;
@@ -47,13 +56,25 @@ pub fn parse_flags(args: &[String]) -> Flags {
flags.executable_path = Some(s.clone());
i += 1;
}
}
},
"--extension" => {
if let Some(s) = args.get(i + 1) {
flags.extensions.push(s.clone());
i += 1;
}
},
"--cdp" => {
if let Some(s) = args.get(i + 1) {
flags.cdp = Some(s.clone());
i += 1;
}
}
"--profile" => {
if let Some(s) = args.get(i + 1) {
flags.profile = Some(s.clone());
i += 1;
}
}
_ => {}
}
i += 1;
@@ -68,7 +89,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
// Global flags that should be stripped from command args
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
// Global flags that take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp"];
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension", "--profile"];
for arg in args.iter() {
if skip_next {
+22 -8
View File
@@ -149,7 +149,7 @@ fn main() {
}
};
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) {
Ok(result) => result,
Err(e) => {
if flags.json {
@@ -161,10 +161,18 @@ fn main() {
}
};
// Warn if executable_path was specified but daemon was already running
if daemon_result.already_running && flags.executable_path.is_some() {
// Warn if executable_path, profile, or extensions were specified but daemon was already running
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty() || flags.profile.is_some()) {
if !flags.json {
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
if flags.executable_path.is_some() {
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
}
if !flags.extensions.is_empty() {
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
}
if flags.profile.is_some() {
eprintln!("\x1b[33m⚠\x1b[0m --profile ignored: daemon already running. Use 'agent-browser close' first to restart with profile.");
}
}
}
@@ -224,16 +232,22 @@ fn main() {
}
// Launch headed browser if --headed flag is set (without CDP)
if flags.headed && flags.cdp.is_none() {
let launch_cmd = json!({
// Also launch with profile if --profile is set
if (flags.headed || flags.profile.is_some()) && flags.cdp.is_none() {
let mut launch_cmd = json!({
"id": gen_id(),
"action": "launch",
"headless": false
"headless": !flags.headed
});
// Add profile path if specified
if let Some(ref profile_path) = flags.profile {
launch_cmd["profile"] = json!(profile_path);
}
if let Err(e) = send_command(launch_cmd, &flags.session) {
if !flags.json {
eprintln!("\x1b[33m⚠\x1b[0m Could not launch headed browser: {}", e);
eprintln!("\x1b[33m⚠\x1b[0m Could not launch browser: {}", e);
}
}
}
+3
View File
@@ -1189,8 +1189,10 @@ Snapshot Options:
Options:
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
--profile <path> Persistent browser profile (or AGENT_BROWSER_PROFILE env)
--headers <json> HTTP headers scoped to URL's origin (for auth)
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
--extension <path> Load browser extensions (repeatable).
--json JSON output
--full, -f Full page screenshot
--headed Show browser window (not headless)
@@ -1206,6 +1208,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
"#
);
}
+60 -24
View File
@@ -12,6 +12,8 @@ import {
type Route,
type Locator,
} from 'playwright-core';
import path from 'node:path';
import os from 'node:os';
import type { LaunchCommand } from './types.js';
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
@@ -40,6 +42,7 @@ interface PageError {
export class BrowserManager {
private browser: Browser | null = null;
private cdpPort: number | null = null;
private isPersistentContext: boolean = false;
private contexts: BrowserContext[] = [];
private pages: Page[] = [];
private activePageIndex: number = 0;
@@ -58,7 +61,7 @@ export class BrowserManager {
* Check if browser is launched
*/
isLaunched(): boolean {
return this.browser !== null;
return this.browser !== null || this.isPersistentContext;
}
/**
@@ -605,12 +608,21 @@ export class BrowserManager {
*/
async launch(options: LaunchCommand): Promise<void> {
const cdpPort = options.cdpPort;
const hasExtensions = !!options.extensions?.length;
const hasProfile = !!options.profile;
if (this.browser) {
const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null;
const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort);
if (hasExtensions && cdpPort) {
throw new Error('Extensions cannot be used with CDP connection');
}
if (switchingFromCdpToBrowser || needsCdpReconnect) {
if (hasProfile && cdpPort) {
throw new Error('Profile cannot be used with CDP connection');
}
if (this.isLaunched()) {
const needsRelaunch =
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
if (needsRelaunch) {
await this.close();
} else {
return;
@@ -622,35 +634,58 @@ export class BrowserManager {
return;
}
// Select browser type
const browserType = options.browser ?? 'chromium';
if (hasExtensions && browserType !== 'chromium') {
throw new Error('Extensions are only supported in Chromium');
}
const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
const viewport = options.viewport ?? { width: 1280, height: 720 };
// Launch browser
this.browser = await launcher.launch({
headless: options.headless ?? true,
executablePath: options.executablePath,
});
this.cdpPort = null;
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';
context = await launcher.launchPersistentContext(
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
{
headless: false,
executablePath: options.executablePath,
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
viewport,
extraHTTPHeaders: options.headers,
}
);
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,
});
this.cdpPort = null;
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
}
// Create context with viewport and optional headers
const context = await this.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 },
extraHTTPHeaders: options.headers,
});
// Set default timeout to 10 seconds (Playwright default is 30s)
context.setDefaultTimeout(10000);
this.contexts.push(context);
// Create initial page
const page = await context.newPage();
const page = context.pages()[0] ?? (await context.newPage());
this.pages.push(page);
this.activePageIndex = 0;
// Automatically start console and error tracking
this.setupPageTracking(page);
}
@@ -877,6 +912,7 @@ export class BrowserManager {
this.pages = [];
this.contexts = [];
this.cdpPort = null;
this.isPersistentContext = false;
this.activePageIndex = 0;
this.refMap = {};
this.lastSnapshot = '';
+6
View File
@@ -158,11 +158,17 @@ export async function startDaemon(): Promise<void> {
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
await browser.launch({
id: 'auto',
action: 'launch',
headless: true,
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
});
}
+2
View File
@@ -15,6 +15,8 @@ export interface LaunchCommand extends BaseCommand {
headers?: Record<string, string>;
executablePath?: string;
cdpPort?: number;
extensions?: string[];
profile?: string; // Path to persistent browser profile directory
}
export interface NavigateCommand extends BaseCommand {